diff --git a/app/src/components/about-me/about-me-view.tsx b/app/src/components/about-me/about-me-view.tsx
index a994eef19..e45f2b483 100644
--- a/app/src/components/about-me/about-me-view.tsx
+++ b/app/src/components/about-me/about-me-view.tsx
@@ -1,8 +1,7 @@
-import { Spinner } from "@houston-ai/core";
import { useTranslation } from "react-i18next";
-import { InstructionsContent } from "../agent/job-description-parts";
-import { useContextSlot, useContextSlotLabels } from "../context/context-slots";
-import { PageContainer, PageHero } from "../shell/page-shell";
+import { ContextEditorPage } from "../context/context-editor";
+import { useContextSlot } from "../context/context-slots";
+import { PageContainer } from "../shell/page-shell";
/**
* About me: what every agent knows about the PERSON before it starts a turn.
@@ -29,23 +28,18 @@ import { PageContainer, PageHero } from "../shell/page-shell";
export function AboutMeView() {
const { t } = useTranslation("context");
const editor = useContextSlot("user");
- const labels = useContextSlotLabels("user");
return (
-
-
- {editor.ready ? (
-
- ) : (
-
-
-
- )}
+
+
);
diff --git a/app/src/components/agent-settings/agent-settings-section.tsx b/app/src/components/agent-settings/agent-settings-section.tsx
index 3b6d47efb..3949109ec 100644
--- a/app/src/components/agent-settings/agent-settings-section.tsx
+++ b/app/src/components/agent-settings/agent-settings-section.tsx
@@ -11,10 +11,10 @@ import type {
import { AgentSettingsPeople } from "./agent-settings-people.tsx";
/**
- * The access bodies (people, apps, models) are deliberately flush (`w-full`) so
- * the mounting surface owns their width. This gives them the SAME column the
- * self-padded bodies (job description, learnings) bring — `max-w-3xl px-6` on
- * one `pt-2` top rhythm — so nothing shifts as the rail switches sections.
+ * The flush bodies (job description, people, apps, models) deliberately own no
+ * width of their own, so the mounting surface does. This gives them the SAME
+ * column the one self-padded body (learnings) brings — `max-w-3xl px-6` on one
+ * `pt-2` top rhythm — so nothing shifts as the rail switches sections.
*/
function AccessColumn({ children }: { children: ReactNode }) {
return (
@@ -34,7 +34,11 @@ export function AgentSettingsSectionView({
}: AgentSectionProps & { section: AgentSettingsSection }) {
switch (section) {
case "job-description":
- return ;
+ return (
+
+
+
+ );
case "learnings":
return ;
case "people":
diff --git a/app/src/components/agent/agent-admin/agent-admin-instructions.tsx b/app/src/components/agent/agent-admin/agent-admin-instructions.tsx
index b8573abdb..93010f768 100644
--- a/app/src/components/agent/agent-admin/agent-admin-instructions.tsx
+++ b/app/src/components/agent/agent-admin/agent-admin-instructions.tsx
@@ -1,22 +1,43 @@
+import { Spinner } from "@houston-ai/core";
+import { useTranslation } from "react-i18next";
import { useInstructions, useSaveInstructions } from "../../../hooks/queries";
import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts";
-import { InstructionsContent } from "../job-description-parts";
+import { ContextEditorBox } from "../../context/context-editor";
-/** Instructions (CLAUDE.md) section. Read-only for non-managers. */
+/**
+ * Instructions (CLAUDE.md) section, drawn with the ONE standing-prose box
+ * (`ContextEditorBox`: always open, saves on blur). No heading of its own —
+ * the settings rail row already says "Job description", and no sibling
+ * section titles itself either — just the one-line helper over the box
+ * (explain ONCE). Read-only for non-managers: the same face, locked, so they
+ * still read what the agent is told.
+ */
export function AgentAdminInstructions({
agent,
readOnly = false,
}: AgentSectionProps) {
+ const { t } = useTranslation("agents");
const path = agent.folderPath;
const { data: instructions } = useInstructions(path);
const saveInstructions = useSaveInstructions(path);
return (
-
- saveInstructions.mutateAsync({ name: "CLAUDE.md", content: c })
- }
- />
+
- );
-}
diff --git a/app/src/components/context/context-editor.tsx b/app/src/components/context/context-editor.tsx
new file mode 100644
index 000000000..f1da2b939
--- /dev/null
+++ b/app/src/components/context/context-editor.tsx
@@ -0,0 +1,155 @@
+import { cn, Spinner } from "@houston-ai/core";
+import { type ComponentProps, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { PageHero } from "../shell/page-shell";
+
+type SaveState = "idle" | "saving" | "saved";
+
+/**
+ * THE standing-prose editor, everywhere the product asks for one: About me,
+ * Admin > Company context, an agent's Job description, a team's shared
+ * context — and whatever context surface appears next. One grammar, held by
+ * this file so no surface can drift:
+ *
+ * - **Always open.** No invite empty state: the greyed suggestion inside the
+ * box is the invitation, and it disappears the moment the user types.
+ * - **Saves on blur, says so quietly.** The slim right-aligned "Saving…/
+ * Saved" line above the box; a save fires only when the text actually
+ * changed, so tabbing through a page writes nothing.
+ * - **Explains itself ONCE.** The title + one-line explanation live in the
+ * heading ({@link ContextEditorPage}'s hero, or a card's own header); the
+ * box never carries a second description.
+ * - **Read-only is the same face, locked.** Someone who may not edit still
+ * reads what the agents are told; the text stays legible, never hidden.
+ */
+export function ContextEditorBox({
+ content,
+ onSave,
+ placeholder,
+ readOnly = false,
+ minRows = 12,
+ ariaLabel,
+ dataTestId,
+}: {
+ content: string;
+ onSave: (content: string) => Promise;
+ /** The greyed suggestion text — a short example of what belongs here. */
+ placeholder: string;
+ readOnly?: boolean;
+ /** Empty-box height; a card in a longer page wants fewer rows than a page. */
+ minRows?: number;
+ /** Accessible name when no visible heading labels the box directly. */
+ ariaLabel?: string;
+ dataTestId?: string;
+}) {
+ const { t } = useTranslation("context");
+ const [value, setValue] = useState(content);
+ const [state, setState] = useState("idle");
+
+ // Re-seed from the store whenever it changes under us: another window's
+ // save, or our own landing back through the query cache.
+ useEffect(() => {
+ setValue(content);
+ }, [content]);
+
+ const handleBlur = async () => {
+ if (readOnly || value === content) return;
+ setState("saving");
+ try {
+ await onSave(value);
+ setState("saved");
+ window.setTimeout(() => setState("idle"), 2000);
+ } catch {
+ // The data layer owns the toast (`lib/tauri.ts` `call()` /
+ // `surfaceEngineError` on every save path) — the box only recovers so
+ // "Saving…" never sticks, and the unsaved text stays in the field for
+ // the user to retry.
+ setState("idle");
+ }
+ };
+
+ return (
+
+ {/* The save state keeps a reserved right-edge seat, so the box never
+ shifts when it appears. */}
+
+ );
+}
+
+/**
+ * A standing-context PAGE: the hero (a clear title + one short line saying
+ * what belongs here) sitting tight over the {@link ContextEditorBox}. Pass
+ * `level={2}` when the screen's `
` already lives in its header strip.
+ * `ready` gates the box behind a spinner while the backing read lands — a
+ * loading frame, which is not an empty state.
+ */
+export function ContextEditorPage({
+ title,
+ subtitle,
+ level = 1,
+ ready = true,
+ ...box
+}: {
+ title: string;
+ subtitle: string;
+ level?: 1 | 2;
+ ready?: boolean;
+} & ComponentProps) {
+ return (
+
+
+ {ready ? (
+ // The hero visually titles the box but is not programmatically
+ // associated with it, so the title doubles as the accessible name
+ // unless the caller passes a more specific one.
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/app/src/components/context/context-slots.ts b/app/src/components/context/context-slots.ts
index afffb9507..f843f7e22 100644
--- a/app/src/components/context/context-slots.ts
+++ b/app/src/components/context/context-slots.ts
@@ -1,10 +1,8 @@
-import { useTranslation } from "react-i18next";
import {
useSaveWorkspaceContext,
useWorkspaceContext,
} from "../../hooks/queries/use-workspace-context";
import { useAgentStore } from "../../stores/agents";
-import type { InstructionsContentLabels } from "../agent/job-description-parts";
/** The two halves of the workspace's standing context, as stored on the wire. */
export type ContextSlot = "workspace" | "user";
@@ -35,19 +33,3 @@ export function useContextSlot(slot: ContextSlot): {
},
};
}
-
-/** The editor copy for one slot, in the shared `InstructionsContent` shape. */
-export function useContextSlotLabels(
- slot: ContextSlot,
-): InstructionsContentLabels {
- const { t } = useTranslation("context");
- return {
- emptyTitle: t(`editor.${slot}.emptyTitle`),
- emptyDescription: t(`editor.${slot}.emptyDescription`),
- writeButton: t(`editor.${slot}.writeButton`),
- helper: t(`editor.${slot}.helper`),
- saving: t("editor.saving"),
- saved: t("editor.saved"),
- placeholder: t(`editor.${slot}.placeholder`),
- };
-}
diff --git a/app/src/components/organization/admin-analytics-header.tsx b/app/src/components/organization/admin-analytics-header.tsx
new file mode 100644
index 000000000..b6269d5aa
--- /dev/null
+++ b/app/src/components/organization/admin-analytics-header.tsx
@@ -0,0 +1,100 @@
+import { Building2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { PageHeader } from "../shell/page-header/page-header";
+import { PageHeaderBackChip } from "../shell/page-header/page-header-back-chip";
+import { headerCollapsesTabs } from "../shell/page-header/page-header-layout";
+import { PageHeaderSwitcher } from "../shell/page-header/page-header-switcher";
+import {
+ type PageHeaderTabItem,
+ PageHeaderTabs,
+} from "../shell/page-header/page-header-tabs";
+import { usePageHeaderMode } from "../shell/page-header/page-header-tools";
+import { type AnalyticsLens, DEFAULT_ANALYTICS_LENS } from "./org-view-model";
+
+/**
+ * The Admin strip DRILLED INTO Analytics: the back chip returning to the
+ * dashboard, then the section's own lozenge cluster — one navigation grammar
+ * on both levels, instead of a second tab style under the first.
+ *
+ * (‹ 🏢 Admin) (Analytics)(Usage)(Time worked)
+ *
+ * **Analytics is the drilled identity lozenge.** It carries the screen's
+ * `
` and stands for the lead lens (Activity) — the same "the identity IS
+ * the first surface" rule the team lozenge follows. Per the drilled-header
+ * rules on {@link PageHeaderBackChip}, it wears NO glyph: only a top-level
+ * cluster's identity does, and the bare words plus the chip are what make
+ * this read as an inner page.
+ *
+ * Stateless: the lens (and the deployment's lens set) is owned by
+ * `OrganizationView` beside the section state and threaded here, the same
+ * way `AdminHeader` receives `active`.
+ *
+ * Narrow: the cluster collapses into a switcher naming the ACTIVE lens; the
+ * back chip stays put.
+ */
+export function AdminAnalyticsHeader({
+ lens,
+ lenses,
+ onSelectLens,
+ onBack,
+}: {
+ lens: AnalyticsLens;
+ lenses: readonly AnalyticsLens[];
+ onSelectLens: (lens: AnalyticsLens) => void;
+ onBack: () => void;
+}) {
+ const { t } = useTranslation("teams");
+ const collapsed = headerCollapsesTabs(usePageHeaderMode());
+
+ const identity = (
+ {t("org.tabs.analytics")}
+ );
+ const tabs = lenses.map((id): PageHeaderTabItem => {
+ const dataAttrs: Record = { "data-analytics-lens-tab": id };
+ if (id !== DEFAULT_ANALYTICS_LENS)
+ return { id, label: t(`org.tabs.${id}`), dataAttrs };
+ // The lead lens doubles as the section lozenge, so it keeps the section
+ // address the `openAdminSection` helper lands on.
+ dataAttrs["data-admin-section-tab"] = "analytics";
+ return { id, heading: true, label: identity, dataAttrs };
+ });
+ // The switcher MENU has to name the lead lens — inside a list of lens
+ // names, "the identity lozenge stands for it" stops being legible.
+ const switcherLenses = lenses.map((id) => ({
+ id,
+ label: t(`org.tabs.${id}`),
+ dataAttrs: { "data-analytics-lens-tab": id },
+ }));
+
+ return (
+
+
+
+ );
+}
diff --git a/app/src/components/organization/admin-header.tsx b/app/src/components/organization/admin-header.tsx
new file mode 100644
index 000000000..fd9852a02
--- /dev/null
+++ b/app/src/components/organization/admin-header.tsx
@@ -0,0 +1,130 @@
+import { Building2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { PageHeader } from "../shell/page-header/page-header";
+import {
+ type HeaderThresholds,
+ headerCollapsesTabs,
+} from "../shell/page-header/page-header-layout";
+import { PageHeaderSwitcher } from "../shell/page-header/page-header-switcher";
+import { PageHeaderTabs } from "../shell/page-header/page-header-tabs";
+import { usePageHeaderMode } from "../shell/page-header/page-header-tools";
+import { AdminAnalyticsHeader } from "./admin-analytics-header";
+import {
+ type AnalyticsLens,
+ DEFAULT_ORG_TAB,
+ type OrgTabId,
+} from "./org-view-model";
+
+/**
+ * The widest forms are Spanish. Dashboard level: identity "Administración"
+ * ~141px (glyph 16 + 6 gap + text + px-3), Personas ~84, Facturación ~99,
+ * Analítica ~86, plus 3 × 2px gaps and the track's 4px padding ≈ 420, plus
+ * the strip's 40px `px-5` = 460, rounded UP to 480. The drilled Analytics
+ * level (back chip ~141 + the lens cluster ~285 + the 8px gap + padding)
+ * lands just under the same line, so ONE threshold serves both levels. There
+ * is no right-zone tools cluster on either, so no compact form exists —
+ * below the line each level collapses into its switcher.
+ */
+export const ADMIN_HEADER_THRESHOLDS: HeaderThresholds = {
+ oneRowMin: 480,
+};
+
+/**
+ * The Admin strip, in the shared header grammar (Integrations, the team
+ * screen): one lozenge cluster where the identity IS the first section.
+ *
+ * **Admin is the first lozenge.** It wears the rail row's mark (`Building2`)
+ * and name — the door and the page agree on what this place looks like —
+ * carries the screen's `
`, and stands for Company context, the landing
+ * section: the standing knowledge every agent starts a turn with is what
+ * Admin looks like when you arrive. (The body says so itself: that section
+ * opens on its own titled hero, so the lozenge doesn't have to name it.) The
+ * other sections (People, Billing when in scope, Analytics) follow as plain
+ * lozenges.
+ *
+ * Narrow: the cluster collapses into the identity switcher, whose menu names
+ * every section — Company context included, because inside a list of section
+ * names "the identity lozenge stands for it" stops being legible.
+ *
+ * Analytics is the one section with a level of its own: opening it swaps this
+ * strip for the drilled-in `AdminAnalyticsHeader` (back chip + the lens
+ * cluster), so the header never stacks two tab rows.
+ */
+export function AdminHeader({
+ active,
+ visibleIds,
+ onSelect,
+ lens,
+ lenses,
+ onSelectLens,
+}: {
+ active: OrgTabId;
+ /** The sections visible for this caller + space, from `orgTabIds`. */
+ visibleIds: readonly OrgTabId[];
+ onSelect: (id: OrgTabId) => void;
+ /** The Analytics lens state, owned by the view beside `active`. */
+ lens: AnalyticsLens;
+ lenses: readonly AnalyticsLens[];
+ onSelectLens: (lens: AnalyticsLens) => void;
+}) {
+ const { t } = useTranslation("teams");
+ const collapsed = headerCollapsesTabs(usePageHeaderMode());
+
+ if (active === "analytics")
+ return (
+ onSelect(DEFAULT_ORG_TAB)}
+ />
+ );
+
+ const identity = (
+ <>
+
+ {t("org.title")}
+ >
+ );
+ const tabs = visibleIds.map((id) =>
+ id === DEFAULT_ORG_TAB
+ ? {
+ id,
+ heading: true,
+ label: identity,
+ dataAttrs: { "data-admin-section-tab": id },
+ }
+ : {
+ id,
+ label: t(`org.tabs.${id}`),
+ dataAttrs: { "data-admin-section-tab": id },
+ },
+ );
+ const switcherSections = visibleIds.map((id) => ({
+ id,
+ label: t(`org.tabs.${id}`),
+ dataAttrs: { "data-admin-section-tab": id },
+ }));
+
+ return (
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/app/src/components/organization/admin-index.tsx b/app/src/components/organization/admin-index.tsx
deleted file mode 100644
index 316169b5f..000000000
--- a/app/src/components/organization/admin-index.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Building2, ChartColumn, CreditCard, Users } from "lucide-react";
-import { useTranslation } from "react-i18next";
-import { SettingsCard, SettingsRow } from "../settings/settings-row";
-import { PageContainer, PageHero } from "../shell/page-shell";
-import type { OrgTabId } from "./org-view-model";
-
-interface AdminIndexProps {
- /** The sections visible for this caller + space, from `orgTabIds`. */
- visibleIds: readonly OrgTabId[];
- /** Roster size from the loaded `GET /org`; undefined while it loads. */
- memberCount?: number;
- onSelect: (id: OrgTabId) => void;
-}
-
-/**
- * The Admin (Organization) landing index. Settings-page grammar
- * (SettingsCard/SettingsRow): rows with icon + title + one-line description + an
- * at-a-glance value that drill into a detail screen, so a non-technical admin
- * scans the whole dashboard instead of reading an anonymous tab strip.
- *
- * Four sections: People (the roster and its invites), Billing (the team's plan
- * and seats), Analytics (the activity feed, message usage, and time worked as
- * lenses), and Company context (the standing knowledge every agent in this
- * workspace starts a turn with).
- *
- * Presentational only: the shell owns loading/gating and passes the visible id
- * set plus each row's value. Every row but Billing always renders; Billing only
- * when it is in the visible set. Per-agent policy (who can use which agent, its
- * ceilings) is NOT here — it is reached through each team's Manage agents page.
- */
-export function AdminIndex({
- visibleIds,
- memberCount,
- onSelect,
-}: AdminIndexProps) {
- const { t } = useTranslation("teams");
- const showBilling = visibleIds.includes("billing");
-
- return (
-
-
-
-
-
- );
-}
diff --git a/app/src/components/organization/admin-section-body.tsx b/app/src/components/organization/admin-section-body.tsx
new file mode 100644
index 000000000..27cf2716d
--- /dev/null
+++ b/app/src/components/organization/admin-section-body.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { PageContainer } from "../shell/page-shell";
+import AnalyticsTab from "./analytics-tab";
+import BillingTab from "./billing-tab";
+import CompanyContextTab from "./company-context-tab";
+import MembersTab from "./members-tab";
+import type { AnalyticsLens, OrgTabId } from "./org-view-model";
+import type { OrgTabProps, OrgViewContext } from "./organization-view";
+
+/**
+ * Each Organization section renders from the shared `{ ctx }` contract —
+ * except Analytics, which also takes the resolved lens and is therefore
+ * rendered by name below, not from this record.
+ */
+const SECTION_COMPONENTS: Record<
+ Exclude,
+ (props: OrgTabProps) => ReactNode
+> = {
+ people: MembersTab,
+ billing: BillingTab,
+ companyContext: CompanyContextTab,
+};
+
+/** Mounts the record's component AS a component, so its hooks stay its own. */
+function PlainSection({
+ active,
+ ctx,
+}: {
+ active: Exclude;
+ ctx: OrgViewContext;
+}) {
+ const Section = SECTION_COMPONENTS[active];
+ return ;
+}
+
+/**
+ * The active section's body under the Admin strip. No heading of its own: the
+ * header's lozenge already names the section (the shared grammar with
+ * Integrations and the team screen), so a hero here would say it twice. Every
+ * section renders from the shared `{ ctx }` contract; Analytics alone also
+ * takes the resolved lens (the activity feed and message usage are lenses
+ * INSIDE it, not sections of their own).
+ *
+ * `data-admin-section-body` names the MOUNTED section for the e2e helpers:
+ * the header lozenge repaints synchronously on click, so the attribute is
+ * what proves the body actually swapped under it.
+ */
+export function AdminSectionBody({
+ active,
+ ctx,
+ isLoading,
+ lens,
+}: {
+ active: OrgTabId;
+ ctx: OrgViewContext | null;
+ isLoading: boolean;
+ lens: AnalyticsLens;
+}) {
+ const { t } = useTranslation("teams");
+ return (
+
+ {!ctx ? (
+
- )}
-
- );
-}
diff --git a/app/src/components/organization/analytics-tab.tsx b/app/src/components/organization/analytics-tab.tsx
index 4138d173d..f08ddf04f 100644
--- a/app/src/components/organization/analytics-tab.tsx
+++ b/app/src/components/organization/analytics-tab.tsx
@@ -1,61 +1,30 @@
-import { Tabs, TabsList, TabsTrigger } from "@houston-ai/core";
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { useCapabilities } from "../../hooks/use-capabilities";
import { ComputeSection } from "../time-worked/compute-section";
-import { showComputeSection } from "../time-worked/compute-usage-model";
import ActivityTab from "./activity-tab";
+import type { AnalyticsLens } from "./org-view-model";
import type { OrgTabProps } from "./organization-view";
import UsageTab from "./usage-tab";
-/** The three questions Analytics answers, in sub-tab order. */
-type AnalyticsLens = "activity" | "usage" | "timeWorked";
-
-const BASE_LENSES: readonly AnalyticsLens[] = ["activity", "usage"];
-
/**
- * Organization > Analytics: everything measured about the org, behind one tab.
- * Three LENSES as sub-tabs rather than stacked sections — Activity is a paged
+ * Organization > Analytics: everything measured about the org, behind one
+ * section. Three LENSES rather than stacked sections — Activity is a paged
* "Show more" feed, so anything under it would be buried below an unbounded
* list. Each lens is the existing surface verbatim, keeping its own data hook;
* only the SELECTED lens renders, so an unopened lens starts no read.
*
- * Time worked appears only where the deployment meters compute
- * (`capabilities.computeUsage`): omitting the sub-tab is what keeps
- * `ComputeSection`'s query from firing elsewhere, and it means the lens can
- * never open empty.
+ * The lens NAVIGATION is not here: opening Analytics drills the Admin header
+ * into `AdminAnalyticsHeader`, whose lozenges are the lens tabs. The view
+ * owns the lens state and threads the RESOLVED lens to both, so the mounted
+ * body can never disagree with the lozenge painted active.
*/
-export default function AnalyticsTab({ ctx }: OrgTabProps) {
- const { t } = useTranslation("teams");
- const { capabilities } = useCapabilities();
- const [lens, setLens] = useState("activity");
-
- const lenses: readonly AnalyticsLens[] = showComputeSection(capabilities)
- ? [...BASE_LENSES, "timeWorked"]
- : BASE_LENSES;
- // Capabilities can resolve (or a space can change) under a selected lens; fall
- // back to the lead lens rather than render nothing.
- const active = lenses.includes(lens) ? lens : "activity";
-
+export default function AnalyticsTab({
+ ctx,
+ lens,
+}: OrgTabProps & { lens: AnalyticsLens }) {
return (
);
}
diff --git a/app/src/components/organization/company-context-tab.tsx b/app/src/components/organization/company-context-tab.tsx
index b81fb6a46..33845ceb6 100644
--- a/app/src/components/organization/company-context-tab.tsx
+++ b/app/src/components/organization/company-context-tab.tsx
@@ -1,6 +1,6 @@
-import { Spinner } from "@houston-ai/core";
-import { InstructionsContent } from "../agent/job-description-parts";
-import { useContextSlot, useContextSlotLabels } from "../context/context-slots";
+import { useTranslation } from "react-i18next";
+import { ContextEditorPage } from "../context/context-editor";
+import { useContextSlot } from "../context/context-slots";
import type { OrgTabProps } from "./organization-view";
/**
@@ -9,30 +9,31 @@ import type { OrgTabProps } from "./organization-view";
* on the Admin dashboard next to People and Billing rather than on a screen of
* its own; the per-user half of the same context lives with the user, not here.
*
+ * This is the section the Admin identity lozenge stands for, so the lozenge
+ * never names it — the page does: `ContextEditorPage` (the ONE standing-prose
+ * editor) with a level-2 hero saying "Company context" and what belongs in
+ * it, over the always-open box whose greyed 3-part example is the invitation.
+ *
* The wire is unchanged — `useContextSlot("workspace")` reads and writes the
- * same blob it always did. `ready` is false until the agent-backed read lands,
- * so the frame shows a spinner instead of an editor over nothing.
+ * same blob it always did.
*
* Takes {@link OrgTabProps} for uniformity with every other section even though
* it reads nothing off the shared context.
*/
export default function CompanyContextTab(_props: OrgTabProps) {
+ const { t } = useTranslation("teams");
+ const { t: tContext } = useTranslation("context");
const editor = useContextSlot("workspace");
- const labels = useContextSlotLabels("workspace");
-
- if (!editor.ready) {
- return (
-
-
-
- );
- }
return (
-
);
}
diff --git a/app/src/components/organization/org-nav-store.ts b/app/src/components/organization/org-nav-store.ts
index 4370c3ccb..0741295dc 100644
--- a/app/src/components/organization/org-nav-store.ts
+++ b/app/src/components/organization/org-nav-store.ts
@@ -4,20 +4,22 @@ import type { OrgTabId } from "./org-view-model.ts";
/**
* A one-shot request to open the Organization dashboard on a specific tab.
*
- * The dashboard owns its own tab state, but the deep link arrives from OUTSIDE
- * it: the C8 team-status banner / trial pill (in the shell) sends the user to
- * the Billing tab. Rather than lift that state into the shared UI store (and
- * couple every consumer to it), this tiny colocated store carries the intent:
- * the caller sets the request, then navigates with
+ * The dashboard owns its own tab state, but the requests arrive from OUTSIDE
+ * it — two callers: the C8 team-status banner / trial pill (in the shell)
+ * deep-links to Billing, and the rail's Admin row pins the landing section on
+ * every click (the rail rule: a rail door opens its screen's HOME, never the
+ * kept-alive leftover). Rather than lift that state into the shared UI store
+ * (and couple every consumer to it), this tiny colocated store carries the
+ * intent: the caller sets the request, then navigates with
* `setViewMode(ORGANIZATION_VIEW_ID)`. `OrganizationView` consumes it and clears
- * it, so a later plain nav to the dashboard lands on the default tab.
+ * it.
*
* Admin is a KEPT-ALIVE top-level screen, so it does not remount per
* navigation: the view consumes the pin from an effect on this field, which
* fires on the first mount AND while the screen is already open (the same shape
* `team-view/team-settings-nav-store.ts` uses). A pin nothing consumes — the
- * gates hide Admin, so the screen is never mounted — cannot mislead either: the
- * caller that sets it is itself behind the team-space gate.
+ * gates hide Admin, so the screen is never mounted — cannot mislead either:
+ * both callers sit beside the same gates that mount the screen.
*
* (Per-agent settings are opened directly by `lib/open-agent.ts`, which routes
* through Team Settings rather than pinning anything here.)
diff --git a/app/src/components/organization/org-view-model.ts b/app/src/components/organization/org-view-model.ts
index 12b5912ba..af9e2b801 100644
--- a/app/src/components/organization/org-view-model.ts
+++ b/app/src/components/organization/org-view-model.ts
@@ -1,5 +1,6 @@
import type { AuditEntry, Capabilities } from "@houston-ai/engine-client";
import { canSeeMembers, isPersonalSpace } from "../../lib/org-roles.ts";
+import { showComputeSection } from "../time-worked/compute-usage-model.ts";
/**
* Pure, DOM-free logic for the Organization dashboard (Teams v2 + C8 billing).
@@ -16,23 +17,32 @@ import { canSeeMembers, isPersonalSpace } from "../../lib/org-roles.ts";
*/
export type OrgTabId = "people" | "billing" | "analytics" | "companyContext";
+/**
+ * The section the dashboard lands on: what the header's Admin identity
+ * lozenge stands for, the same way a team's lozenge IS its board. The section
+ * titles itself in its body (the lozenge says "Admin", not "Company
+ * context"), and the rail's Admin row always returns here.
+ */
+export const DEFAULT_ORG_TAB: OrgTabId = "companyContext";
+
/**
* The always-present sections. `billing` (C8) is the only conditional one, added
* by {@link orgTabIds} on a Spaces host, in a team space, for owner/admin — see
* that function for the authoritative display order.
*/
export const ORG_TAB_IDS: readonly OrgTabId[] = [
+ "companyContext",
"people",
"analytics",
- "companyContext",
] as const;
/**
* The dashboard's tab ids in display order, written out literally so the order
- * reads off the source: People, then Billing when `canSeeBillingTab` (in
- * `lib/billing-gates`) holds, then Analytics, then Company context. Pure so the
- * tab set is unit-tested without React; the view maps each id to its component +
- * `t()` label.
+ * reads off the source: Company context (the identity lozenge and landing
+ * section), then People, then Billing when `canSeeBillingTab` (in
+ * `lib/billing-gates`) holds, then Analytics. Pure so the tab set is
+ * unit-tested without React; the view maps each id to its component + `t()`
+ * label.
*
* Company context takes no gate of its own on purpose: the whole Admin view is
* mounted only behind {@link canSeeOrganization}, which is false on a personal
@@ -41,10 +51,10 @@ export const ORG_TAB_IDS: readonly OrgTabId[] = [
*/
export function orgTabIds(gates: { billing: boolean }): readonly OrgTabId[] {
return [
+ "companyContext",
"people",
...(gates.billing ? (["billing"] as const) : []),
"analytics",
- "companyContext",
];
}
@@ -74,6 +84,42 @@ export function canSeeOrganization(
return canSeeMembers(caps);
}
+/**
+ * The three questions Analytics answers — its LENSES, one level under the
+ * section. Activity is the lead lens: the one the section opens on, and the
+ * one its identity lozenge stands for on the drilled-in header.
+ */
+export type AnalyticsLens = "activity" | "usage" | "timeWorked";
+
+/** The lead lens — what Analytics shows on arrival. */
+export const DEFAULT_ANALYTICS_LENS: AnalyticsLens = "activity";
+
+/**
+ * The lenses this deployment offers, in display order. Time worked exists only
+ * where the deployment meters compute (`capabilities.computeUsage`): omitting
+ * the lens is what keeps `ComputeSection`'s query from firing elsewhere, and
+ * it means the lens can never open empty.
+ */
+export function analyticsLenses(
+ caps: Capabilities | null | undefined,
+): readonly AnalyticsLens[] {
+ return showComputeSection(caps)
+ ? ["activity", "usage", "timeWorked"]
+ : ["activity", "usage"];
+}
+
+/**
+ * The lens actually on screen. Capabilities can resolve (or a space can
+ * change) under a selected lens; fall back to the lead lens rather than
+ * render nothing.
+ */
+export function resolveAnalyticsLens(
+ lens: AnalyticsLens,
+ lenses: readonly AnalyticsLens[],
+): AnalyticsLens {
+ return lenses.includes(lens) ? lens : DEFAULT_ANALYTICS_LENS;
+}
+
/** How many audit entries one page pulls (contract §5: host clamps to ≤ 200). */
export const AUDIT_PAGE_SIZE = 50;
diff --git a/app/src/components/organization/organization-view.tsx b/app/src/components/organization/organization-view.tsx
index c4f56e0fc..4f92d6dcb 100644
--- a/app/src/components/organization/organization-view.tsx
+++ b/app/src/components/organization/organization-view.tsx
@@ -1,17 +1,24 @@
import type { OrgInfo, OrgRole } from "@houston-ai/engine-client";
-import { useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
+import { useCallback, useEffect, useState } from "react";
import { useOrg } from "../../hooks/queries";
import { useCapabilities } from "../../hooks/use-capabilities";
import { analytics } from "../../lib/analytics";
import { canSeeBillingTab } from "../../lib/billing-gates";
import { isTeamWorkspace } from "../../lib/space-id";
import { useWorkspaceStore } from "../../stores/workspaces";
-import { BackBarScreen } from "../shell/back-bar-screen";
-import { AdminIndex } from "./admin-index";
-import { AdminSectionDetail } from "./admin-section-detail";
+import { PageHeaderToolsProvider } from "../shell/page-header/page-header-tools";
+import { ADMIN_HEADER_THRESHOLDS, AdminHeader } from "./admin-header";
+import { AdminSectionBody } from "./admin-section-body";
import { useOrgNav } from "./org-nav-store";
-import { type OrgTabId, orgTabIds } from "./org-view-model";
+import {
+ type AnalyticsLens,
+ analyticsLenses,
+ DEFAULT_ANALYTICS_LENS,
+ DEFAULT_ORG_TAB,
+ type OrgTabId,
+ orgTabIds,
+ resolveAnalyticsLens,
+} from "./org-view-model";
/**
* The shared context every Organization section receives. `org` is the loaded
@@ -33,16 +40,11 @@ export interface OrgTabProps {
}
/**
- * The Admin (Organization) dashboard: People, Billing, Analytics, Company
- * context. A shell only: it loads the org, builds the shared `OrgViewContext`,
- * and switches between two screens in the settings-page grammar —
- *
- * - INDEX (`active === null`): a landing of self-describing rows
- * ({@link AdminIndex}) — People (membership), Billing when in scope, then
- * Analytics (activity / usage / time worked) and Company context.
- * - DETAIL (`active` set): a back bar + section heading + the section body. That
- * bar is now the ONLY one on the page: as a TOP-LEVEL view in the rail's
- * "Workspace" band, Admin owns the whole window and has no level above it.
+ * The Admin (Organization) dashboard: Company context, People, Billing,
+ * Analytics. A shell only: it loads the org, builds the shared
+ * `OrgViewContext`, and swaps sections under the shared header grammar
+ * (`AdminHeader` — the same lozenge cluster Integrations and the team screen
+ * wear), landing on Company context, whose surface the identity lozenge IS.
*
* Permission surfaces (who can use which agent, per-agent ceilings) are NOT
* here: per-agent policy is discovered through each team's Manage agents page,
@@ -57,7 +59,6 @@ export interface OrgTabProps {
* ALREADY open, not on a mount that never happens again.
*/
export function OrganizationView() {
- const { t } = useTranslation("teams");
const { data: org, isLoading } = useOrg(true);
const { capabilities } = useCapabilities();
const current = useWorkspaceStore((s) => s.current);
@@ -65,64 +66,80 @@ export function OrganizationView() {
const clearRequestedTab = useOrgNav((s) => s.clearRequestedTab);
// Billing shows only for owner/admin on a team space (C8). Compute the visible
- // set so a deep link never opens a dead detail screen.
+ // set so a deep link never opens a dead section.
const showBilling = canSeeBillingTab(
capabilities,
current ? isTeamWorkspace(current.id) : false,
);
const visibleIds = orgTabIds({ billing: showBilling });
- // `null` = the index; a section id = its detail screen. Sections start on the
- // index so the admin lands on the scannable overview, not a section body.
- const [active, setActive] = useState(null);
+ const [active, setActive] = useState(DEFAULT_ORG_TAB);
- // One event per section DETAIL opened (index → detail), keyed like the global
- // view switches so a single tab_name breakdown covers everything. Landing on
- // the view at all is the shell's `tab_opened` / `organization`, so this fires
- // strictly below it and the two never double-count.
- useEffect(() => {
- if (active !== null)
- analytics.track("tab_opened", { tab_name: `org:${active}` });
- }, [active]);
+ // The Analytics lens, owned HERE beside the section it narrows — the drilled
+ // header and the section body both draw it, exactly as they draw `active`.
+ // Kept-alive screen state, so leaving and returning lands on the lens you
+ // left; the rail's home pin resets the SECTION, deliberately not the lens.
+ const [lens, setLens] = useState(DEFAULT_ANALYTICS_LENS);
+ const lenses = analyticsLenses(capabilities);
+ const activeLens = resolveAnalyticsLens(lens, lenses);
+
+ // One event per section OPENED (a lozenge click or a deep link), keyed like
+ // the global view switches so a single tab_name breakdown covers everything.
+ // Landing on the view at all is the shell's `tab_opened` / `organization`, so
+ // this fires strictly below it — never on the initial section — and the two
+ // never double-count.
+ const openSection = useCallback(
+ (next: OrgTabId) => {
+ if (next !== active)
+ analytics.track("tab_opened", { tab_name: `org:${next}` });
+ setActive(next);
+ },
+ [active],
+ );
- // Honor a deep link straight into a section's detail — the C8 team-status
- // banner is the one caller, and it asks for Billing — then clear it so a later
- // plain nav to the dashboard opens the index again. (The create-team toast
- // pins nothing: it wants the index, whose lead card is People.) This is an
- // effect on the STORE field, not mount-time state, precisely because the
- // screen is kept alive: it fires on the first mount AND while already open,
- // the same way `team-settings.tsx` consumes its own one-shot pin.
+ // Honor a pinned section request — the C8 team-status banner deep-links to
+ // Billing, and the rail's Admin row pins the landing section on every click
+ // — then clear it. This is an effect on the STORE field, not mount-time
+ // state, precisely because the screen is kept alive: it fires on the first
+ // mount AND while already open, the same way `team-settings.tsx` consumes
+ // its own one-shot pin.
useEffect(() => {
if (requestedTab === null) return;
- if (visibleIds.includes(requestedTab)) setActive(requestedTab);
+ if (visibleIds.includes(requestedTab)) openSection(requestedTab);
clearRequestedTab();
- }, [requestedTab, visibleIds, clearRequestedTab]);
+ }, [requestedTab, visibleIds, openSection, clearRequestedTab]);
// If the visible set drops the active section (e.g. switching out of a team
- // space hides Billing), fall back to the index rather than a blank body.
+ // space hides Billing), fall back to the landing section rather than a blank
+ // body.
useEffect(() => {
- if (active !== null && !visibleIds.includes(active)) setActive(null);
+ if (!visibleIds.includes(active)) setActive(DEFAULT_ORG_TAB);
}, [visibleIds, active]);
const ctx: OrgViewContext | null = org
? { org, role: org.role, isOwner: org.role === "owner" }
: null;
- if (active === null) {
- return (
-
-
+
+
+
+
+
- );
- }
-
- return (
- setActive(null)}>
-
-
+
);
}
diff --git a/app/src/components/shell/back-bar-screen.tsx b/app/src/components/shell/back-bar-screen.tsx
index 702435129..047340fca 100644
--- a/app/src/components/shell/back-bar-screen.tsx
+++ b/app/src/components/shell/back-bar-screen.tsx
@@ -4,14 +4,16 @@ import type { ReactNode } from "react";
/**
* The shared drill-in scaffold: a back-bar with a labelled chevron over a
* full-height scroll region. ONE frame for every screen that sits one level
- * below something else — the Settings sections, the Admin dashboard's section
- * details, the Permissions agent drill-in — so the chevron, its label spacing
- * and the scroll behaviour can never drift between them. It also keeps each
- * level to exactly one back affordance: a screen nested inside another renders
- * its own bar only for its own depth.
+ * below something else — the Settings sections, the Team Settings agent
+ * drill-in — so the chevron, its label spacing and the scroll behaviour can
+ * never drift between them. It also keeps each level to exactly one back
+ * affordance: a screen nested inside another renders its own bar only for its
+ * own depth. (Admin has no drill-in anymore: its sections are header-cluster
+ * siblings, and its drilled Analytics level uses the header's own
+ * `PageHeaderBackChip`, not this bar.)
*
- * `onBack` returns to the level above (the Settings index, the Admin index, the
- * agent list); `backLabel` names it.
+ * `onBack` returns to the level above (the Settings index, the team's agent
+ * list); `backLabel` names it.
*/
export function BackBarScreen({
backLabel,
diff --git a/app/src/components/shell/page-header/page-header-back-chip.tsx b/app/src/components/shell/page-header/page-header-back-chip.tsx
new file mode 100644
index 000000000..68322eace
--- /dev/null
+++ b/app/src/components/shell/page-header/page-header-back-chip.tsx
@@ -0,0 +1,53 @@
+import { ChevronLeft } from "lucide-react";
+import type { ReactNode } from "react";
+import { headerLozengeClasses, headerLozengeTrack } from "./header-lozenge";
+
+/**
+ * The way back from a DRILLED page header — a level a section opened INSIDE a
+ * top-level screen, wearing the screen's own lozenge grammar:
+ *
+ * (‹ 🏢 Admin) (Analytics)(Usage)(Time worked)
+ *
+ * Three rules make an inner level legible, and every drilled header follows
+ * them:
+ *
+ * - **The chip precedes the cluster and never collapses into a menu** — the
+ * way back must stay visible at every width.
+ * - **The chip wears the DESTINATION's glyph** — the same mark the top-level
+ * identity lozenge wears, so "where this goes" is recognizable at a glance,
+ * not just readable.
+ * - **The drilled cluster's identity is text-only.** Only a top-level
+ * identity lozenge carries a glyph, so the bare words plus this chip are
+ * how an inner page reads as inner.
+ *
+ * A quiet unpainted lozenge on its own track: a door, not a place, so it
+ * never takes the active fill.
+ */
+export function PageHeaderBackChip({
+ label,
+ icon,
+ onClick,
+ dataAttrs,
+}: {
+ /** The place the chip returns to, named as its rail row names it. */
+ label: string;
+ /** The destination's glyph — what its top-level identity lozenge wears. */
+ icon: ReactNode;
+ onClick: () => void;
+ dataAttrs?: Record;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/components/shell/page-shell.tsx b/app/src/components/shell/page-shell.tsx
index 5daa000f2..5deda2f6e 100644
--- a/app/src/components/shell/page-shell.tsx
+++ b/app/src/components/shell/page-shell.tsx
@@ -46,7 +46,7 @@ export function PageContainer({
interface PageHeroProps {
/** The words, or a node when the title carries a mark beside them (a team's
- * glyph). It renders inside the one `
` either way, so the typography
+ * glyph). It renders inside the one heading either way, so the typography
* and truncation are the header's, not the caller's. */
title: ReactNode;
/** Optional muted one-line subtitle under the title. */
@@ -55,6 +55,12 @@ interface PageHeroProps {
trailing?: ReactNode;
/** Extra classes, typically the bottom gap to the content (e.g. `mb-6`). */
className?: string;
+ /**
+ * Heading level, default 1. Pass 2 when the page's `
` already lives in
+ * its header strip (a lozenge cluster) and this hero titles a section BODY
+ * under it — same typography, honest outline.
+ */
+ level?: 1 | 2;
}
/**
@@ -67,11 +73,13 @@ export function PageHero({
subtitle,
trailing,
className,
+ level = 1,
}: PageHeroProps) {
+ const Heading = level === 1 ? "h1" : "h2";
return (
-
{title}
+ {title}
{subtitle ? (
{subtitle}
) : null}
diff --git a/app/src/components/shell/sidebar-nav-sections.tsx b/app/src/components/shell/sidebar-nav-sections.tsx
index b6c257103..b826e9aa8 100644
--- a/app/src/components/shell/sidebar-nav-sections.tsx
+++ b/app/src/components/shell/sidebar-nav-sections.tsx
@@ -18,6 +18,8 @@ import {
ORGANIZATION_VIEW_ID,
} from "../../lib/top-level-views";
import { INTEGRATIONS_VIEW_ID } from "../integrations-view";
+import { useOrgNav } from "../organization/org-nav-store.ts";
+import { DEFAULT_ORG_TAB } from "../organization/org-view-model.ts";
import { SKILLS_VIEW_ID } from "../skills-view/id";
import { STORE_VIEW_ID } from "../store-view";
import type { SidebarChromeT } from "./sidebar-chrome";
@@ -100,7 +102,16 @@ export function buildSidebarNavItems(args: {
id: ORGANIZATION_VIEW_ID,
label: t("settings:nav.organization"),
icon: ,
- onClick: () => setViewMode(ORGANIZATION_VIEW_ID),
+ onClick: () => {
+ // The rail rule: a rail door always opens its screen's HOME, never the
+ // kept-alive leftover (a team row opens its board, the footer's
+ // Settings opens the index via `openSettings(null)`). Admin's home is
+ // its landing section, pinned through the same one-shot store the
+ // Billing deep link uses — which also backs out of a drilled section
+ // like Analytics when the screen is already open.
+ useOrgNav.getState().requestTab(DEFAULT_ORG_TAB);
+ setViewMode(ORGANIZATION_VIEW_ID);
+ },
};
const skills: SidebarNavItemEntry = {
id: SKILLS_VIEW_ID,
diff --git a/app/src/components/team-view/team-context-card.tsx b/app/src/components/team-view/team-context-card.tsx
index 03ad70938..a02c4f158 100644
--- a/app/src/components/team-view/team-context-card.tsx
+++ b/app/src/components/team-view/team-context-card.tsx
@@ -19,8 +19,6 @@ function useContextLabels(): TeamContextEditorLabels {
title: t("teamView.context.title"),
explainer: t("teamView.context.explainer"),
placeholder: t("teamView.context.placeholder"),
- saving: t("teamView.context.saving"),
- saved: t("teamView.context.saved"),
};
}
diff --git a/app/src/components/team-view/team-context-editor.tsx b/app/src/components/team-view/team-context-editor.tsx
index 67e30f2cc..4ff1f19e2 100644
--- a/app/src/components/team-view/team-context-editor.tsx
+++ b/app/src/components/team-view/team-context-editor.tsx
@@ -1,26 +1,21 @@
-import { CatalogSectionHeader, cn } from "@houston-ai/core";
-import { useEffect, useState } from "react";
-
-type SaveState = "idle" | "saving" | "saved";
+import { CatalogSectionHeader } from "@houston-ai/core";
+import { ContextEditorBox } from "../context/context-editor";
export interface TeamContextEditorLabels {
title: string;
explainer: string;
placeholder: string;
- saving: string;
- saved: string;
}
/**
* The team's shared context, as the first card of its Manage agents page: what
* every agent of this team is told before it starts a turn.
*
- * It saves ON BLUR and says so quietly, the SAME idiom the agent's own
- * instructions editor uses (`InstructionsContent` in `job-description-parts`).
- * Two editors of standing prose that commit differently would be the surprise;
- * a Save button here and none there is a rule the user has to hold in their
- * head. A save fires only when the text actually changed, so tabbing through
- * the page writes nothing.
+ * The editor is the ONE standing-prose box (`ContextEditorBox` — always open,
+ * saves on blur, says so quietly), under a card-scale header rather than a
+ * page hero: this is a card among the page's other cards, not a page of its
+ * own, so it wears its siblings' `CatalogSectionHeader` and keeps the shared
+ * explain-ONCE rule with its own one-line explainer.
*
* Presentational and props-only: WHERE the content is stored (the sidebar
* group, the layout's default-team field, the gateway) is
@@ -43,59 +38,18 @@ export function TeamContextEditor({
labels: TeamContextEditorLabels;
readOnly?: boolean;
}) {
- const [value, setValue] = useState(content);
- const [state, setState] = useState("idle");
-
- // Re-seed from the store whenever it changes under us: another window's save,
- // or our own landing back through the query cache.
- useEffect(() => {
- setValue(content);
- }, [content]);
-
- const handleBlur = async () => {
- if (readOnly || value === content) return;
- setState("saving");
- await onSave(value);
- setState("saved");
- window.setTimeout(() => setState("idle"), 2000);
- };
-
return (
-
-
);
diff --git a/app/src/hooks/queries/use-workspace-context.ts b/app/src/hooks/queries/use-workspace-context.ts
index 397a2b81d..998fff6bb 100644
--- a/app/src/hooks/queries/use-workspace-context.ts
+++ b/app/src/hooks/queries/use-workspace-context.ts
@@ -2,6 +2,7 @@ import type { WorkspaceContext } from "@houston-ai/engine-client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getEngine } from "../../lib/engine";
import { queryKeys } from "../../lib/query-keys";
+import { surfaceEngineError } from "../../lib/tauri";
/**
* Workspace + user context = the two blobs injected into every chat's system
@@ -60,5 +61,11 @@ export function useSaveWorkspaceContext(agentPath: string | undefined) {
(prev) => ({ workspace: "", user: "", ...prev, [slot]: content }),
);
},
+ // This engine call bypasses `lib/tauri.ts`'s `call()` wrapper, so surface
+ // here or a failed context save is silent (no-silent-failures policy).
+ // The mutation still rejects to the editor, which resets its save state.
+ onError: (err, { slot }) => {
+ void surfaceEngineError("set_workspace_context", err, { slot });
+ },
});
}
diff --git a/app/src/locales/en/agents.json b/app/src/locales/en/agents.json
index 82fdb49f8..aa830e63a 100644
--- a/app/src/locales/en/agents.json
+++ b/app/src/locales/en/agents.json
@@ -54,13 +54,8 @@
"tooLong": "Agent names can be at most {{max}} characters"
},
"instructions": {
- "emptyTitle": "No instructions yet",
- "emptyDescription": "Tell your agent how it should think and act.",
- "writeButton": "Write instructions",
"helper": "How this agent should think and act.",
- "placeholder": "Write instructions for your agent…",
- "saving": "Saving…",
- "saved": "Saved"
+ "placeholder": "Write instructions for your agent…"
},
"learnings": {
"emptyTitle": "No learnings yet",
diff --git a/app/src/locales/en/context.json b/app/src/locales/en/context.json
index 30947dece..1861b8e19 100644
--- a/app/src/locales/en/context.json
+++ b/app/src/locales/en/context.json
@@ -18,18 +18,10 @@
"saving": "Saving…",
"saved": "Saved",
"user": {
- "emptyTitle": "Tell every agent about you",
- "emptyDescription": "Your role, what you care about, how you like to work. Loaded into every new chat in this workspace.",
- "writeButton": "Write user context",
- "helper": "Loaded into each new chat in this workspace. Agents can update this when you tell them something new.",
"placeholder": "e.g. I'm Juan, head of sales. I care about pipeline velocity and prefer short replies."
},
"workspace": {
- "emptyTitle": "Tell every agent about this workspace",
- "emptyDescription": "The company, the product, the customers. Loaded into every new chat in this workspace.",
- "writeButton": "Write workspace context",
- "helper": "Shared by every agent in this workspace. Loaded into each new chat. Agents can update this when you tell them something new.",
- "placeholder": "e.g. Acme Corp, B2B fintech, Series A. Customers are CFOs at mid-market companies."
+ "placeholder": "## Who we are\nAcme Corp. B2B fintech, Series A, 40 people.\n\n## Product and customers\nInvoicing software for CFOs at mid-market companies.\n\n## How we communicate\nFriendly and direct. Short sentences. Never overpromise."
}
}
}
diff --git a/app/src/locales/en/teams.json b/app/src/locales/en/teams.json
index 187a5ecbb..82cb04d0d 100644
--- a/app/src/locales/en/teams.json
+++ b/app/src/locales/en/teams.json
@@ -163,8 +163,9 @@
},
"org": {
"title": "Admin",
- "subtitle": "Your people, your plan, your numbers, and what every agent knows about the company.",
"tabs": {
+ "label": "Admin sections",
+ "lensLabel": "Analytics views",
"people": "People",
"billing": "Billing",
"analytics": "Analytics",
@@ -173,22 +174,11 @@
"usage": "Usage",
"timeWorked": "Time worked"
},
+ "companyContextHint": "What all your agents know on every task and routine they run.",
"agentDetail": {
"subtitle": "Everything you configure on this agent, and who can use it.",
"openAgent": "Open agent"
},
- "index": {
- "rows": {
- "people": "Invite teammates, set roles, remove members.",
- "billing": "Seats, plan, and payment.",
- "analytics": "Activity, usage, and time worked in one place.",
- "companyContext": "What every agent knows about this company."
- },
- "values": {
- "members_one": "{{count}} member",
- "members_other": "{{count}} members"
- }
- },
"loading": "Loading your workspace...",
"unavailable": "We couldn't load your workspace."
},
@@ -506,9 +496,7 @@
"context": {
"title": "Team context",
"explainer": "Every agent in this team knows this.",
- "placeholder": "What should every agent in this team know? The company, the customers, how you work.",
- "saving": "Saving…",
- "saved": "Saved"
+ "placeholder": "What should every agent in this team know? The company, the customers, how you work."
},
"missionControl": {
"empty": {
diff --git a/app/src/locales/es/agents.json b/app/src/locales/es/agents.json
index a0f2cf07e..8edca08f9 100644
--- a/app/src/locales/es/agents.json
+++ b/app/src/locales/es/agents.json
@@ -54,13 +54,8 @@
"tooLong": "El nombre del agente puede tener máximo {{max}} caracteres"
},
"instructions": {
- "emptyTitle": "Aún no hay instrucciones",
- "emptyDescription": "Dile a tu agente cómo debe pensar y actuar.",
- "writeButton": "Escribir instrucciones",
"helper": "Cómo debe pensar y actuar este agente.",
- "placeholder": "Escribe las instrucciones para tu agente…",
- "saving": "Guardando…",
- "saved": "Guardado"
+ "placeholder": "Escribe las instrucciones para tu agente…"
},
"learnings": {
"emptyTitle": "Aún no hay aprendizajes",
diff --git a/app/src/locales/es/context.json b/app/src/locales/es/context.json
index 17f1bbf94..89db817c3 100644
--- a/app/src/locales/es/context.json
+++ b/app/src/locales/es/context.json
@@ -18,18 +18,10 @@
"saving": "Guardando…",
"saved": "Guardado",
"user": {
- "emptyTitle": "Cuéntale a todo agente sobre ti",
- "emptyDescription": "Tu rol, lo que te importa, cómo prefieres trabajar. Se carga en cada chat nuevo de este espacio.",
- "writeButton": "Escribir contexto del usuario",
- "helper": "Se carga en cada chat nuevo de este espacio. Los agentes pueden actualizarlo cuando les cuentes algo nuevo.",
"placeholder": "p. ej. Soy Juan, líder de ventas. Me importa la velocidad del pipeline y prefiero respuestas cortas."
},
"workspace": {
- "emptyTitle": "Cuéntale a todo agente sobre este espacio",
- "emptyDescription": "La empresa, el producto, los clientes. Se carga en cada chat nuevo de este espacio.",
- "writeButton": "Escribir contexto del espacio",
- "helper": "Compartido por todos los agentes de este espacio. Se carga en cada chat nuevo. Los agentes pueden actualizarlo cuando les cuentes algo nuevo.",
- "placeholder": "p. ej. Acme Corp, fintech B2B, Serie A. Los clientes son CFOs de empresas medianas."
+ "placeholder": "## Quiénes somos\nAcme Corp. Fintech B2B, Serie A, 40 personas.\n\n## Producto y clientes\nSoftware de facturación para CFOs de empresas medianas.\n\n## Cómo nos comunicamos\nCercanos y directos. Frases cortas. Nunca prometemos de más."
}
}
}
diff --git a/app/src/locales/es/teams.json b/app/src/locales/es/teams.json
index d74980011..fbca935a4 100644
--- a/app/src/locales/es/teams.json
+++ b/app/src/locales/es/teams.json
@@ -163,8 +163,9 @@
},
"org": {
"title": "Administración",
- "subtitle": "Tu gente, tu plan, tus números y lo que cada agente sabe sobre la empresa.",
"tabs": {
+ "label": "Secciones de administración",
+ "lensLabel": "Vistas de analítica",
"people": "Personas",
"billing": "Facturación",
"analytics": "Analítica",
@@ -173,22 +174,11 @@
"usage": "Uso",
"timeWorked": "Tiempo trabajado"
},
+ "companyContextHint": "Lo que todos tus agentes saben en cada tarea y rutina que ejecutan.",
"agentDetail": {
"subtitle": "Todo lo que configuras en este agente y quién puede usarlo.",
"openAgent": "Abrir agente"
},
- "index": {
- "rows": {
- "people": "Invita compañeros, asigna roles y quita miembros.",
- "billing": "Asientos, plan y pago.",
- "analytics": "Actividad, uso y tiempo trabajado en un solo lugar.",
- "companyContext": "Lo que cada agente sabe sobre esta empresa."
- },
- "values": {
- "members_one": "{{count}} miembro",
- "members_other": "{{count}} miembros"
- }
- },
"loading": "Cargando tu espacio de trabajo...",
"unavailable": "No pudimos cargar tu espacio de trabajo."
},
@@ -506,9 +496,7 @@
"context": {
"title": "Contexto del equipo",
"explainer": "Todos los agentes de este equipo saben esto.",
- "placeholder": "¿Qué debería saber cada agente de este equipo? La empresa, los clientes, cómo trabajan.",
- "saving": "Guardando…",
- "saved": "Guardado"
+ "placeholder": "¿Qué debería saber cada agente de este equipo? La empresa, los clientes, cómo trabajan."
},
"missionControl": {
"empty": {
diff --git a/app/src/locales/pt/agents.json b/app/src/locales/pt/agents.json
index a74676575..4fe973072 100644
--- a/app/src/locales/pt/agents.json
+++ b/app/src/locales/pt/agents.json
@@ -54,13 +54,8 @@
"tooLong": "O nome do agente pode ter no máximo {{max}} caracteres"
},
"instructions": {
- "emptyTitle": "Ainda sem instruções",
- "emptyDescription": "Diga ao seu agente como ele deve pensar e agir.",
- "writeButton": "Escrever instruções",
"helper": "Como este agente deve pensar e agir.",
- "placeholder": "Escreva as instruções para o seu agente…",
- "saving": "Salvando…",
- "saved": "Salvo"
+ "placeholder": "Escreva as instruções para o seu agente…"
},
"learnings": {
"emptyTitle": "Ainda sem aprendizados",
diff --git a/app/src/locales/pt/context.json b/app/src/locales/pt/context.json
index 8d418d197..f4ad4384d 100644
--- a/app/src/locales/pt/context.json
+++ b/app/src/locales/pt/context.json
@@ -18,18 +18,10 @@
"saving": "Salvando…",
"saved": "Salvo",
"user": {
- "emptyTitle": "Conte para todo agente sobre você",
- "emptyDescription": "Seu papel, o que importa pra você, como prefere trabalhar. Entra em cada conversa nova deste espaço.",
- "writeButton": "Escrever contexto do usuário",
- "helper": "Entra em cada conversa nova deste espaço. Os agentes podem atualizar quando você contar algo novo.",
"placeholder": "ex. Sou o Juan, líder de vendas. Me importo com velocidade de pipeline e prefiro respostas curtas."
},
"workspace": {
- "emptyTitle": "Conte para todo agente sobre este espaço",
- "emptyDescription": "A empresa, o produto, os clientes. Entra em cada conversa nova deste espaço.",
- "writeButton": "Escrever contexto do espaço",
- "helper": "Compartilhado por todos os agentes deste espaço. Entra em cada conversa nova. Os agentes podem atualizar quando você contar algo novo.",
- "placeholder": "ex. Acme Corp, fintech B2B, Série A. Os clientes são CFOs de empresas médias."
+ "placeholder": "## Quem somos\nAcme Corp. Fintech B2B, Série A, 40 pessoas.\n\n## Produto e clientes\nSoftware de faturamento para CFOs de empresas de médio porte.\n\n## Como nos comunicamos\nPróximos e diretos. Frases curtas. Nunca prometemos demais."
}
}
}
diff --git a/app/src/locales/pt/teams.json b/app/src/locales/pt/teams.json
index 8799e477a..2ffc2819d 100644
--- a/app/src/locales/pt/teams.json
+++ b/app/src/locales/pt/teams.json
@@ -163,8 +163,9 @@
},
"org": {
"title": "Administração",
- "subtitle": "Suas pessoas, seu plano, seus números e o que cada agente sabe sobre a empresa.",
"tabs": {
+ "label": "Seções de administração",
+ "lensLabel": "Visualizações de análises",
"people": "Pessoas",
"billing": "Cobrança",
"analytics": "Análises",
@@ -173,22 +174,11 @@
"usage": "Uso",
"timeWorked": "Tempo trabalhado"
},
+ "companyContextHint": "O que todos os seus agentes sabem em cada tarefa e rotina que executam.",
"agentDetail": {
"subtitle": "Tudo o que você configura neste agente e quem pode usá-lo.",
"openAgent": "Abrir agente"
},
- "index": {
- "rows": {
- "people": "Convide colegas, defina funções e remova membros.",
- "billing": "Assentos, plano e pagamento.",
- "analytics": "Atividade, uso e tempo trabalhado em um só lugar.",
- "companyContext": "O que cada agente sabe sobre esta empresa."
- },
- "values": {
- "members_one": "{{count}} membro",
- "members_other": "{{count}} membros"
- }
- },
"loading": "Carregando seu espaço de trabalho...",
"unavailable": "Não foi possível carregar seu espaço de trabalho."
},
@@ -506,9 +496,7 @@
"context": {
"title": "Contexto da equipe",
"explainer": "Todos os agentes desta equipe sabem disto.",
- "placeholder": "O que cada agente desta equipe deveria saber? A empresa, os clientes, como vocês trabalham.",
- "saving": "Salvando…",
- "saved": "Salvo"
+ "placeholder": "O que cada agente desta equipe deveria saber? A empresa, os clientes, como vocês trabalham."
},
"missionControl": {
"empty": {
diff --git a/app/tests/context-editor.test.ts b/app/tests/context-editor.test.ts
new file mode 100644
index 000000000..baa1ea9ca
--- /dev/null
+++ b/app/tests/context-editor.test.ts
@@ -0,0 +1,68 @@
+import { ok } from "node:assert";
+import { readFileSync } from "node:fs";
+import { describe, it } from "node:test";
+
+const read = (rel: string) =>
+ readFileSync(new URL(rel, import.meta.url), "utf8");
+
+/**
+ * The node runner has no DOM, so the ONE standing-context editor's wiring is
+ * guarded on its source (the repo's React-test idiom). Each assertion stands
+ * for a review finding that shipped once in the consolidated grammar: a save
+ * failure that stuck the UI on "Saving…", textareas with no accessible name,
+ * and design-token violations carried over from the deleted editor.
+ */
+describe("context-editor source", () => {
+ const src = read("../src/components/context/context-editor.tsx");
+
+ it("recovers from a failed save instead of sticking on Saving…", () => {
+ // The data layer owns the toast; the box must still catch so the promise
+ // is never an unhandled rejection and the state returns to idle.
+ ok(src.includes("try {"), "save path is guarded");
+ ok(
+ /catch\s*\{[\s\S]*?setState\("idle"\)/.test(src),
+ "a rejection resets the save state",
+ );
+ });
+
+ it("derives the box's accessible name from the page title", () => {
+ ok(
+ src.includes(""),
+ "ContextEditorPage names the textarea after its hero",
+ );
+ });
+
+ it("keeps the read-only face from inviting writing", () => {
+ ok(
+ src.includes("placeholder={readOnly ? undefined : placeholder}"),
+ "the greyed suggestion (the write invitation) drops when locked",
+ );
+ });
+
+ it("carries no design-token violations from the deleted editor", () => {
+ // DESIGN.md: no raw rgba/px shadow literals, no off-scale type sizes, and
+ // focus is the ring-focus idiom (which the strip's lozenges use too).
+ ok(!src.includes("rgba("), "no raw shadow literal");
+ ok(!src.includes("text-[11px]"), "no off-scale label size");
+ ok(src.includes("focus-visible:ring-focus"), "focus is the shared ring");
+ });
+});
+
+describe("the Analytics lens is view state, not a module singleton", () => {
+ it("threads lens/lenses as props from the view that owns the section", () => {
+ const view = read("../src/components/organization/organization-view.tsx");
+ ok(view.includes("useState"), "the view owns the lens");
+ ok(
+ view.includes("resolveAnalyticsLens(lens, lenses)"),
+ "the view resolves the visible lens once for header AND body",
+ );
+ const header = read(
+ "../src/components/organization/admin-analytics-header.tsx",
+ );
+ ok(!header.includes("zustand"), "the drilled header is stateless");
+ ok(
+ !header.includes("useCapabilities"),
+ "the lens set arrives resolved, not re-derived",
+ );
+ });
+});
diff --git a/app/tests/org-view-model.test.ts b/app/tests/org-view-model.test.ts
index 808073dce..21100aeb0 100644
--- a/app/tests/org-view-model.test.ts
+++ b/app/tests/org-view-model.test.ts
@@ -3,10 +3,12 @@ import { describe, it } from "node:test";
import type { AuditEntry, Capabilities } from "@houston-ai/engine-client";
import {
AUDIT_PAGE_SIZE,
+ analyticsLenses,
canSeeOrganization,
nextAuditCursor,
ORG_TAB_IDS,
orgTabIds,
+ resolveAnalyticsLens,
} from "../src/components/organization/org-view-model.ts";
const SINGLE_PLAYER: Capabilities = {};
@@ -73,10 +75,11 @@ describe("ORG_TAB_IDS", () => {
it("is the always-present sections in display order", () => {
// Activity, Usage and Time worked are no longer sections: they are the
// three LENSES of one Analytics section, which is why they do not appear
- // here. Company context is unconditional because the whole Admin view is
- // already gated on `canSeeOrganization`, which is false in a personal
- // space, so a second branch for it here would be dead code.
- strictEqual(ORG_TAB_IDS.join(","), "people,analytics,companyContext");
+ // here. Company context leads because it is the header's identity lozenge
+ // (the landing section), and it is unconditional because the whole Admin
+ // view is already gated on `canSeeOrganization`, which is false in a
+ // personal space, so a second branch for it here would be dead code.
+ strictEqual(ORG_TAB_IDS.join(","), "companyContext,people,analytics");
});
});
@@ -84,11 +87,37 @@ describe("orgTabIds", () => {
it("splices billing in after People only when it is in scope", () => {
strictEqual(
orgTabIds({ billing: false }).join(","),
- "people,analytics,companyContext",
+ "companyContext,people,analytics",
);
strictEqual(
orgTabIds({ billing: true }).join(","),
- "people,billing,analytics,companyContext",
+ "companyContext,people,billing,analytics",
+ );
+ });
+});
+
+describe("analyticsLenses", () => {
+ it("offers Time worked only where the deployment meters compute", () => {
+ strictEqual(
+ analyticsLenses({ ...OWNER, computeUsage: true }).join(","),
+ "activity,usage,timeWorked",
+ );
+ strictEqual(analyticsLenses(OWNER).join(","), "activity,usage");
+ strictEqual(analyticsLenses(null).join(","), "activity,usage");
+ });
+});
+
+describe("resolveAnalyticsLens", () => {
+ it("keeps a lens the deployment offers", () => {
+ strictEqual(resolveAnalyticsLens("usage", ["activity", "usage"]), "usage");
+ });
+
+ it("falls back to the lead lens when the offered set drops it", () => {
+ // Capabilities can resolve (or a space can change) under a selected lens:
+ // Time worked selected, then a host without computeUsage.
+ strictEqual(
+ resolveAnalyticsLens("timeWorked", ["activity", "usage"]),
+ "activity",
);
});
});
diff --git a/app/tests/settings-view-gates.test.ts b/app/tests/settings-view-gates.test.ts
index 56347aaf9..21072e543 100644
--- a/app/tests/settings-view-gates.test.ts
+++ b/app/tests/settings-view-gates.test.ts
@@ -69,17 +69,18 @@ describe("settings-view source", () => {
/**
* The promoted screens own the whole window, so neither may wrap itself in a
- * back bar at its top level — the only bar left on each page is the one its own
- * drill-in renders (an Admin section detail; About me has no drill-in at all).
+ * back bar at its top level — and Admin has no drill-in left at all: its
+ * sections are sibling lozenges in one header cluster (the shared grammar
+ * with Integrations and the team screen), so no bar exists anywhere on it.
*/
describe("the promoted top-level screens", () => {
- it("Admin takes no back-bar props and frames its index itself", () => {
+ it("Admin takes no back-bar props and frames its body itself", () => {
const src = read("../src/components/organization/organization-view.tsx");
ok(src.includes("export function OrganizationView() {"), "no props");
ok(!src.includes("backLabel={backLabel}"), "no caller-owned back bar");
ok(
src.includes("[scrollbar-gutter:stable]"),
- "its index scroller reserves the gutter",
+ "its section scroller reserves the gutter",
);
});
@@ -96,12 +97,15 @@ describe("the promoted top-level screens", () => {
);
});
- it("keeps Admin's section detail back bar", () => {
+ it("leaves Admin without any back bar — sections are header siblings", () => {
+ // The index/detail split is gone: every section sits behind a lozenge in
+ // `AdminHeader`, so a BackBarScreen anywhere in the view would resurrect
+ // a level that no longer exists.
ok(
- read("../src/components/organization/organization-view.tsx").includes(
+ !read("../src/components/organization/organization-view.tsx").includes(
"BackBarScreen",
),
- "the section detail still returns to the Admin index",
+ "no drill-in bar left on the Admin screen",
);
});
diff --git a/app/tests/sidebar-nav-sections.test.ts b/app/tests/sidebar-nav-sections.test.ts
index 6e2fa5c70..e967c6b3a 100644
--- a/app/tests/sidebar-nav-sections.test.ts
+++ b/app/tests/sidebar-nav-sections.test.ts
@@ -103,9 +103,15 @@ describe("the rail's Workspace band", () => {
assert.ok(!ungated.includes("id:"), "no row sits outside a gate");
});
- it("routes Admin at the promoted top-level view", () => {
+ it("routes Admin at the promoted top-level view, always onto its home", () => {
assert.ok(NAV.includes("id: ORGANIZATION_VIEW_ID"));
- assert.ok(NAV.includes("onClick: () => setViewMode(ORGANIZATION_VIEW_ID)"));
+ // The rail rule: the door opens the screen's HOME, never the kept-alive
+ // leftover — so the click pins the landing section before navigating.
+ assert.ok(
+ NAV.includes("useOrgNav.getState().requestTab(DEFAULT_ORG_TAB)"),
+ "pins the landing section",
+ );
+ assert.ok(NAV.includes("setViewMode(ORGANIZATION_VIEW_ID)"));
assert.ok(!NAV.includes("PERMISSIONS_VIEW_ID"), "no Permissions row");
assert.ok(!NAV.includes("TIME_WORKED_VIEW_ID"), "no Time worked row");
});
diff --git a/knowledge-base/teams.md b/knowledge-base/teams.md
index 4bdd32b77..6a690ff46 100644
--- a/knowledge-base/teams.md
+++ b/knowledge-base/teams.md
@@ -133,50 +133,55 @@ tightened in one place and forgotten in another.
---
-## Settings > Admin (the org dashboard) — membership + insights + billing
+## Admin (the org dashboard) — company context + membership + insights + billing
-`org.title` ("Admin" / "Administración" / "Administração"), a SETTINGS SECTION with id
-`"organization"` (`app/src/lib/settings-sections.ts`); components in
+`org.title` ("Admin" / "Administración" / "Administração"), a TOP-LEVEL view in the
+rail's Workspace band (`ORGANIZATION_VIEW_ID`); components in
`app/src/components/organization/`. Rendered only when
-`canSeeOrganization(caps, activeSpaceIsTeam)`. The Settings index row and `SettingsView`'s
-blocked-section fallback both guard on it. `OrganizationView` takes `backLabel`/`onBack`
-from `SettingsView` so there is exactly ONE back bar at each depth.
-
-**All policy lives in Settings > Permissions instead** — the Admin page is who's in the
-org, what they're doing, and the bill.
-
-**Index/detail grammar (settings-page style), NOT a tab strip.**
-
-- Landing screen `admin-index.tsx`: grouped, self-describing rows (`SettingsCard` /
- `SettingsRow` reused from `components/settings/settings-row.tsx`), each with an icon, a
- title (`teams:org.tabs.`), a one-line description (`teams:org.index.rows.`) and
- an at-a-glance value chip (`teams:org.index.values.*`). Groups: **People** (membership),
- **Insights** (Activity, Usage), **Billing** (when in scope).
-- Clicking a row opens its detail: a back bar (label `org.title`) + a `PageHeader` section
- heading + the section body at full width. All sections render on the generic `{ ctx }`
- path (`admin-section-detail.tsx` special-cases nothing).
+`canSeeOrganization(caps, activeSpaceIsTeam)`; `blockedTopLevelView` sends a stale
+`viewMode` home when the gates resolve against it.
+
+One header lozenge cluster (the shared page-header grammar with Integrations and the
+team screen):
+
+- `admin-header.tsx`: the identity lozenge (Building2 + "Admin") carries the screen's
+ `
` and opens Company context, the landing section, which titles itself in its
+ body. People, Billing (when `canSeeBillingTab`), and Analytics follow as lozenges.
+ Narrow widths collapse the cluster into a switcher naming the ACTIVE section.
+ Lozenges carry `data-admin-section-tab`; the mounted body carries
+ `data-admin-section-body` — the e2e helpers (`e2e/support/settings-nav.ts`) wait on it.
+- Analytics is a drilled level: `admin-analytics-header.tsx` = `PageHeaderBackChip`
+ (‹ + the destination's glyph + "Admin") + lens lozenges Activity (the drilled
+ identity, text-only per the chip's drilled-header rules) / Usage / Time worked
+ (only with `capabilities.computeUsage`). Lens state lives in `organization-view.tsx`,
+ threaded as props to the header and the body.
+- The rail's Admin row always opens the dashboard HOME through the org-nav one-shot pin
+ (`requestTab(DEFAULT_ORG_TAB)`) — the same rail rule as a team row (its board) and
+ Settings (its index). The C8 team-status banner deep-links Billing through the same
+ store.
- Section set, order fixed by `orgTabIds` in `org-view-model.ts`:
- `OrgTabId = "people" | "activity" | "usage" | "billing"`, with
- `ORG_TAB_IDS = ["people","activity","usage"]` and `orgTabIds({billing})` appending
- Billing conditionally last.
-- `organization-view.tsx` is a thin index/detail shell: it loads `GET /org` once, builds
- the shared `OrgViewContext` (`{org, role, isOwner}`), and each section owns its data + UI.
-- `org-nav-store.ts` is pruned to Billing only (`requestedTab` + `requestTab` +
- `clearRequestedTab`); the sole consumer is `team-status-banner.tsx`'s Billing deep link.
- When the visible set drops the active section the view falls back to the index.
+ `OrgTabId = "companyContext" | "people" | "billing" | "analytics"`, Billing spliced
+ after People when in scope. `organization-view.tsx` loads `GET /org` once and builds
+ the shared `OrgViewContext` (`{org, role, isOwner}`); each section owns its data + UI.
+- Per-agent policy lives in each team's Manage agents page.
Sections:
+- **Company context** (`company-context-tab.tsx`) — the workspace half of standing
+ context, drawn with the ONE standing-prose editor
+ (`app/src/components/context/context-editor.tsx`: always-open box, saves on blur,
+ the data layer owns the failure toast). The same component draws About me, an
+ agent's Job description (agent settings), and the team context card.
- **People** (`members-tab.tsx` / `people-roster.tsx`) — roster + pending invites,
- **membership only**: owner mutates (add/remove/re-role, revoke invite), admin sees them
- read-only. The roster row is NOT a drill-in — per-agent access is managed on the agent
- settings page's People section. This is the ONLY membership surface; "members" is no
- longer a `SettingsSectionId`.
-- **Activity** (`activity-tab.tsx`) — the audit log, paged.
-- **Usage** (`usage-tab.tsx`) — per-agent/user message counters.
+ **membership only**: owner mutates (add/remove/re-role, revoke invite), admin sees
+ them read-only. Per-agent access is managed on the agent settings page's People
+ section. This is the ONLY membership surface.
+- **Analytics** (`analytics-tab.tsx`) — one measurement section; Activity (audit log,
+ paged), Usage (per-agent/user message counters), and Time worked are its lenses.
- **Billing** (`billing-tab.tsx`) — `spaces.md` → *Billing surface*.
-Tests: `org-view-model.test.ts` covers the section set and the billing-only gating.
+Tests: `org-view-model.test.ts` (section set, billing gate, lens set/resolve),
+`context-editor.test.ts` (editor grammar), e2e helpers in `settings-nav.ts`.
---
diff --git a/packages/web/e2e/agent-policy.spec.ts b/packages/web/e2e/agent-policy.spec.ts
index def3196c8..361b567bd 100644
--- a/packages/web/e2e/agent-policy.spec.ts
+++ b/packages/web/e2e/agent-policy.spec.ts
@@ -1,7 +1,7 @@
import { FAKE_HOST_URL } from "@houston/fake-host";
import type { APIRequestContext, Page } from "@playwright/test";
import { expect, test } from "./support/fixtures";
-import { openAdmin } from "./support/settings-nav";
+import { openAdminSection } from "./support/settings-nav";
import {
expectTeamSections,
openAgentSettings,
@@ -437,8 +437,7 @@ test("Admin People roster shows a member's gateway display name, email as a seco
});
await page.goto("/");
- await openAdmin(page);
- await page.getByRole("button", { name: /People/ }).click();
+ await openAdminSection(page, "People");
// Bob's display name is the primary label; his email drops to a muted
// secondary line — the gateway-backed profile lit up the roster row.
diff --git a/packages/web/e2e/context-surface.spec.ts b/packages/web/e2e/context-surface.spec.ts
index 61682180f..121f2bd18 100644
--- a/packages/web/e2e/context-surface.spec.ts
+++ b/packages/web/e2e/context-surface.spec.ts
@@ -61,13 +61,13 @@ test("About me is a rail row of its own, owning the whole window", async ({
"What every agent knows about you before it starts.",
),
).toBeVisible();
- // The PERSON's slot, not the company's: the empty state names who it is about.
- await expect(
- screen(page).getByText("Tell every agent about you"),
- ).toBeVisible();
- await expect(
- screen(page).getByRole("button", { name: "Write user context" }),
- ).toBeVisible();
+ // No invite empty state on a standing-context page: the editor is already
+ // open, and the greyed suggestion — which names the PERSON, not the
+ // company — is the invitation.
+ await expect(screen(page).getByRole("textbox")).toHaveAttribute(
+ "placeholder",
+ /I'm Juan/,
+ );
// Nothing sits above a top-level screen, so it offers no way back — a bar
// naming the Inbox would be the old door leaking through.
@@ -85,23 +85,29 @@ test("Company context is a section of Admin, editing the workspace's half", asyn
await page.goto("/");
await openAdminSection(page, "Company context");
- // The WORKSPACE slot: the same editor, pointed at the shared half.
- await expect(
- screen(page).getByText("Tell every agent about this workspace"),
- ).toBeVisible();
- await expect(
- screen(page).getByRole("button", { name: "Write workspace context" }),
- ).toBeVisible();
+ // The WORKSPACE slot: the editor is already open (no invite empty state),
+ // and its greyed suggestion is a short 3-part example of company context.
+ const editor = screen(page).getByRole("textbox");
+ await expect(editor).toHaveAttribute("placeholder", /## Who we are/);
+ await expect(editor).toHaveAttribute("placeholder", /## How we communicate/);
// And only that half. The person's context is not duplicated inside Admin —
// it is theirs, not the admin's, which is the whole reason the two split.
- await expect(
- screen(page).getByText("Tell every agent about you"),
- ).toHaveCount(0);
+ await expect(editor).toHaveCount(1);
+ await expect(editor).not.toHaveAttribute("placeholder", /I'm Juan/);
- // It sits one level under the Admin index, so it DOES carry a back bar —
- // the mirror of About me's missing one.
+ // The identity lozenge ("Admin") stands for this very section, so it is
+ // the current one — and the section titles ITSELF: a level-2 hero naming
+ // Company context and what belongs in it, since the lozenge doesn't.
await expect(
screen(page).getByRole("button", { name: "Admin", exact: true }),
+ ).toHaveAttribute("aria-current", "page");
+ await expect(
+ screen(page).getByRole("heading", { name: "Company context", level: 2 }),
+ ).toBeVisible();
+ await expect(
+ screen(page).getByText(
+ "What all your agents know on every task and routine they run.",
+ ),
).toBeVisible();
});
diff --git a/packages/web/e2e/spaces-gating.spec.ts b/packages/web/e2e/spaces-gating.spec.ts
index e18e7a35f..51c69457c 100644
--- a/packages/web/e2e/spaces-gating.spec.ts
+++ b/packages/web/e2e/spaces-gating.spec.ts
@@ -136,8 +136,8 @@ test("team space: inviting a fresh email through Admin > People renders a pendin
await page.goto("/");
await switchToSpace(page, TEAM.name);
- // Open Admin (the Organization dashboard) from the rail. It lands on its own
- // INDEX (grouped cards), so drill into the People row to reach the roster.
+ // Open Admin (the Organization dashboard) from the rail on its People
+ // section — a lozenge in the header cluster — to reach the roster.
await openAdminSection(page, "People");
// Invite a fresh email → the fake host mints a pending invite (202
diff --git a/packages/web/e2e/support/settings-nav.ts b/packages/web/e2e/support/settings-nav.ts
index 07fe0bbd6..11d0e5514 100644
--- a/packages/web/e2e/support/settings-nav.ts
+++ b/packages/web/e2e/support/settings-nav.ts
@@ -40,7 +40,14 @@ export function aboutMeRow(page: Page): Locator {
return railRow(page, "About me");
}
-/** Open the Admin (Organization) dashboard from the rail, on its index. */
+/**
+ * Open the Admin (Organization) dashboard from the rail, ALWAYS on its home:
+ * the rail door pins the landing section, so the kept-alive screen never
+ * resumes on a leftover one. Home is Company context, standing behind the
+ * header's identity lozenge — which wears the rail row's glyph and name
+ * ("Admin") and carries the screen's `
`; the section titles itself in its
+ * body instead.
+ */
export async function openAdmin(page: Page): Promise {
await adminRow(page).click();
await expect(
@@ -60,52 +67,79 @@ export async function openAboutMe(page: Page): Promise {
).toBeVisible();
}
-/** The rows of the Admin index, as it labels them (`teams:org.tabs.*`). */
+/** The sections of the Admin header cluster, as it labels them (`teams:org.tabs.*`). */
export type AdminSection =
| "People"
| "Billing"
| "Analytics"
| "Company context";
+/** Section name -> the `data-admin-section-tab` value its lozenge carries. */
+export const ADMIN_SECTION_TAB_IDS: Readonly> = {
+ "Company context": "companyContext",
+ People: "people",
+ Billing: "billing",
+ Analytics: "analytics",
+};
+
/**
- * Open Admin and drill into one of its sections.
+ * Open Admin on one of its sections.
*
- * The index rows are `SettingsRow` buttons whose accessible name is the title
- * followed by the row's description and value ("People Invite teammates, set
- * roles, remove members. 2 members"), so the row is matched on its title as a
- * PREFIX. The detail screen's `
` carries the same words, which is what the
- * wait lands on — an assertion made before it could read the index instead.
+ * The sections are lozenges in the header cluster (the shared grammar with the
+ * team screen), addressed by their `data-admin-section-tab` id so the helper
+ * survives label changes. The landing waits on the BODY's
+ * `data-admin-section-body` marker, not just the lozenge's `aria-current`: the
+ * lozenge repaints synchronously on click, so only the body attribute proves
+ * the section actually swapped in before a spec's first assertion runs.
*/
export async function openAdminSection(
page: Page,
name: AdminSection,
): Promise {
await openAdmin(page);
- await screen(page)
- .getByRole("button", { name: new RegExp(`^${name}`) })
- .click();
+ const id = ADMIN_SECTION_TAB_IDS[name];
+ const tab = screen(page).locator(`[data-admin-section-tab='${id}']`);
+ await tab.click();
+ await expect(tab).toHaveAttribute("aria-current", "page");
await expect(
- screen(page).getByRole("heading", { name, level: 1, exact: true }),
+ screen(page).locator(`[data-admin-section-body='${id}']`),
).toBeVisible();
}
-/** The three lenses of Admin > Analytics, as its sub-tabs label them. */
+/** The three lenses of Admin > Analytics, as its lozenges label them. */
export type AnalyticsLens = "Activity" | "Usage" | "Time worked";
+/** Lens name -> the `data-analytics-lens-tab` value its lozenge carries. */
+export const ANALYTICS_LENS_TAB_IDS: Readonly> = {
+ Activity: "activity",
+ Usage: "usage",
+ "Time worked": "timeWorked",
+};
+
+/** One lens lozenge of the drilled-in Analytics header. */
+export function analyticsLensTab(page: Page, lens: AnalyticsLens): Locator {
+ return screen(page).locator(
+ `[data-analytics-lens-tab='${ANALYTICS_LENS_TAB_IDS[lens]}']`,
+ );
+}
+
/**
- * Open one lens of Admin > Analytics. Only the SELECTED lens is mounted, so
+ * Open one lens of Admin > Analytics. Opening the section drills the header
+ * into the lens cluster (back chip + lozenges); Activity is the identity
+ * lozenge, whose visible words are "Analytics", so lenses are addressed by
+ * their `data-analytics-lens-tab` id. Only the SELECTED lens is mounted, so
* this is also what makes a lens's data load at all — and "Time worked" only
* exists where the gateway advertises `computeUsage`, which is why an absent
- * sub-tab (not an absent rail row) is how that gate is observed now.
+ * lozenge (not an absent rail row) is how that gate is observed now.
*/
export async function openAnalyticsLens(
page: Page,
lens: AnalyticsLens,
): Promise {
await openAdminSection(page, "Analytics");
- const tab = screen(page).getByRole("tab", { name: lens, exact: true });
+ const tab = analyticsLensTab(page, lens);
await tab.click();
- await expect(tab).toHaveAttribute("data-state", "active");
+ await expect(tab).toHaveAttribute("aria-current", "page");
}
/**
diff --git a/packages/web/e2e/team-settings-manager.spec.ts b/packages/web/e2e/team-settings-manager.spec.ts
index a7f86de7c..e5d3936cc 100644
--- a/packages/web/e2e/team-settings-manager.spec.ts
+++ b/packages/web/e2e/team-settings-manager.spec.ts
@@ -260,13 +260,16 @@ test("the member EDITS the agent they manage and reads the other one read-only",
await armMemberWorkspace(page);
await openShell(page);
- // Their own agent: the editable face — the job description offers its write
- // affordance, which `AgentDetail` hides for a non-manager.
+ // Their own agent: the editable face. The standing-prose editor is always
+ // open (no invite empty state), so editable-vs-locked is the box itself —
+ // and only the editable face carries the greyed write invitation.
await openJobDescription(page, "Payroll Bot");
- await expect(page.getByText("No instructions yet")).toBeVisible();
- await expect(
- page.getByRole("button", { name: "Write instructions" }),
- ).toBeVisible();
+ const jobBox = () => page.getByLabel("Job description");
+ await expect(jobBox()).toBeEditable();
+ await expect(jobBox()).toHaveAttribute(
+ "placeholder",
+ "Write instructions for your agent…",
+ );
// Back to the team, then into an agent of the SAME team they only use: the
// page is reachable (it is honest — they can see what the agent is told) and
@@ -282,8 +285,7 @@ test("the member EDITS the agent they manage and reads the other one read-only",
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await openJobDescription(page, "Payroll Helper");
- await expect(page.getByText("No instructions yet")).toBeVisible();
- await expect(
- page.getByRole("button", { name: "Write instructions" }),
- ).toHaveCount(0);
+ await expect(jobBox()).not.toBeEditable();
+ // The locked face drops the write invitation — the user-visible tell.
+ await expect(jobBox()).not.toHaveAttribute("placeholder");
});
diff --git a/packages/web/e2e/time-worked.spec.ts b/packages/web/e2e/time-worked.spec.ts
index 6267d0bfe..f758b5396 100644
--- a/packages/web/e2e/time-worked.spec.ts
+++ b/packages/web/e2e/time-worked.spec.ts
@@ -1,7 +1,11 @@
import { FAKE_HOST_URL } from "@houston/fake-host";
import type { APIRequestContext } from "@playwright/test";
import { expect, test } from "./support/fixtures";
-import { openAdminSection, openAnalyticsLens } from "./support/settings-nav";
+import {
+ analyticsLensTab,
+ openAdminSection,
+ openAnalyticsLens,
+} from "./support/settings-nav";
import { screen } from "./support/team-nav";
/**
@@ -14,7 +18,7 @@ import { screen } from "./support/team-nav";
*
* The gate did not move, only what it hides: the lens exists solely where the
* gateway advertises `capabilities.computeUsage` (desktop/self-host never do),
- * and elsewhere the Analytics sub-tab is simply absent — which is also what
+ * and elsewhere its lens lozenge is simply absent — which is also what
* keeps its query from firing, since only the SELECTED lens is mounted.
*
* Reaching it needs Admin, so every test here arms a Teams OWNER on top of the
@@ -81,15 +85,17 @@ test("without the computeUsage capability Analytics offers no Time worked lens",
// A painted screen FIRST, so the absence below cannot pass on an Analytics
// section that simply has not rendered: the two base lenses are there, and
- // Activity — the lead one — is the mounted body.
- const lens = (name: string) =>
- screen(page).getByRole("tab", { name, exact: true });
- await expect(lens("Activity")).toHaveAttribute("data-state", "active");
- await expect(lens("Usage")).toBeVisible();
-
- // Not merely empty: the sub-tab is absent, so nothing leads to a lens that
+ // Activity — the lead one, the drilled-in header's identity lozenge — is
+ // the mounted body.
+ await expect(analyticsLensTab(page, "Activity")).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await expect(analyticsLensTab(page, "Usage")).toBeVisible();
+
+ // Not merely empty: the lozenge is absent, so nothing leads to a lens that
// would have nothing to show — and its query never fires.
- await expect(lens("Time worked")).toHaveCount(0);
+ await expect(analyticsLensTab(page, "Time worked")).toHaveCount(0);
await expect(screen(page).getByText("Time worked")).toHaveCount(0);
});
@@ -125,7 +131,7 @@ test("with data the lens shows the total, daily bars, and per-agent rows", async
await page.goto("/");
await openAnalyticsLens(page, "Time worked");
- // The Analytics sub-tab names the lens, so the body leads with its range
+ // The header's lens lozenge names the lens, so the body leads with its range
// control rather than a heading of its own — and no "Usage"/"Compute" tab
// grouping from the old screen survives.
await expect(
diff --git a/ui/agent/src/index.ts b/ui/agent/src/index.ts
index 6ab601e5f..088720ede 100644
--- a/ui/agent/src/index.ts
+++ b/ui/agent/src/index.ts
@@ -17,8 +17,6 @@ export {
type FilesColumnLabels,
} from "./files-list-frame";
export { FilesSearch } from "./files-search";
-export type { InstructionsPanelProps } from "./instructions-panel";
-export { InstructionsPanel } from "./instructions-panel";
export {
internalDragPayload,
parseInternalDragPayload,
@@ -28,7 +26,6 @@ export { buildTree, folderAtPath } from "./tree";
export type {
FileEntry,
FilePreviewData,
- InstructionFile,
LoadFilePreview,
} from "./types";
export type { SortDirection, SortKey } from "./utils";
diff --git a/ui/agent/src/instructions-panel.tsx b/ui/agent/src/instructions-panel.tsx
deleted file mode 100644
index ccd81f521..000000000
--- a/ui/agent/src/instructions-panel.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * InstructionsPanel — editable instruction files for an agent workspace.
- * Visual style matches Houston's ContextTab exactly: labeled textareas
- * with auto-save on blur, secondary-surface fill, subtle borders.
- */
-import { useEffect, useState } from "react";
-import type { InstructionFile } from "./types";
-
-export interface InstructionsPanelProps {
- /** Instruction files to display */
- files: InstructionFile[];
- /** Called when a file is edited and the textarea loses focus */
- onSave: (name: string, content: string) => Promise;
- /** Title for empty state */
- emptyTitle?: string;
- /** Description for empty state */
- emptyDescription?: string;
-}
-
-export function InstructionsPanel({
- files,
- onSave,
- emptyTitle = "No instructions yet",
- emptyDescription = "Add a CLAUDE.md to this workspace to configure how the agent behaves.",
-}: InstructionsPanelProps) {
- if (files.length === 0) {
- return (
-
- );
-}
diff --git a/ui/agent/src/types.ts b/ui/agent/src/types.ts
index 09d6b2111..6eeb7374d 100644
--- a/ui/agent/src/types.ts
+++ b/ui/agent/src/types.ts
@@ -30,14 +30,3 @@ export type FilePreviewData =
export type LoadFilePreview = (
file: FileEntry,
) => Promise;
-
-// --- Instructions panel ---
-
-export interface InstructionFile {
- /** File name (e.g., "CLAUDE.md") */
- name: string;
- /** Human-readable label shown above the field (e.g., "CLAUDE.md") */
- label: string;
- /** Current file content */
- content: string;
-}
diff --git a/ui/showcase/specimens/areas/agents/index.ts b/ui/showcase/specimens/areas/agents/index.ts
index b2b75a9b5..51fb5dfab 100644
--- a/ui/showcase/specimens/areas/agents/index.ts
+++ b/ui/showcase/specimens/areas/agents/index.ts
@@ -1,7 +1,6 @@
import type { Specimen } from "../../../src/specimen";
import { specimen as appSidebar } from "./app-sidebar";
import { specimen as filesBrowser } from "./files-browser";
-import { specimen as instructionsPanel } from "./instructions-panel";
import { specimen as sidebarGroupHeader } from "./sidebar-group-header";
import { specimen as sidebarNavItem } from "./sidebar-nav-item";
import { specimen as sidebarRowButton } from "./sidebar-row-button";
@@ -36,6 +35,5 @@ export const specimens: readonly Specimen[] = [
appSidebar,
tabBar,
splitView,
- instructionsPanel,
filesBrowser,
];
diff --git a/ui/showcase/specimens/areas/agents/instructions-panel.tsx b/ui/showcase/specimens/areas/agents/instructions-panel.tsx
deleted file mode 100644
index f5263d82e..000000000
--- a/ui/showcase/specimens/areas/agents/instructions-panel.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import type { InstructionFile } from "@houston-ai/agent";
-import { InstructionsPanel } from "@houston-ai/agent";
-import type { ReactNode } from "react";
-import { useState } from "react";
-
-import type { Specimen } from "../../../src/specimen";
-import {
- SpecimenPage,
- SpecimenProps,
- SpecimenRow,
- SpecimenSection,
- SpecimenTokens,
-} from "../../../src/specimen";
-
-/** The panel fills its parent, so a specimen has to give it a real frame. */
-function Stage({ children }: { children: ReactNode }) {
- return (
-
- {children}
-
- );
-}
-
-const claudeMd: InstructionFile = {
- name: "CLAUDE.md",
- label: "CLAUDE.md",
- content:
- "You triage Julian's inbox every weekday at 07:30.\n\nDraft replies for anything a client sent, file receipts under Finance, and never send without approval.",
-};
-
-const soundsLike: InstructionFile = {
- name: "SOUNDS-LIKE.md",
- label: "How you sound",
- content:
- "Short sentences. No corporate filler. Sign off as Julian, never as an assistant.",
-};
-
-/**
- * `onSave` is the whole contract: the panel keeps a local draft, and on blur it
- * hands the changed content back and shows "Saving…" until the promise settles.
- * Faking that with an instant resolve would hide the one state worth reviewing,
- * so this one takes a beat and then keeps the edit.
- */
-function LivePanel({ initial }: { initial: readonly InstructionFile[] }) {
- const [files, setFiles] = useState([...initial]);
- return (
-
-
- new Promise((done) => {
- setTimeout(() => {
- setFiles((all) =>
- all.map((file) =>
- file.name === name ? { ...file, content } : file,
- ),
- );
- done();
- }, 900);
- })
- }
- />
-
- );
-}
-
-function InstructionsPanelSpecimen() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Promise.resolve()}
- emptyTitle="No instructions yet"
- emptyDescription="Tell Inbox Zero what its mornings look like and it will follow that from the next run."
- />
-
-
-
-
-
-
- `Rule ${i + 1}: one line of the agent's brief.`,
- ).join("\n"),
- },
- ]}
- />
-
-
-
- Promise",
- note: "Required. Fires on blur, only when the content actually changed. The pending promise is what shows Saving….",
- },
- {
- name: "emptyTitle",
- type: "string",
- note: 'Defaults to "No instructions yet".',
- },
- {
- name: "emptyDescription",
- type: "string",
- note: "The line under it. Both shown only when `files` is empty.",
- },
- ]}
- />
-
-
-
- );
-}
-
-/**
- * The `@houston-ai/*` symbols this page documents. `scripts/gen-usage.mjs`
- * reads them to build the "Used in" map, so they are the exported names
- * exactly as a consumer imports them.
- */
-export const sources: string[] = ["InstructionsPanel"];
-
-export const specimen: Specimen = {
- id: "agents-instructions-panel",
- title: "InstructionsPanel",
- group: "Your Agents",
- render: () => ,
-};