From 1bc5236d443d596c5ccf0cccf2c840f335242788 Mon Sep 17 00:00:00 2001 From: Julian Arango Date: Fri, 7 Aug 2026 15:30:44 -0500 Subject: [PATCH 01/19] =?UTF-8?q?feat(app):=20agent=20settings=20page=20?= =?UTF-8?q?=E2=80=94=20Context=20+=20Permissions=20in=20one=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New app/src/components/agent-settings/: a master-detail settings page per agent (Context: job description, learnings; Permissions: people with an Everyone-on-your-team/Only-specific-people choice, allowed apps, allowed AI models, skills). Settings > Permissions drills into it; the Context/Admin agent tabs consume the same shared rail, nav model, and section switch (agent-admin-nav/-sidebar/-screen deleted). Self-lockout and roster-materialization writes are confirm-gated; share analytics fire on an actual access-widening diff. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + .../agent-settings/agent-people-choice.ts | 148 +++++++++ .../agent-settings/agent-people-confirm.tsx | 80 +++++ .../agent-people-model.ts | 0 .../agent-people-tab.tsx | 51 ++-- .../agent-person-row.tsx | 0 .../agent-settings/agent-settings-nav.ts | 122 ++++++++ .../agent-settings/agent-settings-page.tsx | 102 +++++++ .../agent-settings/agent-settings-people.tsx | 173 +++++++++++ .../agent-settings/agent-settings-rail.tsx | 155 ++++++++++ .../agent-settings/agent-settings-section.tsx | 62 ++++ .../agent-settings-selection.ts | 73 +++++ .../ai-hub/models-allowlist-editor.tsx | 9 +- .../integrations/allowlist-editor.tsx | 9 +- .../components/permissions/agent-detail.tsx | 54 ++-- .../permissions/agent-permissions-panel.tsx | 54 ---- .../permissions/permissions-nav-store.ts | 34 +-- .../permissions/permissions-view.tsx | 68 +++-- app/src/components/tabs/admin-tab.tsx | 49 +-- app/src/components/tabs/agent-access-diff.ts | 37 +++ .../tabs/agent-admin/access-choice.tsx | 15 +- .../agent-admin/agent-admin-instructions.tsx | 4 +- .../agent-admin/agent-admin-integrations.tsx | 4 +- .../agent-admin/agent-admin-knowledge.tsx | 4 +- .../tabs/agent-admin/agent-admin-model.tsx | 4 +- .../tabs/agent-admin/agent-admin-nav.ts | 58 ---- .../tabs/agent-admin/agent-admin-screen.tsx | 55 ---- .../tabs/agent-admin/agent-admin-sidebar.tsx | 112 ------- .../tabs/agent-admin/agent-admin-skills.tsx | 15 +- .../components/tabs/agent-share-dialog.tsx | 4 +- app/src/components/tabs/context-tab.tsx | 42 +-- app/src/components/tabs/use-share-agent.ts | 62 ++-- app/src/locales/en/teams.json | 39 ++- app/src/locales/es/teams.json | 39 ++- app/src/locales/pt/teams.json | 39 ++- app/tests/agent-access-diff.test.ts | 96 ++++++ app/tests/agent-admin-a11y.test.ts | 6 +- app/tests/agent-admin-nav.test.ts | 64 ---- app/tests/agent-people-choice.test.ts | 289 ++++++++++++++++++ app/tests/agent-people-model.test.ts | 2 +- app/tests/agent-settings-nav.test.ts | 250 +++++++++++++++ app/tests/settings-view-gates.test.ts | 2 +- knowledge-base/README.md | 1 + knowledge-base/agent-settings.md | 178 +++++++++++ knowledge-base/teams.md | 103 ++++--- packages/web/e2e/ai-models-ia.spec.ts | 8 +- packages/web/e2e/permissions.spec.ts | 241 +++++++++++++-- 47 files changed, 2388 insertions(+), 629 deletions(-) create mode 100644 app/src/components/agent-settings/agent-people-choice.ts create mode 100644 app/src/components/agent-settings/agent-people-confirm.tsx rename app/src/components/{permissions => agent-settings}/agent-people-model.ts (100%) rename app/src/components/{permissions => agent-settings}/agent-people-tab.tsx (64%) rename app/src/components/{permissions => agent-settings}/agent-person-row.tsx (100%) create mode 100644 app/src/components/agent-settings/agent-settings-nav.ts create mode 100644 app/src/components/agent-settings/agent-settings-page.tsx create mode 100644 app/src/components/agent-settings/agent-settings-people.tsx create mode 100644 app/src/components/agent-settings/agent-settings-rail.tsx create mode 100644 app/src/components/agent-settings/agent-settings-section.tsx create mode 100644 app/src/components/agent-settings/agent-settings-selection.ts delete mode 100644 app/src/components/permissions/agent-permissions-panel.tsx create mode 100644 app/src/components/tabs/agent-access-diff.ts delete mode 100644 app/src/components/tabs/agent-admin/agent-admin-nav.ts delete mode 100644 app/src/components/tabs/agent-admin/agent-admin-screen.tsx delete mode 100644 app/src/components/tabs/agent-admin/agent-admin-sidebar.tsx create mode 100644 app/tests/agent-access-diff.test.ts delete mode 100644 app/tests/agent-admin-nav.test.ts create mode 100644 app/tests/agent-people-choice.test.ts create mode 100644 app/tests/agent-settings-nav.test.ts create mode 100644 knowledge-base/agent-settings.md diff --git a/CLAUDE.md b/CLAUDE.md index 1e58d6519..dd2966caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ Need specific knowledge? Load on demand: - Agent manifest, tiers, sidebar, workspaces → `knowledge-base/agent-manifest.md` - Agent Store (public catalog, publish/install, agents.gethouston.ai) → `knowledge-base/agent-store.md` - Multiplayer Teams (orgs) client surface — roles owner/admin/user + per-agent access manager/user, role matrix v2 (`app/src/lib/org-roles.ts`), org dashboard, Share dialog, allowed-models ceiling + per-user model choice, integration allowlists; **plus C8 Spaces** (personal + team spaces, the `org:` workspace switcher / `x-houston-org` active-space pin, create-team, share-via-team pipeline, seat billing + trial + degrade states, `capabilities.spaces` + per-space role); the gateway is the sole enforcer → `knowledge-base/teams.md` (server contracts: `cloud/docs/contracts/C3`,`C4`,`C7-teams.md`,`C8-spaces-billing.md`) +- The ONE per-agent settings surface (section model + caps gating, the shared rail the Permissions drill-in AND the Context/Admin tabs render, the People "everyone vs specific people" choice, read-only, share analytics) → `knowledge-base/agent-settings.md` - v3 wire protocol (REST + SSE) → `packages/protocol/` (types + zod). The host is `packages/host` (run: `pnpm --filter @houston/host dev`). - Provider error taxonomy → `knowledge-base/provider-errors.md` (the shared taxonomy the host/pi providers map to; the old Rust classifier is gone). - Anthropic credential lifecycle (Claude-SDK backend: keychain scoping, cloud push, per-turn access-only serve, single-rotator rule) → `knowledge-base/anthropic-credentials.md` diff --git a/app/src/components/agent-settings/agent-people-choice.ts b/app/src/components/agent-settings/agent-people-choice.ts new file mode 100644 index 000000000..3509e9982 --- /dev/null +++ b/app/src/components/agent-settings/agent-people-choice.ts @@ -0,0 +1,148 @@ +import type { + Agent, + AgentAssignment, + OrgMember, +} from "@houston-ai/engine-client"; +import { + buildSharePeople, + isSharedWithEveryone, + needsSelfLockoutConfirm, +} from "../tabs/agent-access-model.ts"; +import type { AccessMode } from "../tabs/agent-admin/agent-admin-row-values.ts"; + +/** + * Pure, DOM-free logic behind the top-level access choice above the People + * roster: "Everyone on your team" vs "Only specific people". + * + * It writes the SAME wire shape the Share dialog and the People roster write + * (`PUT /v1/agents/:slug/assignments`, set-replace), against the existing + * everyone-agent sentinel: an EMPTY assignee set means org-wide access + * ({@link isSharedWithEveryone}, `tauriAgents.setAssignments` "Empty = + * everyone"), and any explicit set means exactly those people. There is no + * third state on the wire, so the choice is a faithful two-way mapping: + * + * - "Everyone on your team" writes `[]`. + * - "Only specific people" MATERIALIZES the roster the everyone sentinel + * currently expands to ({@link buildSharePeople} — every member, owner as + * manager), which is exactly what the first per-person edit does today. The + * write therefore changes nobody's effective access; it only makes the + * implicit roster explicit so people can then be removed one by one. + * + * The one thing the sentinel CANNOT carry is a per-person level: `[]` has no + * room for a Manager grant, so switching to "Everyone" drops any manager other + * than the org owner (whom {@link buildSharePeople} always re-adds). That is a + * real change to materialized assignments, which is why + * {@link everyoneChangesAssignments} exists: the UI confirm-gates the switch + * whenever it would alter anyone's resolved access. + * + * The GATEWAY is the sole enforcer; these helpers only shape the affordance. + */ + +/** The roster inputs every helper here reads, same shape the People tab builds. */ +export interface AgentRosterInput { + agent: Pick; + members: readonly OrgMember[]; + selfId: string | null; +} + +/** + * Which side of the choice the agent is currently on, in the vocabulary the + * shared `AccessChoice` control speaks: `"any"` = everyone in the team (the + * empty sentinel), `"picked"` = an explicit roster. + */ +export function agentAccessMode( + agent: Pick, +): AccessMode { + return isSharedWithEveryone(agent) ? "any" : "picked"; +} + +/** + * May the choice be offered at all? Only with a visible roster: materializing + * "only specific people" expands the team into an explicit set, and with no + * members to expand that write would be the EMPTY set, i.e. silently the + * everyone sentinel again. Rather than a control whose click does the opposite + * of its label, the People section hides it until the roster loads (the + * gateway also withholds the roster from non-managers). + */ +export function canChooseAgentAccess(members: readonly OrgMember[]): boolean { + return members.length > 0; +} + +/** + * How many people the agent's roster RESOLVES to today — the everyone sentinel + * expanded to the whole team, an explicit set plus the always-present org + * owner. This is the number the People section renders, so it is also the + * number the rail badges; the raw `assignments` array badges neither. + */ +export function agentPeopleCount(input: AgentRosterInput): number { + return buildSharePeople(input).length; +} + +/** The assignee set that means "everyone on your team": the empty sentinel. */ +export function everyoneAssignments(): AgentAssignment[] { + return []; +} + +/** + * The assignee set to write when switching to "Only specific people": today's + * effective roster, made explicit. Reuses {@link buildSharePeople} so the + * expansion (everyone sentinel to the whole team, org owner always manager) + * is identical to the one the roster below already renders. + */ +export function materializeRoster(input: AgentRosterInput): AgentAssignment[] { + return buildSharePeople(input).map((person) => ({ + userId: person.userId, + access: person.access, + })); +} + +function accessByUserId(input: AgentRosterInput): Map { + return new Map(buildSharePeople(input).map((p) => [p.userId, p.access])); +} + +/** + * Would switching to "Everyone on your team" change anybody's resolved access? + * Compares the roster as it resolves today against the roster the empty + * sentinel resolves to. False for an agent already shared with the whole team + * at "Can use" (the switch is then a pure no-op); true whenever someone would + * gain access or lose a Manager seat, which the UI confirm-gates. + */ +export function everyoneChangesAssignments(input: AgentRosterInput): boolean { + const before = accessByUserId(input); + const after = accessByUserId({ + ...input, + agent: { assignments: [], assignedUserIds: [] }, + }); + if (before.size !== after.size) return true; + for (const [userId, access] of before) { + if (after.get(userId) !== access) return true; + } + return false; +} + +/** + * Which confirm the "Everyone on your team" switch must show before it writes. + * + * - `"selfLockout"` — the VIEWER holds a non-owner Manager grant today. The + * empty sentinel cannot carry it, so the switch demotes them to a plain user + * and takes this very page away. That is the same act the per-person control + * already gates ({@link needsSelfLockoutConfirm}), so it gets the same + * destructive `share.selfLockout` warning rather than a cheerful + * "give everyone access". + * - `"changesAccess"` — somebody else's resolved access changes (teammates gain + * access, or another manager is demoted). Informational, not destructive. + * - `"none"` — a pure no-op; write it straight through. + */ +export type EveryoneSwitchConfirm = "selfLockout" | "changesAccess" | "none"; + +export function everyoneSwitchConfirm( + input: AgentRosterInput, +): EveryoneSwitchConfirm { + const self = buildSharePeople(input).find((person) => person.isSelf); + // The switch demotes every non-owner manager to `user`; it only locks the + // VIEWER out if they hold that Manager grant right now. + if (self?.access === "manager" && needsSelfLockoutConfirm(self, "user")) { + return "selfLockout"; + } + return everyoneChangesAssignments(input) ? "changesAccess" : "none"; +} diff --git a/app/src/components/agent-settings/agent-people-confirm.tsx b/app/src/components/agent-settings/agent-people-confirm.tsx new file mode 100644 index 000000000..8c6beb274 --- /dev/null +++ b/app/src/components/agent-settings/agent-people-confirm.tsx @@ -0,0 +1,80 @@ +import { ConfirmDialog } from "@houston-ai/core"; +import { useTranslation } from "react-i18next"; + +/** + * What the People section is asking the manager to confirm before it replaces + * the agent's assignee set. + * + * - `selfLockout` — switching to "Everyone on your team" while the VIEWER holds + * a non-owner Manager grant: the empty sentinel cannot carry it, so they + * demote themselves and lose this page. Same act, same destructive warning as + * the per-person control's self-lockout gate. + * - `everyone` — switching to "Everyone on your team" changes somebody ELSE's + * resolved access. Informational, so it is NOT destructive. + * - `specific` — switching to "Only specific people" materializes today's + * roster. Nobody loses access, but the write is a snapshot: it says how many + * people it is freezing and that future teammates are no longer included. + */ +export type PeopleConfirmKind = "selfLockout" | "everyone" | "specific"; + +/** + * The confirm in front of every team-wide access change. One dialog, three + * voices, so the destructive variant is spent only on the one outcome the + * manager cannot undo alone. + */ +export function AgentPeopleConfirm({ + kind, + count, + onCancel, + onConfirm, +}: { + /** The pending question, or null when nothing is being confirmed. */ + kind: PeopleConfirmKind | null; + /** People the "Only specific people" write would freeze into the roster. */ + count: number; + onCancel: () => void; + onConfirm: () => void; +}) { + const { t } = useTranslation("teams"); + const copy = + kind === "selfLockout" + ? { + title: t("share.selfLockout.title"), + description: t("share.selfLockout.description"), + confirmLabel: t("share.selfLockout.confirm"), + cancelLabel: t("share.selfLockout.cancel"), + variant: "destructive" as const, + } + : kind === "specific" + ? { + title: t("agentSettings.people.confirmSpecific.title"), + description: t("agentSettings.people.confirmSpecific.description", { + count, + }), + confirmLabel: t("agentSettings.people.confirmSpecific.confirm"), + cancelLabel: t("agentSettings.people.confirmSpecific.cancel"), + variant: "default" as const, + } + : { + title: t("agentSettings.people.confirmEveryone.title"), + description: t("agentSettings.people.confirmEveryone.description"), + confirmLabel: t("agentSettings.people.confirmEveryone.confirm"), + cancelLabel: t("agentSettings.people.confirmEveryone.cancel"), + variant: "default" as const, + }; + + return ( + { + if (!open) onCancel(); + }} + title={copy.title} + description={copy.description} + confirmLabel={copy.confirmLabel} + cancelLabel={copy.cancelLabel} + variant={copy.variant} + onConfirm={onConfirm} + /> + ); +} diff --git a/app/src/components/permissions/agent-people-model.ts b/app/src/components/agent-settings/agent-people-model.ts similarity index 100% rename from app/src/components/permissions/agent-people-model.ts rename to app/src/components/agent-settings/agent-people-model.ts diff --git a/app/src/components/permissions/agent-people-tab.tsx b/app/src/components/agent-settings/agent-people-tab.tsx similarity index 64% rename from app/src/components/permissions/agent-people-tab.tsx rename to app/src/components/agent-settings/agent-people-tab.tsx index 2953e4b38..73f1df71e 100644 --- a/app/src/components/permissions/agent-people-tab.tsx +++ b/app/src/components/agent-settings/agent-people-tab.tsx @@ -12,7 +12,7 @@ import { avatarUrlFromProfiles } from "../../hooks/queries/user-profiles-map"; import { useSession } from "../../hooks/use-session"; import type { Agent } from "../../lib/types"; import type { ShareAction } from "../tabs/agent-access-model.ts"; -import { useShareAgent } from "../tabs/use-share-agent"; +import type { ShareAgentMutation } from "../tabs/use-share-agent"; import { agentPeopleView, agentPersonNeedsConfirm, @@ -23,34 +23,47 @@ import { import { AgentPersonRow } from "./agent-person-row.tsx"; /** - * Permissions agent People tab: WHO can use THIS agent. Every org member is a - * row with a None / Can use / Manager control for this one agent. Reads and - * writes reuse the Share dialog's `agent-access-model` (`buildAgentPeople` wraps - * `buildSharePeople`; `writeAgentPerson` wraps `applyShareAction`) over the - * optimistic set-replace `useShareAgent`, so an everyone-agent materializes into - * an explicit roster on first edit exactly as the dialog does, failures already - * surface as a toast, and a self-lockout is confirm-gated identically. + * The per-person roster of the People section: WHO can use THIS agent. Every + * org member is a row with a None / Can use / Manager control for this one + * agent. Reads and writes reuse the Share dialog's `agent-access-model` + * (`buildAgentPeople` wraps `buildSharePeople`; `writeAgentPerson` wraps + * `applyShareAction`) over the optimistic set-replace `useShareAgent`, so an + * everyone-agent materializes into an explicit roster on first edit exactly as + * the dialog does, failures already surface as a toast, and a self-lockout is + * confirm-gated identically. + * + * The `share` mutation is OWNED BY THE SECTION and passed in, so the team-wide + * access choice above and these rows are one write channel: an in-flight + * assignment write disables both, and two overlapping set-replaces can never + * race each other into a lost update. * * `readOnly` renders the roster with static level labels and NO controls — the - * face shown in the agent's Settings People row to a viewer who can't manage it. - * The gateway only serves the roster to owner/admin, so a plain member's `members` - * arrives empty; there the tab degrades to an honest viewer line (`viewerOnly`) - * rather than a misleading empty state. The gateway is the real enforcer. + * face shown to a viewer who can't manage the agent, and also the face of + * "Everyone on your team" mode, where per-person levels are not the agent's + * state to edit. The gateway only serves the roster to owner/admin, so a plain + * member's `members` arrives empty; there the roster degrades to an honest + * viewer line (`viewerOnly`) rather than a misleading empty state. The gateway + * is the real enforcer. */ export function AgentPeopleTab({ agent, members, + share, readOnly = false, + note, }: { agent: Agent; members: OrgMember[]; + /** The section's single assignment mutation (see {@link ShareAgentMutation}). */ + share: ShareAgentMutation; /** View-only: static rows, no controls, and the plain-member viewer line. */ readOnly?: boolean; + /** Muted line above the roster explaining why it is static, when it is. */ + note?: string; }) { const { t } = useTranslation("teams"); const { data: session } = useSession(); const selfId = session?.uid ?? null; - const share = useShareAgent(); const { profiles } = useUserProfiles(members.map((m) => m.userId)); const [pending, setPending] = useState<{ row: PersonRow; @@ -62,6 +75,7 @@ export function AgentPeopleTab({ const write = (userId: string, action: ShareAction) => share.mutate({ agentId: agent.id, + members, assignments: writeAgentPerson({ agent, members, @@ -100,15 +114,11 @@ export function AgentPeopleTab({ ); } - // Width belongs to the mounting surface; the tab body fills the page column - // so its rows align with the tab strip above (never a second, narrower column). + // Width belongs to the mounting surface; the roster fills the page column so + // its rows align with the section above (never a second, narrower column). return (
- {readOnly && ( -

- {t("permissions.agentPeople.readOnlyHint")} -

- )} + {note &&

{note}

}
    {rows.map((row) => ( { const p = pending; setPending(null); diff --git a/app/src/components/permissions/agent-person-row.tsx b/app/src/components/agent-settings/agent-person-row.tsx similarity index 100% rename from app/src/components/permissions/agent-person-row.tsx rename to app/src/components/agent-settings/agent-person-row.tsx diff --git a/app/src/components/agent-settings/agent-settings-nav.ts b/app/src/components/agent-settings/agent-settings-nav.ts new file mode 100644 index 000000000..8abcf4ab0 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-nav.ts @@ -0,0 +1,122 @@ +import type { Capabilities } from "@houston-ai/engine-client"; +import { isMultiplayer } from "../../lib/org-roles.ts"; +import type { Agent } from "../../lib/types"; + +/** + * The ONE nav model for configuring a single agent. Every surface that shows + * agent settings — the canonical settings page and the per-agent Context / + * Admin tabs — names its rail from here, so a section can never mean two + * different things or be gated two different ways. Pure and DOM-free, so the + * visibility rules are unit-tested (`app/tests/agent-settings-nav.test.ts`). + */ +export type AgentSettingsSection = + | "job-description" + | "learnings" + | "people" + | "integrations" + | "models" + | "skills"; + +/** The two rail groups. `context` = what the agent knows; `permissions` = what it and its team may reach. */ +export type AgentSettingsGroupId = "context" | "permissions"; + +export interface AgentSettingsGroup { + id: AgentSettingsGroupId; + /** The group's sections, in rail order. Never empty in a rendered rail. */ + sections: AgentSettingsSection[]; +} + +/** The props EVERY section body takes. `readOnly` renders its non-manager face. */ +export interface AgentSectionProps { + agent: Agent; + readOnly?: boolean; +} + +/** + * Which group a section belongs to, independent of any host's capabilities. + * The deep-link fallback reads it so a hidden section lands on its own group's + * first item rather than answering an unrelated question. + */ +export const SECTION_GROUP: Record = + { + "job-description": "context", + learnings: "context", + people: "permissions", + integrations: "permissions", + models: "permissions", + skills: "permissions", + }; + +/** + * The Context sections: the agent's job description and its learnings + * ("Memory"). Unconditional — every agent has both, and a non-manager reads + * them read-only. + */ +export function contextSections(): AgentSettingsSection[] { + return ["job-description", "learnings"]; +} + +/** + * The ACCESS sections: who may use the agent, plus the app + model ceilings. + * People needs multiplayer (single player has no roster to manage); the two + * ceilings additionally need a Teams-capable host. Empty outside multiplayer, + * which is why single player hides the Admin tab entirely. + * The public-API "Connect" card (C10, `capabilities.apiKeys`) was removed from + * this surface (HOU-806): connecting external apps is a Routines concern now. + */ +export function agentAccessSections( + caps: Capabilities | null | undefined, +): AgentSettingsSection[] { + if (!isMultiplayer(caps)) return []; + return caps?.teams === true + ? ["people", "integrations", "models"] + : ["people"]; +} + +/** + * The full settings-page rail, group by group: Context, then the access + * sections plus Skills, which has no org gate at all (it is the per-agent + * Skills surface every deployment ships). + */ +export function agentSettingsGroups( + caps: Capabilities | null | undefined, +): AgentSettingsGroup[] { + return [ + { id: "context", sections: contextSections() }, + { id: "permissions", sections: [...agentAccessSections(caps), "skills"] }, + ]; +} + +/** The Context tab's rail: the Context sections as ONE unlabelled group. */ +export function contextTabGroups(): AgentSettingsGroup[] { + return [{ id: "context", sections: contextSections() }]; +} + +/** + * The Admin tab's rail: the access sections as ONE unlabelled group (no + * Skills — that tab owns Skills). Empty outside multiplayer, where the tab + * renders nothing at all. + */ +export function adminTabGroups( + caps: Capabilities | null | undefined, +): AgentSettingsGroup[] { + const sections = agentAccessSections(caps); + return sections.length === 0 ? [] : [{ id: "permissions", sections }]; +} + +/** Every visible section of a rail, flattened in rail order. */ +export function agentSettingsSections( + groups: readonly AgentSettingsGroup[], +): AgentSettingsSection[] { + return groups.flatMap((group) => group.sections); +} + +/** + * Deep-link from a turn-summary file target (a semantic file update the agent + * wrote) into the matching Context section. + */ +export function targetToSection( + target: "instructions" | "learnings", +): AgentSettingsSection { + return target === "learnings" ? "learnings" : "job-description"; +} diff --git a/app/src/components/agent-settings/agent-settings-page.tsx b/app/src/components/agent-settings/agent-settings-page.tsx new file mode 100644 index 000000000..edd518c92 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-page.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useCapabilities } from "../../hooks/use-capabilities"; +import type { Agent } from "../../lib/types"; +import { + type AgentSettingsSection, + agentSettingsGroups, +} from "./agent-settings-nav.ts"; +import { AgentSettingsRail } from "./agent-settings-rail.tsx"; +import { AgentSettingsSectionView } from "./agent-settings-section.tsx"; +import { + advanceAgentSettingsSelection, + resolveAgentSettingsSection, +} from "./agent-settings-selection.ts"; + +/** + * The ONE canonical agent settings page: everything an admin configures on a + * single agent, as a master-detail surface — the grouped rail on the left + * (Context, Permissions), the selected section on the right. One section is + * always selected, so the page has no back navigation of its own; the caller + * owns the way out. + * + * Mounted today as the Settings > Permissions agent drill-in and, later, as the + * drill-in of the Team Settings surface. It carries NO authority of its own: + * `readOnly` is the caller's decision, the gateway is the sole enforcer, and + * the rail's visibility rules are the caps-only ones in + * {@link agentSettingsGroups}. + */ +export function AgentSettingsPage({ + agent, + initialSection, + readOnly = false, + onSectionShown, +}: { + agent: Agent; + /** Section to open on first mount (a deep link may land on Apps). */ + initialSection?: AgentSettingsSection; + /** View-only: every section renders its non-manager face. */ + readOnly?: boolean; + /** The section actually ON SCREEN, for the caller's analytics. */ + onSectionShown?: (section: AgentSettingsSection) => void; +}) { + const { t } = useTranslation("teams"); + const { capabilities } = useCapabilities(); + const groups = useMemo( + () => agentSettingsGroups(capabilities), + [capabilities], + ); + const [selected, setSelected] = useState(() => + resolveAgentSettingsSection(groups, initialSection), + ); + // The request is RETAINED until a rail that can show it honors it once: + // `/v1/capabilities` lands after the first render, and a re-fired deep link + // for the agent already on screen must reopen its section too. + const pendingRef = useRef(initialSection); + const requestRef = useRef({ agentId: agent.id, section: initialSection }); + // Mirrors `selected` so the resolve effect reads the live section without + // re-running on every rail click (and without a side effect in an updater). + const selectedRef = useRef(selected); + const select = useCallback((section: AgentSettingsSection) => { + selectedRef.current = section; + setSelected(section); + }, []); + + useEffect(() => { + const request = requestRef.current; + if (request.agentId !== agent.id || request.section !== initialSection) { + requestRef.current = { agentId: agent.id, section: initialSection }; + pendingRef.current = initialSection; + } + const next = advanceAgentSettingsSelection({ + groups, + pending: pendingRef.current, + current: selectedRef.current, + }); + pendingRef.current = next.pending; + if (next.selected !== selectedRef.current) select(next.selected); + }, [agent.id, groups, initialSection, select]); + + useEffect(() => { + onSectionShown?.(selected); + }, [selected, onSectionShown]); + + return ( +
    + +
    + +
    +
    + ); +} diff --git a/app/src/components/agent-settings/agent-settings-people.tsx b/app/src/components/agent-settings/agent-settings-people.tsx new file mode 100644 index 000000000..1849c58f9 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-people.tsx @@ -0,0 +1,173 @@ +import { Skeleton } from "@houston-ai/core"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useOrg } from "../../hooks/queries"; +import { useSession } from "../../hooks/use-session"; +import type { Agent } from "../../lib/types"; +import { AccessChoice } from "../tabs/agent-admin/access-choice.tsx"; +import type { AccessMode } from "../tabs/agent-admin/agent-admin-row-values.ts"; +import { useShareAgent } from "../tabs/use-share-agent"; +import { + agentAccessMode, + agentPeopleCount, + canChooseAgentAccess, + everyoneAssignments, + everyoneSwitchConfirm, + materializeRoster, +} from "./agent-people-choice.ts"; +import { + AgentPeopleConfirm, + type PeopleConfirmKind, +} from "./agent-people-confirm.tsx"; +import { AgentPeopleTab } from "./agent-people-tab.tsx"; + +/** Mirrors the final layout (control pill, its hint line, three rows) so the roster never jumps in. */ +function PeopleSkeleton() { + return ( +
    + + +
    + + + +
    +
    + ); +} + +/** + * The People section of the agent settings page: the team-wide access question + * on top, the per-person roster below. + * + * The choice is the same always-visible {@link AccessChoice} segmented control + * the app + model ceilings use, wired to the real everyone-agent semantics via + * `agent-people-choice` (empty assignee set = everyone; an explicit set = only + * those people). BOTH directions are confirm-gated, because both replace the + * whole roster in one write: switching to Everyone says what it discards + * (and warns destructively when the viewer is discarding their OWN Manager + * grant), switching to specific people says how many it is freezing and that + * future teammates are no longer included. Writes go through ONE shared + * optimistic {@link useShareAgent} — the same instance the roster rows use, so + * one in-flight write disables every control here and two set-replaces can + * never race. + * + * In "Everyone" mode the roster renders STATIC: per-person levels are not the + * agent's state while the sentinel is set, and a live control there would + * silently materialize the roster (the mirror of the confirm-gated switch). + * This follows the `AllowlistEditor` idiom, which likewise stops offering + * per-item controls in its "any" mode. + * + * The section owns the org query, so the roster refetches on mount when stale + * and the body waits behind a skeleton until it lands. The choice itself hides + * until the roster is readable ({@link canChooseAgentAccess}): with no members + * to expand, "Only specific people" would write the empty set and mean the + * opposite of its label. + */ +export function AgentSettingsPeople({ + agent, + readOnly = false, +}: { + agent: Agent; + /** View-only: the choice is disabled and the roster renders without controls. */ + readOnly?: boolean; +}) { + const { t } = useTranslation("teams"); + const headingId = useId(); + const { data: session } = useSession(); + const selfId = session?.uid ?? null; + const org = useOrg(true); + const members = org.data?.members ?? []; + const share = useShareAgent("agent_settings_people"); + const [confirm, setConfirm] = useState(null); + + const mode = agentAccessMode(agent); + const roster = { agent, members, selfId }; + const peopleCount = agentPeopleCount(roster); + + const write = (assignments: ReturnType) => + share.mutate({ agentId: agent.id, members, assignments }); + + const commit = (kind: PeopleConfirmKind) => + write( + kind === "specific" ? materializeRoster(roster) : everyoneAssignments(), + ); + + const handleChoice = (next: AccessMode) => { + if (next === "picked") { + setConfirm("specific"); + return; + } + const gate = everyoneSwitchConfirm(roster); + if (gate === "none") { + write(everyoneAssignments()); + return; + } + setConfirm(gate === "selfLockout" ? "selfLockout" : "everyone"); + }; + + return ( +
    +

    + {t("agentSettings.people.question")} +

    +

    + {t("agentSettings.people.helper")} +

    + + {org.isLoading ? ( + + ) : ( + <> + {canChooseAgentAccess(members) && ( + + )} + +
    + +
    + + )} + + setConfirm(null)} + onConfirm={() => { + const kind = confirm; + setConfirm(null); + if (kind) commit(kind); + }} + /> +
    + ); +} diff --git a/app/src/components/agent-settings/agent-settings-rail.tsx b/app/src/components/agent-settings/agent-settings-rail.tsx new file mode 100644 index 000000000..ab1f4a6b4 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-rail.tsx @@ -0,0 +1,155 @@ +import { Badge, cn } from "@houston-ai/core"; +import { + Boxes, + Brain, + FileText, + LibraryBig, + type LucideIcon, + Sparkles, + Users, +} from "lucide-react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useLearnings, useOrg } from "../../hooks/queries"; +import { useSession } from "../../hooks/use-session"; +import type { Agent } from "../../lib/types"; +import { agentPeopleCount } from "./agent-people-choice.ts"; +import type { + AgentSettingsGroup, + AgentSettingsGroupId, + AgentSettingsSection, +} from "./agent-settings-nav.ts"; + +const ICONS: Record = { + "job-description": FileText, + learnings: Brain, + people: Users, + integrations: Boxes, + models: Sparkles, + skills: LibraryBig, +}; + +/** + * Title i18n key per section. Every string already exists: the job description + * and Skills reuse the `agents:subTabs.*` titles, the rest the + * `teams:agentAdmin.rows.*` ones. Listed explicitly so each key is + * type-checked and locale-validated. + */ +const SECTION_TITLES = { + "job-description": "agents:subTabs.instructions", + learnings: "agentAdmin.rows.knowledge.title", + people: "agentAdmin.rows.people.title", + integrations: "agentAdmin.rows.integrations.title", + models: "agentAdmin.rows.model.title", + skills: "agents:subTabs.skills", +} as const satisfies Record; + +const GROUP_TITLES = { + context: "agentSettings.groups.context", + permissions: "agentSettings.groups.permissions", +} as const satisfies Record; + +/** + * The ONE nav rail every agent-settings surface uses: the settings page's two + * labelled groups, and the Context / Admin tabs' single flat list. A rail of + * one group needs no title (there is nothing to tell it apart from), so group + * titles render only when there is more than one group. + * + * Selected rows read like the app sidebar nav (`bg-hover` fill + `aria-current`) + * with no hover-only affordance. Learnings and People carry bare-number badges + * so a manager reads the counts without opening the section: People counts the + * RESOLVED roster ({@link agentPeopleCount}), which is what the section shows — + * the raw assignee list would render no badge for an everyone-agent and + * undercount the owner on an explicit one. + */ +export function AgentSettingsRail({ + agent, + groups, + ariaLabel, + selected, + onSelect, +}: { + agent: Agent; + groups: readonly AgentSettingsGroup[]; + ariaLabel: string; + selected: AgentSettingsSection; + onSelect: (section: AgentSettingsSection) => void; +}) { + const { t } = useTranslation(["teams", "agents"]); + const { data: learnings } = useLearnings(agent.folderPath); + const showsPeople = groups.some((g) => g.sections.includes("people")); + const { data: org } = useOrg(showsPeople); + const { data: session } = useSession(); + const people = useMemo( + () => + showsPeople + ? agentPeopleCount({ + agent, + members: org?.members ?? [], + selfId: session?.uid ?? null, + }) + : 0, + [showsPeople, agent, org?.members, session?.uid], + ); + + const badgeCount = (section: AgentSettingsSection): number | undefined => { + if (section === "learnings" && learnings?.entries.length) { + return learnings.entries.length; + } + if (section === "people" && people > 0) return people; + return undefined; + }; + + const labelled = groups.length > 1; + + return ( + + ); +} diff --git a/app/src/components/agent-settings/agent-settings-section.tsx b/app/src/components/agent-settings/agent-settings-section.tsx new file mode 100644 index 000000000..b55988001 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-section.tsx @@ -0,0 +1,62 @@ +import type { ReactNode } from "react"; +import { AgentAdminInstructions } from "../tabs/agent-admin/agent-admin-instructions"; +import { AgentAdminIntegrations } from "../tabs/agent-admin/agent-admin-integrations"; +import { AgentAdminKnowledge } from "../tabs/agent-admin/agent-admin-knowledge"; +import { AgentAdminModel } from "../tabs/agent-admin/agent-admin-model"; +import { AgentAdminSkills } from "../tabs/agent-admin/agent-admin-skills"; +import type { + AgentSectionProps, + AgentSettingsSection, +} from "./agent-settings-nav.ts"; +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. + */ +function AccessColumn({ children }: { children: ReactNode }) { + return ( +
    {children}
    + ); +} + +/** + * The ONE section switch: renders the section the rail selected into the right + * pane, for the settings page and the per-agent Context / Admin tabs alike. + * Every branch COMPOSES the existing section component rather than + * re-implementing it, so no two surfaces can drift. + */ +export function AgentSettingsSectionView({ + agent, + section, + readOnly = false, +}: AgentSectionProps & { section: AgentSettingsSection }) { + switch (section) { + case "job-description": + return ; + case "learnings": + return ; + case "people": + return ( + + + + ); + case "integrations": + return ( + + + + ); + case "models": + return ( + + + + ); + case "skills": + return ; + } +} diff --git a/app/src/components/agent-settings/agent-settings-selection.ts b/app/src/components/agent-settings/agent-settings-selection.ts new file mode 100644 index 000000000..1133229b5 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-selection.ts @@ -0,0 +1,73 @@ +import { + type AgentSettingsGroup, + type AgentSettingsSection, + agentSettingsSections, + SECTION_GROUP, +} from "./agent-settings-nav.ts"; + +/** + * Which section a rail actually opens on, and how a deep-link request survives + * the window before `/v1/capabilities` lands. Pure and DOM-free so both rules + * are unit-tested (`app/tests/agent-settings-nav.test.ts`). + */ + +/** + * The section to actually show. A deep link (or a selection held across a + * capabilities reload) may name a section this host hides, e.g. `integrations` + * on a multiplayer host without Teams. + * + * The fallback stays INSIDE the requested section's own group: an Apps request + * is a Permissions intent, and dropping the user on the job description would + * answer a question nobody asked. Only when that group is absent from this rail + * (the Admin tab has no Context group) does it fall back to the first item. + */ +export function resolveAgentSettingsSection( + groups: readonly AgentSettingsGroup[], + requested: AgentSettingsSection | undefined, +): AgentSettingsSection { + const sections = agentSettingsSections(groups); + if (requested !== undefined) { + if (sections.includes(requested)) return requested; + const group = groups.find((g) => g.id === SECTION_GROUP[requested]); + const first = group?.sections[0]; + if (first !== undefined) return first; + } + // The Context group is unconditional on the settings page, so its rail is + // never empty; the default names the first section rather than pretending an + // empty rail is possible. + return sections[0] ?? "job-description"; +} + +/** + * Advance the rail's selection one step, given the request still awaiting a + * rail that can honor it. + * + * A request is RETAINED until it has been honored once. `/v1/capabilities` + * lands after the first render, so a deep link into Apps would otherwise be + * resolved away against a rail that does not yet show Apps and then never + * re-applied — the user lands on the wrong section for the rest of the visit. + * Once the request resolves to itself it is retired, so later caps reloads + * never yank the user off the section they navigated to by hand. + */ +export function advanceAgentSettingsSelection(input: { + groups: readonly AgentSettingsGroup[]; + /** The deep-link request not yet honored, if any. */ + pending: AgentSettingsSection | undefined; + /** The section currently on screen. */ + current: AgentSettingsSection; +}): { + selected: AgentSettingsSection; + pending: AgentSettingsSection | undefined; +} { + if (input.pending !== undefined) { + const selected = resolveAgentSettingsSection(input.groups, input.pending); + return { + selected, + pending: selected === input.pending ? undefined : input.pending, + }; + } + return { + selected: resolveAgentSettingsSection(input.groups, input.current), + pending: undefined, + }; +} diff --git a/app/src/components/ai-hub/models-allowlist-editor.tsx b/app/src/components/ai-hub/models-allowlist-editor.tsx index 8a546e389..3cd4cccb5 100644 --- a/app/src/components/ai-hub/models-allowlist-editor.tsx +++ b/app/src/components/ai-hub/models-allowlist-editor.tsx @@ -1,5 +1,5 @@ import { Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { CatalogModel } from "../../lib/ai-hub/catalog-types.ts"; import { filterModels, searchModels } from "../../lib/ai-hub/search.ts"; import { AccessChoice } from "../tabs/agent-admin/access-choice.tsx"; @@ -70,6 +70,7 @@ export function ModelsAllowlistEditor({ onSave, copy, }: ModelsAllowlistEditorProps) { + const headingId = useId(); const [search, setSearch] = useState(""); // View-only lab filter (never touches saved data); composes with the search. const [lab, setLab] = useState("all"); @@ -120,7 +121,9 @@ export function ModelsAllowlistEditor({ return (
    -

    {copy.question}

    +

    + {copy.question} +

    {copy.policyHelper}

    {readOnly && copy.readOnlyNote && ( @@ -128,7 +131,7 @@ export function ModelsAllowlistEditor({ )} -

    {copy.question}

    +

    + {copy.question} +

    {copy.policyHelper}

    {readOnly && copy.readOnlyNote && (

    {copy.readOnlyNote}

    )} void; }) { const { t } = useTranslation("teams"); const { capabilities } = useCapabilities(); @@ -69,17 +70,12 @@ export function AgentDetail({ />
    - {canManage ? ( - - ) : ( -

    - {t("org.agentDetail.managerOnly")} -

    - )} + ); } diff --git a/app/src/components/permissions/agent-permissions-panel.tsx b/app/src/components/permissions/agent-permissions-panel.tsx deleted file mode 100644 index 4717f0907..000000000 --- a/app/src/components/permissions/agent-permissions-panel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@houston-ai/core"; -import type { OrgMember } from "@houston-ai/engine-client"; -import { useTranslation } from "react-i18next"; -import type { Agent } from "../../lib/types"; -import { AgentAdminIntegrations } from "../tabs/agent-admin/agent-admin-integrations"; -import { AgentAdminModel } from "../tabs/agent-admin/agent-admin-model"; -import { AgentPeopleTab } from "./agent-people-tab"; -import type { PermissionsAgentTab } from "./permissions-nav-store"; - -/** - * The three-tab permissions body for ONE agent — **People** (who can use it, at - * what level), **Integrations** (its app ceiling), and **AI Models** (its model - * ceiling). Shared by two fronts, one target: the top-level Permissions drill-in - * (`agent-detail.tsx`, always editable behind its manager gate). The agent - * workspace's Settings screen reuses the individual sections for its read-only - * access rows; this panel is only the editable top-level drill-in. - */ -export function AgentPermissionsPanel({ - agent, - members, - initialTab = "people", -}: { - agent: Agent; - members: OrgMember[]; - /** Tab to open on first mount (a deep link may land on Integrations). */ - initialTab?: PermissionsAgentTab; -}) { - const { t } = useTranslation("teams"); - - return ( - - - - {t("permissions.agentTabs.people")} - - - {t("permissions.agentTabs.integrations")} - - - {t("permissions.agentTabs.models")} - - - - - - - - - - - - - ); -} diff --git a/app/src/components/permissions/permissions-nav-store.ts b/app/src/components/permissions/permissions-nav-store.ts index 855751918..fe203dada 100644 --- a/app/src/components/permissions/permissions-nav-store.ts +++ b/app/src/components/permissions/permissions-nav-store.ts @@ -1,42 +1,42 @@ import { create } from "zustand"; - -/** Which tab of an agent's Permissions detail a deep link should open on. */ -export type PermissionsAgentTab = "people" | "integrations" | "models"; +import type { AgentSettingsSection } from "../agent-settings/agent-settings-nav.ts"; /** - * A one-shot request to open the Permissions view on a specific agent's detail, - * optionally on a specific tab. + * A one-shot request to open the Permissions view on a specific agent's + * settings page, optionally on a specific section. * * The view owns its own drill-in state, but this deep link arrives from OUTSIDE * it: the role-aware blocked-app CTA in the agent workspace (a locked/forbidden - * app a manager CAN enable) sends the user straight into that agent's detail, on - * the Integrations tab where the fix lives. Rather than lift that state into the + * app a manager CAN enable) sends the user straight into that agent's settings, + * on the Apps section where the fix lives. Rather than lift that state into the * shared UI store, this tiny colocated store carries the intent: the caller sets * the request then calls `openSettings("permissions")` (Permissions is a Settings * section since HOU-788, so that ONE store action replaces the old viewMode * switch); `PermissionsView` consumes it (initial mount AND while already open) * and clears it so a later plain nav lands back on the agent list. * + * The request names any {@link AgentSettingsSection}; a section this host hides + * falls back within its own rail group (`resolveAgentSettingsSection`). + * * A pin outlives the navigation that set it only if the section never renders, * so `SettingsView` clears it (`settings-nav-pins.ts`) whenever a blocked * section falls back to the index. */ interface PermissionsNavState { - /** The agent whose detail to open on the next render, or null for none. */ + /** The agent whose settings to open on the next render, or null for none. */ requestedAgentId: string | null; - /** The tab to open that detail on (defaults to People when unset). */ - requestedAgentTab: PermissionsAgentTab | null; - /** Ask the view to drill into `agentId`, optionally on a specific tab. */ - requestAgentDetail: (agentId: string, tab?: PermissionsAgentTab) => void; + /** The section to open it on (defaults to People when unset). */ + requestedSection: AgentSettingsSection | null; + /** Ask the view to drill into `agentId`, optionally on a specific section. */ + requestAgentDetail: (agentId: string, section?: AgentSettingsSection) => void; /** Drop the pending request once the view has honored it. */ clearRequested: () => void; } export const usePermissionsNav = create((set) => ({ requestedAgentId: null, - requestedAgentTab: null, - requestAgentDetail: (agentId, tab) => - set({ requestedAgentId: agentId, requestedAgentTab: tab ?? null }), - clearRequested: () => - set({ requestedAgentId: null, requestedAgentTab: null }), + requestedSection: null, + requestAgentDetail: (agentId, section) => + set({ requestedAgentId: agentId, requestedSection: section ?? null }), + clearRequested: () => set({ requestedAgentId: null, requestedSection: null }), })); diff --git a/app/src/components/permissions/permissions-view.tsx b/app/src/components/permissions/permissions-view.tsx index a4f2dde00..0f66bae0a 100644 --- a/app/src/components/permissions/permissions-view.tsx +++ b/app/src/components/permissions/permissions-view.tsx @@ -1,32 +1,30 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useOrg } from "../../hooks/queries"; import { analytics } from "../../lib/analytics"; import { useAgentStore } from "../../stores/agents"; +import type { AgentSettingsSection } from "../agent-settings/agent-settings-nav.ts"; import { BackBarScreen } from "../shell/back-bar-screen"; import { PageContainer, PageHeader } from "../shell/page-shell"; import { AgentDetail } from "./agent-detail"; import { AgentsList } from "./agents-list"; -import { - type PermissionsAgentTab, - usePermissionsNav, -} from "./permissions-nav-store"; +import { usePermissionsNav } from "./permissions-nav-store"; /** * The Permissions screen (Settings > Permissions, owner/admin only): the ONE * place that manages who can do what, and it is FULLY AGENT-CENTRIC. It shows - * the agent list; open an agent to manage, across three tabs, WHO can use it - * (People), its app ceiling (Integrations), and its model ceiling (AI Models). - * There is no per-person lens. + * the agent list; open an agent to reach the canonical agent settings page, + * where WHO can use it, its app + model ceilings, its skills and its context + * all live in one rail. There is no per-person lens. * - * A shell only: it loads the org once (roster), owns the drill-in state, and - * consumes a one-shot deep link from {@link usePermissionsNav} (the blocked-app - * CTA in the agent workspace routes straight into that agent's detail). Rendered - * ONLY when `canSeeOrganization` (multiplayer owner/admin, and on a Spaces host - * a TEAM active space, never the personal one) — the Settings index hides the - * row and `SettingsView` falls a stale section back to the index for everyone - * else, so it never mounts in single-player, for a plain member, or in a - * personal space. + * A shell only: it loads the org once (roster for the list), owns the drill-in + * state, and consumes a one-shot deep link from {@link usePermissionsNav} (the + * blocked-app CTA in the agent workspace routes straight into that agent's + * settings). Rendered ONLY when `canSeeOrganization` (multiplayer owner/admin, + * and on a Spaces host a TEAM active space, never the personal one) — the + * Settings index hides the row and `SettingsView` falls a stale section back to + * the index for everyone else, so it never mounts in single-player, for a plain + * member, or in a personal space. * * A settings section since HOU-788 (it had its own sidebar entry before), so the * caller owns the way back out: `onBack`/`backLabel` name the level above, and @@ -44,26 +42,31 @@ export function PermissionsView({ const agents = useAgentStore((s) => s.agents); const requestedAgentId = usePermissionsNav((s) => s.requestedAgentId); - const requestedAgentTab = usePermissionsNav((s) => s.requestedAgentTab); + const requestedSection = usePermissionsNav((s) => s.requestedSection); const clearRequested = usePermissionsNav((s) => s.clearRequested); // Drill-in held as an id (not a snapshot) so a store reload keeps the detail // pointed at the live row; if the id drops out, it falls back to the list. The - // opening tab is captured alongside so a deep link can land on Integrations. + // opening section is captured alongside so a deep link lands on Apps. const [detail, setDetail] = useState<{ agentId: string; - tab: PermissionsAgentTab; + section: AgentSettingsSection; } | null>(null); - // One event per agent drill-in, keyed like the global view switches (the - // opening tab rides along: permissions:people / integrations / models). - useEffect(() => { - if (detail !== null) + // Keyed like the global view switches, and reporting the section the page + // ACTUALLY shows — a request the host hides resolves to a sibling section, + // and analytics must record where the user landed, not what was asked for. + const detailAgentId = detail?.agentId; + const trackSection = useCallback( + (section: AgentSettingsSection) => { + if (detailAgentId === undefined) return; analytics.track("tab_opened", { - tab_name: `permissions:${detail.tab}`, - agent_id: detail.agentId, + tab_name: `permissions:${section}`, + agent_id: detailAgentId, }); - }, [detail]); + }, + [detailAgentId], + ); // Honor a one-shot deep link (the blocked-app "Enable it in Permissions" CTA), // then clear it so a later plain nav lands back on the agent list. @@ -71,10 +74,10 @@ export function PermissionsView({ if (requestedAgentId === null) return; setDetail({ agentId: requestedAgentId, - tab: requestedAgentTab ?? "people", + section: requestedSection ?? "people", }); clearRequested(); - }, [requestedAgentId, requestedAgentTab, clearRequested]); + }, [requestedAgentId, requestedSection, clearRequested]); const members = org?.members ?? []; const detailAgent = @@ -82,8 +85,7 @@ export function PermissionsView({ ? (agents.find((a) => a.id === detail.agentId) ?? null) : null; - // Agent drill-in: one agent's People + Integrations + AI Models. Back returns - // to the agent list. + // Agent drill-in: the whole agent settings page. Back returns to the list. if (detail && detailAgent) { return ( ); @@ -109,7 +111,7 @@ export function PermissionsView({ /> setDetail({ agentId: a.id, tab: "people" })} + onOpenAgent={(a) => setDetail({ agentId: a.id, section: "people" })} /> diff --git a/app/src/components/tabs/admin-tab.tsx b/app/src/components/tabs/admin-tab.tsx index 4ae31737a..210d191d4 100644 --- a/app/src/components/tabs/admin-tab.tsx +++ b/app/src/components/tabs/admin-tab.tsx @@ -1,53 +1,56 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useCapabilities } from "../../hooks/use-capabilities"; import { isAgentManager } from "../../lib/agent-access"; import type { TabProps } from "../../lib/types"; import { - type AgentAdminScreen, - adminScreens, -} from "./agent-admin/agent-admin-nav.ts"; -import { AgentAdminScreenView } from "./agent-admin/agent-admin-screen"; -import { AgentAdminSidebar } from "./agent-admin/agent-admin-sidebar"; + type AgentSettingsSection, + adminTabGroups, + agentSettingsSections, +} from "../agent-settings/agent-settings-nav.ts"; +import { AgentSettingsRail } from "../agent-settings/agent-settings-rail"; +import { AgentSettingsSectionView } from "../agent-settings/agent-settings-section"; /** * The Admin tab (PRODUCT-1256): who can use this agent plus its app and model - * ceilings, in the two-column master-detail layout. Visible to the workspace - * owner and agent managers only ({@link adminScreens} is empty outside - * multiplayer, where the tab is hidden entirely), so its sections are always - * editable; `readOnly` stays wired for the stale-capabilities window while - * `/v1/capabilities` is still loading. + * ceilings, in the two-column master-detail layout, on the shared + * {@link AgentSettingsRail} (one unlabelled group here — Skills is its own + * tab). Visible to the workspace owner and agent managers only + * ({@link adminTabGroups} is empty outside multiplayer, where the tab is hidden + * entirely), so its sections are always editable; `readOnly` stays wired for + * the stale-capabilities window while `/v1/capabilities` is still loading. */ export default function AdminTab({ agent }: TabProps) { const { t } = useTranslation("agents"); const { capabilities } = useCapabilities(); const readOnly = !isAgentManager(capabilities, agent); - const rows = adminScreens(capabilities); - const firstRow = rows[0] ?? "people"; - const [screen, setScreen] = useState(firstRow); + const groups = useMemo(() => adminTabGroups(capabilities), [capabilities]); + const firstSection: AgentSettingsSection = + agentSettingsSections(groups)[0] ?? "people"; + const [section, setSection] = useState(firstSection); const previousAgentIdRef = useRef(agent.id); useEffect(() => { if (previousAgentIdRef.current === agent.id) return; previousAgentIdRef.current = agent.id; - setScreen(firstRow); - }, [agent.id, firstRow]); + setSection(firstSection); + }, [agent.id, firstSection]); - if (rows.length === 0) return null; + if (groups.length === 0) return null; return (
    -
    -
    diff --git a/app/src/components/tabs/agent-access-diff.ts b/app/src/components/tabs/agent-access-diff.ts new file mode 100644 index 000000000..21188e098 --- /dev/null +++ b/app/src/components/tabs/agent-access-diff.ts @@ -0,0 +1,37 @@ +import type { Agent, OrgMember } from "@houston-ai/engine-client"; +import { buildSharePeople } from "./agent-access-model.ts"; + +/** + * Did an assignment write actually WIDEN who can reach the agent? Pure and + * DOM-free so the rule is unit-tested away from the mutation. + * + * Comparing raw `assignments` lengths is not the same question. The everyone + * sentinel is the EMPTY array, so the broadest share in the product looks like + * a shrink, and materializing that sentinel into today's roster (which grants + * nobody anything new) looks like a share. Both are answered correctly by + * comparing the RESOLVED rosters {@link buildSharePeople} produces — sentinel + * expanded to the whole team, org owner always present — and asking whether + * anyone appears in the new one who was not in the old. + * + * Access LEVEL changes (Can use ↔ Manager) are deliberately not widening: the + * `agent_shared` event answers "did more people get this agent", and a + * promotion within the existing roster is a different act. + */ +export function accessWidened(input: { + before: Pick; + after: Pick; + members: readonly OrgMember[]; +}): boolean { + const had = new Set( + buildSharePeople({ + agent: input.before, + members: input.members, + selfId: null, + }).map((person) => person.userId), + ); + return buildSharePeople({ + agent: input.after, + members: input.members, + selfId: null, + }).some((person) => !had.has(person.userId)); +} diff --git a/app/src/components/tabs/agent-admin/access-choice.tsx b/app/src/components/tabs/agent-admin/access-choice.tsx index df1bbfd73..195014fab 100644 --- a/app/src/components/tabs/agent-admin/access-choice.tsx +++ b/app/src/components/tabs/agent-admin/access-choice.tsx @@ -9,8 +9,8 @@ export interface AccessChoiceOption { } interface AccessChoiceProps { - /** The accessible name for the radio group (applied as its `aria-label`). */ - question: string; + /** Id of the VISIBLE heading that asks the question; names the radio group. */ + labelledBy: string; /** Exactly two options: the "any" (allow-all) and "picked" (restricted) modes. */ options: readonly [AccessChoiceOption, AccessChoiceOption]; /** The currently selected mode. */ @@ -28,14 +28,15 @@ interface AccessChoiceProps { * the selected option's description cross-fading below. Keeps full radio * semantics: a labelled `role="radiogroup"` of `role="radio"` segments with * `aria-checked`, a roving tabindex + arrow-key navigation, and a focus-visible - * ring. The question is not rendered as visible text (the section title already - * shows it); it names the radio group via `aria-label`. i18n-agnostic: the - * caller passes the question, labels, and descriptions in. Selecting the + * ring. The group is named by the section's VISIBLE question heading + * (`aria-labelledby`), never by a duplicate `aria-label`, so a screen reader + * announces the question once. i18n-agnostic: the caller passes the labels and + * descriptions in. Selecting the * already-selected option is a no-op, so re-picking "Only ... you pick" never * re-seeds the allowed set. */ export function AccessChoice({ - question, + labelledBy, options, value, disabled, @@ -78,7 +79,7 @@ export function AccessChoice({
    diff --git a/app/src/components/tabs/agent-admin/agent-admin-instructions.tsx b/app/src/components/tabs/agent-admin/agent-admin-instructions.tsx index e6fbab281..b8573abdb 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-instructions.tsx +++ b/app/src/components/tabs/agent-admin/agent-admin-instructions.tsx @@ -1,12 +1,12 @@ import { useInstructions, useSaveInstructions } from "../../../hooks/queries"; +import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts"; import { InstructionsContent } from "../job-description-parts"; -import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; /** Instructions (CLAUDE.md) section. Read-only for non-managers. */ export function AgentAdminInstructions({ agent, readOnly = false, -}: AgentAdminScreenProps & { readOnly?: boolean }) { +}: AgentSectionProps) { const path = agent.folderPath; const { data: instructions } = useInstructions(path); const saveInstructions = useSaveInstructions(path); diff --git a/app/src/components/tabs/agent-admin/agent-admin-integrations.tsx b/app/src/components/tabs/agent-admin/agent-admin-integrations.tsx index 06db4c1cf..115d2ddcb 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-integrations.tsx +++ b/app/src/components/tabs/agent-admin/agent-admin-integrations.tsx @@ -9,9 +9,9 @@ import { useSetAgentSettings, } from "../../../hooks/queries/use-agent-settings"; import { useCapabilities } from "../../../hooks/use-capabilities"; +import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts"; import { INTEGRATION_PROVIDER } from "../../integrations"; import { AgentAllowlistSection } from "../agent-integrations/agent-allowlist-section"; -import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; /** * "Allowed integrations" section (Access group): the relocated agent-manager @@ -27,7 +27,7 @@ import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; export function AgentAdminIntegrations({ agent, readOnly = false, -}: AgentAdminScreenProps & { readOnly?: boolean }) { +}: AgentSectionProps) { const { t } = useTranslation("teams"); const { capabilities } = useCapabilities(); const teams = capabilities?.teams === true; diff --git a/app/src/components/tabs/agent-admin/agent-admin-knowledge.tsx b/app/src/components/tabs/agent-admin/agent-admin-knowledge.tsx index 97f6ced4e..b1b5006c9 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-knowledge.tsx +++ b/app/src/components/tabs/agent-admin/agent-admin-knowledge.tsx @@ -11,14 +11,14 @@ import { collectTaughtByIds, resolveLearningProvenance, } from "../../../lib/learning-provenance"; +import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts"; import { LearningsContent } from "../learnings-content"; -import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; /** Memory (learnings) section. Read-only for non-managers. */ export function AgentAdminKnowledge({ agent, readOnly = false, -}: AgentAdminScreenProps & { readOnly?: boolean }) { +}: AgentSectionProps) { const path = agent.folderPath; const { data } = useLearnings(path); const addLearning = useAddLearning(path); diff --git a/app/src/components/tabs/agent-admin/agent-admin-model.tsx b/app/src/components/tabs/agent-admin/agent-admin-model.tsx index 462a6f6c2..d173a3563 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-model.tsx +++ b/app/src/components/tabs/agent-admin/agent-admin-model.tsx @@ -5,7 +5,7 @@ import { } from "../../../hooks/queries/use-agent-settings"; import { useCapabilities } from "../../../hooks/use-capabilities"; import { useHubCatalog } from "../../../lib/ai-hub/use-hub-catalog"; -import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; +import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts"; import { AgentModelsSection } from "./agent-models-section.tsx"; /** @@ -34,7 +34,7 @@ import { AgentModelsSection } from "./agent-models-section.tsx"; export function AgentAdminModel({ agent, readOnly = false, -}: AgentAdminScreenProps & { readOnly?: boolean }) { +}: AgentSectionProps) { const { capabilities } = useCapabilities(); const teams = capabilities?.teams === true; const settingsQuery = useAgentSettings(agent.id, teams); diff --git a/app/src/components/tabs/agent-admin/agent-admin-nav.ts b/app/src/components/tabs/agent-admin/agent-admin-nav.ts deleted file mode 100644 index 70cd20e88..000000000 --- a/app/src/components/tabs/agent-admin/agent-admin-nav.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Capabilities } from "@houston-ai/engine-client"; -import { isMultiplayer } from "../../../lib/org-roles.ts"; -import type { Agent } from "../../../lib/types"; - -/** - * The sections the Context and Admin agent tabs can show (PRODUCT-1256 split - * the old Settings tab; Skills became its own tab with no rail). Each nav item - * in a tab's rail maps 1:1 to a section, and the two-column layout keeps one - * always selected. Name / color / delete are NOT sections: those three actions - * live on the sidebar agent row, so there is no "general" or "template" concept - * here. Pure, DOM-free so the row visibility is unit-tested. - */ -export type AgentAdminScreen = - | "instructions" - | "knowledge" - | "people" - | "integrations" - | "model"; - -/** Shared props for every Context / Skills / Admin section component. */ -export interface AgentAdminScreenProps { - agent: Agent; -} - -/** - * The Context tab's rows: the agent's job description (instructions) and its - * learnings ("Memory"). Everyone who sees the tab sees both rows; non-managers - * get them read-only. - */ -export function contextScreens(): AgentAdminScreen[] { - return ["instructions", "knowledge"]; -} - -/** - * The Admin tab's rows: people with access, plus allowed apps and models when - * the host supports Teams policy ceilings. Empty outside multiplayer, which is - * why single-player hides the Admin tab entirely. - * The public-API "Connect" card (C10, `capabilities.apiKeys`) was removed from - * this surface (HOU-806): connecting external apps is a Routines concern now. - */ -export function adminScreens( - caps: Capabilities | null | undefined, -): AgentAdminScreen[] { - if (!isMultiplayer(caps)) return []; - return caps?.teams === true - ? ["people", "integrations", "model"] - : ["people"]; -} - -/** - * Deep-link from a turn-summary file target (a semantic file update the agent - * wrote) into the matching Context section. Learnings surface as "Memory". - */ -export function targetToScreen( - target: "instructions" | "learnings", -): AgentAdminScreen { - return target === "learnings" ? "knowledge" : target; -} diff --git a/app/src/components/tabs/agent-admin/agent-admin-screen.tsx b/app/src/components/tabs/agent-admin/agent-admin-screen.tsx deleted file mode 100644 index 0b740722f..000000000 --- a/app/src/components/tabs/agent-admin/agent-admin-screen.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useOrg } from "../../../hooks/queries"; -import { AgentPeopleTab } from "../../permissions/agent-people-tab"; -import { AgentAdminInstructions } from "./agent-admin-instructions"; -import { AgentAdminIntegrations } from "./agent-admin-integrations"; -import { AgentAdminKnowledge } from "./agent-admin-knowledge"; -import { AgentAdminModel } from "./agent-admin-model"; -import type { - AgentAdminScreen, - AgentAdminScreenProps, -} from "./agent-admin-nav.ts"; - -/** - * Renders the section selected in the settings rail into the right pane. Heavy - * editors live in their own files. The access sections carry no padding of - * their own (the Permissions drill-in mounts them inside its PageContainer), - * so THIS surface owns their page column: people in a narrow centered column, - * the apps/models editors in the same max-w-3xl column the configuration - * sections use. Name / color / delete are not sections here — those actions - * live on the sidebar agent row. - */ -export function AgentAdminScreenView({ - agent, - screen, - readOnly = false, -}: AgentAdminScreenProps & { screen: AgentAdminScreen; readOnly?: boolean }) { - const { data: org } = useOrg(screen === "people"); - switch (screen) { - case "instructions": - return ; - case "knowledge": - return ; - case "people": - return ( -
    - -
    - ); - case "integrations": - return ( -
    - -
    - ); - case "model": - return ( -
    - -
    - ); - } -} diff --git a/app/src/components/tabs/agent-admin/agent-admin-sidebar.tsx b/app/src/components/tabs/agent-admin/agent-admin-sidebar.tsx deleted file mode 100644 index 353ee525b..000000000 --- a/app/src/components/tabs/agent-admin/agent-admin-sidebar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Badge, cn } from "@houston-ai/core"; -import { - Boxes, - Brain, - FileText, - type LucideIcon, - Sparkles, - Users, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { useLearnings } from "../../../hooks/queries"; -import type { Agent } from "../../../lib/types"; -import type { AgentAdminScreen } from "./agent-admin-nav.ts"; - -const ICONS: Record = { - instructions: FileText, - knowledge: Brain, - people: Users, - integrations: Boxes, - model: Sparkles, -}; - -/** - * Title i18n key per nav item. Instructions reuses the existing - * `agents:subTabs.*` title; the rest are `teams:agentAdmin.rows.*.title` keys - * (listed explicitly so every key is type-checked and locale-validated). - */ -const ROW_TITLES = { - instructions: "agents:subTabs.instructions", - knowledge: "agentAdmin.rows.knowledge.title", - people: "agentAdmin.rows.people.title", - integrations: "agentAdmin.rows.integrations.title", - model: "agentAdmin.rows.model.title", -} as const satisfies Record; - -/** - * The slim nav rail the Context and Admin tabs share: one flat list of the - * rows the owning tab passes in, in order, with no visible group separation. - * Each nav item surfaces its note / people counts as bare-number badges, so a - * manager reads it without opening the section. The selected item is styled - * like the app sidebar nav (`bg-hover`, aria-current) with no hover-only - * affordance. - */ -export function AgentAdminSidebar({ - agent, - rows, - ariaLabel, - selected, - onSelect, -}: { - agent: Agent; - rows: AgentAdminScreen[]; - ariaLabel: string; - selected: AgentAdminScreen; - onSelect: (screen: AgentAdminScreen) => void; -}) { - const { t } = useTranslation(["teams", "agents"]); - const { data: learnings } = useLearnings(agent.folderPath); - - // Note / people counts render as bare-number badges. - const badgeCount = (s: AgentAdminScreen): number | undefined => { - if (s === "knowledge" && learnings?.entries.length) { - return learnings.entries.length; - } - if (s === "people" && agent.assignments?.length) { - return agent.assignments.length; - } - return undefined; - }; - - return ( - - ); -} diff --git a/app/src/components/tabs/agent-admin/agent-admin-skills.tsx b/app/src/components/tabs/agent-admin/agent-admin-skills.tsx index 588ec77ef..68f01f8e6 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-skills.tsx +++ b/app/src/components/tabs/agent-admin/agent-admin-skills.tsx @@ -1,10 +1,10 @@ import { useMemo } from "react"; import { mergeSharedIntoAgentSkills } from "../../../lib/agent-shared-skills"; +import type { AgentSectionProps } from "../../agent-settings/agent-settings-nav.ts"; import { SkillsContent } from "../skills-content"; import { useAgentSharedSkills } from "../use-agent-shared-skills"; import { useSkillSurface } from "../use-skill-surface"; import { useSkillSurfaceLabels } from "../use-skill-surface-labels"; -import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; /** * Skills section: the catalog-grammar Skills surface (installed-tile strip + @@ -16,9 +16,17 @@ import type { AgentAdminScreenProps } from "./agent-admin-nav.ts"; * the slug itself: a local copy is edited/deleted in place, a store skill's * save writes the ONE workspace copy and its danger action is "Disable for * this agent" (a reversible manifest write). A row's setup chat (HOU-791) - * stays reachable via the dialog's Edit in chat. Always editable. + * stays reachable via the dialog's Edit in chat. + * + * `readOnly` (a non-manager reading the agent settings page) drops the + * discovery tabs and the write affordances via {@link SkillsContent}'s own + * mode, leaving the installed strip. The Skills TAB never passes it: that tab + * is manager-only, so it stays editable. */ -export function AgentAdminSkills({ agent }: AgentAdminScreenProps) { +export function AgentAdminSkills({ + agent, + readOnly = false, +}: AgentSectionProps) { const surface = useSkillSurface(agent.folderPath); const shared = useAgentSharedSkills(agent.folderPath); const { editModalLabels, deleteConfirm } = useSkillSurfaceLabels(); @@ -43,6 +51,7 @@ export function AgentAdminSkills({ agent }: AgentAdminScreenProps) { agent={agent} skills={merged.skills} loading={surface.skillsLoading} + readOnly={readOnly} editingSkillName={surface.editingSkillName} editorState={surface.editorState} onEditSkill={surface.openEditSkill} diff --git a/app/src/components/tabs/agent-share-dialog.tsx b/app/src/components/tabs/agent-share-dialog.tsx index d7eba5945..5562468f0 100644 --- a/app/src/components/tabs/agent-share-dialog.tsx +++ b/app/src/components/tabs/agent-share-dialog.tsx @@ -51,7 +51,7 @@ export function AgentShareDialog({ const { capabilities } = useCapabilities(); const { data: session } = useSession(); const org = useOrg(isMultiplayer(capabilities) && open); - const share = useShareAgent(); + const share = useShareAgent("share_dialog"); // A self-lockout action (remove/demote yourself) held for confirmation. const [pending, setPending] = useState<{ person: SharePerson; @@ -65,7 +65,7 @@ export function AgentShareDialog({ const { profiles } = useUserProfiles(people.map((p) => p.userId)); const write = (assignments: AgentAssignment[]) => - share.mutate({ agentId: agent.id, assignments }); + share.mutate({ agentId: agent.id, assignments, members }); const handleAction = (person: SharePerson, action: ShareAction) => { if (needsSelfLockoutConfirm(person, action)) { diff --git a/app/src/components/tabs/context-tab.tsx b/app/src/components/tabs/context-tab.tsx index fffd94ebd..26d73a1ed 100644 --- a/app/src/components/tabs/context-tab.tsx +++ b/app/src/components/tabs/context-tab.tsx @@ -5,48 +5,50 @@ import { isAgentManager } from "../../lib/agent-access"; import type { TabProps } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; import { - type AgentAdminScreen, - contextScreens, - targetToScreen, -} from "./agent-admin/agent-admin-nav.ts"; -import { AgentAdminScreenView } from "./agent-admin/agent-admin-screen"; -import { AgentAdminSidebar } from "./agent-admin/agent-admin-sidebar"; + type AgentSettingsSection, + contextTabGroups, + targetToSection, +} from "../agent-settings/agent-settings-nav.ts"; +import { AgentSettingsRail } from "../agent-settings/agent-settings-rail"; +import { AgentSettingsSectionView } from "../agent-settings/agent-settings-section"; /** - * The Context tab (PRODUCT-1256): the agent's job description (instructions) - * and its learnings, in the two-column master-detail layout — a slim nav rail - * ({@link AgentAdminSidebar}) on the left, the selected section on the right. - * One section is always selected, so there is no back navigation. Non-managers - * see both sections read-only. A turn-summary file link deep-links straight - * into the matching section via the UI store target. + * The Context tab (PRODUCT-1256): the agent's job description and its + * learnings, in the two-column master-detail layout — the shared + * {@link AgentSettingsRail} on the left (one unlabelled group here), the + * selected section on the right. One section is always selected, so there is no + * back navigation. Non-managers see both sections read-only. A turn-summary + * file link deep-links straight into the matching section via the UI store + * target. */ export default function ContextTab({ agent }: TabProps) { const { t } = useTranslation("agents"); const { capabilities } = useCapabilities(); const readOnly = !isAgentManager(capabilities, agent); - const [screen, setScreen] = useState("instructions"); + const [section, setSection] = + useState("job-description"); const target = useUIStore((s) => s.contextTarget); const setTarget = useUIStore((s) => s.setContextTarget); useEffect(() => { if (!target) return; - setScreen(targetToScreen(target)); + setSection(targetToSection(target)); setTarget(null); }, [target, setTarget]); return (
    -
    -
    diff --git a/app/src/components/tabs/use-share-agent.ts b/app/src/components/tabs/use-share-agent.ts index bf57f8961..6c1e3b41e 100644 --- a/app/src/components/tabs/use-share-agent.ts +++ b/app/src/components/tabs/use-share-agent.ts @@ -1,19 +1,20 @@ -import type { AgentAssignment } from "@houston-ai/engine-client"; +import type { AgentAssignment, OrgMember } from "@houston-ai/engine-client"; import { useMutation } from "@tanstack/react-query"; import { analytics } from "../../lib/analytics"; import { tauriAgents } from "../../lib/tauri"; import type { Agent } from "../../lib/types"; import { useAgentStore } from "../../stores/agents"; import { useWorkspaceStore } from "../../stores/workspaces"; +import { accessWidened } from "./agent-access-diff.ts"; /** - * Optimistic write for the Share dialog (Teams v2). Sends the explicit - * `{userId, access}[]` roster via `tauriAgents.setAssignments`, which routes + * Optimistic write for an agent's assignee roster (Teams v2). Sends the + * explicit `{userId, access}[]` via `tauriAgents.setAssignments`, which routes * through `call()` — so a failure already surfaces as a red toast with the * Report-bug affordance AND reports to Sentry (no `onError` toast here would * double it). This hook adds only the OPTIMISTIC part `call()` can't: it patches * the agent's `assignments` / `assignedUserIds` in the Zustand agent store so - * the dialog and the chat "Shared agent" note update on click, and rolls that + * the surface and the chat "Shared agent" note update on click, and rolls that * patch back if the write fails. `onSettled` reloads the agent list so the * server's authoritative shape wins once the round-trip lands. */ @@ -30,15 +31,24 @@ function patchAgent( }; } -export function useShareAgent() { +/** Where the write came from, for the `agent_shared` event. */ +export type ShareSource = "share_dialog" | "agent_settings_people"; + +export interface ShareAgentVariables { + agentId: string; + /** The roster to write (set-replace). Empty = shared with everyone. */ + assignments: AgentAssignment[]; + /** + * The org roster. Needed to tell a real widening from a no-op: the everyone + * sentinel only expands against the member list ({@link accessWidened}). + */ + members: readonly OrgMember[]; +} + +export function useShareAgent(source: ShareSource) { return useMutation({ - mutationFn: ({ - agentId, - assignments, - }: { - agentId: string; - assignments: AgentAssignment[]; - }) => tauriAgents.setAssignments(agentId, assignments), + mutationFn: ({ agentId, assignments }: ShareAgentVariables) => + tauriAgents.setAssignments(agentId, assignments), onMutate: ({ agentId, assignments }) => { const store = useAgentStore.getState(); const snapshot = { agents: store.agents, current: store.current }; @@ -50,16 +60,21 @@ export function useShareAgent() { }); return snapshot; }, - onSuccess: (_data, { agentId, assignments }, snapshot) => { - // Only count GROWING the roster as a share; shrinking it is revocation. - const prev = - snapshot?.agents.find((a) => a.id === agentId)?.assignments?.length ?? - 0; - if (assignments.length > prev) { - analytics.track("agent_shared", { - agent_id: agentId, - source: "share_dialog", - }); + onSuccess: (_data, { agentId, assignments, members }, snapshot) => { + // Only an actual WIDENING is a share; narrowing is revocation, and + // making the everyone sentinel explicit changes nobody's access at all. + const before = snapshot?.agents.find((a) => a.id === agentId); + if (!before) return; + const widened = accessWidened({ + before, + after: { + assignments, + assignedUserIds: assignments.map((a) => a.userId), + }, + members, + }); + if (widened) { + analytics.track("agent_shared", { agent_id: agentId, source }); } }, onError: (_err, _vars, snapshot) => { @@ -79,3 +94,6 @@ export function useShareAgent() { }, }); } + +/** The mutation object surfaces pass down so one write channel serves them all. */ +export type ShareAgentMutation = ReturnType; diff --git a/app/src/locales/en/teams.json b/app/src/locales/en/teams.json index 1cebb2d67..64942b5c9 100644 --- a/app/src/locales/en/teams.json +++ b/app/src/locales/en/teams.json @@ -47,6 +47,35 @@ "unavailable": "Integration limits are not available here." } }, + "agentSettings": { + "railLabel": "Agent settings sections", + "groups": { + "context": "Context", + "permissions": "Permissions" + }, + "people": { + "question": "Who can use this agent?", + "helper": "Pick whether everyone on your team can use this agent, or only the people you choose.", + "anyLabel": "Everyone on your team", + "anyDesc": "Every teammate can use this agent, including people who join later.", + "pickedLabel": "Only specific people", + "pickedDesc": "Only the people you pick below can use this agent.", + "everyoneNote": "Everyone on your team can use this agent, so there is nothing to set person by person. Switch to Only specific people to manage access individually.", + "confirmEveryone": { + "title": "Give everyone access?", + "description": "This replaces the current list. Everyone on your team will be able to use this agent, and any Manager access granted here will be removed.", + "confirm": "Yes, give everyone access", + "cancel": "Cancel" + }, + "confirmSpecific": { + "title": "Switch to specific people?", + "description_one": "The {{count}} person who can use this agent today keeps access. New teammates will not be added automatically.", + "description_other": "The {{count}} people who can use this agent today keep access. New teammates will not be added automatically.", + "confirm": "Yes, pick people", + "cancel": "Cancel" + } + } + }, "integrations": { "allowlist": { "question": "Which apps can this agent use?", @@ -80,9 +109,8 @@ "billing": "Billing" }, "agentDetail": { - "subtitle": "What this agent is allowed to use.", - "openAgent": "Open agent", - "managerOnly": "Only this agent's Managers can change its access." + "subtitle": "Everything you configure on this agent, and who can use it.", + "openAgent": "Open agent" }, "index": { "groups": { @@ -105,11 +133,6 @@ "permissions": { "title": "Permissions", "subtitle": "Pick an agent to manage who can use it and what it can use.", - "agentTabs": { - "people": "People", - "integrations": "Integrations", - "models": "AI Models" - }, "agentPeople": { "none": "No access", "noneHint": "Cannot use this agent.", diff --git a/app/src/locales/es/teams.json b/app/src/locales/es/teams.json index ca09f46c0..ad989db86 100644 --- a/app/src/locales/es/teams.json +++ b/app/src/locales/es/teams.json @@ -47,6 +47,35 @@ "unavailable": "Los límites de integraciones no están disponibles aquí." } }, + "agentSettings": { + "railLabel": "Secciones de la configuración del agente", + "groups": { + "context": "Contexto", + "permissions": "Permisos" + }, + "people": { + "question": "¿Quién puede usar este agente?", + "helper": "Elige si todo tu equipo puede usar este agente o solo las personas que tú elijas.", + "anyLabel": "Todo tu equipo", + "anyDesc": "Cualquier persona del equipo puede usar este agente, incluidas las que se sumen después.", + "pickedLabel": "Solo personas específicas", + "pickedDesc": "Solo las personas que elijas abajo pueden usar este agente.", + "everyoneNote": "Todo tu equipo puede usar este agente, así que no hay nada que definir persona por persona. Cambia a Solo personas específicas para gestionar el acceso individualmente.", + "confirmEveryone": { + "title": "¿Dar acceso a todo el equipo?", + "description": "Esto reemplaza la lista actual. Todo tu equipo podrá usar este agente y se quitará el acceso de Manager que hayas dado aquí.", + "confirm": "Sí, dar acceso a todos", + "cancel": "Cancelar" + }, + "confirmSpecific": { + "title": "¿Cambiar a personas específicas?", + "description_one": "La {{count}} persona que puede usar este agente hoy conserva el acceso. Las personas que se sumen después no se agregarán automáticamente.", + "description_other": "Las {{count}} personas que pueden usar este agente hoy conservan el acceso. Las personas que se sumen después no se agregarán automáticamente.", + "confirm": "Sí, elegir personas", + "cancel": "Cancelar" + } + } + }, "integrations": { "allowlist": { "question": "¿Qué apps puede usar este agente?", @@ -80,9 +109,8 @@ "billing": "Facturación" }, "agentDetail": { - "subtitle": "Qué tiene permitido utilizar este agente.", - "openAgent": "Abrir agente", - "managerOnly": "Solo los Managers de este agente pueden cambiar su acceso." + "subtitle": "Todo lo que configuras en este agente y quién puede usarlo.", + "openAgent": "Abrir agente" }, "index": { "groups": { @@ -105,11 +133,6 @@ "permissions": { "title": "Permisos", "subtitle": "Elige un agente para gestionar quién puede usarlo y qué puede usar.", - "agentTabs": { - "people": "Personas", - "integrations": "Integraciones", - "models": "Modelos de IA" - }, "agentPeople": { "none": "Sin acceso", "noneHint": "No puede usar este agente.", diff --git a/app/src/locales/pt/teams.json b/app/src/locales/pt/teams.json index bffccf728..eddbbeeb1 100644 --- a/app/src/locales/pt/teams.json +++ b/app/src/locales/pt/teams.json @@ -47,6 +47,35 @@ "unavailable": "Os limites de integrações não estão disponíveis aqui." } }, + "agentSettings": { + "railLabel": "Seções das configurações do agente", + "groups": { + "context": "Contexto", + "permissions": "Permissões" + }, + "people": { + "question": "Quem pode usar este agente?", + "helper": "Escolha se toda a sua equipe pode usar este agente ou apenas as pessoas que você escolher.", + "anyLabel": "Toda a sua equipe", + "anyDesc": "Qualquer pessoa da equipe pode usar este agente, incluindo quem entrar depois.", + "pickedLabel": "Apenas pessoas específicas", + "pickedDesc": "Apenas as pessoas que você escolher abaixo podem usar este agente.", + "everyoneNote": "Toda a sua equipe pode usar este agente, então não há nada para definir pessoa por pessoa. Mude para Apenas pessoas específicas para gerenciar o acesso individualmente.", + "confirmEveryone": { + "title": "Dar acesso a toda a equipe?", + "description": "Isso substitui a lista atual. Toda a sua equipe poderá usar este agente e o acesso de Manager concedido aqui será removido.", + "confirm": "Sim, dar acesso a todos", + "cancel": "Cancelar" + }, + "confirmSpecific": { + "title": "Mudar para pessoas específicas?", + "description_one": "A {{count}} pessoa que pode usar este agente hoje mantém o acesso. Quem entrar depois não será adicionado automaticamente.", + "description_other": "As {{count}} pessoas que podem usar este agente hoje mantêm o acesso. Quem entrar depois não será adicionado automaticamente.", + "confirm": "Sim, escolher pessoas", + "cancel": "Cancelar" + } + } + }, "integrations": { "allowlist": { "question": "Quais apps este agente pode usar?", @@ -80,9 +109,8 @@ "billing": "Cobrança" }, "agentDetail": { - "subtitle": "O que este agente tem permissão para utilizar.", - "openAgent": "Abrir agente", - "managerOnly": "Apenas os Managers deste agente podem alterar o acesso dele." + "subtitle": "Tudo o que você configura neste agente e quem pode usá-lo.", + "openAgent": "Abrir agente" }, "index": { "groups": { @@ -105,11 +133,6 @@ "permissions": { "title": "Permissões", "subtitle": "Escolha um agente para gerenciar quem pode usá-lo e o que ele pode usar.", - "agentTabs": { - "people": "Pessoas", - "integrations": "Integrações", - "models": "Modelos de IA" - }, "agentPeople": { "none": "Sem acesso", "noneHint": "Não pode usar este agente.", diff --git a/app/tests/agent-access-diff.test.ts b/app/tests/agent-access-diff.test.ts new file mode 100644 index 000000000..1558c0b63 --- /dev/null +++ b/app/tests/agent-access-diff.test.ts @@ -0,0 +1,96 @@ +import { strictEqual } from "node:assert"; +import { describe, it } from "node:test"; +import type { Agent, OrgMember } from "@houston-ai/engine-client"; +import { accessWidened } from "../src/components/tabs/agent-access-diff.ts"; + +const member = (userId: string, role: OrgMember["role"]): OrgMember => ({ + userId, + email: `${userId}@acme.test`, + role, +}); + +const MEMBERS = [ + member("u-self", "owner"), + member("u-bob", "user"), + member("u-cara", "admin"), +]; + +const roster = ( + ...assignments: { userId: string; access: "manager" | "user" }[] +): Pick => ({ + assignments, + assignedUserIds: assignments.map((a) => a.userId), +}); + +/** The everyone sentinel: assignee fields present, but empty. */ +const EVERYONE = roster(); +const OWNER_AND_BOB = roster( + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, +); + +describe("accessWidened — what actually counts as sharing", () => { + it("switching to EVERYONE widens, even though the array shrinks to []", () => { + // The bug this guards: `assignments.length > prev` never fires on the + // broadest share in the product, because the sentinel is the empty array. + strictEqual( + accessWidened({ + before: OWNER_AND_BOB, + after: EVERYONE, + members: MEMBERS, + }), + true, + ); + }); + + it("materializing an everyone agent does NOT widen (nobody gains access)", () => { + strictEqual( + accessWidened({ + before: EVERYONE, + after: roster( + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, + { userId: "u-cara", access: "user" }, + ), + members: MEMBERS, + }), + false, + ); + }); + + it("adding a teammate widens", () => { + strictEqual( + accessWidened({ + before: roster({ userId: "u-self", access: "manager" }), + after: OWNER_AND_BOB, + members: MEMBERS, + }), + true, + ); + }); + + it("removing a teammate does not widen", () => { + strictEqual( + accessWidened({ + before: OWNER_AND_BOB, + after: roster({ userId: "u-self", access: "manager" }), + members: MEMBERS, + }), + false, + ); + }); + + it("promoting someone already on the roster is not a share", () => { + strictEqual( + accessWidened({ + before: OWNER_AND_BOB, + after: roster( + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "manager" }, + ), + members: MEMBERS, + }), + false, + ); + }); +}); diff --git a/app/tests/agent-admin-a11y.test.ts b/app/tests/agent-admin-a11y.test.ts index c6f7b998b..af237b048 100644 --- a/app/tests/agent-admin-a11y.test.ts +++ b/app/tests/agent-admin-a11y.test.ts @@ -24,10 +24,10 @@ describe("Agent Settings a11y", () => { }); it("the tab renders no

    ; sections use

    ", () => { - const sidebar = read( - "../src/components/tabs/agent-admin/agent-admin-sidebar.tsx", + const rail = read( + "../src/components/agent-settings/agent-settings-rail.tsx", ); - ok(!sidebar.includes(" = {}): Capabilities => ({ - profile: "cloud", - revealInOs: false, - terminal: false, - tunnel: false, - codeExecution: "remote-sandbox", - providers: [], - openaiCompatible: false, - integrations: [], - ...over, -}); - -describe("contextScreens — the Context tab's rows", () => { - it("always carries instructions and knowledge, in order", () => { - deepStrictEqual(contextScreens(), ["instructions", "knowledge"]); - }); -}); - -describe("adminScreens — the Admin tab's rows", () => { - it("single-player: no rows at all (the Admin tab is hidden there)", () => { - deepStrictEqual(adminScreens(caps()), []); - // A null capabilities host (legacy / pre-Teams) behaves the same. - deepStrictEqual(adminScreens(null), []); - }); - - it("Teams: people, apps, and models", () => { - deepStrictEqual(adminScreens(caps({ multiplayer: true, teams: true })), [ - "people", - "integrations", - "model", - ]); - }); - - it("legacy multiplayer without Teams keeps the People row only", () => { - deepStrictEqual(adminScreens(caps({ multiplayer: true, teams: false })), [ - "people", - ]); - }); - - it("no Connect row anywhere — even on an apiKeys gateway (HOU-806)", () => { - for (const c of [ - caps({ apiKeys: true }), - caps({ multiplayer: true, teams: true, apiKeys: true }), - ]) { - strictEqual(adminScreens(c).includes("connect" as never), false); - } - }); -}); - -describe("targetToScreen — deep-link mapping", () => { - it("maps learnings to the knowledge screen, instructions passes through", () => { - strictEqual(targetToScreen("instructions"), "instructions"); - strictEqual(targetToScreen("learnings"), "knowledge"); - }); -}); diff --git a/app/tests/agent-people-choice.test.ts b/app/tests/agent-people-choice.test.ts new file mode 100644 index 000000000..32e6c16ae --- /dev/null +++ b/app/tests/agent-people-choice.test.ts @@ -0,0 +1,289 @@ +import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; +import type { Agent, OrgMember } from "@houston-ai/engine-client"; +import { + agentAccessMode, + agentPeopleCount, + canChooseAgentAccess, + everyoneAssignments, + everyoneChangesAssignments, + everyoneSwitchConfirm, + materializeRoster, +} from "../src/components/agent-settings/agent-people-choice.ts"; + +const agent = (over: Partial): Agent => ({ + id: over.id ?? "a", + name: over.name ?? "Agent", + folderPath: over.id ?? "a", + configId: "houston", + createdAt: "2024-01-01T00:00:00.000Z", + ...over, +}); + +const member = (over: Partial = {}): OrgMember => ({ + userId: over.userId ?? "u-bob", + email: over.email, + role: over.role ?? "user", +}); + +const SELF = member({ + userId: "u-self", + email: "self@acme.test", + role: "owner", +}); +const BOB = member({ userId: "u-bob", email: "bob@acme.test", role: "user" }); +const CARA = member({ + userId: "u-cara", + email: "cara@acme.test", + role: "admin", +}); +const ROSTER = [SELF, BOB, CARA]; + +/** The everyone sentinel: assignee fields present, but empty. */ +const EVERYONE = agent({ id: "e", assignments: [], assignedUserIds: [] }); + +const PICKED = agent({ + id: "p", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, + ], + assignedUserIds: ["u-self", "u-bob"], +}); + +const input = (a: Agent) => ({ agent: a, members: ROSTER, selfId: "u-self" }); + +describe("agentAccessMode", () => { + it("reads the empty assignee set as everyone in the team", () => { + strictEqual(agentAccessMode(EVERYONE), "any"); + }); + + it("reads an explicit roster as only specific people", () => { + strictEqual(agentAccessMode(PICKED), "picked"); + }); + + it("a single-player agent (no assignee fields) is not an everyone agent", () => { + strictEqual(agentAccessMode(agent({ id: "s" })), "picked"); + }); +}); + +describe("canChooseAgentAccess", () => { + it("needs a readable roster: with none, materializing would write the sentinel", () => { + strictEqual(canChooseAgentAccess([]), false); + strictEqual(canChooseAgentAccess(ROSTER), true); + }); +}); + +describe("everyoneAssignments", () => { + it("is the empty sentinel, and a fresh array each call", () => { + const first = everyoneAssignments(); + const second = everyoneAssignments(); + deepStrictEqual(first, []); + // Never a shared mutable constant: one caller's write must not leak. + ok(first !== second); + }); +}); + +describe("materializeRoster — switching to only specific people", () => { + it("expands an everyone agent to the whole team without changing access", () => { + const written = materializeRoster(input(EVERYONE)); + deepStrictEqual( + [...written].sort((a, b) => a.userId.localeCompare(b.userId)), + [ + { userId: "u-bob", access: "user" }, + { userId: "u-cara", access: "user" }, + { userId: "u-self", access: "manager" }, + ], + ); + }); + + it("never loses the owner row", () => { + for (const a of [EVERYONE, PICKED, agent({ id: "n", assignments: [] })]) { + const owner = materializeRoster(input(a)).find( + (row) => row.userId === "u-self", + ); + deepStrictEqual(owner, { userId: "u-self", access: "manager" }); + } + }); + + it("keeps an explicit roster exactly as it stands (no widening)", () => { + const written = materializeRoster(input(PICKED)); + deepStrictEqual( + [...written].sort((a, b) => a.userId.localeCompare(b.userId)), + [ + { userId: "u-bob", access: "user" }, + { userId: "u-self", access: "manager" }, + ], + ); + }); + + it("materializing then reading back flips the mode to picked", () => { + const written = materializeRoster(input(EVERYONE)); + strictEqual( + agentAccessMode({ + assignments: written, + assignedUserIds: written.map((a) => a.userId), + }), + "picked", + ); + }); +}); + +describe("everyoneChangesAssignments — the confirm gate", () => { + it("true when someone would gain access", () => { + // Cara is not on the roster today; the sentinel would let her in. + strictEqual(everyoneChangesAssignments(input(PICKED)), true); + }); + + it("true when a Manager seat would be dropped (the sentinel cannot carry it)", () => { + const withManager = agent({ + id: "m", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, + { userId: "u-cara", access: "manager" }, + ], + assignedUserIds: ["u-self", "u-bob", "u-cara"], + }); + strictEqual(everyoneChangesAssignments(input(withManager)), true); + }); + + it("false when the explicit roster already resolves to the whole team", () => { + const wholeTeam = agent({ + id: "w", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, + { userId: "u-cara", access: "user" }, + ], + assignedUserIds: ["u-self", "u-bob", "u-cara"], + }); + strictEqual(everyoneChangesAssignments(input(wholeTeam)), false); + }); + + it("false for an agent already on the everyone sentinel", () => { + strictEqual(everyoneChangesAssignments(input(EVERYONE)), false); + }); + + it("materializing an everyone agent is a round trip: no confirm to go back", () => { + const written = materializeRoster(input(EVERYONE)); + strictEqual( + everyoneChangesAssignments( + input( + agent({ + id: "r", + assignments: written, + assignedUserIds: written.map((a) => a.userId), + }), + ), + ), + false, + ); + }); +}); + +describe("everyoneSwitchConfirm — which confirm the switch must show", () => { + /** Cara is an org admin, so she can hold a Manager seat without being owner. */ + const CARA_MANAGES = agent({ + id: "cm", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-cara", access: "manager" }, + ], + assignedUserIds: ["u-self", "u-cara"], + }); + + it("warns about SELF-LOCKOUT when the viewer is a non-owner manager", () => { + // The empty sentinel cannot carry a Manager grant, so Cara demotes HERSELF + // and loses this very page. That is not a generic "everyone gets access". + strictEqual( + everyoneSwitchConfirm({ + agent: CARA_MANAGES, + members: ROSTER, + selfId: "u-cara", + }), + "selfLockout", + ); + }); + + it("the org owner is never locked out (buildSharePeople re-adds them)", () => { + strictEqual( + everyoneSwitchConfirm({ + agent: CARA_MANAGES, + members: ROSTER, + selfId: "u-self", + }), + "changesAccess", + ); + }); + + it("a viewer who only USES the agent is not locking themselves out", () => { + strictEqual( + everyoneSwitchConfirm({ + agent: PICKED, + members: ROSTER, + selfId: "u-bob", + }), + "changesAccess", + ); + }); + + it("no signed-in viewer resolves to no self-lockout", () => { + strictEqual( + everyoneSwitchConfirm({ + agent: CARA_MANAGES, + members: ROSTER, + selfId: null, + }), + "changesAccess", + ); + }); + + it("nothing to confirm when the switch changes nobody's access", () => { + const wholeTeam = agent({ + id: "w", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-bob", access: "user" }, + { userId: "u-cara", access: "user" }, + ], + assignedUserIds: ["u-self", "u-bob", "u-cara"], + }); + strictEqual( + everyoneSwitchConfirm({ + agent: wholeTeam, + members: ROSTER, + selfId: "u-self", + }), + "none", + ); + strictEqual(everyoneSwitchConfirm(input(EVERYONE)), "none"); + }); +}); + +describe("agentPeopleCount — the rail badge", () => { + it("counts the whole team for an everyone agent (the sentinel is not zero)", () => { + strictEqual(agentPeopleCount(input(EVERYONE)), 3); + }); + + it("counts the expansion, so an explicit roster never undercounts the owner", () => { + const withoutOwner = agent({ + id: "no", + assignments: [{ userId: "u-bob", access: "user" }], + assignedUserIds: ["u-bob"], + }); + strictEqual(agentPeopleCount(input(withoutOwner)), 2); + strictEqual(agentPeopleCount(input(PICKED)), 2); + }); + + it("is zero for a single-player agent with no roster to expand", () => { + strictEqual( + agentPeopleCount({ + agent: agent({ id: "s" }), + members: [], + selfId: null, + }), + 0, + ); + }); +}); diff --git a/app/tests/agent-people-model.test.ts b/app/tests/agent-people-model.test.ts index 79ca1dabb..d8b0acdce 100644 --- a/app/tests/agent-people-model.test.ts +++ b/app/tests/agent-people-model.test.ts @@ -7,7 +7,7 @@ import { buildAgentPeople, canPersonBeManager, writeAgentPerson, -} from "../src/components/permissions/agent-people-model.ts"; +} from "../src/components/agent-settings/agent-people-model.ts"; const agent = (over: Partial): Agent => ({ id: over.id ?? "a", diff --git a/app/tests/agent-settings-nav.test.ts b/app/tests/agent-settings-nav.test.ts new file mode 100644 index 000000000..3d070197d --- /dev/null +++ b/app/tests/agent-settings-nav.test.ts @@ -0,0 +1,250 @@ +import { deepStrictEqual, strictEqual } from "node:assert"; +import { describe, it } from "node:test"; +import type { Capabilities } from "@houston-ai/engine-client"; +import { + adminTabGroups, + agentAccessSections, + agentSettingsGroups, + agentSettingsSections, + contextTabGroups, + targetToSection, +} from "../src/components/agent-settings/agent-settings-nav.ts"; +import { + advanceAgentSettingsSelection, + resolveAgentSettingsSection, +} from "../src/components/agent-settings/agent-settings-selection.ts"; + +const caps = (over: Partial = {}): Capabilities => ({ + profile: "cloud", + revealInOs: false, + terminal: false, + tunnel: false, + codeExecution: "remote-sandbox", + providers: [], + openaiCompatible: false, + integrations: [], + ...over, +}); + +const TEAMS = caps({ multiplayer: true, teams: true }); +const LEGACY_MULTIPLAYER = caps({ multiplayer: true, teams: false }); + +describe("agentSettingsGroups — the settings rail", () => { + it("always opens with the Context group, in order", () => { + for (const c of [caps(), LEGACY_MULTIPLAYER, TEAMS, null]) { + const [context] = agentSettingsGroups(c); + deepStrictEqual(context, { + id: "context", + sections: ["job-description", "learnings"], + }); + } + }); + + it("single player: Permissions is Skills only (no roster, no ceilings)", () => { + for (const c of [caps(), null, undefined]) { + deepStrictEqual(agentSettingsGroups(c)[1], { + id: "permissions", + sections: ["skills"], + }); + } + }); + + it("legacy multiplayer without Teams: people, then skills", () => { + deepStrictEqual(agentSettingsGroups(LEGACY_MULTIPLAYER)[1], { + id: "permissions", + sections: ["people", "skills"], + }); + }); + + it("Teams: people, apps, models, then skills", () => { + deepStrictEqual(agentSettingsGroups(TEAMS)[1], { + id: "permissions", + sections: ["people", "integrations", "models", "skills"], + }); + }); + + it("the ceilings need multiplayer, not just teams", () => { + // `teams` without `multiplayer` is not a shape the gateway serves, but the + // gate must not widen on it either: no roster, no ceilings. + deepStrictEqual(agentSettingsGroups(caps({ teams: true }))[1].sections, [ + "skills", + ]); + }); + + it("never yields an empty group", () => { + for (const c of [caps(), LEGACY_MULTIPLAYER, TEAMS]) { + for (const group of agentSettingsGroups(c)) { + strictEqual(group.sections.length > 0, true); + } + } + }); +}); + +describe("agentAccessSections — the access sections the Admin tab shows", () => { + it("single-player: none at all (the Admin tab is hidden there)", () => { + deepStrictEqual(agentAccessSections(caps()), []); + // A null capabilities host (legacy / pre-Teams) behaves the same. + deepStrictEqual(agentAccessSections(null), []); + }); + + it("Teams: people, apps, and models", () => { + deepStrictEqual(agentAccessSections(TEAMS), [ + "people", + "integrations", + "models", + ]); + }); + + it("legacy multiplayer without Teams keeps People only", () => { + deepStrictEqual(agentAccessSections(LEGACY_MULTIPLAYER), ["people"]); + }); + + it("no Connect section anywhere — even on an apiKeys gateway (HOU-806)", () => { + for (const c of [ + caps({ apiKeys: true }), + caps({ multiplayer: true, teams: true, apiKeys: true }), + ]) { + strictEqual(agentAccessSections(c).includes("connect" as never), false); + } + }); +}); + +describe("the agent tabs' rails — one unlabelled group each", () => { + it("Context tab: job description and Memory, in order", () => { + deepStrictEqual(contextTabGroups(), [ + { id: "context", sections: ["job-description", "learnings"] }, + ]); + }); + + it("Admin tab: exactly the access sections, and NO skills", () => { + deepStrictEqual(adminTabGroups(TEAMS), [ + { id: "permissions", sections: ["people", "integrations", "models"] }, + ]); + deepStrictEqual(adminTabGroups(LEGACY_MULTIPLAYER), [ + { id: "permissions", sections: ["people"] }, + ]); + }); + + it("Admin tab: no group at all in single player (the tab renders nothing)", () => { + deepStrictEqual(adminTabGroups(caps()), []); + deepStrictEqual(adminTabGroups(null), []); + }); +}); + +describe("agentSettingsSections — flattened rail order", () => { + it("is Context first, then Permissions", () => { + deepStrictEqual(agentSettingsSections(agentSettingsGroups(TEAMS)), [ + "job-description", + "learnings", + "people", + "integrations", + "models", + "skills", + ]); + }); +}); + +describe("targetToSection — deep-link mapping", () => { + it("maps a turn-summary file target onto its Context section", () => { + strictEqual(targetToSection("instructions"), "job-description"); + strictEqual(targetToSection("learnings"), "learnings"); + }); +}); + +describe("resolveAgentSettingsSection — deep-link fallback", () => { + it("keeps a section the rail actually shows", () => { + strictEqual( + resolveAgentSettingsSection(agentSettingsGroups(TEAMS), "integrations"), + "integrations", + ); + }); + + it("falls back INSIDE the requested section's own group", () => { + // An Apps request is a Permissions intent: landing on the job description + // would answer a question nobody asked. + strictEqual( + resolveAgentSettingsSection( + agentSettingsGroups(LEGACY_MULTIPLAYER), + "integrations", + ), + "people", + ); + strictEqual( + resolveAgentSettingsSection(agentSettingsGroups(caps()), "people"), + "skills", + ); + strictEqual( + resolveAgentSettingsSection(agentSettingsGroups(caps()), "integrations"), + "skills", + ); + }); + + it("falls back to the first rail item when nothing was requested", () => { + strictEqual( + resolveAgentSettingsSection(agentSettingsGroups(TEAMS), undefined), + "job-description", + ); + }); + + it("falls back to the first rail item when the requested group is empty", () => { + // The Admin tab's rail has no Context group, so a Context request there has + // no group of its own to land in. + strictEqual( + resolveAgentSettingsSection(adminTabGroups(TEAMS), "learnings"), + "people", + ); + }); +}); + +describe("advanceAgentSettingsSelection — a request survives the caps window", () => { + it("honors a request the rail can show, and retires it", () => { + deepStrictEqual( + advanceAgentSettingsSelection({ + groups: agentSettingsGroups(TEAMS), + pending: "integrations", + current: "job-description", + }), + { selected: "integrations", pending: undefined }, + ); + }); + + it("keeps the request pending while `/v1/capabilities` is still null", () => { + // The pre-caps rail hides Apps, so the request is served a fallback but NOT + // dropped: it must be re-applied the moment the real rail arrives. + const window = advanceAgentSettingsSelection({ + groups: agentSettingsGroups(null), + pending: "integrations", + current: "job-description", + }); + deepStrictEqual(window, { selected: "skills", pending: "integrations" }); + + deepStrictEqual( + advanceAgentSettingsSelection({ + groups: agentSettingsGroups(TEAMS), + pending: window.pending, + current: window.selected, + }), + { selected: "integrations", pending: undefined }, + ); + }); + + it("with nothing pending, only re-resolves the section on screen", () => { + deepStrictEqual( + advanceAgentSettingsSelection({ + groups: agentSettingsGroups(TEAMS), + pending: undefined, + current: "models", + }), + { selected: "models", pending: undefined }, + ); + // A caps reload that retires the open section moves within its group. + deepStrictEqual( + advanceAgentSettingsSelection({ + groups: agentSettingsGroups(LEGACY_MULTIPLAYER), + pending: undefined, + current: "models", + }), + { selected: "people", pending: undefined }, + ); + }); +}); diff --git a/app/tests/settings-view-gates.test.ts b/app/tests/settings-view-gates.test.ts index b3bd60642..c0bf75e56 100644 --- a/app/tests/settings-view-gates.test.ts +++ b/app/tests/settings-view-gates.test.ts @@ -26,7 +26,7 @@ describe("clearSettingsSectionPin", () => { clearSettingsSectionPin("permissions"); strictEqual(usePermissionsNav.getState().requestedAgentId, null); - strictEqual(usePermissionsNav.getState().requestedAgentTab, null); + strictEqual(usePermissionsNav.getState().requestedSection, null); }); it("drops the Admin tab pin when Admin is the dropped section", () => { diff --git a/knowledge-base/README.md b/knowledge-base/README.md index 783e6ae2e..563a0dd0f 100644 --- a/knowledge-base/README.md +++ b/knowledge-base/README.md @@ -18,6 +18,7 @@ Load on demand. | [skills.md](skills.md) | Skills on disk + UI — frontmatter schema, picker rendering, invocation marker | | [agent-manifest.md](agent-manifest.md) | Three tiers, manifest shape, workspace templates, sidebar | | [teams.md](teams.md) | Multiplayer orgs (client surface) — roles/access, role matrix v2, org dashboard, share dialog, templates, allowlists; gateway is sole enforcer | +| [agent-settings.md](agent-settings.md) | The ONE per-agent configuration surface — section model + gating, shared rail, the People access choice, read-only, analytics | | [auth.md](auth.md) | GCIP / Firebase auth, Google/Microsoft SSO + email OTP, Keychain | | [i18n.md](i18n.md) | Translating UI strings — namespaces, `labels` prop pattern, `t()` rules | | [ui-testing.md](ui-testing.md) | Automated UI / e2e tests — Playwright, web build, fake host, TS engine | diff --git a/knowledge-base/agent-settings.md b/knowledge-base/agent-settings.md new file mode 100644 index 000000000..919617d66 --- /dev/null +++ b/knowledge-base/agent-settings.md @@ -0,0 +1,178 @@ +# Agent settings — the ONE per-agent configuration surface + +`app/src/components/agent-settings/` is the single home for "configure THIS agent". Every +surface that shows agent settings — the Settings > Permissions drill-in and the per-agent +**Context** / **Admin** tabs — names its sections, renders its rail, and switches its bodies +from this directory. Nothing is forked: there is one section union, one gating model, one +rail component, one section switch. + +Related: `teams.md` (the Permissions screen, roles, the Share dialog, the allowlist +ceilings), `agent-manifest.md` (the tab set itself). + +## The section model — `agent-settings-nav.ts` + +```ts +type AgentSettingsSection = + | "job-description" | "learnings" // group "context" + | "people" | "integrations" | "models" | "skills"; // group "permissions" +``` + +`SECTION_GROUP` maps each section to its group, independent of any host's capabilities. + +Builders (pure, caps-only — no agent-shaped predicate exists in these gates): + +| builder | returns | +| --- | --- | +| `contextSections()` | `[job-description, learnings]`, unconditional | +| `agentAccessSections(caps)` | `[]` outside multiplayer · `[people]` on legacy multiplayer · `[people, integrations, models]` with `caps.teams` | +| `agentSettingsGroups(caps)` | the settings PAGE rail: Context group + Permissions group (`agentAccessSections` + `skills`, which has no org gate) | +| `contextTabGroups()` | the Context tab's rail: one group, the Context sections | +| `adminTabGroups(caps)` | the Admin tab's rail: one group, the access sections (no Skills — that is its own tab). `[]` in single player, where the tab renders nothing | +| `agentSettingsSections(groups)` | the flat rail order | +| `targetToSection(target)` | turn-summary file target → Context section | + +`AgentSectionProps` (`{ agent, readOnly? }`) is the contract every section BODY honors. + +Unit test: `app/tests/agent-settings-nav.test.ts`. + +## Selection + deep links — `agent-settings-selection.ts` + +- `resolveAgentSettingsSection(groups, requested)` — the section to show. A requested + section this host hides falls back **inside its own group** (an Apps request is a + Permissions intent; landing on the job description would answer a question nobody asked), + and only falls back to the first rail item when that group is absent from the rail at all. +- `advanceAgentSettingsSelection({ groups, pending, current })` — one step of the page's + selection, returning the section to show plus the request still awaiting a rail that can + honor it. A request is **retained until honored once**: `/v1/capabilities` lands after the + first render, so a deep link into Apps would otherwise be resolved away against a rail that + does not yet show Apps, and never re-applied. Once honored it is retired, so a later caps + reload never yanks the user off a section they picked by hand. + +## The page — `agent-settings-page.tsx` + +`{ agent, initialSection?, readOnly?, onSectionShown? }`. Master-detail: the grouped rail on +the left, the selected section on the right. One section is always selected, so the page has +no back navigation; the caller owns the way out. It carries NO authority — `readOnly` is the +caller's decision and the gateway is the sole enforcer. + +`onSectionShown` reports the section ACTUALLY on screen (never the raw request), which is +what Permissions tracks as `tab_opened` / `permissions:
    `. + +## The rail — `agent-settings-rail.tsx` + +One component for every surface. Group titles render only when there is more than one group, +so the tabs' single flat list is unlabelled and the page's two groups are labelled +(`agentSettings.groups.*`). Selected rows use the sidebar row language (`bg-hover` fill + +`aria-current`), no hover-only affordance, `px-3 py-4` inset. + +`aria-label` = `agentSettings.railLabel` ("Agent settings sections") — the handle e2e specs +scope rail lookups to, because the app sidebar carries same-named entries (Skills, AI models). + +Badges: learnings count, and the People count from `agentPeopleCount` (the RESOLVED roster). +The raw `assignments` array is not the count: the everyone sentinel is `[]` (no badge at all) +and an explicit roster omits the always-present org owner. + +Section titles reuse existing copy — `agents:subTabs.*` for job description + Skills, +`teams:agentAdmin.rows.*` for the rest. + +## The switch — `agent-settings-section.tsx` + +`AgentSettingsSectionView({ agent, section, readOnly })`. Every branch COMPOSES the existing +section component, so no two surfaces can drift: + +| section | body | +| --- | --- | +| `job-description` | `AgentAdminInstructions` | +| `learnings` | `AgentAdminKnowledge` | +| `people` | `AgentSettingsPeople` (in `AccessColumn`) | +| `integrations` | `AgentAdminIntegrations` (in `AccessColumn`) | +| `models` | `AgentAdminModel` (in `AccessColumn`) | +| `skills` | `AgentAdminSkills` | + +The bodies still live in `components/tabs/agent-admin/`. `AccessColumn` gives the flush +access bodies the same `max-w-3xl px-6 pt-2` column the self-padded bodies bring, so nothing +shifts as the rail switches sections. + +## People — the team-wide choice + the roster + +`agent-settings-people.tsx` owns the org query (so the roster refetches on mount when stale +and the body waits behind a skeleton), the single `useShareAgent("agent_settings_people")` +mutation, and both confirms. The roster below is `agent-people-tab.tsx` + +`agent-person-row.tsx` over the pure `agent-people-model.ts`. + +**One write channel.** The mutation is created once and passed into the roster, so an +in-flight assignment write disables the choice AND the rows, and two set-replaces can never +race into a lost update. + +**The choice** is the shared `AccessChoice` segmented control: **"Everyone on your team"** vs +**"Only specific people"**, wired to the EXISTING everyone-agent sentinel, not a new concept +— an empty assignee set means org-wide (`isSharedWithEveryone`), any explicit set means +exactly those people. Pure model `agent-people-choice.ts` (unit test +`app/tests/agent-people-choice.test.ts`): + +- `agentAccessMode(agent)` → `"any"` | `"picked"`, the `AccessMode` vocabulary `AccessChoice` + speaks. +- `canChooseAgentAccess(members)` — the choice hides until the roster is readable: with no + members to expand, "Only specific people" would write the empty set and mean the opposite + of its label. +- `everyoneAssignments()` → `[]`. +- `materializeRoster(...)` — `buildSharePeople` expanded to explicit `{userId, access}[]`, + exactly what the first per-person edit already does. Nobody's access changes. +- `agentPeopleCount(...)` — the resolved roster size (rail badge + confirm copy). +- `everyoneSwitchConfirm(...)` → `"selfLockout"` | `"changesAccess"` | `"none"`. + +**Both directions confirm**, because both replace the whole roster in one write: + +| direction | confirm | variant | +| --- | --- | --- | +| → Everyone, viewer holds a non-owner Manager grant | `share.selfLockout.*` — they demote THEMSELVES (the sentinel cannot carry a Manager grant) and lose the page | destructive | +| → Everyone, someone else's access changes | `agentSettings.people.confirmEveryone.*` | default | +| → Everyone, nothing changes | none — written straight through | — | +| → Only specific people | `agentSettings.people.confirmSpecific.*`, naming how many people it freezes and that new teammates are not added automatically | default | + +The specific direction ALWAYS confirms: the roster it materializes is a snapshot, and a 30s +`staleTime` means freshness cannot be proven, so the write is stated rather than assumed. + +**"Everyone" mode renders the roster STATIC** (no per-person controls, a muted +`agentSettings.people.everyoneNote`). Per-person levels are not the agent's state while the +sentinel is set, and a live control there would silently materialize the roster — the mirror +of the confirm-gated switch. Same idiom as `AllowlistEditor`, which stops offering per-item +controls in its "any" mode. + +**Accessibility.** The visible `

    ` question names the radio group via `aria-labelledby` +(never a duplicate `aria-label`), so a screen reader announces it once. `AccessChoice` takes +`labelledBy`, and all three of its callers (people, apps, models) pass their heading id. + +## Read-only + +`readOnly` renders every section's non-manager face: People rows drop to static level labels +(plus `permissions.agentPeople.readOnlyHint`), the Integrations + AI Models editors disable +their controls and hide their "Add" lists, and Skills drops the discovery tabs. Nothing is +hover-gated. + +Callers: the Context tab passes `!isAgentManager(caps, agent)`; the Permissions drill-in +passes it too, so an admin who can SEE an agent but doesn't manage it reads the whole page +instead of a dead-end note. The gateway is the enforcer either way — `readOnly` only avoids a +dead control. + +## Analytics + +`useShareAgent(source)` takes its `source` from the caller (`"share_dialog"` | +`"agent_settings_people"`) and fires `agent_shared` only on an actual widening, +`accessWidened` (`components/tabs/agent-access-diff.ts`, unit test +`app/tests/agent-access-diff.test.ts`): resolved roster before vs after, so the everyone +switch (the broadest share, and an EMPTY array on the wire) counts and materializing the +sentinel does not. + +## Tests + +- `app/tests/agent-settings-nav.test.ts` — sections, group builders, fallback, selection. +- `app/tests/agent-people-choice.test.ts` — the choice, the confirms, the count. +- `app/tests/agent-people-model.test.ts` — the per-person roster math. +- `app/tests/agent-access-diff.test.ts` — what counts as sharing. +- `app/tests/agent-admin-a11y.test.ts` — the rail renders no `

    `; `AccessChoice` moves + focus with selection. +- `packages/web/e2e/permissions.spec.ts` — the rail's six sections, both choice directions + and their confirms, the static everyone-mode roster, the read-only drill-in, plus the same + sections on the agent's Admin tab. (The self-lockout branch is unit-only: identity is off + in the chromium project, so nothing ever resolves as "self".) diff --git a/knowledge-base/teams.md b/knowledge-base/teams.md index dc6cb43d7..a5b580aa5 100644 --- a/knowledge-base/teams.md +++ b/knowledge-base/teams.md @@ -111,7 +111,7 @@ that also take `Pick` live in `agent-access.ts` their behalf, which is why there is no owner/admin half left to gate. See **Per-user AI accounts** below. (There is still no org-wide model ceiling; model policy is per agent, in the **Permissions** view's per-agent detail (its - AI Models tab), below.) + AI models section), below.) Opening the hub widened nothing else. The usage on a hub card is the VIEWER's own account, and the space-wide roll-up lives in **Settings > Admin > Usage**, still behind `canSeeOrganization` (owner/admin, team space) — the two were @@ -234,8 +234,8 @@ usage; `orgTabIds` only gates billing). > **Fully agent-centric (2026-07-16, Felipe: final — supersedes the People/Agents > top-level split).** There is NO top-level People tab and NO per-person lens. The -> view shows the agent list; opening an agent manages WHO can use it and WHAT it can -> use, across three tabs. The per-PERSON lens (`member-detail*`, `permissions-people-tab`, +> view shows the agent list; opening an agent opens the canonical agent settings page, +> which manages WHO can use it and WHAT it can use. The per-PERSON lens (`member-detail*`, `permissions-people-tab`, > `permissions-agents-tab`, `permissions-people.spec.ts`) was DELETED. **Settings > Permissions** — everything policy, FULLY AGENT-CENTRIC: pick an @@ -250,51 +250,55 @@ label `settings:nav.permissions`, in the "Team" group right AFTER Admin (both inside the `showOrganization` block, `data-testid="settings-row-permissions"` for the e2e specs). `settings-section-body.tsx` mounts it with `backLabel`/`onBack`. -`permissions-view.tsx` is a shell: it loads `useOrg(true)` once (roster + role), owns -the drill-in as an `{agentId, tab}` pair (id-not-snapshot so a store reload keeps the +`permissions-view.tsx` is a shell: it loads `useOrg(true)` once (roster + role), owns the +drill-in as an `{agentId, section}` pair (id-not-snapshot so a store reload keeps the detail on the live row), and renders a `PageHeader` ("Permissions") + the agent list (`agents-list.tsx`) DIRECTLY — no top-level tab strip. Both levels use the shared `shell/back-bar-screen.tsx` (`BackBarScreen`): the list level's bar returns to the -Settings index, the agent drill-in's returns to the list ("Permissions"). +Settings index, the agent drill-in's returns to the list ("Permissions"). It tracks +`tab_opened` / `permissions:
    ` from the settings page's `onSectionShown`, i.e. the +section actually ON SCREEN rather than the one requested. **Deep-linking** — `permissions-nav-store.ts`: zustand `usePermissionsNav` with -`{ requestedAgentId; requestedAgentTab: PermissionsAgentTab|null; requestAgentDetail(agentId, tab?); clearRequested }`. -The role-aware blocked-app CTA calls `requestAgentDetail(agent.id, "integrations")` so it -lands straight on that agent's Integrations tab (where the app-enable fix lives); the view -consumes the request one-shot (initial mount + while open) and clears it (see the -Integration allowlist ceiling section, Part B). - -**Agent detail — three tabs** (`agent-detail.tsx`, takes `{ agent, members, initialTab? }`): -a `PageHeader` (agent avatar + name + "Open agent") over the shared -**`AgentPermissionsPanel`** (`permissions/agent-permissions-panel.tsx`, `{ agent, members, -initialTab? }`): `@houston-ai/core` `Tabs variant="line"` with -**People | Integrations | AI Models** (labels `permissions.agentTabs.{people,integrations,models}`; -`defaultValue = initialTab`, default `"people"`). In the Settings > Permissions drill-in the whole detail is -gated on `isAgentManager(caps, agent)` — a visible-but-not-manager admin gets -`org.agentDetail.managerOnly` instead of the panel. - -**Two fronts, one target — the agent's Settings access rows.** The same People, -Integrations, and AI Models sections mount in the per-agent Settings rail. Settings -is visible to everyone on a Teams host; non-managers see those access rows read-only, -while manager-only configuration rows remain hidden. It never exposes access rows on -single-player/self-host, where there are no ceilings or roster. -`agent-admin-screen.tsx` fetches the roster via `useOrg` and renders -`readOnly = !isAgentManager(caps, agent)`; managers get the -fully editable panel right on the agent. - -**Read-only rule.** `readOnly` threads through the panel to every section: People rows drop to +`{ requestedAgentId; requestedSection: AgentSettingsSection|null; requestAgentDetail(agentId, section?); clearRequested }`. +Any section of the agent settings page can be named; one this host hides falls back within +its own rail group (`resolveAgentSettingsSection`). The role-aware blocked-app CTA calls +`requestAgentDetail(agent.id, "integrations")` so it lands straight on that agent's Apps +section (where the app-enable fix lives); the view consumes the request one-shot (initial +mount + while open) and clears it (see the Integration allowlist ceiling section, Part B). + +**Agent detail** (`agent-detail.tsx`, takes `{ agent, initialSection?, onSectionShown? }`): a +`PageHeader` (agent avatar + name + "Open agent") over **`AgentSettingsPage`**. That page — +the ONE per-agent configuration surface, shared with the agent's Context and Admin tabs — +lives in `app/src/components/agent-settings/` and is documented in +**`knowledge-base/agent-settings.md`** (sections + gating, the rail, the People access +choice, read-only, analytics). Manager authority decides the FACE, not access: +`isAgentManager(caps, agent)` false renders the SAME page `readOnly`, so an admin who can see +an agent but does not manage it reads every section instead of a dead-end note. + +**Two fronts, one target — the agent's Context and Admin tabs.** Those tabs are the same +rail, section switch and section bodies as the settings page, with a narrower group each: +Context = job description + learnings, Admin = people + apps + models (Skills is its own +tab). Both pass `readOnly = !isAgentManager(caps, agent)`. Admin exists only in multiplayer, +so single-player/self-host never shows access rows. + +**Read-only rule.** `readOnly` threads through the settings page and the tabs to every +section: People rows drop to static level labels with NO control (`agent-person-row.tsx` `readOnly`, plus a muted `permissions.agentPeople.readOnlyHint`); the Integrations + AI Models editors use their own -`readOnly` mode (controls disabled, the "Add" list hidden, a muted `readOnlyNote`). No hover +`readOnly` mode (controls disabled, the "Add" list hidden, a muted `readOnlyNote`); Skills +drops its discovery tabs. No hover gating anywhere. **Roster degradation for a plain member:** the gateway serves `members` only to owner/admin (`OrgInfo.members` is absent for org role `user`), so a member's People roster arrives -empty; rather than a misleading "no people yet" empty state the tab shows the honest viewer line +empty; rather than a misleading "no people yet" empty state the roster shows the honest viewer line `permissions.agentPeople.viewerOnly` ("You can use this agent. Someone who manages it can change who has access."). The pure decision is `agentPeopleView(rowCount, readOnly)` in `agent-people-model.ts` (`"roster" | "viewerOnly" | "empty"`, unit-tested). An admin who isn't the -agent's manager still gets the full roster (they can see members) — read-only. The gateway is the +agent's manager still gets the full roster (they can see members) — read-only, on that same +page. The gateway is the sole enforcer; `readOnly` only avoids a dead control. -- **People** (`agent-people-tab.tsx`) — WHO can use THIS agent: every org member is a row +- **People** (`agent-settings/agent-people-tab.tsx`, the roster under the team-wide access + choice — see `agent-settings.md`) — WHO can use THIS agent: every org member is a row (avatar + email + org-role chip) with a **None / Can use / Manager** control (`agent-person-row.tsx`; owner renders static "Owner, always has access"). Read + write reuse the Share dialog's roster math (`components/tabs/agent-access-model.ts`) VERBATIM via @@ -303,8 +307,10 @@ sole enforcer; `readOnly` only avoids a dead control. read `none`); `writeAgentPerson` wraps `buildSharePeople`+`applyShareAction` (so an everyone-agent MATERIALIZES into an explicit roster on first edit exactly as the dialog does, and the owner is never stripped); `agentPersonNeedsConfirm` delegates to the dialog's - `needsSelfLockoutConfirm`. Writes go through `useShareAgent` (optimistic set-replace - `PUT /v1/agents/:slug/assignments`); a self-lockout is confirm-gated. Manager is disabled + `needsSelfLockoutConfirm`. Writes go through the section's ONE `useShareAgent` instance + (optimistic set-replace `PUT /v1/agents/:slug/assignments`, passed in from + `agent-settings-people.tsx` so the choice and the rows share one write channel); a + self-lockout is confirm-gated. Manager is disabled for org-role `user` members (`canPersonBeManager`, gateway 400s `manager_requires_admin`). Copy: `share.levels.*` + `share.ownerAccess`/`share.you`/`share.selfNote` + `permissions.agentPeople.*` (the None label/hint + `changeAccess` aria). Unit test @@ -318,9 +324,11 @@ The agent list is `agents-list.tsx` (`{ members, onOpenAgent }`, rendered by the no wrapper — the old `permissions-agents-tab.tsx` wrapper was deleted); helpers `org-agent-card.tsx` + `org-agents-model.ts` live alongside; `org-roster.ts` + `org-time.ts` stay in `organization/` (cross-dir import). NO "Defaults for every agent" card — policy is -per agent only. Tested: e2e `packages/web/e2e/permissions.spec.ts` (agent list → three-tab -drill-in; People Can use→No access round-trip; Integrations ceiling round-trip; AI Models -present; PLUS the agent's Settings access rows: a manager's editable round-trip and a +per agent only. Tested: e2e `packages/web/e2e/permissions.spec.ts` (agent list → the +settings-page drill-in and its six rail items; both directions of the team-wide access choice +and their confirms; the static everyone-mode roster; People Can use→No access round-trip; +Apps ceiling round-trip; AI models present; Context group switching; a non-manager admin's +read-only drill-in; PLUS the agent's Admin tab rows: a manager's editable round-trip and a role-`user` member's read-only view + viewer-line degradation) via fake host `/__test__/org` (multi-member roster + fleet with per-agent `assignments`/`access`; `/v1/org` omits the roster for role `user`, mirroring the gateway) + `PUT /v1/agents/:slug/assignments`/`settings`. @@ -756,7 +764,7 @@ configure surfaces are gated: non-managers: `standard-tabs.ts` only adds the `job-description` tab for single-player or `isAgentManager` callers, so members never reach it and there is no read-only banner. The tab is a two-column master-detail admin page (a - settings nav rail, `agent-admin-sidebar.tsx`, grouping Configuration / Access + + settings nav rail, the shared `agent-settings/agent-settings-rail.tsx`, plus the selected section), manager-only and fully editable. Name / color / delete live on the sidebar agent row, not a "General" section (the old landing, back-bar, and General card are gone). On a public-API gateway @@ -1029,7 +1037,7 @@ checks the toolkit against the allowlist on a successful OAuth. universe (there is no org-wide ceiling to narrow it). Client: `getAgentSettings` / `setAgentSettings`. Copy under `teams:integrations.allowlist.*`. The per-agent app ceiling ALSO surfaces (via `AgentAdminIntegrations`) in the Permissions agent detail's - Integrations tab (`permissions/agent-detail.tsx`), same editor, same wire. + Apps section (`permissions/agent-detail.tsx`), same editor, same wire. The global Integrations page has no ceiling to apply (policy is per agent), so it never locks a row — it's the personal catalog for every member. Connections are @@ -1062,7 +1070,7 @@ blocked-state surfaces (the disallowed section AND the locked browse rows) accep `PermissionsFix` resolver. A blocked app is always outside the AGENT ceiling (policy is per agent only), so when the VIEWER can lift it, the ask-your-admin line is replaced by an "Enable it in Permissions" button that deep-links to that agent's Permissions detail on its -Integrations tab (`requestAgentDetail(agentId, "integrations")`, `permissions-nav-store.ts`) +Apps section (`requestAgentDetail(agentId, "integrations")`, `permissions-nav-store.ts`) — one `openSettings("permissions")` call on the UI store (never a bare `setViewMode("settings")`; see `agent-manifest.md`) plus a `usePermissionsNav` request. The gate is `canManageAgent` (agent-manager AND `showOrganization`, the DESTINATION's own gate from @@ -1600,9 +1608,14 @@ group were deleted with the org ceilings 2026-07-16), `permissions`, `org`, `sha live in the separate `aiHub` namespace.) The **`permissions.*`** block backs Settings > Permissions: `title`, `subtitle`, -`agentTabs.{people,integrations,models}` (the three agent-detail tab labels), `agentPeople.{none,noneHint,changeAccess,readOnlyHint,viewerOnly,empty.{title,body}}` (the -per-agent People tab; `readOnlyHint` + `viewerOnly` back the agent-tab read-only view). The +per-agent People roster; `readOnlyHint` + `viewerOnly` back the read-only view). The +**`agentSettings.*`** block backs the agent settings page: `railLabel`, +`groups.{context,permissions}`, and +`people.{question,helper,anyLabel,anyDesc,pickedLabel,pickedDesc,everyoneNote,confirmEveryone.*,confirmSpecific.*}` +(the everyone-vs-specific-people choice and its two confirms; the destructive self-lockout +confirm reuses `share.selfLockout.*`). Its rail titles REUSE `agents:subTabs.*` + +`agentAdmin.rows.*` rather than duplicating them. The read-only editors also add `integrations.allowlist.readOnlyNote` + `agentAdmin.models.readOnlyNote`. The agent workspace **Settings tab label** lives in the `agents` namespace like every other tab: `agents:tabLabels.job-description` (en "Settings" / es "Configuración" / pt "Configurações"). diff --git a/packages/web/e2e/ai-models-ia.spec.ts b/packages/web/e2e/ai-models-ia.spec.ts index 5e9fefa6e..5bb57b001 100644 --- a/packages/web/e2e/ai-models-ia.spec.ts +++ b/packages/web/e2e/ai-models-ia.spec.ts @@ -209,7 +209,8 @@ test("Permissions: a per-agent model ceiling offers the full catalog (no org nar // Policy is per agent only: a manager narrowing the agent's model ceiling picks // from the WHOLE catalog — there is no workspace-wide ceiling to narrow it, so // every model (Opus AND Sonnet) is offerable. The per-agent ceilings live in the - // Permissions view's per-agent drill-in (its AI Models tab). + // Permissions view's per-agent drill-in (the agent settings page's AI models + // section). await armCapabilities(request, OWNER_CAPS); await request.post(`${FAKE_HOST_URL}/__test__/org`, { data: { @@ -225,7 +226,10 @@ test("Permissions: a per-agent model ceiling offers the full catalog (no org nar }); await openPermissions(page); await page.getByRole("button", { name: "Open Finance Bot" }).click(); - await page.getByRole("tab", { name: "AI Models" }).click(); + await page + .getByRole("navigation", { name: "Agent settings sections" }) + .getByRole("button", { name: "AI models" }) + .click(); // The per-agent card shows the model ceiling question, starting unrestricted. await expect( diff --git a/packages/web/e2e/permissions.spec.ts b/packages/web/e2e/permissions.spec.ts index 82c0d1a05..82887c5b0 100644 --- a/packages/web/e2e/permissions.spec.ts +++ b/packages/web/e2e/permissions.spec.ts @@ -5,14 +5,16 @@ import { openSettingsSection } from "./support/settings-nav"; /** * Permissions is FULLY AGENT-CENTRIC: the top level is the agent list, and - * opening an agent shows three tabs — People (who can use this agent, at what - * level), Integrations (its app ceiling), and AI Models (its model ceiling). + * opening an agent lands on the ONE canonical agent settings page: a rail with + * a Context group (Job description, Memory) and a Permissions group (People + * with access, Apps, AI models, Skills), and the selected section beside it. * There is no top-level People tab and no per-person lens. * - * This proves the whole shape: the list renders, drilling in shows the three - * tabs, a People access change (Can use -> No access) set-replaces the roster via - * `PUT /v1/agents/:slug/assignments`, and an Integrations ceiling narrow persists - * via `PUT /v1/agents/:slug/settings` — each verified with a full reload so the + * This proves the whole shape: the list renders, drilling in shows the rail, + * the team-wide access choice writes the everyone sentinel, a People access + * change (Can use -> No access) set-replaces the roster via + * `PUT /v1/agents/:slug/assignments`, and an Apps ceiling narrow persists via + * `PUT /v1/agents/:slug/settings` — each verified with a full reload so the * write reached the gateway, not just the client cache. * * The Teams-shaped state single-player can't reach is armed via the fake host's @@ -63,12 +65,24 @@ async function openPermissions(page: Page): Promise { await openSettingsSection(page, "permissions"); } -/** Open Permissions and drill into Finance Bot. */ +/** Open Permissions and drill into Finance Bot's settings page. */ async function openFinance(page: Page): Promise { await openPermissions(page); await page.getByRole("button", { name: "Open Finance Bot" }).click(); } +/** + * One item of the agent settings rail. Scoped to the rail's own landmark: the + * app sidebar carries same-named entries (Skills, AI Models), so an unscoped + * lookup would be ambiguous. Substring matching on purpose — a rail item's + * accessible name also carries its count badge ("People with access 2"). + */ +function railItem(page: Page, name: string) { + return page + .getByRole("navigation", { name: "Agent settings sections" }) + .getByRole("button", { name }); +} + /** * Open the agent workspace Admin tab (PRODUCT-1256 split the old Settings tab), * where the access controls live in their own rail. @@ -78,7 +92,7 @@ async function openAgentAdmin(page: Page): Promise { await page.locator('[data-tour-target="tab-admin"]').click(); } -test("the agent list is the top level, and opening an agent shows the three tabs", async ({ +test("the agent list is the top level, and opening an agent shows the settings rail", async ({ page, request, }) => { @@ -94,13 +108,25 @@ test("the agent list is the top level, and opening an agent shows the three tabs await page.getByRole("button", { name: "Open Finance Bot" }).click(); - // Three tabs: People, Integrations, AI Models. - await expect(page.getByRole("tab", { name: "People" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Integrations" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "AI Models" })).toBeVisible(); + // The rail replaced the three-tab panel: two groups, six sections. + await expect(page.getByRole("tab", { name: "People" })).toHaveCount(0); + for (const section of [ + "Job description", + "Memory", + "People with access", + "Apps", + "AI models", + "Skills", + ]) { + await expect(railItem(page, section)).toBeVisible(); + } + // People opens by default (the drill-in's initial section). + await expect( + page.getByRole("heading", { name: "Who can use this agent?" }), + ).toBeVisible(); }); -test("People tab: every member has a row, and a Can use -> No access change round-trips", async ({ +test("People section: every member has a row, and a Can use -> No access change round-trips", async ({ page, request, }) => { @@ -108,8 +134,8 @@ test("People tab: every member has a row, and a Can use -> No access change roun await armOrg(request); await openFinance(page); - // People is the default tab. The owner is static; Bob has an editable control - // showing his current level (Can use). + // People is the default section. The owner is static; Bob has an editable + // control showing his current level (Can use). await expect(page.getByText("you@acme.test")).toBeVisible(); const bob = page.getByRole("button", { name: "Change access for bob@acme.test", @@ -129,14 +155,168 @@ test("People tab: every member has a row, and a Can use -> No access change roun ).toContainText("No access"); }); -test("Integrations tab: the app ceiling narrows and persists", async ({ +test("People section: the team-wide access choice writes the everyone sentinel", async ({ page, request, }) => { await armCapabilities(request, OWNER_CAPS); await armOrg(request); await openFinance(page); - await page.getByRole("tab", { name: "Integrations" }).click(); + + // Finance Bot has an explicit roster, so it opens on "Only specific people", + // where every teammate carries a live per-person control. + await expect( + page.getByRole("radio", { name: "Only specific people" }), + ).toBeChecked(); + await expect( + page.getByRole("button", { name: "Change access for bob@acme.test" }), + ).toBeVisible(); + + // Its roster already resolves to the whole team at the same levels, so the + // switch changes nobody's access and needs no confirm. + await page.getByRole("radio", { name: "Everyone on your team" }).click(); + await expect( + page.getByRole("radio", { name: "Everyone on your team" }), + ).toBeChecked(); + + // "Everyone" mode drops the per-person controls (the AllowlistEditor idiom): + // using one would silently materialize the roster, the mirror of the + // confirm-gated switch. The roster stays visible, just static. + await expect( + page.getByRole("button", { name: "Change access for bob@acme.test" }), + ).toHaveCount(0); + await expect( + page.getByText("there is nothing to set person by person"), + ).toBeVisible(); + + // GET round-trip: a full reload re-reads /agents; the empty assignee set + // reached the gateway and still reads as the everyone sentinel. + await openFinance(page); + await expect( + page.getByRole("radio", { name: "Everyone on your team" }), + ).toBeChecked(); + + // Going back is confirm-gated: the write FREEZES today's roster, so it says + // how many people it keeps before it replaces the sentinel. + await page.getByRole("radio", { name: "Only specific people" }).click(); + await expect( + page.getByRole("heading", { name: "Switch to specific people?" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Yes, pick people" }).click(); + await expect( + page.getByRole("radio", { name: "Only specific people" }), + ).toBeChecked(); + await openFinance(page); + await expect( + page.getByRole("button", { name: "Change access for bob@acme.test" }), + ).toContainText("Can use"); +}); + +test("People section: switching to everyone is confirm-gated when it drops a Manager", async ({ + page, + request, +}) => { + await armCapabilities(request, OWNER_CAPS); + // Cara is an agent Manager; the everyone sentinel cannot carry that grant, so + // the switch must ask before discarding it. + await request.post(`${FAKE_HOST_URL}/__test__/org`, { + data: { + members: [ + ...ROSTER, + { userId: "u-cara", email: "cara@acme.test", role: "admin" }, + ], + agents: [ + { + id: "agent-finance", + name: "Finance Bot", + assignments: [ + { userId: "u-self", access: "manager" }, + { userId: "u-cara", access: "manager" }, + ], + }, + ], + }, + }); + await openFinance(page); + + // The VIEWER is the org owner, so they keep their own Manager seat: this is + // the informational confirm, not the destructive self-lockout one. (The + // self-lockout branch can't be driven here — identity is off in this project, + // so `useSession()` is null and nothing ever resolves as "self"; it is proven + // in `app/tests/agent-people-choice.test.ts` instead.) + await page.getByRole("radio", { name: "Everyone on your team" }).click(); + await expect( + page.getByRole("heading", { name: "Give everyone access?" }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Yes, give everyone access" }).click(); + await expect( + page.getByRole("radio", { name: "Everyone on your team" }), + ).toBeChecked(); + + // The write landed: Cara keeps access, now as a plain member of the team. + await openFinance(page); + await expect( + page.getByRole("radio", { name: "Everyone on your team" }), + ).toBeChecked(); + await expect( + page.getByRole("listitem").filter({ hasText: "cara@acme.test" }), + ).toContainText("Can use"); +}); + +test("a visible-but-not-manager admin drills into the SAME page, read-only", async ({ + page, + request, +}) => { + // Previously a dead-end note. An admin who can see the agent but does not + // manage it now reads every section, with no dead affordances. + await armCapabilities(request, { + multiplayer: true, + teams: true, + role: "admin", + }); + await request.post(`${FAKE_HOST_URL}/__test__/org`, { + data: { + members: [ + { userId: "u-self", email: "you@acme.test", role: "admin" }, + { userId: "u-bob", email: "bob@acme.test", role: "user" }, + ], + agents: [ + { + id: "agent-finance", + name: "Finance Bot", + access: "user", + assignments: [{ userId: "u-bob", access: "user" }], + }, + ], + }, + }); + await openFinance(page); + + // The rail and the People question are there; the controls are not. + await expect(railItem(page, "People with access")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Who can use this agent?" }), + ).toBeVisible(); + await expect( + page.getByText("Someone who manages this agent can change who has access."), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Change access for bob@acme.test" }), + ).toHaveCount(0); + await expect( + page.getByRole("radio", { name: "Only specific people" }), + ).toBeDisabled(); +}); + +test("Apps section: the app ceiling narrows and persists", async ({ + page, + request, +}) => { + await armCapabilities(request, OWNER_CAPS); + await armOrg(request); + await openFinance(page); + await railItem(page, "Apps").click(); await expect( page.getByRole("heading", { name: "Which apps can this agent use?" }), @@ -153,26 +333,47 @@ test("Integrations tab: the app ceiling narrows and persists", async ({ // GET round-trip: a full reload re-reads the agent settings from the host. await openFinance(page); - await page.getByRole("tab", { name: "Integrations" }).click(); + await railItem(page, "Apps").click(); await expect( page.getByRole("radio", { name: "Only apps you pick" }), ).toBeChecked(); }); -test("AI Models tab: the model ceiling editor is present", async ({ +test("AI models section: the model ceiling editor is present", async ({ page, request, }) => { await armCapabilities(request, OWNER_CAPS); await armOrg(request); await openFinance(page); - await page.getByRole("tab", { name: "AI Models" }).click(); + await railItem(page, "AI models").click(); await expect( page.getByRole("heading", { name: "Which AI models can this agent use?" }), ).toBeVisible(); }); +test("Context group: the agent's job description and Memory live on the same page", async ({ + page, + request, +}) => { + await armCapabilities(request, OWNER_CAPS); + await armOrg(request); + await openFinance(page); + + await railItem(page, "Job description").click(); + await expect(railItem(page, "Job description")).toHaveAttribute( + "aria-current", + "page", + ); + + await railItem(page, "Memory").click(); + await expect(railItem(page, "Memory")).toHaveAttribute( + "aria-current", + "page", + ); +}); + /** * The same access sections also mount on the agent's Admin tab (PRODUCT-1256), * which only the workspace owner and agent managers see. A plain member gets From 7ee706e27b4be1e1b8609ee33f7f1a61e176605c Mon Sep 17 00:00:00 2001 From: Julian Arango Date: Fri, 7 Aug 2026 18:13:05 -0500 Subject: [PATCH 02/19] =?UTF-8?q?feat(app):=20Linear-style=20teams=20?= =?UTF-8?q?=E2=80=94=20Your=20teams=20sidebar=20+=20team=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar becomes Your teams: named groups render as team blocks (section rows: Mission Control, Team Settings; agents beneath), and the ungrouped section becomes the virtual default team named after the workspace — no stored-layout migration. Clicking an agent opens its team's Mission Control with the agent filter pre-set. New team view (viewMode "team"): team-scoped mission board + archive sharing the ONE cross-agent sweep (scopePaths over the full roster), and Team Settings drilling into the agent settings page. Board keyboard/⌘N registration is active-view-gated so kept-alive boards can't steal handlers. ui/layout gains section rows + defaultGroup (props-only, inventory v55). KB: knowledge-base/teams-ui.md. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + app/src/components/board/mission-board.tsx | 10 +- .../board/mission-control-archived.tsx | 95 +++--- .../components/board/mission-control-scope.ts | 70 +++++ .../components/board/use-board-keyboard.ts | 56 +++- .../components/board/use-mc-new-mission.tsx | 10 +- app/src/components/board/use-mc-scope.ts | 105 +++++++ app/src/components/board/use-mc-search.tsx | 83 ++++++ .../use-mission-control-archived-panel.ts | 64 +++++ .../board/use-mission-control-source.tsx | 90 ++---- app/src/components/command-palette.tsx | 14 +- .../components/mission-control-toolbar.tsx | 26 +- app/src/components/permissions/agent-grid.tsx | 53 ++++ .../components/permissions/agents-list.tsx | 49 +--- .../permissions/use-agent-access-line.ts | 39 +++ .../components/shell/agent-sidebar-items.tsx | 51 +--- .../components/shell/group-context-dialog.tsx | 10 +- app/src/components/shell/sidebar-chrome.tsx | 41 ++- app/src/components/shell/sidebar.tsx | 152 +++++----- .../components/shell/team-sidebar-lists.tsx | 103 +++++++ .../shell/top-level-screen-views.tsx | 7 + .../shell/use-sidebar-agent-actions.ts | 81 ++++++ app/src/components/shell/workspace-shell.tsx | 14 +- .../team-view/team-agent-filter-model.ts | 34 +++ .../components/team-view/team-agents-list.tsx | 64 +++++ .../team-view/team-mission-board.tsx | 40 +++ .../team-view/team-mission-control.tsx | 48 ++++ .../team-view/team-mission-empty.tsx | 61 ++++ .../components/team-view/team-settings.tsx | 70 +++++ app/src/components/team-view/team-view.tsx | 44 +++ .../team-view/use-team-board-scope.ts | 60 ++++ app/src/hooks/use-keyboard-shortcuts.ts | 17 +- app/src/hooks/use-sidebar-layout.ts | 46 ++- app/src/hooks/use-teams.ts | 28 ++ app/src/lib/sidebar-teams.ts | 107 +++++++ app/src/lib/teams-model.ts | 131 +++++++++ app/src/lib/top-level-views.ts | 23 +- app/src/locales/en/dashboard.json | 3 +- app/src/locales/en/shell.json | 25 +- app/src/locales/en/teams.json | 20 ++ app/src/locales/es/dashboard.json | 3 +- app/src/locales/es/shell.json | 25 +- app/src/locales/es/teams.json | 20 ++ app/src/locales/pt/dashboard.json | 3 +- app/src/locales/pt/shell.json | 25 +- app/src/locales/pt/teams.json | 20 ++ app/src/stores/ui.ts | 30 ++ app/tests/mission-control-scope.test.ts | 112 ++++++++ app/tests/sidebar-teams.test.ts | 198 +++++++++++++ app/tests/team-agent-filter-model.test.ts | 48 ++++ app/tests/team-one-sweep.test.ts | 176 ++++++++++++ app/tests/teams-model.test.ts | 186 ++++++++++++ app/tests/top-level-views.test.ts | 33 ++- design/inventory/CHANGELOG.md | 29 ++ design/inventory/inventory.yaml | 67 ++++- design/inventory/manifests/android.yaml | 4 + design/inventory/manifests/ios.yaml | 4 + design/inventory/manifests/web.yaml | 6 + knowledge-base/README.md | 1 + knowledge-base/agent-manifest.md | 96 +++++-- knowledge-base/teams-ui.md | 246 ++++++++++++++++ packages/web/e2e/agents.spec.ts | 2 +- .../web/e2e/board-keyboard-ownership.spec.ts | 69 +++++ packages/web/e2e/boot.spec.ts | 8 +- packages/web/e2e/files.spec.ts | 103 +++++-- packages/web/e2e/mobile-shell.spec.ts | 2 +- .../web/e2e/shell-panel-ownership.spec.ts | 29 ++ packages/web/e2e/sidebar-dnd.spec.ts | 85 +++--- packages/web/e2e/sidebar-teams.spec.ts | 141 +++++++++ packages/web/e2e/sidebar.spec.ts | 4 +- packages/web/e2e/support/global-setup.ts | 4 +- packages/web/e2e/team-view.spec.ts | 271 ++++++++++++++++++ packages/web/e2e/theme-pin.spec.ts | 4 +- .../board-dark-darwin.png | Bin 52922 -> 57005 bytes .../shell.visual.spec.ts/board-dark-linux.png | Bin 51557 -> 55598 bytes .../board-light-darwin.png | Bin 52326 -> 56425 bytes .../board-light-linux.png | Bin 50841 -> 54773 bytes .../board-narrow-darwin.png | Bin 23187 -> 22716 bytes .../board-narrow-linux.png | Bin 22762 -> 22586 bytes packages/web/e2e/visual/shell.visual.spec.ts | 4 +- ui/layout/src/index.ts | 21 +- ui/layout/src/sidebar-classes.ts | 49 +++- ui/layout/src/sidebar-default-header.tsx | 31 ++ ui/layout/src/sidebar-flat-list.tsx | 2 +- ui/layout/src/sidebar-group-section.tsx | 144 +++++----- ui/layout/src/sidebar-grouped-list.tsx | 12 +- ui/layout/src/sidebar-groups.ts | 29 ++ ui/layout/src/sidebar-item-row.tsx | 3 + ui/layout/src/sidebar-row-context.ts | 21 ++ ui/layout/src/sidebar-section-rows.tsx | 45 +++ ui/layout/src/sidebar-sortable-row.tsx | 2 +- ui/layout/src/sidebar.tsx | 18 +- .../tests/sidebar-item-row-layout.test.ts | 52 ++++ ui/showcase/scripts/surface-rules.mjs | 7 +- .../specimens/areas/agents/app-sidebar-api.ts | 10 + .../areas/agents/app-sidebar-parts.tsx | 90 +++--- .../areas/agents/app-sidebar-sections.tsx | 45 +++ .../areas/agents/app-sidebar-stage.tsx | 49 ++++ .../specimens/areas/agents/app-sidebar.tsx | 12 +- .../areas/agents/sidebar-group-header-api.ts | 1 - ui/showcase/src/usage.gen.json | 154 ++++++---- ui/showcase/tests/usage.test.ts | 1 + 102 files changed, 4246 insertions(+), 655 deletions(-) create mode 100644 app/src/components/board/mission-control-scope.ts create mode 100644 app/src/components/board/use-mc-scope.ts create mode 100644 app/src/components/board/use-mc-search.tsx create mode 100644 app/src/components/board/use-mission-control-archived-panel.ts create mode 100644 app/src/components/permissions/agent-grid.tsx create mode 100644 app/src/components/permissions/use-agent-access-line.ts create mode 100644 app/src/components/shell/team-sidebar-lists.tsx create mode 100644 app/src/components/shell/use-sidebar-agent-actions.ts create mode 100644 app/src/components/team-view/team-agent-filter-model.ts create mode 100644 app/src/components/team-view/team-agents-list.tsx create mode 100644 app/src/components/team-view/team-mission-board.tsx create mode 100644 app/src/components/team-view/team-mission-control.tsx create mode 100644 app/src/components/team-view/team-mission-empty.tsx create mode 100644 app/src/components/team-view/team-settings.tsx create mode 100644 app/src/components/team-view/team-view.tsx create mode 100644 app/src/components/team-view/use-team-board-scope.ts create mode 100644 app/src/hooks/use-teams.ts create mode 100644 app/src/lib/sidebar-teams.ts create mode 100644 app/src/lib/teams-model.ts create mode 100644 app/tests/mission-control-scope.test.ts create mode 100644 app/tests/sidebar-teams.test.ts create mode 100644 app/tests/team-agent-filter-model.test.ts create mode 100644 app/tests/team-one-sweep.test.ts create mode 100644 app/tests/teams-model.test.ts create mode 100644 knowledge-base/teams-ui.md create mode 100644 packages/web/e2e/board-keyboard-ownership.spec.ts create mode 100644 packages/web/e2e/sidebar-teams.spec.ts create mode 100644 packages/web/e2e/team-view.spec.ts create mode 100644 ui/layout/src/sidebar-default-header.tsx create mode 100644 ui/layout/src/sidebar-row-context.ts create mode 100644 ui/layout/src/sidebar-section-rows.tsx create mode 100644 ui/showcase/specimens/areas/agents/app-sidebar-sections.tsx create mode 100644 ui/showcase/specimens/areas/agents/app-sidebar-stage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index dd2966caa..beaeb195e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ Need specific knowledge? Load on demand: - Skills on disk + UI, picker, invocation marker → `knowledge-base/skills.md` - Integrations (Composio platform mode — provider port, direct vs gateway adapter, sandbox path, app-usage policy = per-agent allowlist ceiling (org ceilings + per-agent grants layer removed), UI map; PLUS custom integrations, HOU-550: the embedded executor engine, `custom` provider, secure credential card, agent setup tools) → `knowledge-base/integrations.md` - Agent manifest, tiers, sidebar, workspaces → `knowledge-base/agent-manifest.md` +- Sidebar TEAMS + the `team` screen (teams-model, `openTeamView` store contract, `visibleTeamSections` gating, the one-sweep team-scoped board, Team Settings drill-in) → `knowledge-base/teams-ui.md` (client-side grouping; NOT the multiplayer orgs in `teams.md`) - Agent Store (public catalog, publish/install, agents.gethouston.ai) → `knowledge-base/agent-store.md` - Multiplayer Teams (orgs) client surface — roles owner/admin/user + per-agent access manager/user, role matrix v2 (`app/src/lib/org-roles.ts`), org dashboard, Share dialog, allowed-models ceiling + per-user model choice, integration allowlists; **plus C8 Spaces** (personal + team spaces, the `org:` workspace switcher / `x-houston-org` active-space pin, create-team, share-via-team pipeline, seat billing + trial + degrade states, `capabilities.spaces` + per-space role); the gateway is the sole enforcer → `knowledge-base/teams.md` (server contracts: `cloud/docs/contracts/C3`,`C4`,`C7-teams.md`,`C8-spaces-billing.md`) - The ONE per-agent settings surface (section model + caps gating, the shared rail the Permissions drill-in AND the Context/Admin tabs render, the People "everyone vs specific people" choice, read-only, share analytics) → `knowledge-base/agent-settings.md` diff --git a/app/src/components/board/mission-board.tsx b/app/src/components/board/mission-board.tsx index 22ab94d0e..fbb310d77 100644 --- a/app/src/components/board/mission-board.tsx +++ b/app/src/components/board/mission-board.tsx @@ -1,5 +1,5 @@ import { AIBoard, type MessageMention } from "@houston-ai/board"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useOpenAgentHref } from "../../hooks/use-open-agent-file"; import { perfSpans } from "../../lib/perf-spans"; @@ -78,12 +78,6 @@ export function MissionBoard({ () => source.setSelectedId(null), [source.setSelectedId], ); - useEffect(() => { - if (!isActive) { - source.setSelectedId(null); - setPanelOpen(false); - } - }, [isActive, source.setSelectedId, setPanelOpen]); const { columns, selectionProps } = useBoardSelectionUI({ baseColumns, allItems: source.allItems, @@ -119,6 +113,7 @@ export function MissionBoard({ }); const { handleCloserReady } = useBoardKeyboard({ + isActive, items: source.items, columns, selectedId: source.selectedId, @@ -126,6 +121,7 @@ export function MissionBoard({ highlightedId: source.highlightedId, setHighlightedId: source.setHighlightedId, missionPanelOpen, + setPanelOpen, isLoaded: source.isLoaded, hasSearchQuery: source.hasSearchQuery, openerReady: source.openerReady, diff --git a/app/src/components/board/mission-control-archived.tsx b/app/src/components/board/mission-control-archived.tsx index db85c998c..81a5b8c3a 100644 --- a/app/src/components/board/mission-control-archived.tsx +++ b/app/src/components/board/mission-control-archived.tsx @@ -1,34 +1,40 @@ import { AIBoard } from "@houston-ai/board"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useArchivedHandoff } from "../../hooks/use-archived-handoff"; -import { useOpenAgentHref } from "../../hooks/use-open-agent-file"; -import { modelAcceptsImages } from "../../lib/providers"; import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; -import { useAttachmentRejectionDialog } from "../attachment-rejection-dialog"; import { MissionControlToolbar } from "../mission-control-toolbar"; import { AgentPanelAvatar } from "../shell/agent-panel-avatar"; +import { useIsActiveView } from "../shell/keep-alive-views"; import { useShellDetailPanel } from "../shell/use-shell-detail-panel"; import { ArchivedEmptyState } from "../tabs/archived-empty-state"; -import { useAgentChatPanel } from "../use-agent-chat-panel"; import { useMissionSearch } from "../use-mission-search"; -import { useLatchedMissionAgent } from "./use-latched-mission-agent"; +import { type MissionControlScope, useMcScope } from "./use-mc-scope.ts"; import { useMissionControlArchived } from "./use-mission-control-archived"; -import { useMissionControlArchivedSend } from "./use-mission-control-archived-send"; +import { useMissionControlArchivedPanel } from "./use-mission-control-archived-panel"; /** * Cross-agent Archived view for Mission Control. Same list UI as the per-agent * Archived tab, but spanning every agent: a column-less list of all archived * missions; clicking one opens its chat; sending re-activates it and hands the * user off to that agent's active board to keep the conversation in view. + * + * `agents` is ALWAYS the full workspace roster, whoever is rendering: the sweep + * behind it (`useMissionControlArchived`) keys the one shared + * `all-conversations` query on it. A team's archive narrows what it RENDERS + * through `scope` instead (the one-sweep rule, `useTeamBoardScope`). */ export function MissionControlArchived({ agents, onShowActive, + scope, }: { + /** The FULL workspace roster, always. Never a team's slice. */ agents: Agent[]; onShowActive: () => void; + /** Narrows what this board renders and names it. Omitted by the GLOBAL + * archive, which shows every agent and keeps the "Archived" title. */ + scope?: MissionControlScope; }) { const { t } = useTranslation("board"); const { panelContainer, setPanelOpen } = useShellDetailPanel(); @@ -37,15 +43,22 @@ export function MissionControlArchived({ const data = useMissionControlArchived(agents); - const [filterPath, setFilterPath] = useState(""); + const { scopedAgents, agentFilteredItems, filterPath, setFilterPath } = + useMcScope(agents, data.items, scope); const [search, setSearch] = useState(""); - const agentFilteredItems = useMemo( - () => - filterPath - ? data.items.filter((i) => i.metadata?.agentPath === filterPath) - : data.items, - [data.items, filterPath], - ); + + // HOU-1165: there is ONE shell detail panel, shared by every kept-alive + // screen. `MissionBoard` releases it when its screen hides, but the archive + // is not a `MissionBoard` -- without its own release, a team archive left + // with a mission open keeps portaling its chat into that panel after the + // user navigates away. + const isActive = useIsActiveView(); + useEffect(() => { + if (isActive) return; + data.setSelectedId(null); + setPanelOpen(false); + }, [isActive, data.setSelectedId, setPanelOpen]); + const handleSearchError = useCallback(() => { addToast({ title: t("search.historyErrorTitle"), @@ -60,49 +73,19 @@ export function MissionControlArchived({ onHistoryLoadError: handleSearchError, }); - const { selectedItem, activeAgent, activeAgentDef } = data; - - const clearSelection = useCallback(() => data.setSelectedId(null), [data]); - // The mission's agent, captured while it is still LISTED: the handoff fires - // after a send that re-activates the mission, by which point this list has - // refetched without it (see `useLatchedMissionAgent`). - const focusMissionAgent = useLatchedMissionAgent( - data.selectedId, - activeAgent, - ); - const { handoff, onSendReactivated } = useArchivedHandoff({ - missionId: data.selectedId, - onReactivated: clearSelection, - focusBoard: focusMissionAgent, - }); - - const panel = useAgentChatPanel({ - agent: activeAgent, - agentDef: activeAgentDef, - selectedSessionKey: data.selectedSessionKey, - onSelectSession: data.setSelectedId, - onSendReactivated, - }); - const attachmentValidation = useAttachmentRejectionDialog({ - modelAcceptsImages: modelAcceptsImages( - panel.effectiveProvider, - panel.effectiveModel, - ), - }); - const openHref = useOpenAgentHref(activeAgent?.folderPath ?? null); - const handleSendMessage = useMissionControlArchivedSend({ - activeAgent, - activeAgentDef, - selectedItem, - providerOverride: panel.effectiveProvider, - modelOverride: panel.effectiveModel, - onHandoff: handoff, - }); + const { selectedItem, activeAgent } = data; + const { panel, attachmentValidation, openHref, onSendMessage } = + useMissionControlArchivedPanel(data); return ( <> · Archived"`). Without it every team's + // archive reads as the same anonymous "Archived" and the user cannot + // tell whose they are looking at. + title={scope?.title} + agents={scopedAgents} filterPath={filterPath} search={search} isSearchingText={missionSearch.isSearchingText} @@ -131,7 +114,7 @@ export function MissionControlArchived({ feedItems={data.feedItems} sessionKeyFor={data.sessionKeyFor} onDelete={data.handleDelete} - onSendMessage={handleSendMessage} + onSendMessage={onSendMessage} onComposerSubmit={panel.onComposerSubmit} onLoadHistory={data.loadHistory} onLoadOlderMessages={data.onLoadOlderMessages} diff --git a/app/src/components/board/mission-control-scope.ts b/app/src/components/board/mission-control-scope.ts new file mode 100644 index 000000000..fe8cc1a18 --- /dev/null +++ b/app/src/components/board/mission-control-scope.ts @@ -0,0 +1,70 @@ +import type { KanbanItem } from "@houston-ai/board"; + +/** + * The pure scoping rules of a Mission Control board, split out so they are + * unit-testable without a React tree (`app/tests/mission-control-scope.test.ts`). + * + * `scopePaths === undefined` means "every agent in the workspace" — the global + * Mission Control. An ARRAY (an empty one included) means "only these agent + * folder paths" — one team's board. The distinction matters: the cross-agent + * sweep still spans the full roster either way, so every team reads the one + * warm `all-conversations` query instead of starting its own for its slice. + */ + +/** Whether a folder path belongs to the board's scope (no scope = everything). */ +export function inScope( + folderPath: string | undefined, + scopePaths: string[] | undefined, +): boolean { + if (scopePaths === undefined) return true; + return folderPath !== undefined && scopePaths.includes(folderPath); +} + +/** The agents a scoped board offers (filter menu, new-mission picker, actions). */ +export function agentsInScope( + agents: T[], + scopePaths: string[] | undefined, +): T[] { + if (scopePaths === undefined) return agents; + return agents.filter((agent) => inScope(agent.folderPath, scopePaths)); +} + +/** The board items a scoped board shows, keyed off each card's owning agent. */ +export function itemsInScope( + items: KanbanItem[], + scopePaths: string[] | undefined, +): KanbanItem[] { + if (scopePaths === undefined) return items; + return items.filter((item) => + inScope(item.metadata?.agentPath as string | undefined, scopePaths), + ); +} + +/** The bare scope the GLOBAL Mission Control board's new-mission draft lives under. */ +export const GLOBAL_MISSION_DRAFT_SCOPE = "mission-control"; + +/** + * The draft scope a board's new-mission composer saves under. The global board + * keeps the bare scope (unchanged behaviour, and unchanged stored drafts); a + * team board gets its own, so a first message parked on one team's board never + * surfaces in another team's composer. + */ +export function missionControlDraftScope(teamId?: string): string { + return teamId + ? `${GLOBAL_MISSION_DRAFT_SCOPE}:${teamId}` + : GLOBAL_MISSION_DRAFT_SCOPE; +} + +/** + * The agent filter the board actually applies. A filter pointing outside the + * scope (the agent was dragged to another team while its board was open) + * resolves to "every agent" rather than an empty board whose filter menu no + * longer lists the agent that emptied it. + */ +export function resolveFilterPath( + filterPath: string, + scopePaths: string[] | undefined, +): string { + if (!filterPath) return ""; + return inScope(filterPath, scopePaths) ? filterPath : ""; +} diff --git a/app/src/components/board/use-board-keyboard.ts b/app/src/components/board/use-board-keyboard.ts index 3ba242a05..0e2c749cf 100644 --- a/app/src/components/board/use-board-keyboard.ts +++ b/app/src/components/board/use-board-keyboard.ts @@ -8,15 +8,22 @@ import { useUIStore } from "../../stores/ui"; * * Owns: the arrow-key "highlight ring" navigator (Enter promotes the ring to * the open selection), the global Escape-to-close wiring, the highlight↔ - * selection sync, and the empty-board auto-open. Refs hold the latest items / - * columns / highlight so the callbacks registered in the UI store stay stable - * while always reading current state. + * selection sync, the empty-board auto-open, and the release of everything + * this board holds of the shared shell panel when it goes off screen. Refs + * hold the latest items / columns / highlight so the callbacks registered in + * the UI store stay stable while always reading current state. + * + * Every global registration here is gated on `isActive`: several mission + * boards are mounted at once (the dashboard board plus every kept-alive team + * board), so an unconditional registration is last-writer-wins and a HIDDEN + * board would own the arrow navigator and the Enter opener. * * View-specific knobs (`autoOpenKey` / `autoOpenItemCount` / `autoOpenBlocked` * / `onAutoOpenEmpty`) come from the source so Mission Control and the board * tab keep their own "open when empty" semantics behind one shared guard. */ export function useBoardKeyboard({ + isActive, items, columns, selectedId, @@ -24,6 +31,7 @@ export function useBoardKeyboard({ highlightedId, setHighlightedId, missionPanelOpen, + setPanelOpen, isLoaded, hasSearchQuery, openerReady, @@ -32,6 +40,8 @@ export function useBoardKeyboard({ autoOpenBlocked, onAutoOpenEmpty, }: { + /** Whether THIS board is the one on screen (see the hook's doc comment). */ + isActive: boolean; items: KanbanItem[]; columns: KanbanColumnConfig[]; selectedId: string | null; @@ -39,6 +49,8 @@ export function useBoardKeyboard({ highlightedId: string | null; setHighlightedId: (id: string | null) => void; missionPanelOpen: boolean; + /** This board's claim on the shared shell detail panel. */ + setPanelOpen: (open: boolean) => void; isLoaded: boolean; hasSearchQuery: boolean; openerReady: boolean; @@ -66,8 +78,14 @@ export function useBoardKeyboard({ }, []); // Arrow navigation walks the HIGHLIGHT (no chat panel open); Enter promotes - // it to the open selection. + // it to the open selection. Only the board ON SCREEN registers them: the + // `if (!isActive) return` with NO cleanup on the inactive path is deliberate, + // because React runs every effect's destroy pass across the tree before the + // create pass — the outgoing board nulls the handler, then the incoming one + // claims it, in that order. Nulling from the inactive path instead would + // clobber whichever board just claimed it. useEffect(() => { + if (!isActive) return; setOnBoardNavigate((dir) => { const next = navigateBoard( { @@ -87,12 +105,19 @@ export function useBoardKeyboard({ setOnBoardNavigate(null); setOnBoardOpen(null); }; - }, [setOnBoardNavigate, setOnBoardOpen, setSelectedId, setHighlightedId]); + }, [ + isActive, + setOnBoardNavigate, + setOnBoardOpen, + setSelectedId, + setHighlightedId, + ]); // Escape closes the open panel — covers both a selected card and the empty // new-mission panel (whose state lives inside AIBoard, hence the closer the // board hands back via onPanelCloserReady). useEffect(() => { + if (!isActive) return; if (!missionPanelOpen) { setOnPanelClose(null); return; @@ -102,7 +127,20 @@ export function useBoardKeyboard({ setSelectedId(null); }); return () => setOnPanelClose(null); - }, [missionPanelOpen, setOnPanelClose, setSelectedId]); + }, [isActive, missionPanelOpen, setOnPanelClose, setSelectedId]); + + // Going off screen releases everything this board holds of the ONE shell + // panel: the empty new-mission composer (state lives inside AIBoard, + // reachable only through the closer it handed back), the open mission, and + // the panel claim. Skipping the composer left AIBoard's `showPanel` stuck + // true, so its open-change effect never fired again and the panel could not + // reopen. + useEffect(() => { + if (isActive) return; + closerRef.current?.(); + setSelectedId(null); + setPanelOpen(false); + }, [isActive, setSelectedId, setPanelOpen]); // Mouse selection (or any external selection change) drags the highlight // ring along, so closing the panel leaves it where the user last was. @@ -113,9 +151,12 @@ export function useBoardKeyboard({ }, [selectedId, setHighlightedId]); // Open the new-mission panel when the in-scope board is empty (and the user - // isn't searching). Fires once per scope via the key ref. + // isn't searching). Fires once per scope via the key ref, and only for the + // board ON SCREEN — an off-screen empty team board would otherwise pop its + // agent picker over whatever the user is actually looking at. const autoOpenKeyRef = useRef(null); useEffect(() => { + if (!isActive) return; if (!isLoaded) return; if (hasSearchQuery) return; if (autoOpenItemCount > 0) { @@ -127,6 +168,7 @@ export function useBoardKeyboard({ autoOpenKeyRef.current = autoOpenKey; onAutoOpenEmpty(); }, [ + isActive, isLoaded, hasSearchQuery, autoOpenItemCount, diff --git a/app/src/components/board/use-mc-new-mission.tsx b/app/src/components/board/use-mc-new-mission.tsx index 19d4e6c9e..d83288b65 100644 --- a/app/src/components/board/use-mc-new-mission.tsx +++ b/app/src/components/board/use-mc-new-mission.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; import { AgentPickerDialog } from "../agent-picker-dialog"; +import { useIsActiveView } from "../shell/keep-alive-views"; /** * Mission Control's "New mission" flow. Because the view is cross-agent, the @@ -24,6 +25,7 @@ export function useMcNewMission({ }) { const setOnStartMission = useUIStore((s) => s.setOnStartMission); const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); + const isActive = useIsActiveView(); const [agentPickerOpen, setAgentPickerOpen] = useState(false); const [pendingAgent, setPendingAgent] = useState(null); @@ -31,10 +33,16 @@ export function useMcNewMission({ const openerRef = useRef(null); const openNewMission = useCallback(() => setAgentPickerOpen(true), []); + // Only the board ON SCREEN owns the global "New mission" handler. Mission + // Control and every team board are kept-alive screens, so several of them are + // mounted at once: an unconditional registration is last-writer-wins, and the + // shortcut would open a hidden team's agent picker while the user is looking + // at the global board. useEffect(() => { + if (!isActive) return; setOnStartMission(openNewMission); return () => setOnStartMission(null); - }, [openNewMission, setOnStartMission]); + }, [isActive, openNewMission, setOnStartMission]); const handlePickAgent = useCallback( (agent: Agent, options?: { focusComposer?: boolean }) => { diff --git a/app/src/components/board/use-mc-scope.ts b/app/src/components/board/use-mc-scope.ts new file mode 100644 index 000000000..cff96a5ff --- /dev/null +++ b/app/src/components/board/use-mc-scope.ts @@ -0,0 +1,105 @@ +import type { KanbanItem } from "@houston-ai/board"; +import { useCallback, useMemo, useState } from "react"; +import type { Agent } from "../../lib/types"; +import { + agentsInScope, + itemsInScope, + resolveFilterPath, +} from "./mission-control-scope.ts"; + +/** + * How a Mission Control board is narrowed and named. Omit it entirely and the + * board is the global one: every agent, its own local agent filter, its own + * title. That is what the Dashboard passes, so its behaviour is untouched by + * the team-scoped board. + */ +export interface MissionControlScope { + /** Restrict the board to these agent folder paths (one team's agents). */ + scopePaths?: string[]; + /** The board's title. A team names its board after the team. */ + title?: string; + /** Identifies the team this board belongs to, for the per-team concerns that + * are not a matter of which cards show — today the new-mission draft scope + * (`missionControlDraftScope`). The global board omits it. */ + teamId?: string; + /** Controlled agent filter: a folder path, or `null` for every agent in + * scope. Pair it with {@link onFilterPathChange}; the filter is local + * (uncontrolled) whenever the callback is absent. */ + filterPath?: string | null; + /** Receives `null` for "all agents", else the picked agent's folder path. */ + onFilterPathChange?: (path: string | null) => void; +} + +export interface McScope { + /** The agents this board offers: filter menu, new-mission picker, actions. */ + scopedAgents: Agent[]; + /** Their folder paths, for the per-agent action + selection routing. */ + paths: string[]; + /** Scoped items with the agent filter applied. */ + agentFilteredItems: KanbanItem[]; + /** The agents the current filter leaves visible (drives the empty auto-open). */ + visibleAgents: Agent[]; + /** The applied filter, `""` for "every agent in scope". */ + filterPath: string; + setFilterPath: (path: string) => void; +} + +/** + * The scope half of {@link useMissionControlSource}: which agents and cards a + * board covers, and the agent filter over them. Separated from the source so + * the "one team's slice of the cross-agent sweep" rules live in one small unit + * (with pure helpers behind them) instead of thickening the source hook. + */ +export function useMcScope( + agents: Agent[], + items: KanbanItem[], + scope?: MissionControlScope, +): McScope { + const scopePaths = scope?.scopePaths; + const onFilterPathChange = scope?.onFilterPathChange; + const controlledFilterPath = scope?.filterPath; + + const [localFilterPath, setLocalFilterPath] = useState(""); + const filterPath = resolveFilterPath( + onFilterPathChange ? (controlledFilterPath ?? "") : localFilterPath, + scopePaths, + ); + const setFilterPath = useCallback( + (path: string) => { + if (onFilterPathChange) onFilterPathChange(path || null); + else setLocalFilterPath(path); + }, + [onFilterPathChange], + ); + + const scopedAgents = useMemo( + () => agentsInScope(agents, scopePaths), + [agents, scopePaths], + ); + const paths = useMemo( + () => scopedAgents.map((a) => a.folderPath), + [scopedAgents], + ); + const agentFilteredItems = useMemo(() => { + const scoped = itemsInScope(items, scopePaths); + return filterPath + ? scoped.filter((i) => i.metadata?.agentPath === filterPath) + : scoped; + }, [items, scopePaths, filterPath]); + const visibleAgents = useMemo( + () => + filterPath + ? scopedAgents.filter((a) => a.folderPath === filterPath) + : scopedAgents, + [scopedAgents, filterPath], + ); + + return { + scopedAgents, + paths, + agentFilteredItems, + visibleAgents, + filterPath, + setFilterPath, + }; +} diff --git a/app/src/components/board/use-mc-search.tsx b/app/src/components/board/use-mc-search.tsx new file mode 100644 index 000000000..1133a8552 --- /dev/null +++ b/app/src/components/board/use-mc-search.tsx @@ -0,0 +1,83 @@ +import type { KanbanItem } from "@houston-ai/board"; +import type { FeedItem } from "@houston-ai/chat"; +import { type ReactNode, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { HistoryLoadOptions } from "../../lib/tauri"; +import { useUIStore } from "../../stores/ui"; +import { MissionBoardEmptyState } from "../mission-board-empty-state"; +import { useMissionSearch } from "../use-mission-search"; + +export interface McSearch { + query: string; + setQuery: (query: string) => void; + /** The items the board renders: `items` narrowed by the query. */ + items: KanbanItem[]; + hasQuery: boolean; + /** A transcript scan is still running behind the current query. */ + isSearchingText: boolean; + /** What the board shows with nothing to show. `undefined` outside a search: + * an empty board with no query keeps its columns and their own CTA. */ + emptyState: ReactNode | undefined; +} + +/** + * The text-search half of {@link useMissionControlSource}: the query, the + * transcript scan behind it (whose failures must reach the user as a toast, per + * the no-silent-failures rule), and the empty state a fruitless search shows. + */ +export function useMcSearch({ + items, + loadHistory, + onNewMission, +}: { + items: KanbanItem[]; + loadHistory: ( + sessionKey: string, + opts?: HistoryLoadOptions, + ) => Promise; + onNewMission: () => void; +}): McSearch { + const { t } = useTranslation("dashboard"); + const addToast = useUIStore((s) => s.addToast); + const [query, setQuery] = useState(""); + + const onHistoryLoadError = useCallback(() => { + addToast({ + title: t("search.historyErrorTitle"), + description: t("search.historyErrorDescription"), + variant: "error", + }); + }, [addToast, t]); + const search = useMissionSearch({ + items, + query, + loadHistory, + onHistoryLoadError, + }); + + return { + query, + setQuery, + items: search.items, + hasQuery: search.hasQuery, + isSearchingText: search.isSearchingText, + emptyState: search.hasQuery ? ( + setQuery("")} + /> + ) : undefined, + }; +} diff --git a/app/src/components/board/use-mission-control-archived-panel.ts b/app/src/components/board/use-mission-control-archived-panel.ts new file mode 100644 index 000000000..69c098772 --- /dev/null +++ b/app/src/components/board/use-mission-control-archived-panel.ts @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { useArchivedHandoff } from "../../hooks/use-archived-handoff"; +import { useOpenAgentHref } from "../../hooks/use-open-agent-file"; +import { modelAcceptsImages } from "../../lib/providers"; +import { useAttachmentRejectionDialog } from "../attachment-rejection-dialog"; +import { useAgentChatPanel } from "../use-agent-chat-panel"; +import { useLatchedMissionAgent } from "./use-latched-mission-agent"; +import type { useMissionControlArchived } from "./use-mission-control-archived"; +import { useMissionControlArchivedSend } from "./use-mission-control-archived-send"; + +type ArchivedData = ReturnType; + +/** + * The chat half of the cross-agent Archived view: the panel for the selected + * archived mission, the send that RE-ACTIVATES it, and the handoff that then + * carries the user to that agent's active board so the conversation stays in + * view. One unit because they are one story, and every piece of it needs the + * panel's effective provider/model (which is why the data hook stays data-only). + * + * Independent of how the list is scoped or searched: this hook only ever sees + * the ONE selected mission. + */ +export function useMissionControlArchivedPanel(data: ArchivedData) { + const { selectedItem, activeAgent, activeAgentDef } = data; + + const clearSelection = useCallback(() => data.setSelectedId(null), [data]); + // The mission's agent, captured while it is still LISTED: the handoff fires + // after a send that re-activates the mission, by which point this list has + // refetched without it (see `useLatchedMissionAgent`). + const focusMissionAgent = useLatchedMissionAgent( + data.selectedId, + activeAgent, + ); + const { handoff, onSendReactivated } = useArchivedHandoff({ + missionId: data.selectedId, + onReactivated: clearSelection, + focusBoard: focusMissionAgent, + }); + + const panel = useAgentChatPanel({ + agent: activeAgent, + agentDef: activeAgentDef, + selectedSessionKey: data.selectedSessionKey, + onSelectSession: data.setSelectedId, + onSendReactivated, + }); + const attachmentValidation = useAttachmentRejectionDialog({ + modelAcceptsImages: modelAcceptsImages( + panel.effectiveProvider, + panel.effectiveModel, + ), + }); + const openHref = useOpenAgentHref(activeAgent?.folderPath ?? null); + const onSendMessage = useMissionControlArchivedSend({ + activeAgent, + activeAgentDef, + selectedItem, + providerOverride: panel.effectiveProvider, + modelOverride: panel.effectiveModel, + onHandoff: handoff, + }); + + return { panel, attachmentValidation, openHref, onSendMessage }; +} diff --git a/app/src/components/board/use-mission-control-source.tsx b/app/src/components/board/use-mission-control-source.tsx index 3d5c09bac..03044c7dc 100644 --- a/app/src/components/board/use-mission-control-source.tsx +++ b/app/src/components/board/use-mission-control-source.tsx @@ -1,18 +1,18 @@ import { useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; import { missionMatchesPerson } from "../../lib/mission-people"; import type { Agent } from "../../lib/types"; import { useAgentCatalogStore } from "../../stores/agent-catalog"; import { useUIStore } from "../../stores/ui"; -import { MissionBoardEmptyState } from "../mission-board-empty-state"; import { MissionControlToolbar } from "../mission-control-toolbar"; import type { MissionsToolbarMentions } from "../mission-toolbar-actions"; import { useMissionControl } from "../use-mission-control"; -import { useMissionSearch } from "../use-mission-search"; import type { BoardSource } from "./board-source"; +import { missionControlDraftScope } from "./mission-control-scope.ts"; import { useCrossAgentSelection } from "./use-cross-agent-selection"; import { useMcActions } from "./use-mc-actions"; import { useMcNewMission } from "./use-mc-new-mission"; +import { type MissionControlScope, useMcScope } from "./use-mc-scope.ts"; +import { useMcSearch } from "./use-mc-search.tsx"; /** * Builds the {@link BoardSource} for cross-agent Mission Control: every @@ -20,34 +20,38 @@ import { useMcNewMission } from "./use-mc-new-mission"; * mission, an agent filter + search toolbar, and bulk actions routed per * agent. The active agent that scopes the right panel is whichever the * selected card belongs to, or the one just picked for a new mission. + * + * `scope` narrows all of that to one team's agents and lets the caller own the + * agent filter (see {@link MissionControlScope}); omitting it gives the global + * board exactly the behaviour it had before teams existed. The cross-agent + * sweep always spans the agents it is handed, so a team board passes the FULL + * roster and scopes what it renders — one shared query, no per-team re-sweep. */ export function useMissionControlSource( agents: Agent[], onShowArchived: () => void, mentions?: MissionsToolbarMentions, + scope?: MissionControlScope, ): BoardSource { - const { t } = useTranslation(["dashboard", "board"]); const getAgentDef = useAgentCatalogStore((s) => s.getById); - const addToast = useUIStore((s) => s.addToast); const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); const mc = useMissionControl(agents); - const [filterPath, setFilterPath] = useState(""); const [filterUserId, setFilterUserId] = useState(null); - const [missionSearchQuery, setMissionSearchQuery] = useState(""); const [highlightedId, setHighlightedId] = useState( mc.selectedId, ); - const paths = useMemo(() => agents.map((a) => a.folderPath), [agents]); - const agentFilteredItems = useMemo( - () => - filterPath - ? mc.items.filter((i) => i.metadata?.agentPath === filterPath) - : mc.items, - [mc.items, filterPath], - ); + const { + scopedAgents, + paths, + agentFilteredItems, + visibleAgents, + filterPath, + setFilterPath, + } = useMcScope(agents, mc.items, scope); + // Person filter runs AFTER the agent filter, BEFORE text search: narrow to the // missions the chosen person is on. `null` (Everyone) is a no-op. The filter // menu's roster stays keyed off `agentFilteredItems` so every person is always @@ -61,32 +65,18 @@ export function useMissionControlSource( : agentFilteredItems, [agentFilteredItems, filterUserId], ); - const visibleAgents = useMemo( - () => - filterPath ? agents.filter((a) => a.folderPath === filterPath) : agents, - [agents, filterPath], - ); - - const handleMissionSearchError = useCallback(() => { - addToast({ - title: t("dashboard:search.historyErrorTitle"), - description: t("dashboard:search.historyErrorDescription"), - variant: "error", - }); - }, [addToast, t]); - const missionSearch = useMissionSearch({ - items: personFilteredItems, - query: missionSearchQuery, - loadHistory: mc.loadHistory, - onHistoryLoadError: handleMissionSearchError, - }); const newMission = useMcNewMission({ - agents, + agents: scopedAgents, visibleAgents, selectedId: mc.selectedId, setSelectedId: mc.setSelectedId, }); + const missionSearch = useMcSearch({ + items: personFilteredItems, + loadHistory: mc.loadHistory, + onNewMission: newMission.openNewMission, + }); const selectedItem = mc.selectedId ? (mc.items.find((i) => i.id === mc.selectedId) ?? null) @@ -122,36 +112,18 @@ export function useMissionControlSource( agentPathForId, }); - const emptyState = missionSearch.hasQuery ? ( - setMissionSearchQuery("")} - /> - ) : undefined; - const toolbar = ( s.agents); const setCurrentAgent = useAgentStore((s) => s.setCurrent); const workspaceId = useWorkspaceStore((s) => s.current?.id); - const { layout } = useSidebarLayout(workspaceId); + const layout = useSidebarLayoutValue(workspaceId); const orderedAgents = useMemo( () => flatSidebarOrder(agents, layout), [agents, layout], @@ -127,8 +128,13 @@ export function CommandPalette() { // change runs, so focus lands on the right place. setTimeout(() => { const ui = useUIStore.getState(); - if (ui.viewMode === "dashboard") { - ui.onStartMission?.(); + // Mission Control and a team's board both own the handler already. The + // guard is two-part because the `team` view also renders Team Settings + // and the no-agents empty state, neither of which mounts a board — with + // no registered handler this has to fall through to the per-agent path + // instead of silently doing nothing. + if (isMissionBoardView(ui.viewMode) && ui.onStartMission) { + ui.onStartMission(); } else if (useAgentStore.getState().current) { if (ui.viewMode !== "activity") { ui.setViewMode("activity"); diff --git a/app/src/components/mission-control-toolbar.tsx b/app/src/components/mission-control-toolbar.tsx index 1a26aa294..4eb0c9098 100644 --- a/app/src/components/mission-control-toolbar.tsx +++ b/app/src/components/mission-control-toolbar.tsx @@ -13,6 +13,13 @@ import { * bar and simply omits the callbacks it has nothing to do with. */ interface MissionControlToolbarProps extends MissionToolbarActionsProps { + /** Names the BOARD this bar belongs to. A team's board is titled with the + * TEAM's name: without it the team board and the global one read + * identically and the user cannot tell which board they are looking at. + * It names the board, not the mode — the Archived line composes the two + * (see `heading`), so a team's archive says which team AND that it is the + * archive. */ + title?: string; search?: string; isSearchingText?: boolean; /** When set, renders the text-search field. Omitted by the Mentions inbox, @@ -28,6 +35,7 @@ interface MissionControlToolbarProps extends MissionToolbarActionsProps { export function MissionControlToolbar(props: MissionControlToolbarProps) { const { t } = useTranslation("dashboard"); const { + title, search = "", isSearchingText = false, onSearchChange, @@ -35,6 +43,18 @@ export function MissionControlToolbar(props: MissionControlToolbarProps) { archivedActive = false, mentionsActive = false, } = props; + // The board's name and the mode it is in are two different facts, and the + // archive needs BOTH: titling a team's archive with the bare team name makes + // it read exactly like that team's active board, which is the same "which + // board am I on?" confusion `title` exists to remove. So Archived composes, + // and only the board-less global archive falls back to the mode alone. + const heading = mentionsActive + ? t("mentions.title") + : archivedActive + ? title + ? t("archived.titleForBoard", { name: title }) + : t("archived.title") + : (title ?? t("title")); return (
    @@ -46,11 +66,7 @@ export function MissionControlToolbar(props: MissionControlToolbarProps) { HOME must survive, so the title gives up space before the back button does. */}

    - {mentionsActive - ? t("mentions.title") - : archivedActive - ? t("archived.title") - : t("title")} + {heading}

    {onSearchChange && ( diff --git a/app/src/components/permissions/agent-grid.tsx b/app/src/components/permissions/agent-grid.tsx new file mode 100644 index 000000000..b745b67ae --- /dev/null +++ b/app/src/components/permissions/agent-grid.tsx @@ -0,0 +1,53 @@ +import { CatalogSectionHeader, resolveAgentColor } from "@houston-ai/core"; +import type { OrgMember } from "@houston-ai/engine-client"; +import { useTranslation } from "react-i18next"; +import type { Agent } from "../../lib/types"; +import { PermissionsAgentRow } from "./agent-row"; +import { useAgentAccessLine } from "./use-agent-access-line"; + +/** + * The one home for "a list of agents you can open": a counted section header + * over a two-column grid of rows, each carrying the ONE plain-language access + * line every agent list in the app shares and opening that agent's settings. + * + * Settings > Permissions and a team's settings both render it, so the two can + * never drift into two slightly different renderings of the same information. + * Each caller keeps its OWN empty state: they are different situations (no + * agents in the workspace at all vs. none moved into this team yet) and say + * different things. + */ +export function PermissionsAgentGrid({ + agents, + members, + onOpenAgent, +}: { + agents: Agent[]; + /** Org roster used to name each agent's managers. Empty on single player. */ + members: OrgMember[]; + onOpenAgent: (agent: Agent) => void; +}) { + const { t } = useTranslation("teams"); + const accessLine = useAgentAccessLine(members); + + return ( +
    + +
    + {agents.map((agent) => ( + onOpenAgent(agent)} + /> + ))} +
    +
    + ); +} diff --git a/app/src/components/permissions/agents-list.tsx b/app/src/components/permissions/agents-list.tsx index 9e1f691fd..7b4dcadd6 100644 --- a/app/src/components/permissions/agents-list.tsx +++ b/app/src/components/permissions/agents-list.tsx @@ -1,20 +1,16 @@ import { Button, - CatalogSectionHeader, Empty, EmptyContent, EmptyDescription, EmptyTitle, - resolveAgentColor, } from "@houston-ai/core"; import type { OrgMember } from "@houston-ai/engine-client"; import { useTranslation } from "react-i18next"; import type { Agent } from "../../lib/types"; import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; -import { memberLabel } from "../organization/org-roster"; -import { PermissionsAgentRow } from "./agent-row"; -import { summarizeAgentAccess } from "./org-agents-model"; +import { PermissionsAgentGrid } from "./agent-grid"; /** * The Permissions plane: the agents the caller can see (owner: every org @@ -24,6 +20,9 @@ import { summarizeAgentAccess } from "./org-agents-model"; * and opens that agent's permission card (People | Integrations | AI Models). * Fresh orgs get a "create your first agent" empty state. * + * The populated list is {@link PermissionsAgentGrid}, shared with a team's + * settings; this view owns only the workspace-level empty state. + * * Deliberately NOT here: last-opened (dashboard information, not permission * information) and the pinned model (one config fetch per row). */ @@ -58,40 +57,10 @@ export function AgentsList({ } return ( -
    - -
    - {agents.map((agent) => { - const summary = summarizeAgentAccess(agent); - const access = summary.everyone - ? t("agentsTab.access.everyone") - : summary.peopleCount !== null - ? t("agentsTab.access.people", { count: summary.peopleCount }) - : t("agentsTab.access.you"); - const managedBy = - summary.managerIds.length > 0 - ? t("agentsTab.managedBy", { - names: summary.managerIds - .map((id) => memberLabel(id, members)) - .join(", "), - }) - : null; - return ( - onOpenAgent(agent)} - /> - ); - })} -
    -
    + ); } diff --git a/app/src/components/permissions/use-agent-access-line.ts b/app/src/components/permissions/use-agent-access-line.ts new file mode 100644 index 000000000..dcbcf000c --- /dev/null +++ b/app/src/components/permissions/use-agent-access-line.ts @@ -0,0 +1,39 @@ +import type { OrgMember } from "@houston-ai/engine-client"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { Agent } from "../../lib/types"; +import { memberLabel } from "../organization/org-roster"; +import { summarizeAgentAccess } from "./org-agents-model"; + +/** + * The ONE plain-language access line an agent row wears ("Everyone on your + * team", "3 people · Managed by Ana"). Shared by every agent list that drills + * into the canonical agent settings page — Settings > Permissions and a team's + * settings — so the same agent can never describe itself two different ways. + * + * `members` is the org roster used to name managers; single-player hosts pass + * an empty list and simply get the access half of the line. + */ +export function useAgentAccessLine( + members: OrgMember[], +): (agent: Agent) => string { + const { t } = useTranslation("teams"); + return useCallback( + (agent: Agent) => { + const summary = summarizeAgentAccess(agent); + const access = summary.everyone + ? t("agentsTab.access.everyone") + : summary.peopleCount !== null + ? t("agentsTab.access.people", { count: summary.peopleCount }) + : t("agentsTab.access.you"); + if (summary.managerIds.length === 0) return access; + const managedBy = t("agentsTab.managedBy", { + names: summary.managerIds + .map((id) => memberLabel(id, members)) + .join(", "), + }); + return `${access} · ${managedBy}`; + }, + [t, members], + ); +} diff --git a/app/src/components/shell/agent-sidebar-items.tsx b/app/src/components/shell/agent-sidebar-items.tsx index 65d2c4900..d77f86b52 100644 --- a/app/src/components/shell/agent-sidebar-items.tsx +++ b/app/src/components/shell/agent-sidebar-items.tsx @@ -1,10 +1,5 @@ import { DropdownMenuItem } from "@houston-ai/core"; -import type { SidebarLayout } from "@houston-ai/engine-client"; -import type { SidebarGroupView, SidebarItem } from "@houston-ai/layout"; -import { - flatSidebarOrder, - resolveSidebarSections, -} from "../../lib/agent-order"; +import type { SidebarItem } from "@houston-ai/layout"; import type { Agent } from "../../lib/types"; import type { AgentActivitySummary } from "./agent-activity-summary-model"; import { AgentSidebarColorMenu } from "./agent-sidebar-color-menu"; @@ -14,8 +9,8 @@ import { UnreadDot, } from "./agent-sidebar-status"; -interface BuildAgentSidebarItemsArgs { - agents: Agent[]; +/** Everything an agent row needs beyond the agents themselves. */ +export interface AgentItemArgs { summaries: Record; runningLabel: (count: number) => string; needsYouLabel: (count: number) => string; @@ -25,6 +20,10 @@ interface BuildAgentSidebarItemsArgs { shareLabel: string; } +interface BuildAgentSidebarItemsArgs extends AgentItemArgs { + agents: Agent[]; +} + export function buildAgentSidebarItems({ agents, summaries, @@ -87,39 +86,3 @@ export function buildAgentSidebarItems({ }; }); } - -interface BuildAgentSidebarListsArgs - extends Omit { - agents: Agent[]; - layout: SidebarLayout; -} - -/** - * Derive the `AppSidebar` `items` + `groups` from the raw agents and the - * sidebar layout. `items` is ALL agents in flat visible order (so the default - * section and ⌘[/⌘] cycling agree); `groups` places the grouped subset by id, - * each in its resolved order. - */ -export function buildAgentSidebarLists({ - agents, - layout, - ...itemArgs -}: BuildAgentSidebarListsArgs): { - items: SidebarItem[]; - groups: SidebarGroupView[]; -} { - const resolved = resolveSidebarSections(agents, layout); - const items = buildAgentSidebarItems({ - agents: flatSidebarOrder(agents, layout), - ...itemArgs, - }); - const groups: SidebarGroupView[] = resolved.groups.map( - ({ group, agents: members }) => ({ - id: group.id, - name: group.name, - collapsed: group.collapsed, - itemIds: members.map((a) => a.id), - }), - ); - return { items, groups }; -} diff --git a/app/src/components/shell/group-context-dialog.tsx b/app/src/components/shell/group-context-dialog.tsx index fd4f776fa..461249e4f 100644 --- a/app/src/components/shell/group-context-dialog.tsx +++ b/app/src/components/shell/group-context-dialog.tsx @@ -53,16 +53,16 @@ export function GroupContextDialog({ - {t("sidebar.groups.contextDialog.title", { name: groupName })} + {t("sidebar.teams.contextDialog.title", { name: groupName })} - {t("sidebar.groups.contextDialog.description")} + {t("sidebar.teams.contextDialog.description")}