diff --git a/CLAUDE.md b/CLAUDE.md index 7b84bf243..112889b3b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,33 +33,63 @@ Deploying / shipping a release? → `/release` Manual macOS build, notarize, staple? → `/build-app-local` Bug? Don't guess → `/debug` -Need specific knowledge? Load on demand: -- **Host architecture (host + pi runtime + adapter profiles, protocol v3, Composio-as-REST) → `knowledge-base/architecture.md`.** `convergence/` is the record of how we got here (the Rust→host cutover) — historical, not the day-to-day map. -- Design — the rules before ANY UI work → **`/DESIGN.md`** (repo-root compact agent spec: identity, hard rules, tokens, motion, banned defaults, polish checklist; **MANDATORY, hold it in context**). The deep narrative (rationale, component/animation detail, futuristic-theme internals) → `knowledge-base/design-system.md` +Need specific knowledge? Load on demand. Every `knowledge-base/` doc is routed below; the index with one-line topics is `knowledge-base/README.md`. + +**Foundations** +- **Host architecture (host + pi runtime + adapter profiles, protocol v3, every product surface incl. the live native iOS app) → `knowledge-base/architecture.md`.** `convergence/` is the record of how we got here (the Rust→host cutover) — historical, not the day-to-day map. - Client architecture — SDK / tokens / inventory / parity procedures → `knowledge-base/client-architecture.md` -- `.houston/` layout, schemas, reactivity → `knowledge-base/files-first.md` -- 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` -- 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`) +- `.houston/` layout, schemas, reactivity, boot migrations → `knowledge-base/files-first.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). +- Windows builds + the UTM VM loop, and the Windows rules for `app/src-tauri` code → `knowledge-base/windows-vm.md` + +**Design + shell** +- Design — the rules before ANY UI work → **`/DESIGN.md`** (repo-root compact agent spec: identity, hard rules, tokens, motion, banned defaults, polish checklist; **MANDATORY, hold it in context**). The deep narrative (rationale, component/animation detail, futuristic-theme internals) → `knowledge-base/design-system.md` +- Sidebar TEAMS + the `team` screen — **the whole shell since the agent tab strip was deleted** (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`). The "agent X's lives WHERE now" map is `knowledge-base/agent-manifest.md` → *Where an agent's surfaces live*. +- Sidebar ROW anatomy in `ui/layout` (row component, geometry module, the test that pins them) → `knowledge-base/sidebar-anatomy.md` +- Kept-alive screens, keyboard ownership, the ONE shared detail panel → `knowledge-base/board-shell.md` +- Translating UI strings, namespaces, ui/ labels prop pattern, `t()` rules → `knowledge-base/i18n.md` + +**Agents + their surfaces** +- Agent manifest, tiers, creation/import/activation, workspaces → `knowledge-base/agent-manifest.md` +- The ONE per-agent settings surface (section model + caps gating; two doors onto the same page — Team Settings → the agent's row in every deployment, and Settings > Permissions → the agent's row in multiplayer only — with `openAgentSettings` always taking the Team Settings door; the People "everyone vs specific people" choice, read-only, share analytics) → `knowledge-base/agent-settings.md` +- Files UI (grid + list, upload caps, preview dialog, chat links into it) → `knowledge-base/files-ui.md` +- Skills on disk + UI, picker, manage dialog, invocation marker → `knowledge-base/skills.md` +- Agent Store (public catalog, publish/install, agents.gethouston.ai) → `knowledge-base/agent-store.md` +- Portable agents (`.houstonagent` export/import wizards) → `knowledge-base/portable-agents.md` +- Routine triggers — cron `schedule` OR a `trigger` binding (Composio event / incoming webhook) → `knowledge-base/routine-triggers.md` + +**AI providers** +- Provider + model wiring (catalog, chat model picker, reasoning effort, turn mode, mid-conversation switching) → `knowledge-base/providers.md` +- AI Hub — the connect-an-account / browse-providers view → `knowledge-base/ai-hub.md` +- Provider error taxonomy — the eight typed wire kinds and their chat cards → `knowledge-base/provider-errors.md` - Anthropic credential lifecycle (Claude-SDK backend: keychain scoping, cloud push, per-turn access-only serve, single-rotator rule) → `knowledge-base/anthropic-credentials.md` -- Integrations: Composio is an in-process REST tool behind the `IntegrationProvider` port (`packages/host/src/integrations/`) — no bundled CLI, pi has no provider CLIs. +- BYO local model (LM Studio / Jan / Ollama) from a cloud agent, incl. team endpoint sharing → `knowledge-base/local-models.md` +- Desktop-only dictation (whisper.cpp sidecar, model download UX) → `knowledge-base/dictation.md` +- Adding a provider: pi talks to providers **in-process** — no provider CLIs. + +**Integrations** +- Composio platform mode — the `IntegrationProvider` port, direct vs gateway adapter, sandbox path, per-agent allowlist ceiling, UI map → `knowledge-base/integrations.md` +- User-added APIs & MCP servers (the `custom` provider, secure credential card, agent setup tools) → `knowledge-base/custom-integrations.md` + +**Multiplayer** +- 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, integration allowlists; the gateway is the sole enforcer → `knowledge-base/teams.md` (server contracts: `cloud/docs/contracts/C3`, `C7-teams.md`, `C8-spaces-billing.md`) +- Spaces (C8) — personal + team spaces, the `org:` switcher / `x-houston-org` pin, invites, seat billing + trial + degrade states → `knowledge-base/spaces.md` +- Per-user AI accounts + the per-agent model ceiling (no shared credential in a team space) → `knowledge-base/ai-accounts.md` +- Mission attribution — faces, senders, @mentions, unread → `knowledge-base/mission-attribution.md` +- GCIP (Firebase) auth, Google/Apple/Microsoft SSO + email OTP, Keychain → `knowledge-base/auth.md` + +**Ship + operate** +- Updater, analytics, in-app bug reports, release env vars, code signing, CI/CD → `knowledge-base/production-infra.md` +- Crash reporting (Sentry — three runtimes, one project, dormant without a DSN) → `knowledge-base/sentry.md` +- Firebase Hosting for the web client + marketing site → `knowledge-base/hosting.md` +- Automated UI / end-to-end tests (Playwright, web build, fake host, visual regression) → `knowledge-base/ui-testing.md` + `packages/web/e2e/README.md` +- Marketing landing (black-and-white system, download gate) → `knowledge-base/website-landing.md` +- Bootcamp participation certificates (`/c/`, satori/resvg pipeline, claim wizard) → `knowledge-base/website-certificates.md` - Self-host the TS engine on a VPS (Docker + Caddy TLS) → `selfhost/README.md` -- Windows testing loop from a Mac (UTM VM, SSH bridge, cross-compile, log fetch) → `knowledge-base/windows-testing.md` -- _[REMOVED]_ Custom-frontend integration reference (`examples/smartbooks/`) was deleted in the convergence sweep -- _[REMOVED feature]_ Mobile PWA (tunnel, pairing, relay) was cut; `mobile/` + `houston-relay/` are deleted — `docs/mobile-architecture.md` + `docs/relay-operations.md` are historical only -- Houston Cloud (control plane, per-turn runtime, code sandbox, credential model) → `cloud/README.md` + `cloud/code-execution.md` -- Updater, analytics, Sentry, env vars, CI → `knowledge-base/production-infra.md` -- Daily/weekly/monthly data rituals + dashboard reading guide → `knowledge-base/data-rituals.md` +- Houston Cloud (gateway, per-agent engine pods, code sandbox, credential model) → `cloud/README.md` + `cloud/code-execution.md` - UTM conventions, campaign attribution, IRL event tracking → `growth/utm-conventions.md` + `growth/campaigns/_template.md` + `scripts/event-qr.sh` -- GCIP (Firebase) auth, Google SSO, Keychain → `knowledge-base/auth.md` -- Translating UI strings, namespaces, ui/ labels prop pattern, `t()` rules → `knowledge-base/i18n.md` -- Automated UI / end-to-end tests (Playwright, web build, fake host, new TS engine) → `knowledge-base/ui-testing.md` + `packages/web/e2e/README.md` -UI / design work? Two skills: **`/frontend-design`** when creating a new surface (3–5 genuinely distinct variants, two-pass discipline) and **`/design-review`** (the screenshot self-critique loop — MANDATORY before calling any UI done). +UI / design work? Build to `/DESIGN.md` (tokens, motion, hard rules). **Design judgment is Julian's alone — show him, never self-review the look.** No `/design-review` screenshot loops; `/frontend-design` variants only if he asks for options. --- @@ -71,7 +101,7 @@ The phases themselves are in the workspace CLAUDE.md. In this repo they mean: - Phase 3 (challenge): library or app? Generic → ui/engine. App-specific → app/. Props generic, no store imports, no app types? - Phase 4 (plan): tag each step `[ui/board]`, `[host]`, `[app]`. Library before app. - Phase 6 (test): host/runtime/domain → vitest; the Tauri shell (`app/src-tauri`) → `cargo test`, not just check. -- Phase 7 (verify): UI touched → run the `/design-review` loop + `pnpm --filter houston-web test:visual`. Issue? Add logging first (`/debug`), never blind fix. +- Review gate: UI touched → `pnpm --filter houston-web test:visual` with baselines blessed once per element (never during iteration). Design judgment is Julian's. Issue? Add logging first (`/debug`), never blind fix. - Phase 9 (cleanup): ui/ → no `@/`, no Zustand, no Tauri. app/ → no duplicated logic. - Phase 10 (document): `knowledge-base/*.md`, skills, showcase. @@ -102,7 +132,7 @@ The dev loop (`pnpm dev` — doctor + mprocs, THE single entry point; `knowledge ## Hard rules (ALWAYS) -### Features default ON — no dark switches (Felipe's rule) +### Features default ON — no dark switches (Julian's rule) Polarity is fixed: everything on; turning something OFF is an explicit, committed, visible act. Three classes, three rules: 1. **Needs nothing external → no switch exists.** Merging = releasing. Not ready to be seen = not merged (short branches, not long-lived flags). Never add an `X_ENABLED` boolean for a plain code path — the Apple button rotted invisible for months behind one. 2. **Needs a credential → the credential IS the switch.** Key present = feature on, automatically. Key absent = loud, named OFF with the remedy (the dev doctor's feature matrix line; in-app, a visible error — never a hidden surface). Never layer a boolean on top of a credential. diff --git a/DESIGN.md b/DESIGN.md index b1be020c5..da162ca19 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -30,15 +30,16 @@ Houston is a calm, futuristic desktop AI product — "quiet expert," not flashy, ## 4. Tokens quick reference Every value below is a `--ht-*` token, re-exported to Tailwind `--color-*` (use the utility, e.g. `bg-card`, `text-ink`). Never the raw value. -**Type scale** (`scale/typography.json`) — system font stack `ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif`; **no webfonts**. Weights 400 / 500 / 600. +**Type scale** (`scale/typography.json`) — system font stack `ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif`; **no webfonts**. Weights 400 / 500 / 600, plus **510** for the sidebar rail (`font-weight-510`, defined in `ui/core/src/globals.css`): Linear's interface weight, delivered via `font-variation-settings` where the platform font is variable (SF Pro on macOS) and anchored at `font-weight: 500` so a static family (Segoe UI) matches downward instead of overshooting to semibold. | role | size | weight | Tailwind | |---|---|---|---| -| h1 / page title | 28px | 400 | `text-[28px] font-normal` | +| h1 / page title | 24px | 400 | `text-2xl font-normal` (what `PageHeader` renders) | | model selector | 18px | 400 | `text-lg` | | body / input | 16px | 400 | `text-base` | | buttons | 14px | 500 | `text-sm font-medium` | -| sidebar / small labels | 14 / 12px | 400 | `text-sm` / `text-xs` | +| sidebar rows / band | 13 / 12px | 510 | `text-[13px] font-weight-510` / `text-xs font-weight-510` (`sidebarRowType`) | +| small labels | 12px | 400 | `text-xs` | Section headers: sentence case, `text-sm font-medium`. Never uppercase / `tracking-wider`. @@ -122,7 +123,7 @@ Merge the tokenized scale (§4) with these craft rules: ## 8. Process (mandatory for UI tasks) 1. Load THIS file first, plus `knowledge-base/design-system.md` for the surface it touches. 2. **New surface/screen** → generate **3–5 genuinely distinct** design directions, judge them, pin the winner before building → `skills/frontend-design/SKILL.md`. -3. Before declaring done → run the screenshot self-critique loop → `skills/design-review/SKILL.md`. +3. **Never self-review the look.** Design judgment is Julian's alone — show him. No `/design-review` screenshot loops. 4. Scoped checks only (biome + your vitest); run **`pnpm check:parity`** whenever a shared/`ui/` component changed. ## Component inventory (`@houston-ai/core` — the primitive lock) diff --git a/README.md b/README.md index 4786f81fc..cfea0a829 100644 --- a/README.md +++ b/README.md @@ -170,10 +170,10 @@ Two tiers: | Tier | What you write | What you get | |------|---------------|-------------| -| **JSON-only** | `houston.json` + `CLAUDE.md` | A new agent. Renders the standard shell (Activity, Routines, Files, Job Description, Integrations). | +| **JSON-only** | `houston.json` + `CLAUDE.md` | A new agent in a team, listed in the sidebar. Its missions land on the team's Mission Control board; its routines, files and configuration live in that team's sections. | | **Workspace template** | `workspace.json` + agents folder | Multiple agents, one import. | -Every agent shows the same five tabs. The list lives in `app/src/agents/standard-tabs.ts` if you want to read it in code. +An agent has no screen of its own. Everything about it is a slice of its **team**: work on the team's Mission Control board, schedules under Routines, its documents under Files, and everything configurable (job description, memory, skills, who may use it, which apps and models it may reach) on one per-agent settings page reached from Team Settings. --- diff --git a/app/src/agents/standard-tabs.ts b/app/src/agents/standard-tabs.ts deleted file mode 100644 index ca6a363cd..000000000 --- a/app/src/agents/standard-tabs.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** Standard tab set every agent shows. */ - -import type { Agent, Capabilities } from "@houston-ai/engine-client"; -import { isAgentManager } from "../lib/agent-access.ts"; -import { isMultiplayer } from "../lib/org-roles.ts"; - -export interface AgentTab { - /** Tab identifier (also matches the built-in component key in tab-resolver). */ - id: string; - /** Display label fallback when no i18n key is available. */ - label: string; - /** Built-in component key consumed by tab-resolver. */ - builtIn: string; - /** Badge source: "activity" shows count of items needing attention. */ - badge?: "activity"; -} - -export const STANDARD_TABS: AgentTab[] = [ - { id: "activity", label: "Activity", builtIn: "board", badge: "activity" }, - { id: "context", label: "Context", builtIn: "context" }, - { id: "skills", label: "Skills", builtIn: "skills" }, - { id: "integrations", label: "Integrations", builtIn: "integrations" }, - { id: "routines", label: "Routines", builtIn: "routines" }, - { id: "files", label: "Files", builtIn: "files" }, - { id: "admin", label: "Admin", builtIn: "admin" }, -]; - -export const DEFAULT_TAB_ID = "activity"; - -export const STANDARD_TAB_IDS: ReadonlySet = new Set( - STANDARD_TABS.map((tab) => tab.id), -); - -/** - * The tabs a caller may see on an agent (PRODUCT-1256 split the old Settings - * tab into Context / Skills / Admin): - * - * - **Context** (job description + learnings) keeps the old Settings tab's - * visibility rule: everyone on a Teams host (members read it read-only), - * otherwise the agent manager only. - * - **Skills** is manager-only: the surface is an editor with no read-only - * mode, and members never saw the configuration rows before the split. - * - **Admin** (people / apps / models) is for the workspace owner and agent - * managers only, and only exists in multiplayer: single-player has no - * access rows, so the tab would be empty there. - */ -export function visibleAgentTabs( - caps: Capabilities | null | undefined, - agent: Pick, -): AgentTab[] { - // Check manager membership against the caller-visible agent record. The - // gateway already resolved `access` for this caller, so never infer it from - // organization role or a broader agent list. - const manager = isAgentManager(caps, agent); - return STANDARD_TABS.filter((tab) => { - if (tab.id === "context") { - return caps?.teams === true || !isMultiplayer(caps) || manager; - } - if (tab.id === "skills") return manager; - if (tab.id === "admin") return manager && isMultiplayer(caps); - return true; - }); -} - -/** Whether `tabId` is a tab the caller may actually see on this agent. */ -export function isVisibleAgentTab( - caps: Capabilities | null | undefined, - agent: Pick, - tabId: string, -): boolean { - return visibleAgentTabs(caps, agent).some((tab) => tab.id === tabId); -} - -/** Returns a visible tab id, falling back to Activity for stale routes. */ -export function agentTabFallback( - caps: Capabilities | null | undefined, - agent: Pick, - tabId: string, -): string { - return isVisibleAgentTab(caps, agent, tabId) ? tabId : DEFAULT_TAB_ID; -} diff --git a/app/src/agents/tab-resolver.ts b/app/src/agents/tab-resolver.ts deleted file mode 100644 index 3728c1e84..000000000 --- a/app/src/agents/tab-resolver.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentType } from "react"; -import AdminTab from "../components/tabs/admin-tab"; -import BoardTab from "../components/tabs/board-tab"; -import ContextTab from "../components/tabs/context-tab"; -import FilesTab from "../components/tabs/files-tab"; -import IntegrationsTab from "../components/tabs/integrations-tab"; -import RoutinesTab from "../components/tabs/routines-tab"; -import SkillsTab from "../components/tabs/skills-tab"; -import type { TabProps } from "../lib/types"; -import type { AgentTab } from "./standard-tabs"; - -const BUILTIN_TABS: Record> = { - admin: AdminTab, - board: BoardTab, - context: ContextTab, - files: FilesTab, - integrations: IntegrationsTab, - routines: RoutinesTab, - skills: SkillsTab, -}; - -export function resolveTabComponent(tab: AgentTab): ComponentType { - const Component = BUILTIN_TABS[tab.builtIn]; - if (!Component) { - throw new Error(`Unknown built-in tab: ${tab.builtIn}`); - } - return Component; -} diff --git a/app/src/components/about-me/about-me-view.tsx b/app/src/components/about-me/about-me-view.tsx new file mode 100644 index 000000000..a994eef19 --- /dev/null +++ b/app/src/components/about-me/about-me-view.tsx @@ -0,0 +1,52 @@ +import { Spinner } from "@houston-ai/core"; +import { useTranslation } from "react-i18next"; +import { InstructionsContent } from "../agent/job-description-parts"; +import { useContextSlot, useContextSlotLabels } from "../context/context-slots"; +import { PageContainer, PageHero } from "../shell/page-shell"; + +/** + * About me: what every agent knows about the PERSON before it starts a turn. + * + * A TOP-LEVEL view for everyone, sitting under the Inbox. It used to be one of + * two tabs on a Context screen hidden behind a door in the Inbox's masthead, + * which asked the user to find a door and then pick a tab before reaching the + * one thing they came for. Standing knowledge about you is not a preference and + * it is not somebody's admin territory, so it is a row of its own and it owns + * the whole window: no back bar, because there is no level above it. + * + * The other half of that screen, what the agents know about the COMPANY, is not + * here on purpose. It is shared by everyone in the space, so it belongs to Admin + * (its Company context section) and it exists only in a team space. On a + * personal install this page is the whole of the product's standing context, + * which is exactly right: a second "about this workspace" editor there would + * duplicate this one. + * + * The stored files are unchanged: this reads and writes the SAME `user` slot of + * the workspace context blob the Context screen wrote (`context-slots.ts` → + * `use-workspace-context.ts`), so nothing a user already told their agents + * moves or needs migrating. + */ +export function AboutMeView() { + const { t } = useTranslation("context"); + const editor = useContextSlot("user"); + const labels = useContextSlotLabels("user"); + + return ( +
+ + + {editor.ready ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/app/src/components/about-me/id.ts b/app/src/components/about-me/id.ts new file mode 100644 index 000000000..e7b80d38e --- /dev/null +++ b/app/src/components/about-me/id.ts @@ -0,0 +1,2 @@ +/** The About me top-level view id (a rail destination, under the Inbox). */ +export const ABOUT_ME_VIEW_ID = "about-me"; diff --git a/app/src/components/about-me/index.ts b/app/src/components/about-me/index.ts new file mode 100644 index 000000000..19cfed79e --- /dev/null +++ b/app/src/components/about-me/index.ts @@ -0,0 +1,2 @@ +export { AboutMeView } from "./about-me-view"; +export { ABOUT_ME_VIEW_ID } from "./id"; diff --git a/app/src/components/agent-file-preview-host.tsx b/app/src/components/agent-file-preview-host.tsx index 4f535c721..63aa6b28f 100644 --- a/app/src/components/agent-file-preview-host.tsx +++ b/app/src/components/agent-file-preview-host.tsx @@ -6,7 +6,7 @@ import { FilePreviewDialog } from "./file-preview-dialog"; * fed by `useUIStore.filePreview` (set by `useOpenAgentFile` from chat file * cards, turn summaries, and prose file pills). Mounted once per app tree — * app/src/main.tsx and packages/web/src/app-tree.tsx — so it works on every - * screen, onboarding included. The Files tab keeps its own local instance. + * screen, onboarding included. The Files section keeps its own local instance. */ export function AgentFilePreviewHost() { const preview = useUIStore((s) => s.filePreview); diff --git a/app/src/components/agent-person-scope-context.tsx b/app/src/components/agent-person-scope-context.tsx deleted file mode 100644 index 53978cc78..000000000 --- a/app/src/components/agent-person-scope-context.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { - createContext, - type ReactNode, - useContext, - useMemo, - useState, -} from "react"; -import { DEFAULT_SCOPE, type PersonScope } from "../lib/agent-person-scope"; - -interface AgentPersonScopeValue { - /** Agent path this scope belongs to. */ - path: string; - /** The scope for this keyed agent view. */ - scope: PersonScope; - setScope: (scope: PersonScope) => void; -} - -const AgentPersonScopeContext = createContext( - null, -); - -/** - * Holds the per-agent PERSON SCOPE and shares it across the agent view so the - * header trigger ({@link AgentPersonScopeMenu}) and the board filter - * ({@link useAgentBoardScope}) — which sit in different subtrees — read and - * write ONE selection. The whole agent view is keyed by agent identity, so a - * fresh provider starts at {@link DEFAULT_SCOPE} for every agent. - */ -export function AgentPersonScopeProvider({ - path, - children, -}: { - path: string; - children: ReactNode; -}) { - const [scope, setScope] = useState(DEFAULT_SCOPE); - - const value = useMemo( - () => ({ path, scope, setScope }), - [path, scope], - ); - return ( - - {children} - - ); -} - -/** - * The active per-agent person scope. Returns the default (everyone) with a - * no-op setter when rendered outside a provider, so a board mounted without the - * agent view header (never in practice) degrades to the unfiltered default - * rather than throwing. - */ -export function useAgentPersonScope(): AgentPersonScopeValue { - return ( - useContext(AgentPersonScopeContext) ?? { - path: "", - scope: DEFAULT_SCOPE, - setScope: () => {}, - } - ); -} diff --git a/app/src/components/agent-person-scope-menu.tsx b/app/src/components/agent-person-scope-menu.tsx deleted file mode 100644 index d90df748c..000000000 --- a/app/src/components/agent-person-scope-menu.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@houston-ai/core"; -import { UserPlus } from "lucide-react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useCapabilities } from "../hooks/use-capabilities"; -import { useMyProfile } from "../hooks/use-my-profile"; -import { buildScopeOptions } from "../lib/agent-person-scope"; -import { distinctBoardPeople, shortUserLabel } from "../lib/mission-people"; -import { isTeamWorkspace } from "../lib/space-id"; -import type { Agent } from "../lib/types"; -import { useWorkspaceStore } from "../stores/workspaces"; -import { useAgentPersonScope } from "./agent-person-scope-context"; -import { useAgentBoardPeople } from "./board/use-agent-board-people"; -import { - type FilterFace, - FilterTrigger, - PersonFace, -} from "./mission-person-face"; -import { agentShareSurface } from "./tabs/agent-access-model"; -import { AgentShareSurfaces } from "./tabs/agent-share-surfaces"; -import { usePersonFilterMode } from "./use-person-filter-mode"; - -/** - * The per-agent PERSON SCOPE control that lives in the agent header, beside the - * Share button (it replaces the old per-agent board toolbar filter). The - * trigger shows the SELECTED person's face + short name; on the default — - * Everyone — it wears the generic people glyph + "Everyone", the same face the - * cross-agent Mission Control filter shows at rest. - * - * Menu (order owned by {@link buildScopeOptions}): Everyone first, then me (my - * missions), then every other contributor on this agent's items, a divider, and - * a quiet "Invite teammates" row. That last row is not a scope — it opens the - * SAME share flow as the header Share button ({@link AgentShareSurfaces} keyed - * by {@link agentShareSurface}), the actionable end of the sharing reminder. - * - * Same gate as the cross-agent filter via {@link usePersonFilterMode}: nothing - * renders off spaces / single-player / signed out. - */ -export function AgentPersonScopeMenu({ - agent, - collapsed, -}: { - agent: Agent; - collapsed: boolean; -}) { - const { t } = useTranslation(["dashboard", "teams"]); - const { mode, user } = usePersonFilterMode(); - const { scope, setScope } = useAgentPersonScope(); - const { capabilities } = useCapabilities(); - const current = useWorkspaceStore((s) => s.current); - const peopleById = useAgentBoardPeople(agent.folderPath); - const myProfile = useMyProfile(); - const [shareOpen, setShareOpen] = useState(false); - - if (mode === "hidden" || !user) return null; - - const selfName = myProfile?.name ?? user.email ?? shortUserLabel(user.id); - const selfShort = selfName.split(/\s+/)[0] || selfName; - // Carrying the `id` is what earns the initials fallback this person's opaque - // `person.*` tone — the same one their face wears on every board card. - const selfFace: FilterFace = { - id: user.id, - label: selfName, - imageUrl: myProfile?.avatarUrl ?? undefined, - }; - - const roster = distinctBoardPeople( - Array.from(peopleById.values()).map((people) => ({ people })), - ); - const options = buildScopeOptions(roster, user.id); - - // Everyone (the default) has no face: a null face makes {@link FilterTrigger} - // fall back to its generic people glyph beside the "Everyone" label. - let activeFace: FilterFace | null = null; - let activeText = t("dashboard:peopleFilter.everyone"); - if (scope.kind === "me") { - activeFace = selfFace; - activeText = selfShort; - } else if (scope.kind === "person") { - const person = roster.find((p) => p.id === scope.userId); - activeFace = person ?? null; - activeText = person?.label ?? shortUserLabel(scope.userId); - } - - const inPersonalSpace = !isTeamWorkspace(current?.id ?? ""); - const surface = agentShareSurface(capabilities, agent, inPersonalSpace); - const inviteLabel = - surface === "view" - ? t("teams:share.viewButton") - : t("dashboard:peopleFilter.invite"); - - return ( - <> - - - - - - {options.map((option) => - option.scope.kind === "everyone" ? ( - setScope(option.scope)} - > - {t("dashboard:peopleFilter.everyone")} - - ) : option.scope.kind === "me" ? ( - setScope(option.scope)} - className="gap-2" - > - - {selfName} - - {t("dashboard:peopleFilter.mine")} - - - ) : ( - option.person && ( - setScope(option.scope)} - className="gap-2" - > - - {option.person.label} - - ) - ), - )} - {surface !== "none" && ( - <> - - setShareOpen(true)} - className="gap-2 text-ink-muted" - > - - {inviteLabel} - - - )} - - - - - ); -} diff --git a/app/src/components/agent-picker-dialog.tsx b/app/src/components/agent-picker-dialog.tsx index b58ea14f2..134f5d83f 100644 --- a/app/src/components/agent-picker-dialog.tsx +++ b/app/src/components/agent-picker-dialog.tsx @@ -15,20 +15,37 @@ interface Props { onOpenChange: (open: boolean) => void; agents: Agent[]; onPick: (agent: Agent) => void; + /** Override the heading for a caller that is not starting a mission (a + * team's Routines asks the same question about a routine). Defaults to the + * mission wording, so the board is unchanged. */ + title?: string; + description?: string; } /** - * Modal that asks "which agent should run this mission?" and renders one - * card per agent. Picking an agent switches the app to that agent's board - * view and opens the new-mission right panel — the same flow you get from - * the per-agent New Mission button. See the dashboard wiring for that - * sequencing (it lives there because it depends on view-mode state). + * Modal that asks "which agent should run this?" and renders one card per + * agent. It is the ONE answer to that question wherever a create starts from a + * surface that spans several agents — but it only ASKS: what picking an agent + * leads to belongs entirely to the caller, and differs by caller. + * + * - A team's Mission Control "New mission" switches to that agent's board view + * and opens the new-mission right panel, the same flow the per-agent New + * Mission button gives. That sequencing lives in the board's own wiring, + * because it depends on view-mode state. + * - A team's Routines opens that agent's routine intake in the shared shell + * panel; nothing navigates. + * + * So the copy is the caller's too: pass `title` / `description` whenever "a + * fresh conversation" is not what happens next. The defaults are the mission + * wording, so the board is unchanged. */ export function AgentPickerDialog({ open, onOpenChange, agents, onPick, + title, + description, }: Props) { const { t } = useTranslation("dashboard"); @@ -36,8 +53,10 @@ export function AgentPickerDialog({ - {t("agentPicker.title")} - {t("agentPicker.description")} + {title ?? t("agentPicker.title")} + + {description ?? t("agentPicker.description")} +
diff --git a/app/src/components/agent-reads-failed.tsx b/app/src/components/agent-reads-failed.tsx new file mode 100644 index 000000000..aa4696926 --- /dev/null +++ b/app/src/components/agent-reads-failed.tsx @@ -0,0 +1,87 @@ +import { Button } from "@houston-ai/core"; +import { RefreshCw, TriangleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { AgentReadFailures } from "../lib/agent-read-failures"; +import { ReportBugButton } from "./cards/report-bug-button"; + +/** + * The ONE strip a surface shows when an agent's read did not answer — a team + * section naming the members it could not reach, or a Files tree saying that + * this agent's files failed to load rather than sitting there looking empty. + * + * It stays INSIDE the surface, above the content, rather than becoming a toast: + * the fact is durable (those agents are still missing from what is on screen), + * and a background refetch would otherwise fire one toast per unreachable agent + * every time the window regained focus. Retry is the loud, user-initiated path + * — it refetches only what failed, so a healthy fleet is never re-swept to fix + * one pod. + * + * Beside it sits the standard Report-bug pill (`components/cards/`, the same + * one the provider-error cards mount), because a durable visible failure with + * no way to tell us about it is exactly what the no-silent-failures policy + * forbids: quiet in the background, never a dead end once a person is looking + * at it. The agents are named in its payload, since "which pods" is the whole + * diagnosis. + * + * It carries NO horizontal inset of its own: the surfaces that render it keep + * different gutters (a routines list, a files browser), and a strip whose left + * edge does not land on the content's reads as a floating banner rather than as + * a statement about what is under it. The caller pays the gutter. + */ +export function AgentReadsFailed({ + failures, + onRetry, + retrying, +}: { + failures: AgentReadFailures; + onRetry: () => void; + retrying: boolean; +}) { + const { t } = useTranslation("shell"); + const { failed, total } = failures; + if (failed.length === 0) return null; + + return ( +
+ +
+

+ {total === 1 + ? t("agentReads.failedOne", { name: failed[0].name }) + : // `failed`, not `count`: naming it `count` would send i18next + // looking for `failed_one` / `failed_other` plurals that do not + // exist, and "N of M agents" is plural in every branch anyway. + t("agentReads.failed", { failed: failed.length, total })} +

+ {total > 1 && ( +

+ {failed.map((a) => a.name).join(", ")} +

+ )} +
+
+ `${a.name} (${a.folderPath})`) + .join(", ")}`} + label={t("agentReads.report")} + /> + +
+
+ ); +} 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..d25eb2fbd --- /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 "../agent/agent-access-model.ts"; +import type { AccessMode } from "../agent/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 99% rename from app/src/components/permissions/agent-people-model.ts rename to app/src/components/agent-settings/agent-people-model.ts index b7883ff00..7cd70aca1 100644 --- a/app/src/components/permissions/agent-people-model.ts +++ b/app/src/components/agent-settings/agent-people-model.ts @@ -10,7 +10,7 @@ import { needsSelfLockoutConfirm, type ShareAction, type SharePerson, -} from "../tabs/agent-access-model.ts"; +} from "../agent/agent-access-model.ts"; /** * Pure, DOM-free logic behind the Permissions agent People tab: ONE agent, every diff --git a/app/src/components/permissions/agent-people-tab.tsx b/app/src/components/agent-settings/agent-people-tab.tsx similarity index 63% rename from app/src/components/permissions/agent-people-tab.tsx rename to app/src/components/agent-settings/agent-people-tab.tsx index 2953e4b38..a9fa552a5 100644 --- a/app/src/components/permissions/agent-people-tab.tsx +++ b/app/src/components/agent-settings/agent-people-tab.tsx @@ -11,8 +11,8 @@ import { useUserProfiles } from "../../hooks/queries/use-user-profiles"; 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 { ShareAction } from "../agent/agent-access-model.ts"; +import type { ShareAgentMutation } from "../agent/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 71% rename from app/src/components/permissions/agent-person-row.tsx rename to app/src/components/agent-settings/agent-person-row.tsx index 62fb89dd0..a3e218a17 100644 --- a/app/src/components/permissions/agent-person-row.tsx +++ b/app/src/components/agent-settings/agent-person-row.tsx @@ -1,7 +1,4 @@ import { - Avatar, - AvatarFallback, - AvatarImage, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -11,23 +8,26 @@ import { } from "@houston-ai/core"; import { Check, ChevronDown } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { initialsFor, memberLabel } from "../organization/people-tab-model.ts"; -import type { ShareAction } from "../tabs/agent-access-model.ts"; -import type { AgentPersonRow as PersonRow } from "./agent-people-model.ts"; +import type { ShareAction } from "../agent/agent-access-model.ts"; +import { memberLabel } from "../organization/people-tab-model.ts"; +import { + PersonRow, + personRowTriggerClass, +} from "../organization/person-row.tsx"; +import type { AgentPersonRow as Row } from "./agent-people-model.ts"; /** The translated label for a member's current level on this agent. */ -function levelLabel( - level: PersonRow["level"], - t: (k: string) => string, -): string { +function levelLabel(level: Row["level"], t: (k: string) => string): string { if (level === "manager") return t("share.levels.manager"); if (level === "user") return t("share.levels.user"); return t("permissions.agentPeople.none"); } /** - * One member row in the Permissions agent People tab: the member's identity and - * org-role chip on the left, a None / Can use / Manager control on the right. + * One member row in the Permissions agent People tab: the shared + * {@link PersonRow} shell (the same one a team's Members card wears) carrying + * the member's identity over their org role, with a None / Can use / Manager + * control on the right. * The org owner renders as a static "Owner, always has access" (never editable); * everyone else gets a dropdown whose Manager option is disabled with an inline * reason for teammates without a Manager seat (`canBeManager` false). The trigger @@ -45,7 +45,7 @@ export function AgentPersonRow({ readOnly, onAction, }: { - row: PersonRow; + row: Row; /** Resolved avatar photo, or null for initials-only. */ avatarUrl?: string | null; /** Locks the control while a write is in flight. */ @@ -59,30 +59,15 @@ export function AgentPersonRow({ const label = levelLabel(row.level, t); return ( - // The flat page language: transparent row, no card chrome. Identity reads - // as two calm lines (name, org role) so the right edge carries exactly ONE - // element — the access control (or its static label). -
  • - - {avatarUrl && ( - - )} - {initialsFor(name)} - -
    -

    - {name} - {row.isSelf && ( - - {t("share.you")} - - )} -

    -

    - {t(`people.roles.${row.member.role}`)} -

    -
    - + // Identity reads as two calm lines (name, org role) so the right edge + // carries exactly ONE element — the access control (or its static label). + {row.isOwner ? ( {t("share.ownerAccess")} @@ -94,7 +79,7 @@ export function AgentPersonRow({ {label} @@ -147,6 +132,6 @@ export function AgentPersonRow({ )} -
  • + ); } 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..d67cafac6 --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-nav.ts @@ -0,0 +1,111 @@ +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. + * + * There is one surface now — the canonical settings page — reached through two + * doors (Team Settings, and Settings > Permissions in multiplayer). It names + * its rail from here, so a section can never mean two different things or be + * gated two different ways. The per-agent Context / Admin tabs that used to + * render their own one-group rails from `contextTabGroups` / `adminTabGroups` + * went away with the agent tab shell, and so did those builders. + * + * 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 a single-player rail is Context plus Skills and nothing else. + * 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"] }, + ]; +} + +/** 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..ed276ffe6 --- /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 "../agent/agent-admin/access-choice.tsx"; +import type { AccessMode } from "../agent/agent-admin/agent-admin-row-values.ts"; +import { useShareAgent } from "../agent/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..3b6d47efb --- /dev/null +++ b/app/src/components/agent-settings/agent-settings-section.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { AgentAdminInstructions } from "../agent/agent-admin/agent-admin-instructions"; +import { AgentAdminIntegrations } from "../agent/agent-admin/agent-admin-integrations"; +import { AgentAdminKnowledge } from "../agent/agent-admin/agent-admin-knowledge"; +import { AgentAdminModel } from "../agent/agent-admin/agent-admin-model"; +import { AgentAdminSkills } from "../agent/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. 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..cb1833a81 --- /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 + * 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/agent/agent-access-diff.ts b/app/src/components/agent/agent-access-diff.ts new file mode 100644 index 000000000..21188e098 --- /dev/null +++ b/app/src/components/agent/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-access-model.ts b/app/src/components/agent/agent-access-model.ts similarity index 100% rename from app/src/components/tabs/agent-access-model.ts rename to app/src/components/agent/agent-access-model.ts diff --git a/app/src/components/tabs/agent-admin/access-choice.tsx b/app/src/components/agent/agent-admin/access-choice.tsx similarity index 92% rename from app/src/components/tabs/agent-admin/access-choice.tsx rename to app/src/components/agent/agent-admin/access-choice.tsx index df1bbfd73..195014fab 100644 --- a/app/src/components/tabs/agent-admin/access-choice.tsx +++ b/app/src/components/agent/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/agent/agent-admin/agent-admin-instructions.tsx similarity index 84% rename from app/src/components/tabs/agent-admin/agent-admin-instructions.tsx rename to app/src/components/agent/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/agent/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/agent/agent-admin/agent-admin-integrations.tsx similarity index 95% rename from app/src/components/tabs/agent-admin/agent-admin-integrations.tsx rename to app/src/components/agent/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/agent/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/agent/agent-admin/agent-admin-knowledge.tsx similarity index 94% rename from app/src/components/tabs/agent-admin/agent-admin-knowledge.tsx rename to app/src/components/agent/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/agent/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/agent/agent-admin/agent-admin-model.tsx similarity index 95% rename from app/src/components/tabs/agent-admin/agent-admin-model.tsx rename to app/src/components/agent/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/agent/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-row-values.ts b/app/src/components/agent/agent-admin/agent-admin-row-values.ts similarity index 100% rename from app/src/components/tabs/agent-admin/agent-admin-row-values.ts rename to app/src/components/agent/agent-admin/agent-admin-row-values.ts diff --git a/app/src/components/tabs/agent-admin/agent-admin-skills.tsx b/app/src/components/agent/agent-admin/agent-admin-skills.tsx similarity index 85% rename from app/src/components/tabs/agent-admin/agent-admin-skills.tsx rename to app/src/components/agent/agent-admin/agent-admin-skills.tsx index 588ec77ef..5a3d66e2f 100644 --- a/app/src/components/tabs/agent-admin/agent-admin-skills.tsx +++ b/app/src/components/agent/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,16 @@ 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. */ -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 +50,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-admin/agent-models-section.tsx b/app/src/components/agent/agent-admin/agent-models-section.tsx similarity index 97% rename from app/src/components/tabs/agent-admin/agent-models-section.tsx rename to app/src/components/agent/agent-admin/agent-models-section.tsx index bb5bdc9ba..f0412b1bb 100644 --- a/app/src/components/tabs/agent-admin/agent-models-section.tsx +++ b/app/src/components/agent/agent-admin/agent-models-section.tsx @@ -53,6 +53,7 @@ export function AgentModelsSection({ allowedEmpty: t("agentAdmin.models.allowedEmpty"), allowedEmptyLab: t("agentAdmin.models.allowedEmptyLab"), searchModels: t("agentAdmin.models.searchModels"), + clearSearch: t("agentAdmin.models.clearSearch"), noModels: t("agentAdmin.models.noModels"), allowModel: (name) => t("agentAdmin.models.allowModel", { name }), }} diff --git a/app/src/components/tabs/agent-admin/lab-filter.tsx b/app/src/components/agent/agent-admin/lab-filter.tsx similarity index 100% rename from app/src/components/tabs/agent-admin/lab-filter.tsx rename to app/src/components/agent/agent-admin/lab-filter.tsx diff --git a/app/src/components/tabs/agent-admin/model-allow-row.tsx b/app/src/components/agent/agent-admin/model-allow-row.tsx similarity index 100% rename from app/src/components/tabs/agent-admin/model-allow-row.tsx rename to app/src/components/agent/agent-admin/model-allow-row.tsx diff --git a/app/src/components/tabs/agent-admin/model-allowlist.ts b/app/src/components/agent/agent-admin/model-allowlist.ts similarity index 100% rename from app/src/components/tabs/agent-admin/model-allowlist.ts rename to app/src/components/agent/agent-admin/model-allowlist.ts diff --git a/app/src/components/agent/agent-files/agent-files-capabilities.ts b/app/src/components/agent/agent-files/agent-files-capabilities.ts new file mode 100644 index 000000000..9147ce347 --- /dev/null +++ b/app/src/components/agent/agent-files/agent-files-capabilities.ts @@ -0,0 +1,38 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { useCapabilities } from "../../../hooks/use-capabilities"; +import { isCoLocatedEngine, newEngineActive } from "../../../lib/engine"; +import type { Agent } from "../../../lib/types"; + +/** + * Whether this deployment can hand a file to the OS, and which directory it + * would hand it to. The ONE answer both Files surfaces gate on, so a web build + * and a cloud pod can never end up offering "Reveal in Finder" on one screen + * and "Download" on the other. + */ +export interface LocalFilesAccess { + /** The directory the OS can open, or `undefined` when there is none. */ + osDir: string | undefined; + /** OS open / reveal are available. When false the surface previews in-app + * and offers per-file Download plus Download all instead. */ + canUseLocalFiles: boolean; +} + +export function useLocalFilesAccess(agent: Agent): LocalFilesAccess { + // No OS to open/reveal with (web build, cloud pod, remote host): double-click + // previews in-browser, the context menu offers Download, and the header's + // secondary action becomes "Download all" instead of "Open in File Manager". + const desktop = isTauri(); + const { capabilities } = useCapabilities(); + // The directory the OS can actually open: the host-reported real path (TS + // engine, co-located hosts only), or the legacy engine's folderPath (already + // absolute). On the TS engine folderPath is a route key, never a path — + // handing it to the OS was HOU-677. + const osDir = + agent.localDir ?? (newEngineActive() ? undefined : agent.folderPath); + const canUseLocalFiles = + desktop && + isCoLocatedEngine() && + (capabilities?.revealInOs ?? true) && + osDir !== undefined; + return { osDir, canUseLocalFiles }; +} diff --git a/app/src/components/agent/agent-files/agent-files-downloads.ts b/app/src/components/agent/agent-files/agent-files-downloads.ts new file mode 100644 index 000000000..d8a0c227f --- /dev/null +++ b/app/src/components/agent/agent-files/agent-files-downloads.ts @@ -0,0 +1,46 @@ +import type { FileEntry } from "@houston-ai/agent"; +import { useSaveDownload } from "../../../hooks/use-save-download"; +import { tauriFiles } from "../../../lib/tauri"; + +/** + * The three "save it to my machine" paths a Files surface offers when the OS + * cannot open the file itself (web build, cloud pod, remote host): one file, + * one folder as a zip, the whole workspace as a zip. + * + * Extracted from `use-agent-files.tsx` only to keep that module inside the + * 200-line limit — it is the same code, still used by exactly one caller. + */ +export interface AgentFileDownloads { + downloadFile: (file: FileEntry) => void; + downloadFolder: (folder: FileEntry) => void; + downloadAll: () => void; +} + +export function useAgentFileDownloads( + agentPath: string, + agentName: string, +): AgentFileDownloads { + // save() surfaces its own success/failure toasts and never rejects; the + // empty catch below only silences the fetch failure call() already toasted. + const save = useSaveDownload(); + return { + downloadFile: (file) => { + tauriFiles + .download(agentPath, file.path) + .then(({ blob }) => save(file.name, blob)) + .catch(() => {}); + }, + downloadFolder: (folder) => { + tauriFiles + .downloadArchive(agentPath, folder.path) + .then(({ blob }) => save(`${folder.name}.zip`, blob)) + .catch(() => {}); + }, + downloadAll: () => { + tauriFiles + .downloadArchive(agentPath) + .then(({ blob }) => save(`${agentName} files.zip`, blob)) + .catch(() => {}); + }, + }; +} diff --git a/app/src/components/agent/agent-files/index.ts b/app/src/components/agent/agent-files/index.ts new file mode 100644 index 000000000..4e6845b1f --- /dev/null +++ b/app/src/components/agent/agent-files/index.ts @@ -0,0 +1,6 @@ +/** + * The per-agent Files wiring, mounted by the team view's Files section. + * `useAgentFiles` is the single assembler behind every team accordion section. + */ +export type { AgentFiles } from "./use-agent-files"; +export { useAgentFiles } from "./use-agent-files"; diff --git a/app/src/components/agent/agent-files/use-agent-files.tsx b/app/src/components/agent/agent-files/use-agent-files.tsx new file mode 100644 index 000000000..18722a8e0 --- /dev/null +++ b/app/src/components/agent/agent-files/use-agent-files.tsx @@ -0,0 +1,185 @@ +import type { FileEntry, FilesBrowserProps } from "@houston-ai/agent"; +import { type ReactNode, useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + useCreateFolder, + useDeleteFile, + useFiles, + useRenameFile, + useUploadFiles, +} from "../../../hooks/queries"; +import { useFilePreviewLoader } from "../../../hooks/use-file-preview-loader"; +import { useMoveWithConflict } from "../../../hooks/use-move-with-conflict"; +import { newEngineActive } from "../../../lib/engine"; +import { sharedBytesKey } from "../../../lib/file-bytes-cache"; +import { tauriFiles } from "../../../lib/tauri"; +import type { Agent } from "../../../lib/types"; +import { FilePreviewDialog } from "../../file-preview-dialog"; +import { MoveConflictDialog } from "../../move-conflict-dialog"; +import { useFilesDeleteConfirm } from "../files-delete-confirm"; +import { buildBrowserLabels, buildMenuLabels } from "../files-tab-labels"; +import { buildUploadIntake } from "../files-upload-intake"; +import { useFilesUploadPickers } from "../files-upload-pickers"; +import { useLocalFilesAccess } from "./agent-files-capabilities"; +import { useAgentFileDownloads } from "./agent-files-downloads"; + +/** + * ONE agent's entire Files wiring: the read, every mutation, the capability + * gates, the translated labels and the four overlays a Files surface needs. + * + * **Why it is a module and not a component.** There can only ever be one answer + * to "what happens when I rename this file". Copying the wiring into a surface + * would mean a second capability gate that can drift, a second upload intake, a + * second delete confirm, a second move-conflict flow, and a bug fixed in one + * place but not the other. So it lives here once; `agent-files-surface.tsx` + * renders it, and the team view's Files section (`team-view/team-files/`) is + * nothing but a frame around that. + * + * **It is also what keeps the read cache-shared.** The tree comes from + * `useFiles(agent.folderPath)` — query key `queryKeys.files(agentPath)` — the + * SAME cache entry every other files read uses, so every mutation's + * invalidation lands in one place and `use-agent-invalidation.ts`'s + * `FilesChanged` → `queryKeys.files(path)` refreshes whatever is on screen. + * There is no second key and no cross-agent fan-out anywhere in the Files story. + * + * The hook returns props rather than rendering: `agent-files-surface.tsx` is + * the one component that assembles them. + */ +export interface AgentFiles { + /** Everything `FilesBrowser` needs. Spread it. */ + browserProps: FilesBrowserProps; + actions: { + upload: () => void; + uploadFolder?: () => void; + reveal?: () => void; + downloadAll?: () => void; + }; + /** The hidden upload inputs + preview / move-conflict / delete dialogs. + * Render once, anywhere inside the surface. */ + overlays: ReactNode; + /** `useFiles`' error, so the surface can SAY the read failed rather than + * showing a tree that is empty for an unexplained reason. */ + error: unknown; + /** User-initiated retry of the read. */ + refetch: () => void; + /** A read is in flight (initial or a retry) — disables a retry control. */ + isFetching: boolean; +} + +export function useAgentFiles( + agent: Agent, + options?: { enabled?: boolean }, +): AgentFiles { + const { t, i18n } = useTranslation("agents"); + const { osDir, canUseLocalFiles } = useLocalFilesAccess(agent); + const [preview, setPreview] = useState(null); + const browserLabels = buildBrowserLabels(t); + const menuLabels = buildMenuLabels(t, canUseLocalFiles); + const path = agent.folderPath; + const loadPreview = useFilePreviewLoader(path); + const { + data: files, + isLoading: loading, + error, + isFetching, + refetch, + } = useFiles(path, options?.enabled); + const deleteFile = useDeleteFile(path); + const renameFile = useRenameFile(path); + const createFolder = useCreateFolder(path); + const uploadFiles = useUploadFiles(path); + const move = useMoveWithConflict(path, files); + // One mutation call per file, deliberately: `useDeleteFile` routes through + // `call`, which toasts + reports every failure on its own, so a batch where + // the third file fails still tells the user about that third file instead of + // one aggregate promise swallowing it. Nothing here catches or awaits, so + // there is no place for a rejection to go quiet. + const deleteConfirm = useFilesDeleteConfirm((selected) => { + for (const file of selected) deleteFile.mutate(file.path); + }); + const { downloadFile, downloadFolder, downloadAll } = useAgentFileDownloads( + path, + agent.name, + ); + const { ingest, onDropError } = buildUploadIntake(t, (picked, targetDir) => + uploadFiles.mutate({ files: picked, targetDir }), + ); + const { pickFiles, pickFolder, inputs } = useFilesUploadPickers(ingest); + + const browserProps: FilesBrowserProps = { + files: files ?? [], + loading, + uploading: uploadFiles.isPending, + dragScope: agent.id, + // The Modified column formats its dates in the user's language. + locale: i18n.language, + loadPreview, + onOpen: (file) => + canUseLocalFiles && osDir + ? tauriFiles.open(osDir, file.path) + : setPreview(file), + onReveal: + canUseLocalFiles && osDir + ? (file) => tauriFiles.reveal(osDir, file.path) + : undefined, + onDownload: canUseLocalFiles ? undefined : downloadFile, + onDownloadFolder: canUseLocalFiles ? undefined : downloadFolder, + onDelete: deleteConfirm.requestDelete, + onDeleteMany: deleteConfirm.requestDeleteMany, + onRename: (file, newName) => + renameFile.mutate({ relativePath: file.path, newName }), + onCreateFolder: (name) => createFolder.mutate(name), + onFilesDropped: (dropped, targetFolder) => + ingest(dropped, targetFolder ?? null), + onDropError, + // Drag-move needs the TS host's move route; the legacy engine has none. + onMove: newEngineActive() ? move.requestMove : undefined, + labels: browserLabels, + menuLabels, + onUpload: pickFiles, + // Folder structure needs the TS host's relPath-aware import route; + // the legacy engine's import flattens everything to the root. + onUploadFolder: newEngineActive() ? pickFolder : undefined, + }; + const actions = { + upload: pickFiles, + uploadFolder: newEngineActive() ? pickFolder : undefined, + reveal: + canUseLocalFiles && osDir + ? () => tauriFiles.revealAgent(osDir) + : undefined, + downloadAll: canUseLocalFiles ? undefined : downloadAll, + }; + + const overlays = ( + <> + {inputs} + setPreview(null)} + /> + void move.replace()} + onKeepBoth={() => void move.keepBoth()} + onCancel={move.cancel} + /> + {deleteConfirm.dialog} + + ); + + // TanStack's refetch resolves with the query result and never rejects (a + // failure lands back in `error`), so there is nothing here to catch. + const retry = useCallback(() => { + void refetch(); + }, [refetch]); + + return { browserProps, actions, overlays, error, refetch: retry, isFetching }; +} diff --git a/app/src/components/tabs/agent-integrations/agent-allowlist-section.tsx b/app/src/components/agent/agent-integrations/agent-allowlist-section.tsx similarity index 100% rename from app/src/components/tabs/agent-integrations/agent-allowlist-section.tsx rename to app/src/components/agent/agent-integrations/agent-allowlist-section.tsx diff --git a/app/src/components/tabs/agent-share-add-people.tsx b/app/src/components/agent/agent-share-add-people.tsx similarity index 100% rename from app/src/components/tabs/agent-share-add-people.tsx rename to app/src/components/agent/agent-share-add-people.tsx diff --git a/app/src/components/tabs/agent-share-dialog.tsx b/app/src/components/agent/agent-share-dialog.tsx similarity index 97% rename from app/src/components/tabs/agent-share-dialog.tsx rename to app/src/components/agent/agent-share-dialog.tsx index d7eba5945..5562468f0 100644 --- a/app/src/components/tabs/agent-share-dialog.tsx +++ b/app/src/components/agent/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/agent-share-people-dialog.tsx b/app/src/components/agent/agent-share-people-dialog.tsx similarity index 100% rename from app/src/components/tabs/agent-share-people-dialog.tsx rename to app/src/components/agent/agent-share-people-dialog.tsx diff --git a/app/src/components/tabs/agent-share-person-row.tsx b/app/src/components/agent/agent-share-person-row.tsx similarity index 100% rename from app/src/components/tabs/agent-share-person-row.tsx rename to app/src/components/agent/agent-share-person-row.tsx diff --git a/app/src/components/tabs/agent-share-surfaces.tsx b/app/src/components/agent/agent-share-surfaces.tsx similarity index 100% rename from app/src/components/tabs/agent-share-surfaces.tsx rename to app/src/components/agent/agent-share-surfaces.tsx diff --git a/app/src/components/tabs/agent-skill-manage-dialog.tsx b/app/src/components/agent/agent-skill-manage-dialog.tsx similarity index 98% rename from app/src/components/tabs/agent-skill-manage-dialog.tsx rename to app/src/components/agent/agent-skill-manage-dialog.tsx index ea6eb30b4..efe4b34d2 100644 --- a/app/src/components/tabs/agent-skill-manage-dialog.tsx +++ b/app/src/components/agent/agent-skill-manage-dialog.tsx @@ -8,7 +8,7 @@ import { useWorkspaceSkills } from "../skills-view/use-workspace-skills"; import { useAgentSharedSkills } from "./use-agent-shared-skills"; /** - * The per-agent Skills tab's skill dialog (HOU-792): the shared manage dialog + * The agent settings page's Skills dialog (HOU-792): the shared manage dialog * scoped to ONE agent — content editing, Edit in chat, and no "Agents with * this skill" section (cross-agent assignment lives ONLY on the global Skills * page). A LOCAL skill's Save writes this agent's copy and Delete removes it diff --git a/app/src/components/tabs/archived-empty-state.tsx b/app/src/components/agent/archived-empty-state.tsx similarity index 100% rename from app/src/components/tabs/archived-empty-state.tsx rename to app/src/components/agent/archived-empty-state.tsx diff --git a/app/src/components/tabs/automation-intake/automation-intake.tsx b/app/src/components/agent/automation-intake/automation-intake.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/automation-intake.tsx rename to app/src/components/agent/automation-intake/automation-intake.tsx diff --git a/app/src/components/tabs/automation-intake/connect-inline.tsx b/app/src/components/agent/automation-intake/connect-inline.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/connect-inline.tsx rename to app/src/components/agent/automation-intake/connect-inline.tsx diff --git a/app/src/components/tabs/automation-intake/index.ts b/app/src/components/agent/automation-intake/index.ts similarity index 100% rename from app/src/components/tabs/automation-intake/index.ts rename to app/src/components/agent/automation-intake/index.ts diff --git a/app/src/components/tabs/automation-intake/intake-machine.test.mjs b/app/src/components/agent/automation-intake/intake-machine.test.mjs similarity index 100% rename from app/src/components/tabs/automation-intake/intake-machine.test.mjs rename to app/src/components/agent/automation-intake/intake-machine.test.mjs diff --git a/app/src/components/tabs/automation-intake/intake-machine.ts b/app/src/components/agent/automation-intake/intake-machine.ts similarity index 100% rename from app/src/components/tabs/automation-intake/intake-machine.ts rename to app/src/components/agent/automation-intake/intake-machine.ts diff --git a/app/src/components/tabs/automation-intake/intake-template-card.tsx b/app/src/components/agent/automation-intake/intake-template-card.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/intake-template-card.tsx rename to app/src/components/agent/automation-intake/intake-template-card.tsx diff --git a/app/src/components/tabs/automation-intake/intake-trigger-card.tsx b/app/src/components/agent/automation-intake/intake-trigger-card.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/intake-trigger-card.tsx rename to app/src/components/agent/automation-intake/intake-trigger-card.tsx diff --git a/app/src/components/tabs/automation-intake/intake-trigger-selected.tsx b/app/src/components/agent/automation-intake/intake-trigger-selected.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/intake-trigger-selected.tsx rename to app/src/components/agent/automation-intake/intake-trigger-selected.tsx diff --git a/app/src/components/tabs/automation-intake/intake-webhook-card.tsx b/app/src/components/agent/automation-intake/intake-webhook-card.tsx similarity index 100% rename from app/src/components/tabs/automation-intake/intake-webhook-card.tsx rename to app/src/components/agent/automation-intake/intake-webhook-card.tsx diff --git a/app/src/components/tabs/automation-intake/templates.test.mjs b/app/src/components/agent/automation-intake/templates.test.mjs similarity index 100% rename from app/src/components/tabs/automation-intake/templates.test.mjs rename to app/src/components/agent/automation-intake/templates.test.mjs diff --git a/app/src/components/tabs/automation-intake/templates.ts b/app/src/components/agent/automation-intake/templates.ts similarity index 100% rename from app/src/components/tabs/automation-intake/templates.ts rename to app/src/components/agent/automation-intake/templates.ts diff --git a/app/src/components/tabs/automation-intake/trigger-app-grid.tsx b/app/src/components/agent/automation-intake/trigger-app-grid.tsx similarity index 99% rename from app/src/components/tabs/automation-intake/trigger-app-grid.tsx rename to app/src/components/agent/automation-intake/trigger-app-grid.tsx index 90efed140..b44e6ea15 100644 --- a/app/src/components/tabs/automation-intake/trigger-app-grid.tsx +++ b/app/src/components/agent/automation-intake/trigger-app-grid.tsx @@ -23,7 +23,7 @@ interface TriggerAppGridProps { } /** Map a trigger app's slim identity onto the shared integrations display shape - * so it renders through the SAME {@link AppRow} the Integrations tab uses. */ + * so it renders through the SAME {@link AppRow} the Integrations page uses. */ function toDisplay(app: { toolkit: string; name: string; diff --git a/app/src/components/tabs/automation-intake/types.ts b/app/src/components/agent/automation-intake/types.ts similarity index 96% rename from app/src/components/tabs/automation-intake/types.ts rename to app/src/components/agent/automation-intake/types.ts index aa08aa7bb..d9506cbf3 100644 --- a/app/src/components/tabs/automation-intake/types.ts +++ b/app/src/components/agent/automation-intake/types.ts @@ -7,8 +7,8 @@ * {@link IntakeResult}. Only then is the model called: the draft chat opens with * a handoff carrying everything collected (see `lib/routine-chat-handoff.ts`). * - * These types are the single source of truth: the tab surface (a sibling agent) - * is built blind against this file, so keep it authoritative. + * These types are the single source of truth: the surface that mounts the + * intake is built against this file alone, so keep it authoritative. */ import type { Agent } from "../../../lib/types"; diff --git a/app/src/components/tabs/automation-intake/use-intake-card-labels.ts b/app/src/components/agent/automation-intake/use-intake-card-labels.ts similarity index 100% rename from app/src/components/tabs/automation-intake/use-intake-card-labels.ts rename to app/src/components/agent/automation-intake/use-intake-card-labels.ts diff --git a/app/src/components/tabs/automation-intake/use-intake-questions.ts b/app/src/components/agent/automation-intake/use-intake-questions.ts similarity index 100% rename from app/src/components/tabs/automation-intake/use-intake-questions.ts rename to app/src/components/agent/automation-intake/use-intake-questions.ts diff --git a/app/src/components/tabs/automation-intake/use-trigger-step.ts b/app/src/components/agent/automation-intake/use-trigger-step.ts similarity index 100% rename from app/src/components/tabs/automation-intake/use-trigger-step.ts rename to app/src/components/agent/automation-intake/use-trigger-step.ts diff --git a/app/src/components/tabs/files-delete-confirm.tsx b/app/src/components/agent/files-delete-confirm.tsx similarity index 96% rename from app/src/components/tabs/files-delete-confirm.tsx rename to app/src/components/agent/files-delete-confirm.tsx index def7fc80a..b117bb1df 100644 --- a/app/src/components/tabs/files-delete-confirm.tsx +++ b/app/src/components/agent/files-delete-confirm.tsx @@ -9,7 +9,7 @@ import { } from "./files-delete-copy"; /** - * "Move to Trash" confirmation for the Files tab. Deleting used to fire + * "Move to Trash" confirmation for the Files section. Deleting used to fire * straight off the context menu with no undo anywhere in the product, so a * mis-click on the wrong row silently destroyed the agent's work (HOU-970). * diff --git a/app/src/components/tabs/files-delete-copy.ts b/app/src/components/agent/files-delete-copy.ts similarity index 100% rename from app/src/components/tabs/files-delete-copy.ts rename to app/src/components/agent/files-delete-copy.ts diff --git a/app/src/components/tabs/files-tab-labels.ts b/app/src/components/agent/files-tab-labels.ts similarity index 86% rename from app/src/components/tabs/files-tab-labels.ts rename to app/src/components/agent/files-tab-labels.ts index d2cf0fd04..10b190aad 100644 --- a/app/src/components/tabs/files-tab-labels.ts +++ b/app/src/components/agent/files-tab-labels.ts @@ -12,23 +12,14 @@ export function buildBrowserLabels(t: TFunction<"agents">): FilesBrowserLabels { columnSize: t("files.columns.size"), modifiedToday: t("files.modifiedToday"), loading: t("files.loading"), - browseFiles: t("files.browseFiles"), - viewGrid: t("files.viewGrid"), - viewList: t("files.viewList"), - sortBy: t("files.sortBy"), - newMenu: t("files.newMenu"), newFolder: t("files.newFolder"), newFolderPlaceholder: t("files.newFolderPlaceholder"), emptyFolder: t("files.emptyFolder"), itemSingular: t("files.itemSingular"), itemPlural: t("files.itemPlural"), menuButton: t("files.menuButton"), - breadcrumbs: t("files.breadcrumbs"), uploadFiles: t("files.uploadFiles"), uploadFolder: t("files.uploadFolder"), - openInFileManager: t("files.openInFileManager"), - downloadAll: t("files.downloadAll"), - dropHint: t("files.dropHint"), uploadingBusy: t("files.uploadingBusy"), // The empty-folder CTAs say exactly what the header actions say; one // string per phrase keeps them from drifting apart in translation. diff --git a/app/src/components/tabs/files-upload-intake.ts b/app/src/components/agent/files-upload-intake.ts similarity index 96% rename from app/src/components/tabs/files-upload-intake.ts rename to app/src/components/agent/files-upload-intake.ts index ff53ade82..e86580422 100644 --- a/app/src/components/tabs/files-upload-intake.ts +++ b/app/src/components/agent/files-upload-intake.ts @@ -1,6 +1,6 @@ /** - * Intake for Files-tab uploads (file picker, folder picker, drag-drop), - * mirroring the chat composer's folder intake (HOU-808) for the Files tab + * Intake for Files-section uploads (file picker, folder picker, drag-drop), + * mirroring the chat composer's folder intake (HOU-808) for the Files section * (HOU-889): hidden files a folder pick sweeps in (.DS_Store, .git/**) are * dropped, batches with too many files are refused loudly — uploading a silent * subset would misrepresent what got uploaded — and dropped-folder expansion diff --git a/app/src/components/tabs/files-upload-pickers.tsx b/app/src/components/agent/files-upload-pickers.tsx similarity index 96% rename from app/src/components/tabs/files-upload-pickers.tsx rename to app/src/components/agent/files-upload-pickers.tsx index f423760e3..97552e076 100644 --- a/app/src/components/tabs/files-upload-pickers.tsx +++ b/app/src/components/agent/files-upload-pickers.tsx @@ -2,7 +2,7 @@ import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react"; import { useRef } from "react"; /** - * The Files tab's two hidden pickers (files, whole folder) and the triggers + * The Files section's two hidden pickers (files, whole folder) and the triggers * that open them. * * The OS picker reports back long after it was opened, so the folder the user diff --git a/app/src/components/tabs/installed-skills-strip.tsx b/app/src/components/agent/installed-skills-strip.tsx similarity index 100% rename from app/src/components/tabs/installed-skills-strip.tsx rename to app/src/components/agent/installed-skills-strip.tsx diff --git a/app/src/components/tabs/job-description-parts.tsx b/app/src/components/agent/job-description-parts.tsx similarity index 100% rename from app/src/components/tabs/job-description-parts.tsx rename to app/src/components/agent/job-description-parts.tsx diff --git a/app/src/components/tabs/learning-card-preview.tsx b/app/src/components/agent/learning-card-preview.tsx similarity index 100% rename from app/src/components/tabs/learning-card-preview.tsx rename to app/src/components/agent/learning-card-preview.tsx diff --git a/app/src/components/tabs/learning-card.tsx b/app/src/components/agent/learning-card.tsx similarity index 100% rename from app/src/components/tabs/learning-card.tsx rename to app/src/components/agent/learning-card.tsx diff --git a/app/src/components/tabs/learning-provenance.tsx b/app/src/components/agent/learning-provenance.tsx similarity index 100% rename from app/src/components/tabs/learning-provenance.tsx rename to app/src/components/agent/learning-provenance.tsx diff --git a/app/src/components/tabs/learnings-content.tsx b/app/src/components/agent/learnings-content.tsx similarity index 100% rename from app/src/components/tabs/learnings-content.tsx rename to app/src/components/agent/learnings-content.tsx diff --git a/app/src/components/tabs/other-agent-skill-preview.tsx b/app/src/components/agent/other-agent-skill-preview.tsx similarity index 100% rename from app/src/components/tabs/other-agent-skill-preview.tsx rename to app/src/components/agent/other-agent-skill-preview.tsx diff --git a/app/src/components/tabs/other-agent-skills.tsx b/app/src/components/agent/other-agent-skills.tsx similarity index 100% rename from app/src/components/tabs/other-agent-skills.tsx rename to app/src/components/agent/other-agent-skills.tsx diff --git a/app/src/components/tabs/provider-auth-feed.ts b/app/src/components/agent/provider-auth-feed.ts similarity index 100% rename from app/src/components/tabs/provider-auth-feed.ts rename to app/src/components/agent/provider-auth-feed.ts diff --git a/app/src/components/tabs/routine-activation-chip.tsx b/app/src/components/agent/routine-activation-chip.tsx similarity index 98% rename from app/src/components/tabs/routine-activation-chip.tsx rename to app/src/components/agent/routine-activation-chip.tsx index 8ac6da828..6ad81023f 100644 --- a/app/src/components/tabs/routine-activation-chip.tsx +++ b/app/src/components/agent/routine-activation-chip.tsx @@ -5,7 +5,7 @@ * settles (checking -> activating -> active), and an alert block with the human * reason plus a one-click Reconnect when it needs the user. * - * It reads the same per-agent trigger-status query the Automations grid does + * It reads the same per-agent trigger-status query the Routines grid does * (shared cache), so opening the chat right after creation streams the live * activation. Reconnect routes to the Integrations surface, the same hand-off * the grid's row badge uses. diff --git a/app/src/components/tabs/routine-leading-icon-model.test.mjs b/app/src/components/agent/routine-leading-icon-model.test.mjs similarity index 100% rename from app/src/components/tabs/routine-leading-icon-model.test.mjs rename to app/src/components/agent/routine-leading-icon-model.test.mjs diff --git a/app/src/components/tabs/routine-leading-icon-model.ts b/app/src/components/agent/routine-leading-icon-model.ts similarity index 93% rename from app/src/components/tabs/routine-leading-icon-model.ts rename to app/src/components/agent/routine-leading-icon-model.ts index d1821715b..a7a30ee01 100644 --- a/app/src/components/tabs/routine-leading-icon-model.ts +++ b/app/src/components/agent/routine-leading-icon-model.ts @@ -1,7 +1,7 @@ import type { Routine } from "@houston-ai/engine-client"; /** - * The Automations grid's per-row leading glyph, decided purely from the + * The Routines grid's per-row leading glyph, decided purely from the * routine's wake binding (kept DOM/React-free so it unit-tests under bare * node; the `.tsx` hook turns each descriptor into the actual element): * diff --git a/app/src/components/tabs/routine-leading-icon.tsx b/app/src/components/agent/routine-leading-icon.tsx similarity index 95% rename from app/src/components/tabs/routine-leading-icon.tsx rename to app/src/components/agent/routine-leading-icon.tsx index c7bc281ad..21f2bb8b8 100644 --- a/app/src/components/tabs/routine-leading-icon.tsx +++ b/app/src/components/agent/routine-leading-icon.tsx @@ -8,7 +8,7 @@ import { INTEGRATION_PROVIDER } from "../integrations/model"; import { routineLeadingIcon } from "./routine-leading-icon-model"; /** - * Builds the Automations grid's per-row leading glyph — the routine's identity + * Builds the Routines grid's per-row leading glyph — the routine's identity * at a glance. Schedule routines return `null` so the grid keeps its default * clock; a trigger routine shows what wakes it: the triggering app's real logo * for a Composio binding (resolved from the toolkits catalog, favicon fallback diff --git a/app/src/components/tabs/routine-model-selector.tsx b/app/src/components/agent/routine-model-selector.tsx similarity index 93% rename from app/src/components/tabs/routine-model-selector.tsx rename to app/src/components/agent/routine-model-selector.tsx index b8ebb3a8f..677b85522 100644 --- a/app/src/components/tabs/routine-model-selector.tsx +++ b/app/src/components/agent/routine-model-selector.tsx @@ -11,7 +11,10 @@ import type { Routine, RoutineUpdate } from "@houston-ai/engine-client"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useAgentModelChoice, useUpdateRoutine } from "../../hooks/queries"; +import { + useAgentModelChoice, + useRoutineWritesForAnyAgent, +} from "../../hooks/queries"; import { useCapabilities } from "../../hooks/use-capabilities"; import { genericErrorDescription } from "../../lib/error-report"; import { modelSelectorDecision } from "../../lib/model-selector-lock"; @@ -31,7 +34,7 @@ export function RoutineModelSelector({ agent, routine, bordered }: Props) { const { t } = useTranslation("routines"); const addToast = useUIStore((s) => s.addToast); const [open, setOpen] = useState(false); - const updateRoutine = useUpdateRoutine(agent.folderPath); + const { update: updateRoutine } = useRoutineWritesForAnyAgent(); // Teams E8: the pickable set is clamped to the agent's allowed-models // ceiling, exactly like the composer's picker (ChatModelSelector applies the @@ -44,7 +47,7 @@ export function RoutineModelSelector({ agent, routine, bordered }: Props) { const pinned = !!routine.provider && !!routine.model; const save = (updates: RoutineUpdate) => updateRoutine.mutate( - { routineId: routine.id, updates }, + { agentPath: agent.folderPath, routineId: routine.id, updates }, { onError: (err) => addToast({ diff --git a/app/src/components/tabs/routine-run-overrides.ts b/app/src/components/agent/routine-run-overrides.ts similarity index 100% rename from app/src/components/tabs/routine-run-overrides.ts rename to app/src/components/agent/routine-run-overrides.ts diff --git a/app/src/components/tabs/routine-runs-dialog.tsx b/app/src/components/agent/routine-runs-dialog.tsx similarity index 100% rename from app/src/components/tabs/routine-runs-dialog.tsx rename to app/src/components/agent/routine-runs-dialog.tsx diff --git a/app/src/components/tabs/routine-screen-header.tsx b/app/src/components/agent/routine-screen-header.tsx similarity index 100% rename from app/src/components/tabs/routine-screen-header.tsx rename to app/src/components/agent/routine-screen-header.tsx diff --git a/app/src/components/tabs/routine-screen-sections.tsx b/app/src/components/agent/routine-screen-sections.tsx similarity index 100% rename from app/src/components/tabs/routine-screen-sections.tsx rename to app/src/components/agent/routine-screen-sections.tsx diff --git a/app/src/components/tabs/routine-screen.tsx b/app/src/components/agent/routine-screen.tsx similarity index 95% rename from app/src/components/tabs/routine-screen.tsx rename to app/src/components/agent/routine-screen.tsx index 5b648f556..6f132838d 100644 --- a/app/src/components/tabs/routine-screen.tsx +++ b/app/src/components/agent/routine-screen.tsx @@ -12,7 +12,7 @@ import type { Routine, RoutineUpdate } from "@houston-ai/engine-client"; import type { RoutineRun } from "@houston-ai/routines"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useUpdateRoutine } from "../../hooks/queries"; +import { useRoutineWritesForAnyAgent } from "../../hooks/queries"; import { useRoutineLabels } from "../../hooks/use-routine-labels"; import { genericErrorDescription } from "../../lib/error-report"; import type { Agent } from "../../lib/types"; @@ -54,12 +54,12 @@ export function RoutineScreen({ const { t } = useTranslation("routines"); const labels = useRoutineLabels(); const addToast = useUIStore((s) => s.addToast); - const updateRoutine = useUpdateRoutine(agent.folderPath); + const { update: updateRoutine } = useRoutineWritesForAnyAgent(); const [runsOpen, setRunsOpen] = useState(false); const save = (updates: RoutineUpdate) => updateRoutine.mutate( - { routineId: routine.id, updates }, + { agentPath: agent.folderPath, routineId: routine.id, updates }, { onError: (err) => addToast({ diff --git a/app/src/components/tabs/routine-setup-chat-board.tsx b/app/src/components/agent/routine-setup-chat-board.tsx similarity index 92% rename from app/src/components/tabs/routine-setup-chat-board.tsx rename to app/src/components/agent/routine-setup-chat-board.tsx index 86f141f90..33dfb5d37 100644 --- a/app/src/components/tabs/routine-setup-chat-board.tsx +++ b/app/src/components/agent/routine-setup-chat-board.tsx @@ -10,11 +10,12 @@ import { AIBoard } from "@houston-ai/board"; import type { FeedItem } from "@houston-ai/chat"; import type { Activity } from "@houston-ai/engine-client"; import { type ReactNode, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useConversationFeed } from "../../hooks/use-conversation-vm"; import { useOpenAgentHref } from "../../hooks/use-open-agent-file"; import { modelAcceptsImages } from "../../lib/providers"; import { type HistoryLoadOptions, tauriChat } from "../../lib/tauri"; -import type { TabProps } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; import { useAttachmentRejectionDialog } from "../attachment-rejection-dialog"; import { useAgentBoardSend } from "../board/use-agent-board-send"; @@ -27,7 +28,10 @@ import { setupChatItem } from "./routines-tab-model"; const noop = () => {}; -interface Props extends TabProps { +interface Props { + /** The agent this chat runs against: its engine answers, its folder resolves + * links, and the panel shows its avatar, colour and name. */ + agent: Agent; activity: Activity; sessionKey: string | null; /** The shell-level panel node this board portals its detail panel into — the @@ -40,10 +44,9 @@ interface Props extends TabProps { * it deselects the item (closing the pane). Omit for a non-dismissable * companion (the integration chat exits via its own chrome). */ onPanelClose?: () => void; - /** Overrides the panel's auto "Mission: {title}" line (routines pass - * "Routine: {name}"). Omit to keep the default "Mission: {title}" — the - * custom-integration setup chat reuses this board and IS a mission, so it - * wants that default. */ + /** Overrides the panel's task line (routines pass "Routine: {name}"). Omit + * to keep the localized "Task: {title}" — the custom-integration setup chat + * reuses this board and IS a task, so it wants that line. */ missionLabel?: string; /** Header actions on the panel's right side (the integration setup chat * puts its "Done" button here). Omit for none (routines). */ @@ -55,7 +58,6 @@ interface Props extends TabProps { export function RoutineSetupChatBoard({ agent, - agentDef, activity, sessionKey, panelContainer, @@ -65,6 +67,7 @@ export function RoutineSetupChatBoard({ onPanelClose, promptContext, }: Props) { + const { t } = useTranslation("board"); const path = agent.folderPath; const openHref = useOpenAgentHref(path); const queuedLabels = useQueuedMessageLabels(); @@ -73,7 +76,6 @@ export function RoutineSetupChatBoard({ const panel = useAgentChatPanel({ agent, - agentDef, selectedSessionKey: sessionKey, onSelectSession: noop, // The setup chat's kickoff turn runs Ask first (execute) — the interview @@ -97,14 +99,7 @@ export function RoutineSetupChatBoard({ ); const rawItems = useMemo(() => [activity], [activity]); - const send = useAgentBoardSend({ - agent, - agentDef, - rawItems, - pendingAgentMode: null, - setPendingAgentMode: noop, - promptContext, - }); + const send = useAgentBoardSend({ agent, rawItems, promptContext }); const sendQueue = useBoardSendQueue({ selectedSessionKey: sessionKey, selectedAgentPath: path, @@ -180,7 +175,9 @@ export function RoutineSetupChatBoard({ panelLeading={panelLeading} panelActions={panelActions ? () => panelActions : undefined} panelAgentName={agent.name} - panelMissionLabel={missionLabel} + panelMissionLabel={ + missionLabel ?? t("board:panel.taskLabel", { title: activity.title }) + } panelAvatar={} chatEmptyState={panel.chatEmptyState} composerHeader={panel.composerHeader} diff --git a/app/src/components/tabs/routine-setup-chat.tsx b/app/src/components/agent/routine-setup-chat.tsx similarity index 95% rename from app/src/components/tabs/routine-setup-chat.tsx rename to app/src/components/agent/routine-setup-chat.tsx index 1d86019aa..11c84b939 100644 --- a/app/src/components/tabs/routine-setup-chat.tsx +++ b/app/src/components/agent/routine-setup-chat.tsx @@ -4,13 +4,16 @@ import { Loader2, X } from "lucide-react"; import { type ReactNode, useEffect } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import type { TabProps } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; import { useBoardLabels } from "../board/use-board-labels"; import { RoutineActivationChip } from "./routine-activation-chip"; import { RoutineSetupChatBoard } from "./routine-setup-chat-board"; -interface Props extends TabProps { +interface Props { + /** The agent that owns this routine — its chat runs against that agent's + * engine, and the panel shows its avatar and name. */ + agent: Agent; /** The routine's chat activity, or the draft create-chat for a brand-new * one. Null while it's still being created, or during the pre-model intake * (renders the calm empty chat surface instead of the live board). */ @@ -31,7 +34,7 @@ interface Props extends TabProps { * and intake clear the selection outright. */ onClose: () => void; /** The shell-level panel node this chat portals into (workspace-shell's - * sibling panel, the SAME one the Activity board opens). Null until the + * sibling panel, the SAME one the mission board opens). Null until the * panel mounts — the surface renders nothing until it exists. */ panelContainer: HTMLElement | null; /** Intake only: the locally-driven question cards, floated over the composer @@ -45,7 +48,7 @@ interface Props extends TabProps { /** * The selected routine's chat — rendered into the big shell-level panel - * (`panelContainer`), the EXACT panel the Activity board opens. Each routine IS + * (`panelContainer`), the EXACT panel the mission board opens. Each routine IS * a chat: this is the one surface for setting up a routine, changing it, and * finding out what it did. No side-by-side editor, no run-history list. * @@ -66,7 +69,6 @@ interface Props extends TabProps { */ export function RoutineSetupChat({ agent, - agentDef, activity, kind, routineName, @@ -193,7 +195,6 @@ export function RoutineSetupChat({
    r.trigger).map((r) => r.id); +} + /** Index a trigger-status list by routine id (empty when the host serves none). */ export function toStatusMap( items: TriggerStatusItem[] | null | undefined, diff --git a/app/src/components/tabs/routines-tab-model.ts b/app/src/components/agent/routines-tab-model.ts similarity index 97% rename from app/src/components/tabs/routines-tab-model.ts rename to app/src/components/agent/routines-tab-model.ts index d31e5968b..e9b202be3 100644 --- a/app/src/components/tabs/routines-tab-model.ts +++ b/app/src/components/agent/routines-tab-model.ts @@ -3,9 +3,8 @@ import type { Activity, Routine } from "@houston-ai/engine-client"; import type { RoutineRun } from "@houston-ai/routines"; /** - * Routines-tab selection state: which item, if any, owns the right-hand pane. - * The list is now ALWAYS visible (email-client split) — this is the cursor - * into it, not a full-page view swap: + * Routines section selection state: which item, if any, owns the main-content + * drill-in or the right-hand pane. * - `null` — nothing selected; the list runs full width, no pane. * - `intake` — the pre-model create flow (chat surface + locally-driven intake * cards floating over it), before any model call. diff --git a/app/src/components/tabs/routines-tab-pane.tsx b/app/src/components/agent/routines-tab-pane.tsx similarity index 91% rename from app/src/components/tabs/routines-tab-pane.tsx rename to app/src/components/agent/routines-tab-pane.tsx index f9ef5f339..9dad060b2 100644 --- a/app/src/components/tabs/routines-tab-pane.tsx +++ b/app/src/components/agent/routines-tab-pane.tsx @@ -1,6 +1,6 @@ import type { Routine } from "@houston-ai/engine-client"; import type { RoutineRun } from "@houston-ai/routines"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { AutomationIntake, type IntakeResult } from "./automation-intake"; import { RoutineSetupChat } from "./routine-setup-chat"; import { runChatActivity, type Selection } from "./routines-tab-model"; @@ -10,7 +10,6 @@ interface Props { /** The active selection (the parent renders this pane only when non-null). */ selected: Selection; agent: Agent; - agentDef: AgentDefinition; routines: Routine[] | undefined; chatSetup: ReturnType; /** ALL of the agent's runs (the tab's cached query) — the run-chat lookup @@ -32,16 +31,15 @@ interface Props { } /** - * The Routines tab's CHAT surfaces, rendered into the shell-level panel + * The Routines section's CHAT surfaces, rendered into the shell-level panel * (PRODUCT-1208): the create intake, a draft's chat, a routine's setup chat, * and one execution's result chat. The routine's own screen is NOT here — it - * replaces the tab's main content (`routine-screen.tsx`); a chat opened from + * replaces the section's main content (`routine-screen.tsx`); a chat opened from * that screen closes back to it, so the X (and Escape) never strand the user. */ export function RoutinesTabPane({ selected, agent, - agentDef, routines, chatSetup, allRuns, @@ -58,7 +56,6 @@ export function RoutinesTabPane({ return ( { setPanelOpen(true); return () => setPanelOpen(false); @@ -90,18 +91,6 @@ export function SkillSetupChat({ return () => document.removeEventListener("keydown", onKeyDown); }, [onClose]); - // The Skills section has no ambient AgentDefinition. RoutineSetupChatBoard - // needs one only for its agent-modes list, which a setup chat never uses — - // synthesize a minimal def so the send path still compiles and runs (the - // same shim the custom-integration setup chat uses). - const agentDef = useMemo( - () => ({ - config: { id: agent.configId, name: agent.name, description: "" }, - source: "builtin", - }), - [agent.configId, agent.name], - ); - const missionLabel = kind === "draft" ? t("setupChat.missionTitle") @@ -156,7 +145,6 @@ export function SkillSetupChat({
    the rows fall back to the unknown state). No - * trigger routines -> no status fetch. - */ -export function useRoutineTriggers( - agent: Agent, - routines: Routine[] | undefined, -): { +/** Exactly the trigger props `RoutinesGrid` takes, plus the capability gate. */ +export interface TriggerSurface { + /** Whether this host can offer NEW event triggers (`capabilities.triggers`). */ triggersEnabled: boolean; + /** Live status per ROW id — every trigger row resolves to one, always. */ triggerStatuses: Record; + /** The humanized "what wakes this" line per ROW id. */ triggerSummaries: Record; onReconnectTrigger: () => void; -} { +} + +/** + * The event-trigger surface (C9) for ANY list of routines, agent-agnostic: the + * capability gate, the per-row status badges, the humanized row summaries, the + * reconnect hand-off, and the machinery that stops a row saying "verifying" + * forever. + * + * The FETCH stays with the caller and everything downstream of it lives here: + * a team's cross-agent list (`team-routines/use-team-trigger-statuses.ts`) fans + * status out per agent and hands it over. Copying the timeout rule into a + * second surface would be the one duplication we cannot afford: it is the only + * reason a trigger row cannot lie. + * + * The contract, and the reason this works for a merged list: `routines[i].id` + * and `statusItems[j].routine_id` are both the GRID's row id. The team list + * namespaces its rows with `teamRoutineKey`, so it re-keys the status reads to + * match before handing them over. + * + * `triggersEnabled` gates ONLY offering NEW event triggers (the wizard's event + * option and the app catalog). Status runs off the routines themselves: a + * routine that can never fire here must still show its health (an older host + * 404s -> the rows fall back to the unknown state, then time out). + */ +export function useTriggerStatusViewModel( + routines: Routine[] | undefined, + statusItems: TriggerStatusItem[] | null | undefined, +): TriggerSurface { const { t } = useTranslation("routines"); const { capabilities } = useCapabilities(); const triggersEnabled = !!capabilities?.triggers; - // Status runs off the routines themselves, not the capability: a bound - // routine's health must show even where it can never fire. - const triggerRoutineIds = useMemo( - () => (routines ?? []).filter((r) => r.trigger).map((r) => r.id), + // The rows that can sit in a "verifying" state at all — the only ones the + // timeout below has anything to say about. + const triggerRowIds = useMemo( + () => triggerBoundRoutineIds(routines), [routines], ); - const statusQuery = useAgentTriggerStatus( - agent.id, - triggerRoutineIds.length > 0, - triggerRoutineIds, - ); const catalog = useIntegrationToolkits(INTEGRATION_PROVIDER, triggersEnabled); const setViewMode = useUIStore((s) => s.setViewMode); const onReconnectTrigger = useCallback(() => { - // Same routing the Integrations tab's "Manage all" uses: the global - // Integrations page, which everyone can reach. + // A broken trigger is repaired at the CONNECTION, so this routes to the + // global Integrations page, which everyone can reach. setViewMode(INTEGRATIONS_VIEW_ID); }, [setViewMode]); @@ -69,16 +77,15 @@ export function useRoutineTriggers( // "verifying" forever (an older host, a create that silently failed). Track // when each first appears WITHOUT a status, and once that has lasted past the // timeout, synthesize a concrete error so the row/chip stop spinning. - const statusItems = statusQuery.data; const firstSeenRef = useRef>({}); const [timeoutTick, setTimeoutTick] = useState(0); useEffect(() => { const now = Date.now(); - const ids = new Set(triggerRoutineIds); + const ids = new Set(triggerRowIds); const known = new Set((statusItems ?? []).map((i) => i.routine_id)); const seen = firstSeenRef.current; - for (const id of triggerRoutineIds) { + for (const id of triggerRowIds) { if (!known.has(id) && seen[id] === undefined) seen[id] = now; } for (const id of Object.keys(seen)) { @@ -95,19 +102,19 @@ export function useRoutineTriggers( Math.min(...waits), ); return () => clearTimeout(timer); - }, [triggerRoutineIds, statusItems]); + }, [triggerRowIds, statusItems]); // biome-ignore lint/correctness/useExhaustiveDependencies: timeoutTick is a deliberate recompute trigger — the body reads firstSeenRef.current + Date.now() live, and the timer bumps timeoutTick so an elapsed timeout flips the routine to the error copy. const triggerStatuses = useMemo(() => { const base = toStatusMap(statusItems); const timedOut = timedOutTriggerIds( - triggerRoutineIds, + triggerRowIds, statusItems, firstSeenRef.current, Date.now(), ); return withTriggerTimeouts(base, timedOut, t("trigger.statusTimeout")); - }, [statusItems, triggerRoutineIds, timeoutTick, t]); + }, [statusItems, triggerRowIds, timeoutTick, t]); const triggerSummaries = useMemo(() => { const bySlug = new Map( diff --git a/app/src/components/tabs/use-agent-shared-skills.ts b/app/src/components/agent/use-agent-shared-skills.ts similarity index 100% rename from app/src/components/tabs/use-agent-shared-skills.ts rename to app/src/components/agent/use-agent-shared-skills.ts diff --git a/app/src/components/tabs/use-community-skill-handlers.ts b/app/src/components/agent/use-community-skill-handlers.ts similarity index 100% rename from app/src/components/tabs/use-community-skill-handlers.ts rename to app/src/components/agent/use-community-skill-handlers.ts diff --git a/app/src/components/tabs/use-org-skill-default.ts b/app/src/components/agent/use-org-skill-default.ts similarity index 98% rename from app/src/components/tabs/use-org-skill-default.ts rename to app/src/components/agent/use-org-skill-default.ts index e7c312536..e71245606 100644 --- a/app/src/components/tabs/use-org-skill-default.ts +++ b/app/src/components/agent/use-org-skill-default.ts @@ -20,7 +20,8 @@ import { useUIStore } from "../../stores/ui"; import { useWorkspaceStore } from "../../stores/workspaces"; /** Cross-instance dedupe: the claim can be observed by more than one mounted - * hook (per-agent tab + global chat) — the first observer runs the share, + * hook (the agent's Skills section + global chat) — the first observer runs + * the share, * the rest see the key and skip. */ const inFlight = new Set(); diff --git a/app/src/components/tabs/use-routine-chat-heal.ts b/app/src/components/agent/use-routine-chat-heal.ts similarity index 100% rename from app/src/components/tabs/use-routine-chat-heal.ts rename to app/src/components/agent/use-routine-chat-heal.ts diff --git a/app/src/components/tabs/use-routine-chat-setup.ts b/app/src/components/agent/use-routine-chat-setup.ts similarity index 95% rename from app/src/components/tabs/use-routine-chat-setup.ts rename to app/src/components/agent/use-routine-chat-setup.ts index b337ef0b7..b250cd1de 100644 --- a/app/src/components/tabs/use-routine-chat-setup.ts +++ b/app/src/components/agent/use-routine-chat-setup.ts @@ -26,8 +26,8 @@ import { useRoutineChatHeal } from "./use-routine-chat-heal"; /** * Owns every automation's setup chat (HOU-725), tagged with the setup sentinel - * so it never shows as a board card — its only home is the tab's full-page - * chat view. A chat starts as a "draft" (no routine yet); once a routine + * so it never shows as a board card — its only home is the Routines section's + * setup-chat panel. A chat starts as a "draft" (no routine yet); once a routine * claims one (link resolution in `lib/routine-chat-setup.ts`, stored both * directions) the chat is the routine's for good, and reopening resumes it. * Routines without a chat get one on first open via `startForRoutine`. The @@ -82,7 +82,8 @@ export function useRoutineChatSetup( * navigate straight to it. * `compose` overrides the first message the agent receives. It defaults to * the interview kickoff (`encodeRoutineSetupMessage`); the intake flow passes - * the composed wake handoff instead (see `completeIntake` in the tab view, + * the composed wake handoff instead (see `completeIntake` in + * `use-routines-tab-view.ts`, * which calls `encodeRoutineIntakeHandoffMessage` with `connectedProviders`). */ const startDraft = useCallback( @@ -180,9 +181,9 @@ export function useRoutineChatSetup( draftActivities, activityFor, /** Whether the activity query has resolved (vs. still loading) — lets the - * tab distinguish "no match yet" from "loaded, genuinely no match". */ + * list distinguish "no match yet" from "loaded, genuinely no match". */ activitiesLoaded: rawItems !== undefined, - /** The user's connected providers (null while still loading) — the tab's + /** The user's connected providers (null while still loading) — the intake's * wizard handoff names them so the agent never pins an unconnected one. */ connectedProviders: connectedProvidersRef.current, startDraft, diff --git a/app/src/components/tabs/use-routines-tab-view.ts b/app/src/components/agent/use-routines-tab-view.ts similarity index 89% rename from app/src/components/tabs/use-routines-tab-view.ts rename to app/src/components/agent/use-routines-tab-view.ts index 59129b18c..80c3fffb9 100644 --- a/app/src/components/tabs/use-routines-tab-view.ts +++ b/app/src/components/agent/use-routines-tab-view.ts @@ -16,7 +16,8 @@ import { import type { useRoutineChatSetup } from "./use-routine-chat-setup"; /** - * The Automations tab's selection state machine (`routines-tab.tsx`): which + * The Routines section's selection state machine (mounted by + * `team-view/team-routines/team-routine-panel.tsx`): which * item — if any — owns the right-hand chat pane, and the effects that move the * cursor. The list is ALWAYS visible now (email-client split), so this is a * cursor into it, never a full-page view swap. Pure transition logic lives in @@ -26,23 +27,27 @@ import type { useRoutineChatSetup } from "./use-routine-chat-setup"; export function useRoutinesTabView( routines: Routine[] | undefined, chatSetup: ReturnType, + /** The agent this chat belongs to, so a one-shot notification target meant + * for a DIFFERENT agent's chat is left alone rather than resolved (and + * cleared) against the wrong routines. */ + agentId: string, ) { const [selected, setSelected] = useState(null); - // A session-finished notification (#401) lands as a one-shot activity id; - // resolvePendingActivity selects its routine/draft chat, or clears a + // A session-finished notification (#401) lands as a one-shot {agent, activity} + // target; resolvePendingActivity selects its routine/draft chat, or clears a // stale/foreign id once both data sources have loaded (see the helper). - const pendingActivityId = useUIStore((s) => s.pendingRoutineActivityId); - const setPendingRoutineActivityId = useUIStore( - (s) => s.setPendingRoutineActivityId, - ); + const pending = useUIStore((s) => s.pendingRoutineChat); + const setPendingRoutineChat = useUIStore((s) => s.setPendingRoutineChat); + const pendingActivityId = + pending && pending.agentId === agentId ? pending.activityId : null; useEffect(() => { if (!pendingActivityId) return; const res = resolvePendingActivity(pendingActivityId, routines, chatSetup); if (res.action === "wait") return; - setPendingRoutineActivityId(null); + setPendingRoutineChat(null); if (res.action === "open") setSelected(res.selection); - }, [pendingActivityId, routines, chatSetup, setPendingRoutineActivityId]); + }, [pendingActivityId, routines, chatSetup, setPendingRoutineChat]); // The selected routine vanished (deleted from its row menu, or removed by // the agent): drop the cursor so the tab returns to the plain centered list diff --git a/app/src/components/tabs/use-share-agent.ts b/app/src/components/agent/use-share-agent.ts similarity index 52% rename from app/src/components/tabs/use-share-agent.ts rename to app/src/components/agent/use-share-agent.ts index bf57f8961..6c1e3b41e 100644 --- a/app/src/components/tabs/use-share-agent.ts +++ b/app/src/components/agent/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/components/tabs/use-skill-chat-setup.ts b/app/src/components/agent/use-skill-chat-setup.ts similarity index 99% rename from app/src/components/tabs/use-skill-chat-setup.ts rename to app/src/components/agent/use-skill-chat-setup.ts index e94c5fa5a..e34fa70bc 100644 --- a/app/src/components/tabs/use-skill-chat-setup.ts +++ b/app/src/components/agent/use-skill-chat-setup.ts @@ -27,7 +27,7 @@ import { readAgentRunOverrides } from "./routine-run-overrides"; import { useOrgSkillDefault } from "./use-org-skill-default"; /** - * Owns a custom skill's setup chat (HOU-791 — the Automations-tab experience + * Owns a custom skill's setup chat (HOU-791 — a routine's setup-chat experience * on the Skills surface), tagged with the skill-setup sentinel so it never * shows as a board card. A chat starts as a "draft" (no skill yet); once the * agent creates the skill with the chat's `setup_activity_id` in its diff --git a/app/src/components/tabs/use-skill-setup-view.ts b/app/src/components/agent/use-skill-setup-view.ts similarity index 98% rename from app/src/components/tabs/use-skill-setup-view.ts rename to app/src/components/agent/use-skill-setup-view.ts index 8d552f7f4..d599a2b2c 100644 --- a/app/src/components/tabs/use-skill-setup-view.ts +++ b/app/src/components/agent/use-skill-setup-view.ts @@ -19,7 +19,7 @@ export type SkillChatSelection = /** * The Skills surface's chat selection state machine (HOU-791, mirrors the - * Automations tab's `useRoutinesTabView`): which skill's setup chat — or + * Routines section's `useRoutinesTabView`): which skill's setup chat — or * which unclaimed draft — is open, and the effects that move the cursor. */ export function useSkillSetupView( diff --git a/app/src/components/tabs/use-skill-surface-labels.ts b/app/src/components/agent/use-skill-surface-labels.ts similarity index 100% rename from app/src/components/tabs/use-skill-surface-labels.ts rename to app/src/components/agent/use-skill-surface-labels.ts diff --git a/app/src/components/tabs/use-skill-surface.ts b/app/src/components/agent/use-skill-surface.ts similarity index 100% rename from app/src/components/tabs/use-skill-surface.ts rename to app/src/components/agent/use-skill-surface.ts diff --git a/app/src/components/tabs/use-skills-chat-surface.tsx b/app/src/components/agent/use-skills-chat-surface.tsx similarity index 100% rename from app/src/components/tabs/use-skills-chat-surface.tsx rename to app/src/components/agent/use-skills-chat-surface.tsx diff --git a/app/src/components/tabs/use-usable-toolkits.ts b/app/src/components/agent/use-usable-toolkits.ts similarity index 98% rename from app/src/components/tabs/use-usable-toolkits.ts rename to app/src/components/agent/use-usable-toolkits.ts index 90118057a..ac0458419 100644 --- a/app/src/components/tabs/use-usable-toolkits.ts +++ b/app/src/components/agent/use-usable-toolkits.ts @@ -36,7 +36,7 @@ export interface UsableToolkits { * The apps THIS agent can build an event trigger on (C9): the user's active * connections, narrowed to the agent's effective allowlist on a Teams host * (single-player has no ceiling → every connected app is usable). "Usable" means - * exactly what it means on the Integrations tab now — connection ∩ effective + * exactly what it means on the Integrations page now — connection ∩ effective * allowlist (the per-agent grants layer is gone). Returns the shape * `@houston-ai/routines`' TriggerPicker takes, with an account per connection so * a multi-account app can be pinned. diff --git a/app/src/components/tabs/webhook-activation-chip.tsx b/app/src/components/agent/webhook-activation-chip.tsx similarity index 100% rename from app/src/components/tabs/webhook-activation-chip.tsx rename to app/src/components/agent/webhook-activation-chip.tsx diff --git a/app/src/components/tabs/webhook-key-dialog.tsx b/app/src/components/agent/webhook-key-dialog.tsx similarity index 100% rename from app/src/components/tabs/webhook-key-dialog.tsx rename to app/src/components/agent/webhook-key-dialog.tsx diff --git a/app/src/components/tabs/workspace-shared-skills-section.tsx b/app/src/components/agent/workspace-shared-skills-section.tsx similarity index 100% rename from app/src/components/tabs/workspace-shared-skills-section.tsx rename to app/src/components/agent/workspace-shared-skills-section.tsx diff --git a/app/src/components/tabs/workspace-skill-preview.tsx b/app/src/components/agent/workspace-skill-preview.tsx similarity index 100% rename from app/src/components/tabs/workspace-skill-preview.tsx rename to app/src/components/agent/workspace-skill-preview.tsx diff --git a/app/src/components/ai-hub/hub-hero.tsx b/app/src/components/ai-hub/hub-hero.tsx index 81e5b1bac..d75a21777 100644 --- a/app/src/components/ai-hub/hub-hero.tsx +++ b/app/src/components/ai-hub/hub-hero.tsx @@ -1,10 +1,10 @@ import { useTranslation } from "react-i18next"; -import { PageHeader } from "../shell/page-shell"; +import { PageHero } from "../shell/page-shell"; import { fewModels, roundedModelCount } from "./format.ts"; /** * The AI models hub masthead: the app-wide landing-page header (matching - * Settings / Integrations / Organization) via the shared `PageHeader`, so every + * Settings / Integrations / Organization) via the shared `PageHero`, so every * top-level surface opens the same way. The model count rounds DOWN to the * nearest 50 so the copy reads as a confident round number ("400+"); when few * providers are visible the rounded number would read "0+", so the subtitle @@ -14,7 +14,7 @@ export function HubHero({ modelCount }: { modelCount: number }) { const { t } = useTranslation("aiHub"); return ( - string; @@ -70,6 +71,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 +122,9 @@ export function ModelsAllowlistEditor({ return (
    -

    {copy.question}

    +

    + {copy.question} +

    {copy.policyHelper}

    {readOnly && copy.readOnlyNote && ( @@ -128,7 +132,7 @@ export function ModelsAllowlistEditor({ )}
    -
    - - setSearch(e.target.value)} - placeholder={copy.searchModels} - className="h-9 w-full rounded-full border border-line bg-input pl-9 pr-3 text-sm text-ink placeholder:text-ink-muted focus:outline-none focus:ring-2 focus:ring-focus/20" - /> -
    +
    {results.length === 0 ? ( diff --git a/app/src/components/ai-hub/models-browser.tsx b/app/src/components/ai-hub/models-browser.tsx index 8326bde13..93b37f833 100644 --- a/app/src/components/ai-hub/models-browser.tsx +++ b/app/src/components/ai-hub/models-browser.tsx @@ -19,13 +19,13 @@ import { CatalogGrid, + CatalogSearchField, cn, Empty, EmptyDescription, EmptyHeader, EmptyTitle, } from "@houston-ai/core"; -import { Search } from "lucide-react"; import { useDeferredValue, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { CatalogModel } from "../../lib/ai-hub/catalog-types.ts"; @@ -117,16 +117,12 @@ export function ModelsBrowser({
    {!controlled && ( -
    - - setInternalQuery(e.target.value)} - placeholder={searchPlaceholder} - className="h-9 w-full rounded-full border border-line bg-input pl-9 pr-3 text-sm text-ink placeholder:text-ink-muted focus:outline-none focus:ring-2 focus:ring-focus/20" - /> -
    + )} ) : ( - + {results.slice(0, visible).map((model) => ( []; - /** Translated tag for a mission a routine started. */ - routineLabel: string; - /** Translated tag for a mission the agent started itself (PRODUCT-1244). */ - agentStartedLabel: string; -}): KanbanItem[] { - return selectActive(activities).map((activity) => ({ - id: activity.id, - title: activity.title, - // A Skill / attachment first message persists as a marker; show the - // user's words on the card, never the raw `` (HOU-425). - description: messagePreviewText(activity.description), - status: activity.status, - updatedAt: activity.updated_at ?? new Date().toISOString(), - group: agentName, - tags: missionCardTags({ - agent: activity.agent, - agentModes, - routineId: activity.routine_id, - routineLabel, - originSessionKey: activity.origin_session_key, - agentStartedLabel, - }), - metadata: { - ...(activity.session_key ? { sessionKey: activity.session_key } : {}), - ...(activity.routine_id ? { routineId: activity.routine_id } : {}), - ...(activity.agent ? { agent: activity.agent } : {}), - // The parent chat this mission was started from (PRODUCT-1244) — what the - // child-mission list above that chat's composer filters on. - ...(activity.origin_session_key - ? { originSessionKey: activity.origin_session_key } - : {}), - }, - })); -} - -/** - * The per-agent Archived tab's cards: this agent's ARCHIVED missions, as a - * column-less list. Deliberately barer than the active board's items — an - * archived mission carries no routine/mode tag and no status column — but it - * shares the same "activities in, kanban items out" shape and purity. - */ -export function buildArchivedBoardItems({ - activities, - agentName, -}: { - activities: Activity[]; - agentName: string; -}): KanbanItem[] { - return selectArchived(activities).map((activity) => ({ - id: activity.id, - title: activity.title, - // A Skill / attachment first message persists as a marker; show the - // user's words on the card, never the raw `` (HOU-425). - description: messagePreviewText(activity.description), - status: activity.status, - updatedAt: activity.updated_at ?? new Date().toISOString(), - group: agentName, - metadata: { - ...(activity.session_key ? { sessionKey: activity.session_key } : {}), - }, - })); -} diff --git a/app/src/components/board/board-back-button.tsx b/app/src/components/board/board-back-button.tsx deleted file mode 100644 index e0bd1fe51..000000000 --- a/app/src/components/board/board-back-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "@houston-ai/core"; -import { ArrowLeft } from "lucide-react"; - -interface BoardBackButtonProps { - /** Names the destination, e.g. "Back to missions". */ - label: string; - onClick: () => void; -} - -/** - * The one way out of a side view of the board (Archived, Mentions) back to the - * active missions. It always carries its label as TEXT and never collapses to a - * bare arrow or a tooltip: the return path is the first thing someone looks for - * once they land somewhere secondary, so it has to be readable at a glance - * (HOU-1043). Both board surfaces render this same control, so the way out - * looks identical whichever door the user came through. - */ -export function BoardBackButton({ label, onClick }: BoardBackButtonProps) { - return ( - - ); -} diff --git a/app/src/components/board/board-source.ts b/app/src/components/board/board-source.ts index 468242640..8fd4a2714 100644 --- a/app/src/components/board/board-source.ts +++ b/app/src/components/board/board-source.ts @@ -3,23 +3,23 @@ import type { FeedItem, MessageMention } from "@houston-ai/chat"; import type { ReactNode } from "react"; import type { HistoryLoadOptions } from "../../lib/tauri"; import type { TurnMode } from "../../lib/turn-mode"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; /** * Shared mission-board architecture. * - * `` owns every piece of wiring that the cross-agent Mission - * Control view and the per-agent board tab used to duplicate: the AIBoard - * prop spread, the `useAgentChatPanel` integration, the message queue, - * draft persistence, keyboard navigation, the bulk-action UI, columns, and - * all i18n labels. + * `` owns every piece of wiring a cross-agent board needs: the + * AIBoard prop spread, the `useAgentChatPanel` integration, the message queue, + * draft persistence, keyboard navigation, the bulk-action UI, columns, and all + * i18n labels. * - * The genuinely divergent parts — where the data comes from, who the active - * agent is, how a new mission is started, and how bulk mutations are routed - * to the right agent — live behind this `BoardSource` interface. Each view - * builds a source with a hook (`useAgentBoardSource` / `useMissionControlSource`) - * and hands it to the shared component. This is the headless-logic pattern: - * one presentational/wiring component, two injected data backends. + * The scope-dependent parts — where the data comes from, who the active agent + * is, how a new mission is started, and how bulk mutations are routed to the + * right agent — live behind this `BoardSource` interface, which + * `useMissionControlSource` builds. This is the headless-logic pattern: one + * presentational/wiring component, an injected data backend. Mission Control + * and each team board are the SAME source narrowed by a + * `MissionControlScope`, so they never drift apart. */ /** Everything a user-typed send carries beside its text and files: the @@ -39,11 +39,11 @@ export interface SendOverrides { /** * Multi-select state + bulk mutations for one board. The set-state half is - * identical for both views (see `useSelectionSet`); only the bulk dispatch - * (`move` / `archive` / `remove`) differs — per-agent for the - * board tab, grouped-by-agent for cross-agent Mission Control. The section - * lock, toggle guard, header actions, and bulk-bar labels are derived by - * `` and stay out of here. + * generic (see `useSelectionSet`); the bulk dispatch (`move` / `archive` / + * `remove`) groups the selection by agent before writing, because one + * cross-agent selection can span several agents. The section lock, toggle + * guard, header actions, and bulk-bar labels are derived by `` + * and stay out of here. */ export interface BoardSelectionModel { selectedIds: ReadonlySet; @@ -64,14 +64,12 @@ export interface BoardSelectionModel { } /** - * Everything the shared `` needs that differs between the - * cross-agent and per-agent views. Anything that can be derived from these - * fields (panel avatar, columns, section lock, labels) is built by the - * component, not duplicated here. + * Everything the shared `` needs that depends on the board's + * scope. Anything that can be derived from these fields (panel avatar, + * columns, section lock, labels) is built by the component, not duplicated + * here. */ export interface BoardSource { - variant: "mission-control" | "agent"; - // ── Data ────────────────────────────────────────────────────────────────── /** Already filtered + searched: exactly what renders on the board. */ items: KanbanItem[]; @@ -85,8 +83,9 @@ export interface BoardSource { isLoaded: boolean; // ── Open-chat selection + keyboard highlight ────────────────────────────── - // Owned by the source because the per-agent board reconciles both across - // agent switches; Mission Control never switches agent. + // Owned by the source, not the component: the open mission's session key + // and owning agent are resolved from the swept data, which only the source + // holds. selectedId: string | null; setSelectedId: (id: string | null) => void; highlightedId: string | null; @@ -94,12 +93,11 @@ export interface BoardSource { // ── Panel scope (the agent whose chat features the right panel shows) ───── activeAgent: Agent | null; - activeAgentDef: AgentDefinition | null; /** - * Scope for the new-conversation composer draft (HOU-730): the agent id on - * the per-agent board (a parked first message must never surface in another - * agent's composer), a view constant in Mission Control (one composer whose - * draft survives switching the target agent). + * Scope for the new-conversation composer draft (HOU-730): a per-board + * constant (Mission Control, or one per team board), so a parked first + * message survives switching the target agent but never surfaces in another + * board's composer. */ draftScope: string; selectedSessionKey: string | null; @@ -149,7 +147,7 @@ export interface BoardSource { ) => Promise; stopSession: (sessionKey: string) => void; - // ── Drag & drop (per-agent board only) ──────────────────────────────────── + // ── Drag & drop ─────────────────────────────────────────────────────────── onItemMove?: (item: KanbanItem, toColumnId: string) => void; canDropItem?: (item: KanbanItem, toColumnId: string) => boolean; @@ -186,12 +184,9 @@ export interface BoardSource { * dot). Resolved against the full in-scope set so a search that hides the * open card doesn't drop the indicator. */ selectedRunning: boolean; - /** Per-card avatar for the per-agent board. Mission Control sets a per-card - * `icon` on each item instead and leaves this undefined. */ - cardAvatar?: ReactNode; // ── Slots rendered by the component ─────────────────────────────────────── - /** Toolbar rendered above the board (Mission Control only). */ + /** Toolbar rendered above the board (filters, search, New mission). */ toolbar?: ReactNode; /** Dialogs mounted alongside the board (agent picker, attachment rejection, * skill picker). */ diff --git a/app/src/components/board/mention-row-nav.ts b/app/src/components/board/mention-row-nav.ts index a8d43c0d7..8f1888b99 100644 --- a/app/src/components/board/mention-row-nav.ts +++ b/app/src/components/board/mention-row-nav.ts @@ -1,11 +1,14 @@ /** * The one navigation a mention notification performs, shared by the Mission * Control inbox and the header bell so a row can never land two different - * places: pick the agent, switch to its Activity view, open the mission's - * panel — the same three-step nav a completion notification does. + * places: make the agent current, open the board its missions live on — + * its TEAM's Mission Control, filtered to that agent ({@link openAgentBoard}) — + * and publish the mission for that board to open. The same three-step nav a + * completion notification does. */ import { activityIdForSessionKey } from "../../lib/notification-nav"; +import { openAgentBoard } from "../../lib/open-agent"; import type { Agent } from "../../lib/types"; import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; @@ -29,6 +32,6 @@ export function openMentionRow( row.sessionKey, ) ?? row.conversationId; useAgentStore.getState().setCurrent(agent); - useUIStore.getState().setViewMode("activity"); + openAgentBoard(agent.id); useUIStore.getState().setActivityPanelId(activityId, { forceOpen: true }); } diff --git a/app/src/components/board/mentions-inbox-model.ts b/app/src/components/board/mentions-inbox-model.ts index 8970928ae..17f7ae6ed 100644 --- a/app/src/components/board/mentions-inbox-model.ts +++ b/app/src/components/board/mentions-inbox-model.ts @@ -5,10 +5,12 @@ import { mentionReadFloorFor, type ReadCursorStore, } from "../../lib/read-cursors.ts"; +import type { Agent } from "../../lib/types.ts"; import { isUnreadForMe, type UnreadConversationInput, } from "../../lib/unread-model.ts"; +import { missionCardAgentName } from "./mission-card-agent.ts"; /** * Pure, DOM-free model for the Mentions inbox (HOU-945): every mission where a @@ -33,9 +35,9 @@ import { * same verdict the board card and the sidebar paint). The ROW's dot reads it, * and reading it broadly is right there: the dot promises news, not a name. * - `mentionOutstanding` — "somebody typed my name and I have not been back - * since". The toolbar PILL reads this one, and only this one. + * since". The notifications BELL's count reads this one, and only this one. * - * Collapsing them would make the pill lie. Its accessible name is literally "N + * Collapsing them would make the bell lie. Its accessible name is literally "N * unread mentions", so a mission that merely MOVED would inflate a number the * user reads as "N people typed my name" — the one count in the product that has * to mean exactly that, or the whole mention signal becomes noise. @@ -61,7 +63,7 @@ export interface MentionInboxRow { /** Something new here for me, mention or ambient movement. The row's dot. */ unread: boolean; /** A mention of me newer than my read cursor for this conversation — strictly - * narrower than {@link MentionInboxRow.unread}. The toolbar pill's count. */ + * narrower than {@link MentionInboxRow.unread}. The bell's count. */ mentionOutstanding: boolean; } @@ -70,14 +72,17 @@ export interface MentionInboxRow { * on conversation id so the list never reshuffles between renders. * * `selfId === null` yields `[]`: with nobody signed in there is no "me" to be - * mentioned, and the inbox itself is a multiplayer surface that never renders - * on desktop. Guided setup chats are excluded like everywhere else — they have - * no board card to open, so a row for one would navigate nowhere. + * mentioned. The Inbox screen is a top-level row for EVERYONE (it is the app's + * landing surface before any team resolves), so single player reaches this and + * gets the honest empty list rather than a hidden screen. Guided setup chats are + * excluded like everywhere else — they have no board card to open, so a row for + * one would navigate nowhere. */ export function buildMentionInbox( convs: readonly MentionInboxConversation[], store: ReadCursorStore, selfId: string | null, + agentsByPath: Map = new Map(), ): MentionInboxRow[] { if (selfId === null) return []; @@ -89,7 +94,9 @@ export function buildMentionInbox( rows.push({ conversationId: conv.id, agentPath: conv.agent_path, - agentName: conv.agent_name, + agentName: + missionCardAgentName(agentsByPath, conv.agent_path, conv.agent_name) ?? + conv.agent_name, sessionKey: conv.session_key, title: conv.title, at: latest.at, diff --git a/app/src/components/board/mentions-inbox-view-model.ts b/app/src/components/board/mentions-inbox-view-model.ts index 8cb21094e..545e37f6d 100644 --- a/app/src/components/board/mentions-inbox-view-model.ts +++ b/app/src/components/board/mentions-inbox-view-model.ts @@ -16,8 +16,8 @@ import type { */ /** - * The unread count as it appears on the Mentions pill. Clamped because the - * control shares one line with the Mission Control title and the search field: + * The unread count as it appears on the notifications bell. Clamped because the + * bell shares one line with a board's title, its filters and the search field: * a four-digit backlog would push the title off-screen, and past "lots" the * exact number tells the user nothing they act on differently. */ diff --git a/app/src/components/board/mentions-inbox.tsx b/app/src/components/board/mentions-inbox.tsx deleted file mode 100644 index 6935bca7b..000000000 --- a/app/src/components/board/mentions-inbox.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "@houston-ai/core"; -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { useAllConversations } from "../../hooks/queries"; -import { useUserProfiles } from "../../hooks/queries/use-user-profiles"; -import { useReadCursorStore } from "../../hooks/use-read-cursors"; -import { useSession } from "../../hooks/use-session"; -import type { Agent } from "../../lib/types"; -import { MissionControlToolbar } from "../mission-control-toolbar"; -import { openMentionRow } from "./mention-row-nav"; -import { - buildMentionInbox, - type MentionInboxConversation, - type MentionInboxRow, -} from "./mentions-inbox-model"; -import { - MentionsInboxRow, - MentionsInboxRowSkeleton, -} from "./mentions-inbox-row"; -import { - mentionerIds, - resolveMentionerName, - storedContributorNames, -} from "./mentions-inbox-view-model"; - -/** - * Everything the inbox rows AND the toolbar's Mentions pill read, in one hook, - * so the number on the pill and the rows behind it are derived from one list. - * Both consumers share the one `useAllConversations` query, so mounting this - * twice costs no extra fetch. - * - * The pill counts `mentionOutstanding`, NOT the broader row `unread` flag: the - * pill says "N unread mentions" out loud, so counting a mission that merely - * moved would announce teammates typing my name when nobody did. The rows keep - * their broader dot, which claims only "something new here" (see - * `mentions-inbox-model.ts` for the two claims). Nothing renders the row-level - * unread total today, so the hook does not compute one. - */ -export function useMentionInbox(agents: Agent[]) { - const { data: session } = useSession(); - const selfId = session?.uid ?? null; - const cursors = useReadCursorStore(); - const paths = useMemo(() => agents.map((a) => a.folderPath), [agents]); - const { data, isPending } = useAllConversations(paths); - const conversations: MentionInboxConversation[] = useMemo( - () => data ?? [], - [data], - ); - const rows = useMemo( - () => buildMentionInbox(conversations, cursors, selfId), - [conversations, cursors, selfId], - ); - const mentionCount = useMemo( - () => rows.reduce((n, row) => n + (row.mentionOutstanding ? 1 : 0), 0), - [rows], - ); - return { rows, conversations, mentionCount, isPending }; -} - -/** - * Mission Control's Mentions inbox: every mission where a teammate typed my - * name, newest first. A multiplayer-only surface — `Dashboard` never routes - * here without the capability, so single player gains no chrome at all. - * - * Deliberately NOT a board: there are no columns to move a mention between and - * no status to read off it, only "who pinged me, where, and how long ago". So - * it is a plain scrollable list of plane rows, each of which navigates to the - * mission's chat the same way a completion notification does. - */ -export function MentionsInbox({ - agents, - onShowActive, -}: { - agents: Agent[]; - onShowActive: () => void; -}) { - const { t, i18n } = useTranslation("dashboard"); - const { rows, conversations, mentionCount, isPending } = - useMentionInbox(agents); - - const senderIds = useMemo(() => mentionerIds(rows), [rows]); - const { profiles } = useUserProfiles(senderIds); - const fallbackNames = useMemo( - () => storedContributorNames(conversations), - [conversations], - ); - const nameFor = useCallback( - (userId: string) => resolveMentionerName(userId, profiles, fallbackNames), - [profiles, fallbackNames], - ); - - const openRow = useCallback( - (row: MentionInboxRow) => openMentionRow(agents, conversations, row), - [agents, conversations], - ); - - return ( - <> - -
    - {isPending ? ( -
    - {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
    - ) : rows.length === 0 ? ( - - - {t("mentions.empty.title")} - - {t("mentions.empty.description")} - - - - ) : ( -
      - {rows.map((row) => ( -
    • - -
    • - ))} -
    - )} -
    - - ); -} diff --git a/app/src/components/board/mission-board.tsx b/app/src/components/board/mission-board.tsx index b991e032c..edb4de046 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 { childMissionsOf, parentMissionOf } from "../../lib/child-missions"; @@ -18,6 +18,7 @@ import { useShellDetailPanel } from "../shell/use-shell-detail-panel"; import { useAgentChatPanel } from "../use-agent-chat-panel"; import { useQueuedMessageLabels } from "../use-queued-message-labels"; import type { BoardSource } from "./board-source"; +import { panelTaskLabel } from "./panel-task-label"; import { useBoardDrafts } from "./use-board-drafts"; import { useBoardKeyboard } from "./use-board-keyboard"; import { useBoardLabels } from "./use-board-labels"; @@ -25,34 +26,23 @@ import { useBoardSelectionUI } from "./use-board-selection-ui"; import { useBoardSendQueue } from "./use-board-send-queue"; /** - * The one board both views render. It owns every shared concern — columns, + * The one board every mission surface renders (Mission Control and each team + * board, which is the same source narrowed by a scope). It owns every shared + * concern — columns, * the multi-select UI, the `useAgentChatPanel` integration, the message * queue, draft persistence, keyboard navigation, run-in-terminal actions, and * the full AIBoard prop spread — and pulls the divergent pieces (data, active * agent, new-mission flow, bulk routing, toolbar, dialogs) from `source`. */ -export function MissionBoard({ - source, - isActive: tabActive, -}: { - source: BoardSource; - /** When this board is one agent-detail TAB (the Activity tab), the tab's own - * active flag. Every agent tab stays mounted (only CSS-hidden), so without - * it the board keeps its selected mission and keeps portaling a detail panel - * into the shared shell panel while another tab (Routines) portals its own — - * two stacked panels, the chat "split in half" (HOU-1165). Composed with the - * screen-level active-view signal below. Omit for the top-level Mission - * Control board, which the screen signal governs alone. */ - isActive?: boolean; -}) { +export function MissionBoard({ source }: { source: BoardSource }) { const { t } = useTranslation(["dashboard", "board"]); const { panelContainer, setPanelOpen } = useShellDetailPanel(); - // "Active" here means BOTH the enclosing screen is visible AND (when this is a - // tab) the tab is the selected one. `useIsActiveView` is only tab-accurate for - // top-level kept-alive screens, not agent tabs, so tab callers pass their own - // flag; the top-level board passes none and rides the screen signal alone. - const screenActive = useIsActiveView(); - const isActive = screenActive && (tabActive ?? true); + // Every board is the whole of a kept-alive top-level screen, so the + // screen-level signal alone says whether this one is on the glass. It gates + // the keyboard nav and the shell detail panel: a hidden-but-mounted screen + // must stop portaling its panel, or two screens stack their panels into the + // one shared slot and the chat renders "split in half" (HOU-1165). + const isActive = useIsActiveView(); const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); const addToast = useUIStore((s) => s.addToast); const queuedLabels = useQueuedMessageLabels(); @@ -75,16 +65,24 @@ export function MissionBoard({ ), [t, source.openNewMission], ); + // The panel's own task line, composed here rather than left to `ui/`'s + // i18n-agnostic English fallback (`panelTaskLabel`). + const panelLabel = useMemo( + () => + panelTaskLabel( + { + task: (title) => t("board:panel.taskLabel", { title }), + newTask: t("board:panel.newTask"), + }, + source.selectedId, + source.allItems.find((item) => item.id === source.selectedId)?.title, + ), + [t, source.selectedId, source.allItems], + ); const closeOpenChat = useCallback( () => 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, @@ -115,7 +113,6 @@ export function MissionBoard({ ); const panel = useAgentChatPanel({ agent: source.activeAgent, - agentDef: source.activeAgentDef, selectedSessionKey: source.selectedSessionKey, onSelectSession: source.onSelectSession, draftScope: source.draftScope, @@ -142,6 +139,7 @@ export function MissionBoard({ }); const { handleCloserReady } = useBoardKeyboard({ + isActive, items: source.items, columns, selectedId: source.selectedId, @@ -149,6 +147,7 @@ export function MissionBoard({ highlightedId: source.highlightedId, setHighlightedId: source.setHighlightedId, missionPanelOpen, + setPanelOpen, isLoaded: source.isLoaded, hasSearchQuery: source.hasSearchQuery, openerReady: source.openerReady, @@ -231,9 +230,9 @@ export function MissionBoard({ prepareAttachments={attachmentValidation.prepareAttachments} onAttachmentRejections={attachmentValidation.onAttachmentRejections} onOpenLink={handleOpenLink} - cardAvatar={source.cardAvatar} thinkingIndicator={panel.thinkingIndicator} panelAgentName={source.panelAgentName} + panelMissionLabel={panelLabel} panelAvatar={ , + agentPath: string, + rowAgentName: string | undefined, +): string | undefined { + return agentsByPath.get(agentPath)?.name ?? rowAgentName; +} + +/** The roster keyed by folder path — a card's only handle on its agent. */ +export function agentsByPath(agents: Agent[]): Map { + return new Map(agents.map((a) => [a.folderPath, a])); +} diff --git a/app/src/components/board/mission-control-active.tsx b/app/src/components/board/mission-control-active.tsx deleted file mode 100644 index ab6daf6b0..000000000 --- a/app/src/components/board/mission-control-active.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Agent } from "../../lib/types"; -import type { MissionsToolbarMentions } from "../mission-toolbar-actions"; -import { MissionBoard } from "./mission-board"; -import { useMissionControlSource } from "./use-mission-control-source"; - -/** - * Mission Control's active board. A thin wrapper so its source hooks (which - * register the global "New mission" handler, keyboard nav, etc.) only run - * while the active view is mounted — `Dashboard` swaps it out for the Archived - * view rather than hiding it, so nothing lingers behind the archived list. - */ -export function MissionControlActive({ - agents, - onShowArchived, - mentions, -}: { - agents: Agent[]; - onShowArchived: () => void; - /** Mentions-inbox entry point for the toolbar. Multiplayer only: undefined - * on single player, where no Mentions control renders at all. */ - mentions?: MissionsToolbarMentions; -}) { - const source = useMissionControlSource(agents, onShowArchived, mentions); - return ; -} diff --git a/app/src/components/board/mission-control-archived.tsx b/app/src/components/board/mission-control-archived.tsx index 3277b8f33..a6e0468bf 100644 --- a/app/src/components/board/mission-control-archived.tsx +++ b/app/src/components/board/mission-control-archived.tsx @@ -1,34 +1,60 @@ import { AIBoard } from "@houston-ai/board"; -import { useCallback, useMemo, useState } from "react"; +import { type ReactNode, 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 { pendingMissionSurface } from "../../lib/board-surface-nav"; import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; -import { useAttachmentRejectionDialog } from "../attachment-rejection-dialog"; +import { ArchivedEmptyState } from "../agent/archived-empty-state"; import { MissionControlToolbar } from "../mission-control-toolbar"; import { AgentPanelAvatar } from "../shell/agent-panel-avatar"; +import { useIsActiveView } from "../shell/keep-alive-views"; +import { PageHeaderTools } from "../shell/page-header/page-header-tools"; 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 { panelTaskLabel } from "./panel-task-label"; +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"; +import { usePendingMissionTarget } from "./use-pending-mission-target"; /** - * 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. + * Cross-agent Archived view for Mission Control: a column-less list of every + * archived mission; clicking one opens its chat; sending re-activates it and + * hands the user back to this screen's active board, with the mission's chat + * open, 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`). + * + * It says nothing about WHERE it is: the Archived TAB is lit for exactly as + * long as this is on screen, so a title, a qualifier or a trail crumb here + * would be the third thing on one screen saying the same word. */ export function MissionControlArchived({ agents, onShowActive, + scope, + agentFilter, + scopedAgents, + newMissionMenuOpen, + onNewMissionMenuChange, + onNewMission, }: { + /** The FULL workspace roster, always. Never a team's slice. */ agents: Agent[]; onShowActive: () => void; + /** Narrows what this archive renders. Every live caller is a team, so it is + * always passed; omitting it archives the whole roster. */ + scope?: MissionControlScope; + /** The section's own agent filter capsule, rendered in the tools row. */ + agentFilter?: ReactNode; + /** The team's agents — the "New task" menu's roster. */ + scopedAgents: Agent[]; + newMissionMenuOpen: boolean; + onNewMissionMenuChange: (open: boolean) => void; + onNewMission: (agent: Agent) => void; }) { const { t } = useTranslation("board"); const { panelContainer, setPanelOpen } = useShellDetailPanel(); @@ -37,15 +63,40 @@ export function MissionControlArchived({ const data = useMissionControlArchived(agents); - const [filterPath, setFilterPath] = useState(""); - const [search, setSearch] = useState(""); - const agentFilteredItems = useMemo( - () => - filterPath - ? data.items.filter((i) => i.metadata?.agentPath === filterPath) - : data.items, - [data.items, filterPath], + // An @mention (or a notification) can name a mission that was archived long + // ago, and the archive is the only surface that can open it. It claims those + // targets and only those: an ACTIVE mission's id stays published for the + // board this archive belongs to, which the owner's router swaps back in. + const pendingId = useUIStore((s) => s.activityPanelId); + const pendingSurface = pendingMissionSurface( + data.rawConversations, + pendingId, ); + usePendingMissionTarget({ + surface: "archived", + pendingSurface, + selectedId: data.selectedId, + setSelectedId: data.setSelectedId, + missionPanelOpen, + }); + + // Only the NARROWING is this surface's business now: the scope picker moved + // to the team strip's breadcrumb, which owns the pin both surfaces read. + const { agentFilteredItems } = useMcScope(agents, data.items, scope); + const [search, setSearch] = useState(""); + + // 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,65 +111,33 @@ 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, onShowActive); return ( <> - { - // Mirror the per-agent Archived tab: New mission lives in the bar - // here too. Return to the active board, then open its agent picker - // (the active source registers onStartMission once it mounts). - onShowActive(); - setTimeout(() => useUIStore.getState().onStartMission?.(), 50); - }} - collapsed={missionPanelOpen} - /> + + {(oneRow) => ( + + )} +
    t("panel.taskLabel", { title }), + newTask: t("panel.newTask"), + }, + data.selectedId, + selectedItem?.title, + )} panelAvatar={ } 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..14cb4d4f9 --- /dev/null +++ b/app/src/components/board/mission-control-scope.ts @@ -0,0 +1,84 @@ +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 : ""; +} + +/** + * The agent the board's title picker names, or `null` for "every agent on this + * board". A path no agent on the board owns is deliberately NOT an agent: the + * title is the filter now, so a path with nobody behind it would leave the + * board's only heading blank while the board itself shows everything. + */ +export function filteredScopeAgent( + agents: T[], + filterPath: string, +): T | null { + if (!filterPath) return null; + return agents.find((agent) => agent.folderPath === filterPath) ?? null; +} diff --git a/app/src/components/board/new-mission-target.ts b/app/src/components/board/new-mission-target.ts new file mode 100644 index 000000000..e41d0653e --- /dev/null +++ b/app/src/components/board/new-mission-target.ts @@ -0,0 +1,47 @@ +import type { Agent } from "../../lib/types.ts"; + +/** + * Who a new task belongs to, before the composer opens. + * + * A team board is cross-agent, so "New task" cannot just open a composer — it + * has to know whose. But asking is only honest when there is genuinely a + * choice, and there often is not: + * + * - the board is PINNED to one agent, so the user already answered the + * question by narrowing the board they are looking at. Asking again is the + * app forgetting what it is showing; + * - the team holds exactly ONE agent, so there is nothing to pick. + * + * In both cases the composer opens straight onto that agent. Only a genuinely + * ambiguous board asks, and it asks with a MENU hung off the button rather + * than a modal — a modal for "which of these three" stops the world for a + * choice the user can make in place, and it covered the rail they were reading + * the agents' names from a second ago. + * + * Pure, and unit-tested in `app/tests/new-mission-target.test.ts`. + */ + +/** Where "New task" goes: straight to an agent, or to a menu of them. */ +export type NewMissionTarget = + | { kind: "direct"; agent: Agent } + | { kind: "menu" }; + +/** + * The rule, in order: + * + * 1. a pinned agent wins — the board is already narrowed to them; + * 2. else a single scoped agent wins — there is no choice to offer; + * 3. else the menu. + * + * An EMPTY scope also answers `menu`: a board with no agents has no composer + * to open, and the caller's own empty state is what the user sees instead. + */ +export function newMissionTarget( + pinnedAgent: Agent | null, + scopedAgents: Agent[], +): NewMissionTarget { + if (pinnedAgent) return { kind: "direct", agent: pinnedAgent }; + if (scopedAgents.length === 1) + return { kind: "direct", agent: scopedAgents[0] }; + return { kind: "menu" }; +} diff --git a/app/src/components/board/panel-task-label.ts b/app/src/components/board/panel-task-label.ts new file mode 100644 index 000000000..edc1e0bd1 --- /dev/null +++ b/app/src/components/board/panel-task-label.ts @@ -0,0 +1,34 @@ +/** + * The task line under the agent's name in the board's detail panel. + * + * `ui/` is i18n-agnostic, so `KanbanDetailPanel` can only fall back to a raw + * English "Mission: {title}" — which every normally-opened task wore, in every + * language. The app therefore composes this line itself and ALWAYS passes it + * down as `panelMissionLabel`, so the ui default is never the thing on screen. + * + * Pure and DOM-free so the three states are unit-tested + * (`app/tests/panel-task-label.test.ts`). + */ + +export interface PanelTaskLabels { + /** "Task: {{title}}" for an opened task. */ + task: (title: string) => string; + /** The line for the panel a new task is being composed in. */ + newTask: string; +} + +/** + * Compose the line for the panel's current state: + * - nothing selected -> the panel is a NEW task's composer; + * - a selected task -> its title; + * - selected but its card has not resolved yet -> BLANK, never the new-task + * line: naming an existing chat "New task" for a beat is worse than a gap. + */ +export function panelTaskLabel( + labels: PanelTaskLabels, + selectedId: string | null, + title: string | undefined, +): string { + if (!selectedId) return labels.newTask; + return title ? labels.task(title) : ""; +} diff --git a/app/src/components/board/use-agent-board-data.ts b/app/src/components/board/use-agent-board-data.ts deleted file mode 100644 index c978caedd..000000000 --- a/app/src/components/board/use-agent-board-data.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { KanbanItem } from "@houston-ai/board"; -import type { FeedItem } from "@houston-ai/chat"; -import { useCallback, useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { - useActivity, - useChatHistory, - useDeleteActivity, - useUpdateActivity, -} from "../../hooks/queries"; -import { useConversationVm } from "../../hooks/use-conversation-vm"; -import { useWarmingBoardRows } from "../../hooks/use-warming-board-rows"; -import { armMissionDoneCelebration } from "../../lib/mission-done-celebration"; -import { - ARCHIVED_STATUS, - canDropMission, - DONE_STATUS, -} from "../../lib/mission-selection"; -import { perfSpans } from "../../lib/perf-spans"; -import { - type HistoryLoadOptions, - tauriActivity, - tauriChat, -} from "../../lib/tauri"; -import type { Agent, AgentDefinition } from "../../lib/types"; -import { mergeWarmingRows } from "../../lib/warming-board-rows"; -import { useUIStore } from "../../stores/ui"; -import { missionColumnIdForStatus } from "../mission-board-columns"; -import { buildAgentBoardItems } from "./agent-board-items"; - -const EMPTY_FEED: FeedItem[] = []; - -/** - * Per-agent board data: maps this agent's activities to kanban items, exposes - * its feed slice, and the card-level mutations (delete / approve / archive / - * rename / drag-move / history). Archived missions live in their own tab, so - * they're kept off the active board here. - */ -export function useAgentBoardData({ - agent, - agentDef, - selectedId, - setSelectedId, -}: { - agent: Agent; - agentDef: AgentDefinition; - selectedId: string | null; - setSelectedId: (id: string | null) => void; -}) { - const { t } = useTranslation(["board", "dashboard", "chat"]); - const path = agent.folderPath; - const agentModes = agentDef.config.agents; - const addToast = useUIStore((s) => s.addToast); - const { data: fetchedItems } = useActivity(path); - // App-open → board perf mark (HOU-1011): the cards' data resolved; the rAF - // waits for the render that paints them. Latched once per app session - // inside perfSpans, so re-mounts and agent switches are free no-ops. - const cardsResolved = fetchedItems !== undefined; - useEffect(() => { - if (!cardsResolved) return; - if (typeof requestAnimationFrame === "function") - requestAnimationFrame(() => perfSpans.boardRendered()); - else perfSpans.boardRendered(); - }, [cardsResolved]); - const deleteActivity = useDeleteActivity(path); - const updateActivity = useUpdateActivity(path); - - // While the engine warms up the list read above is held for the whole cold - // start — overlay the queued missions so the card shows up as `running` the - // moment the user sends it (HOU-713). Identity pass-through when nothing is - // queued, so the normal path (including its `undefined` = "still loading" - // contract) is untouched. - const warmingRows = useWarmingBoardRows(agent.id); - const rawItems = useMemo( - () => mergeWarmingRows(fetchedItems, warmingRows), - [fetchedItems, warmingRows], - ); - - const items: KanbanItem[] = useMemo( - () => - buildAgentBoardItems({ - activities: rawItems ?? [], - agentName: agent.name, - agentModes, - routineLabel: t("board:tags.routine"), - agentStartedLabel: t("board:tags.agentStarted"), - }), - [agent.name, agentModes, rawItems, t], - ); - - const sessionKeyFor = useCallback( - (activityId: string) => { - const item = (rawItems ?? []).find((a) => a.id === activityId); - return item?.session_key ?? `activity-${activityId}`; - }, - [rawItems], - ); - - // The open conversation's reactive feed from the SDK conversation VM - // (history seeded by the adapter's loadHistory; live turns folded by the - // SDK). AIBoard only reads `feedItems[activeSessionKey]`, so the - // single-entry map is the whole contract. - const activeSessionKey = selectedId ? sessionKeyFor(selectedId) : null; - // Live resync (HOU-731): subscribe the open conversation to the - // chat-history query key, so a ConversationsChanged event re-reads it and - // reseeds the VM (see useChatHistory) — turns written by a teammate, - // another device, or a routine repaint without reselecting the mission. - useChatHistory( - activeSessionKey ? path : undefined, - activeSessionKey ?? undefined, - ); - const activeVm = useConversationVm(path, activeSessionKey); - const activeFeed = activeVm?.feed ?? EMPTY_FEED; - // Card-click → chat perf mark (HOU-1011): the opened conversation's - // messages are in the feed; the rAF waits for the paint. No-ops unless a - // card click armed the mark (perfSpans.cardClicked in mission-board). The - // dep is the painted conversation's KEY (not a boolean): switching cards - // while both feeds are cached keeps a boolean true, but the key change is - // what re-fires the mark for the newly opened chat. - const paintedSessionKey = - activeSessionKey !== null && activeFeed.length > 0 - ? activeSessionKey - : null; - useEffect(() => { - if (paintedSessionKey === null) return; - if (typeof requestAnimationFrame === "function") - requestAnimationFrame(() => perfSpans.chatRendered()); - else perfSpans.chatRendered(); - }, [paintedSessionKey]); - const feedItems = useMemo>( - () => (activeSessionKey ? { [activeSessionKey]: activeFeed } : {}), - [activeSessionKey, activeFeed], - ); - // Scroll-up lazy-load (HOU-819): the open chat renders only the transcript's - // tail window; when older messages exist server-side the panel prepends the - // previous page as the user scrolls up. `hasOlderMessages` comes off the - // VM's stamped window, so it flips as pages land. - const hasOlderMessages = (activeVm?.historyWindow?.earliestLoaded ?? 0) > 0; - const onLoadOlderMessages = useCallback(async () => { - if (!activeSessionKey) return; - await tauriChat.loadOlderHistory(path, activeSessionKey); - }, [path, activeSessionKey]); - - const loadHistory = useCallback( - async (sessionKey: string, opts?: HistoryLoadOptions) => { - const history = await tauriChat.loadHistory(path, sessionKey, opts); - return history as FeedItem[]; - }, - [path], - ); - - const handleDelete = useCallback( - async (item: KanbanItem) => { - await deleteActivity.mutateAsync(item.id); - if (selectedId === item.id) setSelectedId(null); - }, - [deleteActivity, selectedId, setSelectedId], - ); - // The card checkmark: the user signing a mission off. The celebration is - // armed before the write (it measures the card so the burst comes off it) and - // fired after it lands — a rejection propagates to the global error toast and - // never celebrates. See armMissionDoneCelebration for the full contract. - const handleApprove = useCallback( - async (item: KanbanItem) => { - const celebrate = armMissionDoneCelebration(item, DONE_STATUS); - await updateActivity.mutateAsync({ - activityId: item.id, - update: { status: DONE_STATUS }, - }); - celebrate(); - }, - [updateActivity], - ); - // The Done card's archive box: the user filing a mission they already signed - // off. No celebration — the win was the checkmark; this is the tidy-up after - // it. Archiving drops the card off the active board, so the open panel is - // closed the same way `handleDelete` and the bulk archive close it. - const handleArchive = useCallback( - async (item: KanbanItem) => { - await updateActivity.mutateAsync({ - activityId: item.id, - update: { status: ARCHIVED_STATUS }, - }); - if (selectedId === item.id) setSelectedId(null); - }, - [updateActivity, selectedId, setSelectedId], - ); - // Drag a card onto another column to change its status. The board only fires - // this for a column `canDropItem` accepted, so `toColumnId` doubles as the - // new status. Failure surfaces as a toast rather than a silent swallow, and - // the celebration follows the same arm-before / fire-after rule as the - // checkmark above (it also declines a dragged `error` card: those share the - // Needs you column, and filing a failure away is not a win). - const handleItemMove = useCallback( - async (item: KanbanItem, toColumnId: string) => { - const celebrate = armMissionDoneCelebration(item, toColumnId); - try { - await updateActivity.mutateAsync({ - activityId: item.id, - update: { status: toColumnId }, - }); - } catch (err) { - addToast({ - title: t("board:dnd.moveError", { error: String(err) }), - variant: "error", - }); - return; - } - // Outside the try: a throwing celebration must never read as a failed move. - celebrate(); - }, - [updateActivity, addToast, t], - ); - const canDropItem = useCallback( - (item: KanbanItem, toColumnId: string) => - canDropMission(missionColumnIdForStatus(item.status), toColumnId), - [], - ); - const onRename = useCallback( - (item: KanbanItem, title: string) => { - tauriActivity.update(path, item.id, { title }).catch(console.error); - }, - [path], - ); - - return { - rawItems, - items, - feedItems, - sessionKeyFor, - loadHistory, - onLoadOlderMessages, - hasOlderMessages, - handleDelete, - handleApprove, - handleArchive, - handleItemMove, - canDropItem, - onRename, - }; -} diff --git a/app/src/components/board/use-agent-board-people.ts b/app/src/components/board/use-agent-board-people.ts deleted file mode 100644 index 271283d54..000000000 --- a/app/src/components/board/use-agent-board-people.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { KanbanPerson } from "@houston-ai/board"; -import { useMemo } from "react"; -import { useConversations } from "../../hooks/queries"; -import { useUserProfiles } from "../../hooks/queries/use-user-profiles"; -import { useCapabilities } from "../../hooks/use-capabilities"; -import { - buildBoardPeopleById, - collectContributorIds, -} from "../../lib/mission-people"; -import { isMultiplayer } from "../../lib/org-roles"; - -const EMPTY: ReadonlyMap = new Map(); - -/** - * Per-mission attribution for ONE agent's board, keyed by mission id. Mirrors - * the cross-agent Mission Control attribution block ({@link useMissionControl}): - * resolve every contributor on the agent's conversations to a display profile, - * then fold them into face stacks. Multiplayer (hosted Teams) only — the - * conversations query, the profiles lookup, and the fold are all gated so - * single-player / desktop runs neither request and gets an empty map (the board - * stays byte-identical, no `people` on any card). The activity list the board - * renders from carries no attribution, so the caller joins these on by id. - */ -export function useAgentBoardPeople( - agentPath: string, -): ReadonlyMap { - const { capabilities } = useCapabilities(); - const multiplayer = isMultiplayer(capabilities); - const { data: convos } = useConversations( - multiplayer ? agentPath : undefined, - ); - const contributorIds = useMemo( - () => (multiplayer && convos ? collectContributorIds(convos) : []), - [multiplayer, convos], - ); - const { profiles } = useUserProfiles(contributorIds); - - return useMemo( - () => - multiplayer && convos ? buildBoardPeopleById(convos, profiles) : EMPTY, - [multiplayer, convos, profiles], - ); -} diff --git a/app/src/components/board/use-agent-board-scope.tsx b/app/src/components/board/use-agent-board-scope.tsx deleted file mode 100644 index cb48616ae..000000000 --- a/app/src/components/board/use-agent-board-scope.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { KanbanItem } from "@houston-ai/board"; -import { useMemo } from "react"; -import { useSession } from "../../hooks/use-session"; -import { missionMatchesScope } from "../../lib/agent-person-scope"; -import { attachBoardPeople } from "../../lib/mission-people"; -import { useAgentPersonScope } from "../agent-person-scope-context"; -import { useAgentBoardPeople } from "./use-agent-board-people"; - -/** - * Join the per-mission truth the activity list does not carry onto a single - * agent's cards, then narrow them to the active PERSON SCOPE. Split out of - * {@link useAgentBoardSource} so the source stays a thin composition. The scope - * itself is chosen in the agent header ({@link AgentPersonScopeMenu}) and shared - * via {@link useAgentPersonScope}; this only applies it to the cards: - * - * - joins server-stamped attribution onto the activity-derived cards (which - * carry none) by mission id, multiplayer-gated so desktop stays identical - * (the default "everyone" scope matches every card, and off multiplayer the - * empty attribution map keeps narrower scopes an identity pass-through); - * - filters BEFORE text search, exactly as the cross-agent board; - * - defaults to "everyone"; a user narrowing to "me" still sees unattributed / - * legacy missions (see {@link missionMatchesScope}). - */ -export function useAgentBoardScope({ - path, - items, -}: { - path: string; - /** Active missions from the activity list, before attribution / filtering. */ - items: KanbanItem[]; -}): KanbanItem[] { - const { scope } = useAgentPersonScope(); - const { data: session } = useSession(); - const selfId = session?.uid ?? ""; - - const peopleById = useAgentBoardPeople(path); - // Join server-stamped attribution onto the activity-derived cards (which carry - // none) so the bottom people strip and the person-scope roster can read it. - // The card ICON stays the shared agent avatar on every board — contributors - // live only in the strip. Identity pass-through off multiplayer (empty map). - const peopledItems = useMemo( - () => attachBoardPeople(items, peopleById), - [items, peopleById], - ); - return useMemo( - () => - peopledItems.filter((i) => missionMatchesScope(i.people, scope, selfId)), - [peopledItems, scope, selfId], - ); -} diff --git a/app/src/components/board/use-agent-board-selection.ts b/app/src/components/board/use-agent-board-selection.ts deleted file mode 100644 index a15010e25..000000000 --- a/app/src/components/board/use-agent-board-selection.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback } from "react"; -import { - useBulkDeleteActivity, - useBulkUpdateActivity, -} from "../../hooks/queries"; -import { ARCHIVED_STATUS } from "../../lib/mission-selection"; -import type { BoardSelectionModel } from "./board-source"; -import { useSelectionSet } from "./use-selection-set"; - -/** - * Per-agent multi-select + bulk actions for the board tab. The selection - * set + UI live in {@link useSelectionSet}; this layer wires the agent-scoped - * bulk mutations. Bulk actions clear the selection on success; failures - * propagate so the caller surfaces a toast (no silent swallow). - */ -export function useAgentBoardSelection( - agentPath: string | undefined, -): BoardSelectionModel { - const { selectedIds, toggle, selectAll, clear } = useSelectionSet(); - const bulkUpdate = useBulkUpdateActivity(agentPath); - const bulkDelete = useBulkDeleteActivity(agentPath); - - const move = useCallback( - async (status: string) => { - await bulkUpdate.mutateAsync({ - ids: Array.from(selectedIds), - update: { status }, - }); - clear(); - }, - [bulkUpdate, selectedIds, clear], - ); - - const archive = useCallback(async () => { - await bulkUpdate.mutateAsync({ - ids: Array.from(selectedIds), - update: { status: ARCHIVED_STATUS }, - }); - clear(); - }, [bulkUpdate, selectedIds, clear]); - - const remove = useCallback(async () => { - await bulkDelete.mutateAsync(Array.from(selectedIds)); - clear(); - }, [bulkDelete, selectedIds, clear]); - - return { selectedIds, toggle, selectAll, clear, move, archive, remove }; -} diff --git a/app/src/components/board/use-agent-board-send.ts b/app/src/components/board/use-agent-board-send.ts index 63d684f2f..14f2f4925 100644 --- a/app/src/components/board/use-agent-board-send.ts +++ b/app/src/components/board/use-agent-board-send.ts @@ -12,7 +12,7 @@ import { perfSpans } from "../../lib/perf-spans"; import { queryKeys } from "../../lib/query-keys"; import { formatVisibleMessageText } from "../../lib/queued-chat"; import { tauriAttachments, tauriChat } from "../../lib/tauri"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { useAgentProvisioningStore } from "../../stores/agent-provisioning"; import { useUIStore } from "../../stores/ui"; import type { SendOverrides } from "./board-source"; @@ -29,17 +29,11 @@ import type { SendOverrides } from "./board-source"; */ export function useAgentBoardSend({ agent, - agentDef, rawItems, - pendingAgentMode, - setPendingAgentMode, promptContext, }: { agent: Agent; - agentDef: AgentDefinition; rawItems: Activity[] | undefined; - pendingAgentMode: string | null; - setPendingAgentMode: (mode: string | null) => void; /** * Model-facing context prepended to EVERY outgoing prompt, hidden from the * chat (the bubble keeps the user's words via `displayText` / the @@ -50,7 +44,6 @@ export function useAgentBoardSend({ }) { const { t } = useTranslation(["board", "chat", "common"]); const path = agent.folderPath; - const agentModes = agentDef.config.agents; const addToast = useUIStore((s) => s.addToast); const queryClient = useQueryClient(); const [loadingState, setLoading] = useState>({}); @@ -97,8 +90,6 @@ export function useAgentBoardSend({ modeOverride, mentions, }: { text: string; files: File[] } & SendOverrides) => { - const agentMode = pendingAgentMode ?? agentModes?.[0]?.id; - const mode = agentModes?.find((m) => m.id === agentMode); const visible = formatVisibleMessageText(text, files, (names) => t("chat:queue.attached", { names }), ); @@ -111,8 +102,6 @@ export function useAgentBoardSend({ }, text, { - agentMode, - promptFile: mode?.promptFile, providerOverride, modelOverride, modeOverride, @@ -131,7 +120,6 @@ export function useAgentBoardSend({ // itself — no app-side optimistic push. Warming agents included: the // parked message shows the standard in-flight indicator (HOU-713). setLoading((prev) => ({ ...prev, [sessionKey]: true })); - setPendingAgentMode(null); // First-mission pre-prompt: a contextual, one-time nudge to turn on // completion notifications, shown here — the moment a user kicks off a // mission — only when delivery isn't already granted and we've never @@ -140,7 +128,6 @@ export function useAgentBoardSend({ // createMission bypassed useCreateActivity so invalidate manually. queryClient.invalidateQueries({ queryKey: queryKeys.activity(path) }); analytics.track("mission_created", { - agent_mode: agentMode ?? "default", provider: providerOverride, model: modelOverride, }); @@ -153,18 +140,7 @@ export function useAgentBoardSend({ analytics.track("file_attached", { file_kind: classifyFileKind(f) }); return conversationId; }, - [ - path, - agent.id, - agent.name, - agent.color, - pendingAgentMode, - agentModes, - queryClient, - addToast, - t, - setPendingAgentMode, - ], + [path, agent.id, agent.name, agent.color, queryClient, addToast, t], ); const sendMessageNow = useCallback( @@ -183,7 +159,6 @@ export function useAgentBoardSend({ // A follow-up into a still-warming agent parks with the same queue the // first message used (HOU-693): rendered now, delivered on ready. A // held wire send would die with infrastructure timeouts or a reload. - const warmingMode = agentModes?.find((m) => m.id === activity?.agent); const queuedWarm = useAgentProvisioningStore .getState() .queueWarmingSend(agent.id, { @@ -208,7 +183,6 @@ export function useAgentBoardSend({ ); } : undefined, - promptFile: warmingMode?.promptFile, provider: overrides.providerOverride, model: overrides.modelOverride, mode: overrides.modeOverride, @@ -237,9 +211,7 @@ export function useAgentBoardSend({ try { const paths = await tauriAttachments.save(scopeId, files); const prompt = buildAttachmentPrompt(text, files, paths, promptContext); - const mode = agentModes?.find((m) => m.id === activity?.agent); await tauriChat.send(path, prompt, sessionKey, { - mode: mode?.promptFile, providerOverride: overrides.providerOverride, modelOverride: overrides.modelOverride, modeOverride: overrides.modeOverride, @@ -274,7 +246,7 @@ export function useAgentBoardSend({ throw err; } }, - [path, agent.id, addToast, rawItems, agentModes, t, promptContext], + [path, agent.id, addToast, rawItems, t, promptContext], ); const stopSession = useCallback( diff --git a/app/src/components/board/use-agent-board-source.tsx b/app/src/components/board/use-agent-board-source.tsx deleted file mode 100644 index 4ce7d65e9..000000000 --- a/app/src/components/board/use-agent-board-source.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { resolvePendingActivitySelection } from "../../lib/notification-nav"; -import type { Agent, AgentDefinition } from "../../lib/types"; -import { useUIStore } from "../../stores/ui"; -import { MissionBoardEmptyState } from "../mission-board-empty-state"; -import { AgentCardAvatar } from "../shell/agent-card-avatar"; -import { useMissionSearch } from "../use-mission-search"; -import type { BoardSource } from "./board-source"; -import { useAgentBoardData } from "./use-agent-board-data"; -import { useAgentBoardScope } from "./use-agent-board-scope"; -import { useAgentBoardSelection } from "./use-agent-board-selection"; -import { useAgentBoardSend } from "./use-agent-board-send"; -import { useAgentNewMission } from "./use-agent-new-mission"; - -/** - * Builds the {@link BoardSource} for a single agent's board tab: per-agent - * data + send, multi-select, the default-mode "New mission" flow, and the - * cross-agent navigation handoff (a notification / command-palette / Mission - * Control click publishes its target via `activityPanelId`). - */ -export function useAgentBoardSource( - agent: Agent, - agentDef: AgentDefinition, -): BoardSource { - const { t } = useTranslation(["board", "dashboard"]); - const path = agent.folderPath; - - const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); - const missionSearchQuery = useUIStore( - (s) => s.agentMissionSearchQueries[path] ?? "", - ); - const setAgentMissionSearchQuery = useUIStore( - (s) => s.setAgentMissionSearchQuery, - ); - const setAgentMissionSearchLoading = useUIStore( - (s) => s.setAgentMissionSearchLoading, - ); - const addToast = useUIStore((s) => s.addToast); - - const pendingId = useUIStore((s) => s.activityPanelId); - const pendingForceOpen = useUIStore((s) => s.activityPanelForceOpen); - const clearPending = useUIStore((s) => s.setActivityPanelId); - - const [selectedId, setSelectedId] = useState(pendingId); - const [highlightedId, setHighlightedId] = useState(pendingId); - - useEffect(() => { - if (!pendingId) return; - // Preserve an open same-agent conversation or New Mission composer unless - // this is an explicit, force-open navigation. - const next = resolvePendingActivitySelection({ - pendingActivityId: pendingId, - forceOpen: pendingForceOpen, - selectedId, - missionPanelOpen, - }); - if (next) setSelectedId(next); - clearPending(null); - }, [pendingId, pendingForceOpen, clearPending, selectedId, missionPanelOpen]); - - const newMission = useAgentNewMission({ agentDef, selectedId }); - const data = useAgentBoardData({ - agent, - agentDef, - selectedId, - setSelectedId, - }); - const send = useAgentBoardSend({ - agent, - agentDef, - rawItems: data.rawItems, - pendingAgentMode: newMission.pendingAgentMode, - setPendingAgentMode: newMission.setPendingAgentMode, - }); - const selection = useAgentBoardSelection(path); - - // Attribution + person-scope narrowing (hosted Teams only; off-multiplayer - // identity pass-through), on the active cards BEFORE text search, as the cross - // board. The scope is chosen in the agent header (AgentPersonScopeMenu) and - // read here through the shared per-agent scope context. - const personFilteredItems = useAgentBoardScope({ path, items: data.items }); - - const selectedSessionKey = useMemo(() => { - if (!selectedId) return null; - const item = (data.rawItems ?? []).find((a) => a.id === selectedId); - return item?.session_key ?? `activity-${selectedId}`; - }, [selectedId, data.rawItems]); - - const handleMissionSearchError = useCallback(() => { - addToast({ - title: t("board:search.historyErrorTitle"), - description: t("board:search.historyErrorDescription"), - variant: "error", - }); - }, [addToast, t]); - const missionSearch = useMissionSearch({ - items: personFilteredItems, - query: missionSearchQuery, - loadHistory: data.loadHistory, - onHistoryLoadError: handleMissionSearchError, - }); - useEffect(() => { - setAgentMissionSearchLoading(path, missionSearch.isSearchingText); - return () => setAgentMissionSearchLoading(path, false); - }, [missionSearch.isSearchingText, path, setAgentMissionSearchLoading]); - - const emptyState = missionSearch.hasQuery ? ( - setAgentMissionSearchQuery(path, "")} - /> - ) : undefined; - - const cardAvatar = useMemo( - () => , - [agent.color], - ); - - return { - variant: "agent", - items: missionSearch.items, - // Bulk section actions (archive-all / select-all) act within the current - // person filter, matching the cross-agent board. - allItems: personFilteredItems, - feedItems: data.feedItems, - loading: send.effectiveLoading, - isLoaded: data.rawItems !== undefined, - selectedId, - setSelectedId, - highlightedId, - setHighlightedId, - activeAgent: agent, - activeAgentDef: agentDef, - draftScope: agent.id, - selectedSessionKey, - selectedAgentPath: path, - onSelectSession: setSelectedId, - sessionKeyFor: data.sessionKeyFor, - onDelete: data.handleDelete, - onApprove: data.handleApprove, - onArchive: data.handleArchive, - onRename: data.onRename, - loadHistory: data.loadHistory, - onLoadOlderMessages: data.onLoadOlderMessages, - hasOlderMessages: data.hasOlderMessages, - sendMessageNow: send.sendMessageNow, - createConversation: send.createConversation, - stopSession: send.stopSession, - onItemMove: data.handleItemMove, - canDropItem: data.canDropItem, - selection, - registerOpener: newMission.registerOpener, - openerReady: newMission.openerReady, - openNewMission: newMission.openDefaultMission, - onAutoOpenEmpty: newMission.onAutoOpenEmpty, - autoOpenKey: path, - autoOpenItemCount: data.rawItems?.length ?? 0, - autoOpenBlocked: selectedId != null, - hasSearchQuery: missionSearch.hasQuery, - emptyState, - panelAgentName: agent.name, - selectedRunning: (data.rawItems ?? []).some( - (a) => a.id === selectedId && a.status === "running", - ), - cardAvatar, - }; -} diff --git a/app/src/components/board/use-agent-new-mission.ts b/app/src/components/board/use-agent-new-mission.ts deleted file mode 100644 index 0bb4f78eb..000000000 --- a/app/src/components/board/use-agent-new-mission.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { NewPanelOpener } from "@houston-ai/board"; -import { useCallback, useEffect, useRef, useState } from "react"; -import type { AgentDefinition } from "../../lib/types"; -import { useUIStore } from "../../stores/ui"; - -/** - * The per-agent "New mission" flow: a default-mode opener (registered as the - * tab bar's New Mission button) plus extra buttons for the agent's additional - * modes. Owns the pending agent mode so the create path knows which prompt - * profile to run, and clears it when the panel closes unselected. - */ -export function useAgentNewMission({ - agentDef, - selectedId, -}: { - agentDef: AgentDefinition; - selectedId: string | null; -}) { - const setOnStartMission = useUIStore((s) => s.setOnStartMission); - const setBoardActions = useUIStore((s) => s.setBoardActions); - const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); - const agentModes = agentDef.config.agents; - - const [pendingAgentMode, setPendingAgentMode] = useState(null); - const [openerReady, setOpenerReady] = useState(false); - const openerRef = useRef(null); - - const openDefaultMission = useCallback(() => { - if (agentModes?.length) setPendingAgentMode(agentModes[0].id); - openerRef.current?.({ focusComposer: true }); - }, [agentModes]); - - const registerOpener = useCallback( - (opener: NewPanelOpener) => { - openerRef.current = opener; - setOpenerReady(true); - // Default New Mission button — always registered. - setOnStartMission(openDefaultMission); - // Extra board buttons for additional agent modes (skip the first — it's - // the default New Mission button). - if (agentModes && agentModes.length > 1) { - setBoardActions( - agentModes.slice(1).map((mode) => ({ - id: mode.id, - label: mode.createLabel, - onClick: () => { - setPendingAgentMode(mode.id); - opener({ focusComposer: true }); - }, - })), - ); - } - }, - [setOnStartMission, setBoardActions, agentModes, openDefaultMission], - ); - - const onAutoOpenEmpty = useCallback(() => { - if (agentModes?.length) setPendingAgentMode(agentModes[0].id); - openerRef.current?.(); - }, [agentModes]); - - useEffect( - () => () => { - setOnStartMission(null); - setBoardActions([]); - }, - [setOnStartMission, setBoardActions], - ); - // Reset the pending mode when the panel closes without a selection so the - // next new-mission panel doesn't inherit the previous mode. - useEffect(() => { - if (!missionPanelOpen && !selectedId) setPendingAgentMode(null); - }, [missionPanelOpen, selectedId]); - - return { - pendingAgentMode, - setPendingAgentMode, - openerReady, - openDefaultMission, - registerOpener, - onAutoOpenEmpty, - }; -} diff --git a/app/src/components/board/use-board-keyboard.ts b/app/src/components/board/use-board-keyboard.ts index 3ba242a05..c2c92eb07 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`: a mission board can be + * mounted while hidden (the kept-alive team screen), 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-board-surface-on-nav.ts b/app/src/components/board/use-board-surface-on-nav.ts new file mode 100644 index 000000000..d580af786 --- /dev/null +++ b/app/src/components/board/use-board-surface-on-nav.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { + type BoardSurface, + pendingMissionSurface, + type SurfaceRow, + surfaceOnActivate, +} from "../../lib/board-surface-nav"; +import { useUIStore } from "../../stores/ui"; +import { useIsActiveView } from "../shell/keep-alive-views"; + +/** + * Put the right SURFACE of a mission board on screen — its active board or its + * archive — mounted by the owner of the two (Mission Control, a team's Mission + * Control section), the one component that survives the swap. + * + * It answers two questions, and they are one mechanism because both are + * "which surface should be showing right now?": + * + * 1. **A navigation arrived.** Every "open this mission" handoff publishes its + * target as `activityPanelId` and lets the board on the glass consume it. + * The target's surface is decided from the RAW sweep rows + * ({@link pendingMissionSurface}) — never from a board's own items, which + * each hold half the workspace — and that surface is swapped in so the + * mission's owner can claim it. An @mention on an archived mission used to + * force the ACTIVE board on screen and open a null session behind a dead + * composer. + * 2. **The board came back on the glass.** A kept-alive board the user left + * while looking at the archive is still mounted, archive and all. A genuine + * `viewMode` / team change therefore has to reset it + * ({@link surfaceOnActivate}) — the archive is somewhere you go, never + * somewhere you are returned to. Only the false→true edge of "am I visible" + * does this, so toggling the archive from the toolbar (Archived / Back), + * which never leaves the glass, is completely unaffected. + * + * `show` should be stable; asking for the surface already up is a no-op. + */ +export function useBoardSurfaceOnNav({ + rows, + show, +}: { + /** Raw sweep rows — the ONE place a target's surface is decided. */ + rows: readonly SurfaceRow[] | undefined; + show: (surface: BoardSurface) => void; +}): void { + const isActiveScreen = useIsActiveView(); + const pendingId = useUIStore((s) => s.activityPanelId); + const surface = pendingMissionSurface(rows, pendingId); + // Where the board was last time this ran, so an ARRIVAL (rule 2) is told + // apart from a nav landing on a board that was already up (rule 1). + const wasActiveScreen = useRef(isActiveScreen); + + useEffect(() => { + const cameBack = isActiveScreen && !wasActiveScreen.current; + wasActiveScreen.current = isActiveScreen; + if (!isActiveScreen) return; + if (cameBack) { + show(surfaceOnActivate(surface)); + return; + } + if (surface) show(surface); + }, [isActiveScreen, surface, show]); +} diff --git a/app/src/components/board/use-cross-agent-selection.ts b/app/src/components/board/use-cross-agent-selection.ts index c822eed28..ba5faf8a4 100644 --- a/app/src/components/board/use-cross-agent-selection.ts +++ b/app/src/components/board/use-cross-agent-selection.ts @@ -15,7 +15,7 @@ import { useSelectionSet } from "./use-selection-set"; * `bulkDelete` call is scoped to a single agent. So a bulk action groups the * selection by owning agent ({@link groupIdsByAgent}) and fans out one call * per agent, then refreshes both the flattened cross-agent query and each - * touched agent's per-agent activity query (so the board tab stays in sync). + * touched agent's per-agent activity query (so every board stays in sync). * Failures propagate so the caller surfaces a toast (no silent swallow). */ export function useCrossAgentSelection({ diff --git a/app/src/components/board/use-just-created-mission.ts b/app/src/components/board/use-just-created-mission.ts new file mode 100644 index 000000000..4fa06c54b --- /dev/null +++ b/app/src/components/board/use-just-created-mission.ts @@ -0,0 +1,56 @@ +import type { KanbanItem } from "@houston-ai/board"; +import { useCallback, useEffect, useState } from "react"; +import { + type CreatedMission, + readCreatedMission, + subscribeCreatedMission, +} from "../../lib/created-mission-handoff"; + +/** + * The mission just CREATED, until the cross-agent sweep returns it. + * + * The create hands back an id and the board selects it on the spot, but the + * sweep is a beat behind — and against a cold agent, seconds behind. With no + * row to read, the open panel loses its session key and its agent path, and the + * user's first message disappears from under them. The per-agent board carried + * this fallback built in (its `sessionKeyFor` derived `activity-` for an + * unknown id); that board is gone, so it lives here. + * + * Two creates reach it. The board's own composer calls `remember` directly. A + * mission created OUTSIDE the board — the agent's self-setup mission, fired by + * the create dialog — publishes to `lib/created-mission-handoff.ts` before it + * opens the panel, and this hook adopts it: on the publish if the board is + * already mounted, else on the mount that follows it. Every kept-alive board + * adopts the same offer (see that module on why it is not one-shot); only the + * one whose selection IS that mission ever reads the fallback back out. + * + * The fallback is dropped the moment the real row lands: from then on the row + * is the truth, because it carries the status the turn stream writes. + */ +export function useJustCreatedMission(items: KanbanItem[]) { + const [created, setCreated] = useState(null); + + useEffect(() => { + const adopt = () => { + const published = readCreatedMission(); + if (published) setCreated(published); + }; + adopt(); + return subscribeCreatedMission(adopt); + }, []); + + useEffect(() => { + if (created && items.some((i) => i.id === created.activityId)) { + setCreated(null); + } + }, [created, items]); + + /** The fallback for `selectedId`, or null when the row is (or was) real. */ + const fallbackFor = useCallback( + (selectedId: string | null): CreatedMission | null => + created && created.activityId === selectedId ? created : null, + [created], + ); + + return { remember: setCreated, fallbackFor }; +} diff --git a/app/src/components/board/use-mc-actions.ts b/app/src/components/board/use-mc-actions.ts index acece34fe..d2318f855 100644 --- a/app/src/components/board/use-mc-actions.ts +++ b/app/src/components/board/use-mc-actions.ts @@ -6,7 +6,7 @@ import { armMissionDoneCelebration } from "../../lib/mission-done-celebration"; import { canDropMission } from "../../lib/mission-selection"; import { queryKeys } from "../../lib/query-keys"; import { tauriActivity, tauriChat } from "../../lib/tauri"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; import { missionColumnIdForStatus } from "../mission-board-columns"; import { planNewMission } from "../mission-control-create"; @@ -19,20 +19,18 @@ import type { SendOverrides } from "./board-source"; /** * Mission Control's card/composer actions, routed to the right agent. Create - * resolves the active agent's default mode via {@link planNewMission}; send - * delegates to `mc.handleSendMessage` (which re-resolves provider/model from - * the target activity, so composer overrides are intentionally ignored); stop - * resolves its agent from the session metadata. + * resolves the target agent via {@link planNewMission}; send delegates to + * `mc.handleSendMessage` (which re-resolves provider/model from the target + * activity, so composer overrides are intentionally ignored); stop resolves + * its agent from the session metadata. */ export function useMcActions({ mc, activeAgent, - activeAgentDef, paths, }: { mc: ReturnType; activeAgent: Agent | null; - activeAgentDef: AgentDefinition | null; /** Every agent path on the view, for query invalidation after a drag-move. */ paths: string[]; }) { @@ -50,7 +48,6 @@ export function useMcActions({ }: { text: string; files: File[] } & SendOverrides) => { const plan = planNewMission({ activeAgent, - activeAgentDef, providerOverride, modelOverride, }); @@ -62,14 +59,12 @@ export function useMcActions({ throw new Error("New mission submitted with no active agent"); } return mc.handleCreateConversation(plan.agent, text, files, { - agentMode: plan.agentMode, - promptFile: plan.promptFile, providerOverride: plan.providerOverride, modelOverride: plan.modelOverride, mentions, }); }, - [activeAgent, activeAgentDef, mc.handleCreateConversation, addToast, t], + [activeAgent, mc.handleCreateConversation, addToast, t], ); // Cross-agent: ignore the composer's provider/model overrides, re-resolve diff --git a/app/src/components/board/use-mc-new-mission.tsx b/app/src/components/board/use-mc-new-mission.tsx index 19d4e6c9e..d0d50e7fb 100644 --- a/app/src/components/board/use-mc-new-mission.tsx +++ b/app/src/components/board/use-mc-new-mission.tsx @@ -3,38 +3,91 @@ 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"; +import { newMissionTarget } from "./new-mission-target"; /** - * Mission Control's "New mission" flow. Because the view is cross-agent, the - * button first asks "which agent?" via {@link AgentPickerDialog}; picking one - * scopes the right panel to that agent (`pendingAgent`) and opens the empty - * composer. The pending agent clears when the panel closes unselected. + * A board's "New task" flow. + * + * The board is cross-agent, so the act needs an owner — but it only ASKS when + * there is a real choice (`newMissionTarget`). A board pinned to one agent, or + * a team holding one, opens that agent's composer straight away; anything else + * opens a MENU hung off the button. The modal picker no longer serves this + * path at all: it stopped the world for a three-item choice and covered the + * rail the user was reading those names from. + * + * {@link AgentPickerDialog} survives for the other path — an EMPTY board + * auto-opening its composer, where there is no button to hang a menu on. + * + * Picking scopes the right panel to that agent (`pendingAgent`) and opens the + * empty composer; the pending agent clears when the panel closes unselected. */ export function useMcNewMission({ agents, visibleAgents, + scopedAgents, + pinnedAgent, selectedId, setSelectedId, }: { agents: Agent[]; /** Agents in scope of the current filter (drives the empty auto-open). */ visibleAgents: Agent[]; + /** The board's own agents — the menu's roster. */ + scopedAgents: Agent[]; + /** The agent the board is narrowed to, if any. */ + pinnedAgent: Agent | null; selectedId: string | null; setSelectedId: (id: string | null) => void; }) { const setOnStartMission = useUIStore((s) => s.setOnStartMission); const missionPanelOpen = useUIStore((s) => s.missionPanelOpen); + const isActive = useIsActiveView(); const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [pendingAgent, setPendingAgent] = useState(null); const [openerReady, setOpenerReady] = useState(false); const openerRef = useRef(null); - const openNewMission = useCallback(() => setAgentPickerOpen(true), []); + const handlePickAgentRef = useRef< + (agent: Agent, options?: { focusComposer?: boolean }) => void + >(() => {}); + /** + * The button's whole behaviour, and the keyboard shortcut's: a request to + * OPEN the menu that answers itself directly when there is nothing to ask. + * Radix drives this from the trigger's own click, so a direct target never + * opens a menu for a frame before closing it. + */ + const requestNewMission = useCallback( + (open: boolean) => { + if (!open) { + setMenuOpen(false); + return; + } + const target = newMissionTarget(pinnedAgent, scopedAgents); + if (target.kind === "direct") { + handlePickAgentRef.current(target.agent); + return; + } + setMenuOpen(true); + }, + [pinnedAgent, scopedAgents], + ); + const openNewMission = useCallback( + () => requestNewMission(true), + [requestNewMission], + ); + // 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 }) => { @@ -44,6 +97,7 @@ export function useMcNewMission({ }, [setSelectedId], ); + handlePickAgentRef.current = handlePickAgent; const registerOpener = useCallback((opener: NewPanelOpener) => { openerRef.current = opener; setOpenerReady(true); @@ -71,6 +125,11 @@ export function useMcNewMission({ return { pendingAgent, openNewMission, + /** The board's agents, for the button's own menu. */ + newMissionAgents: scopedAgents, + menuOpen, + requestNewMission, + pickNewMissionAgent: handlePickAgent, registerOpener, openerReady, onAutoOpenEmpty, diff --git a/app/src/components/board/use-mc-open-conversation.ts b/app/src/components/board/use-mc-open-conversation.ts new file mode 100644 index 000000000..4945ccd70 --- /dev/null +++ b/app/src/components/board/use-mc-open-conversation.ts @@ -0,0 +1,66 @@ +import type { KanbanItem } from "@houston-ai/board"; +import type { FeedItem } from "@houston-ai/chat"; +import { useCallback, useMemo } from "react"; +import { useConversationVm } from "../../hooks/use-conversation-vm"; +import { tauriChat } from "../../lib/tauri"; +import { useJustCreatedMission } from "./use-just-created-mission"; + +/** + * WHICH conversation the cross-agent board has open, and its live feed. + * + * Two facts name it — the session key and the agent path — and on a + * cross-agent board neither is implicit: the selected CARD carries both in its + * metadata. The one case where the card does not exist yet is a mission created + * on this board, whose row the sweep has not returned; `useJustCreatedMission` + * holds its identity for exactly that beat, so the panel that just opened never + * loses the user's first message. + * + * `AIBoard` only ever reads `feedItems[activeSessionKey]`, so the single-entry + * map is the whole contract. + */ +export function useMcOpenConversation( + items: KanbanItem[], + selectedId: string | null, +) { + const selectedItem = useMemo( + () => items.find((i) => i.id === selectedId) ?? null, + [items, selectedId], + ); + const justCreated = useJustCreatedMission(items); + const created = justCreated.fallbackFor(selectedId); + + const activeSessionKey = selectedItem + ? ((selectedItem.metadata?.sessionKey as string | undefined) ?? + `activity-${selectedItem.id}`) + : (created?.sessionKey ?? null); + const activeAgentPath = + (selectedItem?.metadata?.agentPath as string | undefined) ?? + created?.agentPath ?? + null; + + const activeVm = useConversationVm(activeAgentPath, activeSessionKey); + const feedItems = useMemo>( + () => + activeSessionKey ? { [activeSessionKey]: activeVm?.feed ?? [] } : {}, + [activeSessionKey, activeVm], + ); + // Scroll-up lazy-load (HOU-819): the open chat renders the transcript's tail + // window; older pages prepend on scroll. + const hasOlderMessages = (activeVm?.historyWindow?.earliestLoaded ?? 0) > 0; + const onLoadOlderMessages = useCallback(async () => { + if (!activeAgentPath || !activeSessionKey) return; + await tauriChat.loadOlderHistory(activeAgentPath, activeSessionKey); + }, [activeAgentPath, activeSessionKey]); + + return { + selectedItem, + activeSessionKey, + activeAgentPath, + activeVm, + feedItems, + hasOlderMessages, + onLoadOlderMessages, + /** Hold a just-created mission's identity until its row lands. */ + rememberCreated: justCreated.remember, + }; +} 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..46e3440c1 --- /dev/null +++ b/app/src/components/board/use-mc-scope.ts @@ -0,0 +1,106 @@ +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. Every board on screen is a team's, + * so every live caller passes one; omitting it leaves an unnarrowed board over + * the whole roster, which nothing renders today. + * + * It carries no TITLE: the team screen's row 1 names the team above every one + * of its sections, so a board that named itself again would print the same + * words twice on one screen. + */ +export interface MissionControlScope { + /** Restrict the board to these agent folder paths (one team's agents). */ + scopePaths?: 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-mention-inbox.ts b/app/src/components/board/use-mention-inbox.ts new file mode 100644 index 000000000..42250f79d --- /dev/null +++ b/app/src/components/board/use-mention-inbox.ts @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { useAllConversations } from "../../hooks/queries"; +import { useCapabilities } from "../../hooks/use-capabilities"; +import { useReadCursorStore } from "../../hooks/use-read-cursors"; +import { useSession } from "../../hooks/use-session"; +import { isMultiplayer } from "../../lib/org-roles"; +import type { Agent } from "../../lib/types"; +import { + buildMentionInbox, + type MentionInboxConversation, +} from "./mentions-inbox-model"; +import { agentsByPath } from "./mission-card-agent"; + +/** + * Everything the Inbox screen, its rail badge AND the notifications bell read, + * in one hook, so the number on the row, the number on the bell and the rows + * behind both are derived from one list. Every consumer shares the one + * `useAllConversations` query, so mounting this three times costs no extra + * fetch (*the one-sweep rule*, `teams-ui.md`). + * + * **A MENTION IS A TEAM FACT, and the gate lives HERE.** Off multiplayer there + * is nobody to be mentioned BY, so the answer is empty: no rows, no count, on + * every surface at once. It was previously each consumer's job (the bell passed + * an empty roster), and the moment a third consumer arrived it drifted — the + * rail badge lit on single-player desktop over an aggregate on disk that no + * screen would show. One gate, one place, no consumer able to get it wrong. + * The Inbox SCREEN stays reachable for everyone regardless (it is the app's + * landing surface when no team has resolved); single player simply finds its + * honest empty state. + * + * `mentionCount` counts `mentionOutstanding`, NOT the broader row `unread` + * flag: the badge and the bell say "N unread mentions" out loud, so counting a + * mission that merely moved would announce teammates typing my name when nobody + * did. The rows keep their broader dot, which claims only "something new here" + * (see `mentions-inbox-model.ts` for the two claims). Nothing renders the + * row-level unread total today, so the hook does not compute one. + */ +export function useMentionInbox(agents: Agent[]) { + const { data: session } = useSession(); + const { capabilities } = useCapabilities(); + const mentionsExist = isMultiplayer(capabilities); + const selfId = session?.uid ?? null; + const cursors = useReadCursorStore(); + // An empty roster off multiplayer: no paths, so the sweep this hook would + // otherwise join is never even asked for on a deployment with no mentions. + const paths = useMemo( + () => (mentionsExist ? agents.map((a) => a.folderPath) : []), + [agents, mentionsExist], + ); + const { data, isPending } = useAllConversations(paths); + const conversations: MentionInboxConversation[] = useMemo( + () => data ?? [], + [data], + ); + const rows = useMemo( + () => + buildMentionInbox(conversations, cursors, selfId, agentsByPath(agents)), + [conversations, cursors, selfId, agents], + ); + const mentionCount = useMemo( + () => rows.reduce((n, row) => n + (row.mentionOutstanding ? 1 : 0), 0), + [rows], + ); + return { rows, conversations, mentionCount, isPending }; +} 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..31b4e7ec4 --- /dev/null +++ b/app/src/components/board/use-mission-control-archived-panel.ts @@ -0,0 +1,73 @@ +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 back to this screen'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, + /** Swap the archive out for the board it belongs to — the global one, or the + * team's. The re-activated mission lands there, and only the active board + * consumes the published target. */ + onShowActive: () => void, +) { + const { selectedItem, activeAgent } = 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`). It only makes the + // agent CURRENT — the destination is this screen's own board. + const focusMissionAgent = useLatchedMissionAgent( + data.selectedId, + activeAgent, + ); + const focusBoard = useCallback(() => { + focusMissionAgent(); + onShowActive(); + }, [focusMissionAgent, onShowActive]); + const { handoff, onSendReactivated } = useArchivedHandoff({ + missionId: data.selectedId, + onReactivated: clearSelection, + focusBoard, + }); + + const panel = useAgentChatPanel({ + agent: activeAgent, + 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, + selectedItem, + providerOverride: panel.effectiveProvider, + modelOverride: panel.effectiveModel, + onHandoff: handoff, + }); + + return { panel, attachmentValidation, openHref, onSendMessage }; +} diff --git a/app/src/components/board/use-mission-control-archived-send.ts b/app/src/components/board/use-mission-control-archived-send.ts index a234d5644..a3d944fe7 100644 --- a/app/src/components/board/use-mission-control-archived-send.ts +++ b/app/src/components/board/use-mission-control-archived-send.ts @@ -8,7 +8,7 @@ import { classifyFileKind } from "../../lib/file-kind"; import { perfSpans } from "../../lib/perf-spans"; import { tauriAttachments, tauriChat } from "../../lib/tauri"; import { DEFAULT_TURN_MODE } from "../../lib/turn-mode"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; import { useUIStore } from "../../stores/ui"; /** @@ -20,14 +20,12 @@ import { useUIStore } from "../../stores/ui"; */ export function useMissionControlArchivedSend({ activeAgent, - activeAgentDef, selectedItem, providerOverride, modelOverride, onHandoff, }: { activeAgent: Agent | null; - activeAgentDef: AgentDefinition | null; selectedItem: KanbanItem | null; providerOverride: string; modelOverride: string; @@ -48,9 +46,6 @@ export function useMissionControlArchivedSend({ if (!activeAgent || !selectedItem) return; const agentPath = activeAgent.folderPath; const missionId = selectedItem.id; - const mode = activeAgentDef?.config.agents?.find( - (m) => m.id === (selectedItem.metadata?.agent as string | undefined), - ); try { const paths = await tauriAttachments.save( `activity-${missionId}`, @@ -60,7 +55,6 @@ export function useMissionControlArchivedSend({ // The turn stream pushes the user bubble into the conversation VM // itself — no app-side optimistic push. await tauriChat.send(agentPath, prompt, sessionKey, { - mode: mode?.promptFile, providerOverride, modelOverride, modeOverride: DEFAULT_TURN_MODE, @@ -87,7 +81,6 @@ export function useMissionControlArchivedSend({ }, [ activeAgent, - activeAgentDef, selectedItem, providerOverride, modelOverride, diff --git a/app/src/components/board/use-mission-control-archived.ts b/app/src/components/board/use-mission-control-archived.ts index 4d4de530a..f814a3cb9 100644 --- a/app/src/components/board/use-mission-control-archived.ts +++ b/app/src/components/board/use-mission-control-archived.ts @@ -7,14 +7,15 @@ import { useAllConversations } from "../../hooks/queries"; import { useOpenConversationFeed } from "../../hooks/use-open-conversation-feed"; import { isSetupChatMode } from "../../lib/integration-chat-setup"; import { missionCardTags } from "../../lib/mission-card"; +import { ARCHIVED_STATUS } from "../../lib/mission-selection"; import { type HistoryLoadOptions, tauriActivity, tauriChat, } from "../../lib/tauri"; import type { Agent } from "../../lib/types"; -import { useAgentCatalogStore } from "../../stores/agent-catalog"; import { AgentCardAvatar } from "../shell/agent-card-avatar"; +import { agentsByPath, missionCardAgentName } from "./mission-card-agent"; /** * Cross-agent archived data: every agent's *archived* missions on one list, @@ -25,16 +26,15 @@ import { AgentCardAvatar } from "../shell/agent-card-avatar"; */ export function useMissionControlArchived(agents: Agent[]) { const { t } = useTranslation(["board"]); - const getAgentDef = useAgentCatalogStore((s) => s.getById); const agentPaths = useMemo(() => agents.map((a) => a.folderPath), [agents]); const { data: convos } = useAllConversations(agentPaths); - const agentColorMap = useMemo(() => { - const m: Record = {}; - for (const a of agents) m[a.folderPath] = a.color; - return m; - }, [agents]); + // ONE roster lookup behind both halves of a card's identity. The colour was + // always taken from here; the NAME used to come off the swept row, which the + // web adapter stamps from a registry that does not know the host's agents — + // so every card read "Houston". See `mission-card-agent.ts`. + const agentsByFolderPath = useMemo(() => agentsByPath(agents), [agents]); const agentMap = useMemo(() => { const m: Record = {}; for (const a of agents) m[a.folderPath] = a; @@ -55,17 +55,15 @@ export function useMissionControlArchived(agents: Agent[]) { { agentPath: string; activityId: string } > = {}; const result = convos + // The mirror of the active board's filter: archived missions only, and + // never a guided-setup chat (it was never a mission the user managed). .filter( (c) => c.type === "activity" && - c.status === "archived" && + c.status === ARCHIVED_STATUS && !isSetupChatMode(c.agent), ) .map((c) => { - const agent = agentMap[c.agent_path]; - const agentModes = agent - ? getAgentDef(agent.configId)?.config.agents - : undefined; map[c.id] = c.agent_path; sessionMap[c.session_key] = { agentPath: c.agent_path, @@ -77,15 +75,17 @@ export function useMissionControlArchived(agents: Agent[]) { // Decode a Skill / attachment first-message marker to the user's // words; never echo the raw `` on the card (HOU-425). description: messagePreviewText(c.description), - group: c.agent_name, + group: missionCardAgentName( + agentsByFolderPath, + c.agent_path, + c.agent_name, + ), icon: createElement(AgentCardAvatar, { - color: agentColorMap[c.agent_path], + color: agentsByFolderPath.get(c.agent_path)?.color, }), status: c.status ?? "archived", updatedAt: c.updated_at ?? new Date().toISOString(), tags: missionCardTags({ - agent: c.agent, - agentModes, routineId: c.routine_id, routineLabel: t("board:tags.routine"), originSessionKey: c.origin_session_key, @@ -102,7 +102,7 @@ export function useMissionControlArchived(agents: Agent[]) { pathMapRef.current = map; sessionMapRef.current = sessionMap; return result; - }, [convos, agentColorMap, agentMap, getAgentDef, t]); + }, [convos, agentsByFolderPath, t]); const sessionKeyFor = useCallback( (activityId: string) => { @@ -116,7 +116,7 @@ export function useMissionControlArchived(agents: Agent[]) { ); // The open conversation's reactive feed, plus its scroll-up lazy-load — the - // same seam the per-agent Archived tab uses. + // same seam the active board uses. const activeSessionKey = selectedId ? sessionKeyFor(selectedId) : null; const activeAgentPath = activeSessionKey ? (sessionMapRef.current[activeSessionKey]?.agentPath ?? null) @@ -154,25 +154,25 @@ export function useMissionControlArchived(agents: Agent[]) { // The open mission resolved all the way through to the agent that owns it: // the chat panel, the send path and the archived → active handoff all need - // the same four values, so they are derived ONCE here rather than in the view. + // the same values, so they are derived ONCE here rather than in the view. const selectedItem = selectedId ? (items.find((i) => i.id === selectedId) ?? null) : null; const activeAgent = selectedItem ? (agentMap[selectedItem.metadata?.agentPath as string] ?? null) : null; - const activeAgentDef = activeAgent - ? (getAgentDef(activeAgent.configId) ?? null) - : null; return { items, + /** The swept rows BEFORE the archived filter — the one view of the + * workspace where active and archived missions coexist, and so the only + * honest input to the surface decision (`lib/board-surface-nav.ts`). */ + rawConversations: convos, feedItems, selectedId, setSelectedId, selectedItem, activeAgent, - activeAgentDef, selectedSessionKey: activeSessionKey, sessionKeyFor, loadHistory, diff --git a/app/src/components/board/use-mission-control-source.tsx b/app/src/components/board/use-mission-control-source.tsx index 3d5c09bac..ba07b9fdb 100644 --- a/app/src/components/board/use-mission-control-source.tsx +++ b/app/src/components/board/use-mission-control-source.tsx @@ -1,53 +1,71 @@ import { useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { pendingMissionSurface } from "../../lib/board-surface-nav"; 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 { PageHeaderTools } from "../shell/page-header/page-header-tools"; import { useMissionControl } from "../use-mission-control"; -import { useMissionSearch } from "../use-mission-search"; import type { BoardSource } from "./board-source"; +import { + filteredScopeAgent, + 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"; +import { usePendingMissionTarget } from "./use-pending-mission-target"; /** * Builds the {@link BoardSource} for cross-agent Mission Control: every * agent's missions on one board, a "which agent?" picker before a new - * mission, an agent filter + search toolbar, and bulk actions routed per + * mission, a search + person filter 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}). 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(""); + // Every "open this mission" navigation lands on ONE of the board's two + // surfaces (notification, @mention row, palette, archived handoff). This is + // the ACTIVE one, so it claims only the targets the raw sweep rows say are + // active — an archived mission's id is left published for the archive, which + // the owner's surface router is about to swap in. + const pendingId = useUIStore((s) => s.activityPanelId); + const pendingSurface = pendingMissionSurface(mc.rawConversations, pendingId); + usePendingMissionTarget({ + surface: "active", + pendingSurface, + selectedId: mc.selectedId, + setSelectedId: mc.setSelectedId, + missionPanelOpen, + }); + 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], - ); + // No `setFilterPath` any more: the scope is a breadcrumb in row 1 of the + // team strip, which reads and writes the same `teamAgentFilter` pin + // directly. `filterPath` is still READ, for the auto-open key. The scope's + // `onFilterPathChange` still has to exist, because its presence is what + // makes `filterPath` CONTROLLED rather than local to this hook. + const { scopedAgents, paths, agentFilteredItems, visibleAgents, filterPath } = + 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 +79,23 @@ 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, - }); + // The agent the board is NARROWED to, if any: "New task" on a pinned board + // must not ask a question the board already answered. + const pinnedAgent = filteredScopeAgent(scopedAgents, filterPath); const newMission = useMcNewMission({ - agents, + agents: scopedAgents, visibleAgents, + scopedAgents, + pinnedAgent, 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) @@ -98,17 +107,13 @@ export function useMissionControlSource( } return newMission.pendingAgent; }, [selectedItem, newMission.pendingAgent, agents]); - const activeAgentDef = activeAgent - ? (getAgentDef(activeAgent.configId) ?? null) - : null; - const selectedSessionKey = selectedItem - ? ((selectedItem.metadata?.sessionKey as string | undefined) ?? - `activity-${selectedItem.id}`) - : null; - const selectedAgentPath = - (selectedItem?.metadata?.agentPath as string | undefined) ?? null; + // Straight from the data hook, so the chat panel and the feed always name + // the same conversation — including the beat after a create, before the + // cross-agent sweep has returned the new mission's row. + const selectedSessionKey = mc.activeSessionKey; + const selectedAgentPath = mc.activeAgentPath; - const actions = useMcActions({ mc, activeAgent, activeAgentDef, paths }); + const actions = useMcActions({ mc, activeAgent, paths }); const agentPathForId = useCallback( (id: string) => @@ -122,46 +127,32 @@ export function useMissionControlSource( agentPathForId, }); - const emptyState = missionSearch.hasQuery ? ( - setMissionSearchQuery("")} - /> - ) : undefined; - const toolbar = ( - + // One row or two is the STRIP's call, not this hook's: it is the only + // thing that knows how much room the three zones actually have. + + {(oneRow) => ( + + )} + ); return { - variant: "mission-control", items: missionSearch.items, allItems: personFilteredItems, feedItems: mc.feedItems, @@ -172,8 +163,7 @@ export function useMissionControlSource( highlightedId, setHighlightedId, activeAgent, - activeAgentDef, - draftScope: "mission-control", + draftScope: missionControlDraftScope(scope?.teamId), selectedSessionKey, selectedAgentPath, onSelectSession: mc.setSelectedId, @@ -199,7 +189,7 @@ export function useMissionControlSource( autoOpenItemCount: personFilteredItems.length, autoOpenBlocked: newMission.agentPickerOpen, hasSearchQuery: missionSearch.hasQuery, - emptyState, + emptyState: missionSearch.emptyState, panelAgentName: activeAgent?.name ?? selectedItem?.subtitle, selectedRunning: selectedItem?.status === "running", toolbar, diff --git a/app/src/components/board/use-pending-mission-target.ts b/app/src/components/board/use-pending-mission-target.ts new file mode 100644 index 000000000..71118bc80 --- /dev/null +++ b/app/src/components/board/use-pending-mission-target.ts @@ -0,0 +1,75 @@ +import { useEffect } from "react"; +import type { BoardSurface } from "../../lib/board-surface-nav"; +import { resolvePendingActivitySelection } from "../../lib/notification-nav"; +import { useUIStore } from "../../stores/ui"; +import { useIsActiveView } from "../shell/keep-alive-views"; + +/** + * The cross-agent nav handoff, consumed by the surface that OWNS the mission. + * + * Every "open this mission" navigation — a session-finished notification, a + * @mention row, the command palette's recent missions, the archived → active + * handoff — publishes its target as `activityPanelId` and lets a board open it. + * Two things decide who may: + * + * - **Only the screen on the glass.** Several kept-alive boards are mounted at + * once, and an unguarded consumer lets a hidden team's board eat the target + * and clear it. + * - **Only the surface the target belongs to.** A board is two surfaces that + * swap — the active one and the archive — and each holds half the workspace. + * `pendingSurface` names the target's own surface, decided once from the raw + * sweep rows (`pendingMissionSurface`); a target belonging to the OTHER + * surface is left completely alone, published and unconsumed, so the owner of + * the two can swap boards and the right one can claim it. + * + * That second guard has to live HERE rather than in the owner because of + * ordering: React runs child effects before parent effects, so this hook fires + * a full commit BEFORE the owner's surface router (`useBoardSurfaceOnNav`) gets + * a chance to route anything. Consuming first and asking later is what made an + * archived @mention open a blank dead chat — the active board swallowed the + * target and cleared it before the archive was ever asked for. + */ +export function usePendingMissionTarget({ + surface, + pendingSurface, + selectedId, + setSelectedId, + missionPanelOpen, +}: { + /** Which of the board's two surfaces is calling. */ + surface: BoardSurface; + /** The published target's own surface, or null when nothing is published. */ + pendingSurface: BoardSurface | null; + selectedId: string | null; + setSelectedId: (id: string) => void; + missionPanelOpen: boolean; +}): void { + const isActiveScreen = useIsActiveView(); + const pendingId = useUIStore((s) => s.activityPanelId); + const pendingForceOpen = useUIStore((s) => s.activityPanelForceOpen); + const clearPending = useUIStore((s) => s.setActivityPanelId); + useEffect(() => { + if (!isActiveScreen || !pendingId) return; + if (pendingSurface !== surface) return; + // A passive nav must not yank the user out of an open conversation or + // composer; an explicit one (a notification click) always may. + const next = resolvePendingActivitySelection({ + pendingActivityId: pendingId, + forceOpen: pendingForceOpen, + selectedId, + missionPanelOpen, + }); + if (next) setSelectedId(next); + clearPending(null); + }, [ + isActiveScreen, + pendingId, + pendingSurface, + surface, + pendingForceOpen, + selectedId, + setSelectedId, + missionPanelOpen, + clearPending, + ]); +} diff --git a/app/src/components/cards/report-bug-button.tsx b/app/src/components/cards/report-bug-button.tsx new file mode 100644 index 000000000..3f586c884 --- /dev/null +++ b/app/src/components/cards/report-bug-button.tsx @@ -0,0 +1,79 @@ +/** + * ReportBugButton — the ONE "tell us about this" pill. + * + * The repo's no-silent-failures policy asks every user-visible failure to carry + * a report path, and there can only be one of them: one payload shape (the + * command tag, the diagnostic, the log tail `reportBug` bundles), one pending + * look, one pair of result toasts. It started life inside the provider-error + * cards and moved here the moment a second surface needed it (the team + * sections' "we could not read these agents" strip), because a copied version + * is how one of them quietly stops sending logs. + * + * `command` is the flat, snake_case triage tag every report site uses — never + * composed per instance, or one issue fans out into unbounded Sentry groups. + */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { reportBug } from "../../lib/bug-report"; +import { getCurrentUserEmail } from "../../lib/current-user"; +import { logAndReportError } from "../../lib/error-report"; +import { useUIStore } from "../../stores/ui"; +import { useWorkspaceStore } from "../../stores/workspaces"; +import { RowCardButton } from "./row-card-button"; + +export function ReportBugButton({ + command, + details, + label, +}: { + command: string; + /** The diagnostic that goes in the report body. */ + details: string; + label: string; +}) { + const { t } = useTranslation(["shell"]); + const addToast = useUIStore((s) => s.addToast); + const workspaceName = useWorkspaceStore((s) => s.current?.name); + const [sending, setSending] = useState(false); + const send = async () => { + if (sending) return; + setSending(true); + try { + await reportBug({ + command, + error: details || "(no detail)", + timestamp: new Date().toISOString(), + appVersion: __APP_VERSION__, + userEmail: getCurrentUserEmail(), + workspaceName, + }); + addToast({ + title: t("shell:toolRuntimeError.reportSuccessTitle"), + description: t("shell:toolRuntimeError.reportSuccessDescription"), + variant: "success", + }); + } catch (err) { + // The surfaces own their copy, so this can't go through + // genericErrorDescription — but the reason still has to reach the log and + // Sentry: a bug report that fails to send is exactly the failure we would + // otherwise never hear about. + logAndReportError("report_bug", err); + addToast({ + title: t("shell:toolRuntimeError.reportErrorTitle"), + description: t("shell:toolRuntimeError.reportErrorDescription"), + variant: "error", + }); + } finally { + setSending(false); + } + }; + return ( + + ); +} diff --git a/app/src/components/chat-signin-interaction-card.tsx b/app/src/components/chat-signin-interaction-card.tsx index 82abd79a2..5ee83c1d1 100644 --- a/app/src/components/chat-signin-interaction-card.tsx +++ b/app/src/components/chat-signin-interaction-card.tsx @@ -39,7 +39,7 @@ interface ChatSigninInteractionCardProps extends StepChrome { * `ChatInteractionCard` sequence (its `renderSignin` prop). A tool call hit * `signin_required` (the desktop host has an integration registry but the user * is signed out), so the host queued this step; the Sign in button drives the - * SAME Google SSO the Integrations tab uses (via {@link useIntegrationsGate}), + * SAME Google SSO the Integrations page uses (via {@link useIntegrationsGate}), * and the sequence advances the instant the gate reports `ready`. * * Following the reference "Coworker card" language, it renders as its OWN @@ -58,7 +58,7 @@ interface ChatSigninInteractionCardProps extends StepChrome { * signing in is offered. * * Auto-advance also covers the STALE step: the user may have signed in elsewhere - * (the Integrations tab) between the turn ending and this card rendering, so the + * (the Integrations page) between the turn ending and this card rendering, so the * gate is ALREADY `ready` on first render with no button to click — fire * `onSignedIn` once so the queued connects/answers still send. */ diff --git a/app/src/components/command-palette-actions.tsx b/app/src/components/command-palette-actions.tsx new file mode 100644 index 000000000..4b230cdb0 --- /dev/null +++ b/app/src/components/command-palette-actions.tsx @@ -0,0 +1,82 @@ +/** + * The command palette's Actions group: the handful of destinations that are + * always reachable, whatever the workspace holds. Split out of + * `command-palette.tsx`, which keeps the dialog and the data-backed lists. + * + * Starting a mission is the one action with real routing behind it, so it + * arrives as a prop; the rest are plain store navigations and read the store + * here rather than travelling as five more props. + */ + +import { CommandGroup, CommandItem, CommandShortcut } from "@houston-ai/core"; +import { Inbox, Keyboard, Plus, Settings, Store } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { shortcutLabel } from "../lib/shortcuts"; +import { INBOX_VIEW_ID } from "../lib/top-level-views"; +import { useUIStore } from "../stores/ui"; +import { STORE_VIEW_ID } from "./store-view"; + +export function PaletteActions({ + onNewMission, + onClose, +}: { + onNewMission: () => void; + onClose: () => void; +}) { + const { t } = useTranslation("shell"); + const setViewMode = useUIStore((s) => s.setViewMode); + const openSettings = useUIStore((s) => s.openSettings); + const setCheatsheetOpen = useUIStore((s) => s.setCheatsheetOpen); + + return ( + + + + {t("palette.actions.newMission")} + {shortcutLabel("newMission")} + + { + setViewMode(INBOX_VIEW_ID); + onClose(); + }} + value="action inbox" + > + + {t("palette.actions.inbox")} + {shortcutLabel("inbox")} + + { + setViewMode(STORE_VIEW_ID); + onClose(); + }} + value="action agent-store" + > + + {t("sidebar.agentStore")} + + { + openSettings(null); + onClose(); + }} + value="action settings" + > + + {t("palette.actions.settings")} + + { + onClose(); + setTimeout(() => setCheatsheetOpen(true), 30); + }} + value="action shortcuts" + > + + {t("palette.actions.shortcuts")} + {shortcutLabel("cheatsheet")} + + + ); +} diff --git a/app/src/components/command-palette-lists.tsx b/app/src/components/command-palette-lists.tsx new file mode 100644 index 000000000..dfc692907 --- /dev/null +++ b/app/src/components/command-palette-lists.tsx @@ -0,0 +1,137 @@ +/** + * The command palette's "jump to a thing" groups: teams, agents, recent + * missions. Split out of `command-palette.tsx`, which keeps the dialog, the + * data it reads and the actions; these are pure lists over what it hands them. + * + * Each group renders NOTHING when it is empty (its separator included), so the + * palette never shows a heading over no rows. + */ + +import { + CommandGroup, + CommandItem, + CommandSeparator, + HoustonAvatar, + resolveAgentColor, +} from "@houston-ai/core"; +import { Users } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { RawConversation } from "../lib/tauri"; +import type { TeamView } from "../lib/teams-model"; +import type { Agent } from "../lib/types"; +import { missionCardAgentName } from "./board/mission-card-agent"; + +// 28px outer circle so the inner helmet (forced to 20px by cmdk's +// `[&_[cmdk-item]_svg]:h-5` rule) sits at roughly its native 65% +// proportion instead of overflowing the badge. +const AVATAR_PX = 28; + +function PaletteAvatar({ color }: { color?: string }) { + return ( + + ); +} + +/** + * The teams, each opening its Mission Control. Every mission board belongs to + * a team now, so this group is how the palette reaches a board at all. + */ +export function PaletteTeams({ + teams, + onSelect, +}: { + teams: TeamView[]; + onSelect: (teamId: string) => void; +}) { + const { t } = useTranslation("shell"); + if (teams.length === 0) return null; + return ( + <> + + + {teams.map((team) => ( + onSelect(team.id)} + > + + {team.name} + + ))} + + + ); +} + +export function PaletteAgents({ + agents, + onSelect, +}: { + agents: Agent[]; + onSelect: (agentId: string) => void; +}) { + const { t } = useTranslation("shell"); + if (agents.length === 0) return null; + return ( + <> + + + {agents.map((agent) => ( + onSelect(agent.id)} + > + + {agent.name} + + ))} + + + ); +} + +export function PaletteMissions({ + missions, + colorByPath, + agentsByPath, + onSelect, +}: { + missions: RawConversation[]; + colorByPath: Record; + agentsByPath: Map; + onSelect: (agentPath: string, missionId: string) => void; +}) { + const { t } = useTranslation("shell"); + if (missions.length === 0) return null; + return ( + <> + + + {missions.map((m) => { + const agentName = missionCardAgentName( + agentsByPath, + m.agent_path, + m.agent_name, + ); + return ( + onSelect(m.agent_path, m.id)} + > + +
    + {m.title} + + {agentName} + +
    +
    + ); + })} +
    + + ); +} diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index 6de1d1b9a..93fd43b8b 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -1,40 +1,31 @@ import { CommandDialog, CommandEmpty, - CommandGroup, CommandInput, - CommandItem, CommandList, - CommandSeparator, - CommandShortcut, - HoustonAvatar, - resolveAgentColor, } from "@houston-ai/core"; -import { Keyboard, LayoutDashboard, Plus, Settings, Store } from "lucide-react"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TAB_ID } from "../agents/standard-tabs"; import { useAllConversations } from "../hooks/queries"; -import { useSidebarLayout } from "../hooks/use-sidebar-layout"; +import { useSidebarLayoutValue } from "../hooks/use-sidebar-layout"; +import { useTeams } from "../hooks/use-teams"; import { flatSidebarOrder } from "../lib/agent-order"; import { analytics } from "../lib/analytics"; +import { openHome } from "../lib/home-nav"; import { isSetupChatMode } from "../lib/integration-chat-setup"; -import { shortcutLabel } from "../lib/shortcuts"; +import { ARCHIVED_STATUS } from "../lib/mission-selection"; +import { openAgentBoard } from "../lib/open-agent"; +import { isMissionBoardView } from "../lib/top-level-views"; import { useAgentStore } from "../stores/agents"; import { useUIStore } from "../stores/ui"; import { useWorkspaceStore } from "../stores/workspaces"; -import { STORE_VIEW_ID } from "./store-view"; - -// 28px outer circle so the inner helmet (forced to 20px by cmdk's -// `[&_[cmdk-item]_svg]:h-5` rule) sits at roughly its native 65% -// proportion instead of overflowing the badge. -const AVATAR_PX = 28; - -function PaletteAvatar({ color }: { color?: string }) { - return ( - - ); -} +import { agentsByPath } from "./board/mission-card-agent"; +import { PaletteActions } from "./command-palette-actions"; +import { + PaletteAgents, + PaletteMissions, + PaletteTeams, +} from "./command-palette-lists"; const RECENT_MISSION_LIMIT = 12; @@ -42,27 +33,28 @@ const RECENT_MISSION_LIMIT = 12; * Global ⌘K command palette. Open state lives in the UI store so any * shortcut handler can toggle it. Sections: * - Actions: top-level navigation + new-mission + * - Teams: open a team's Mission Control (every board belongs to a team) * - Agents: jump to any agent (sidebar order) - * - Recent missions: open a card directly in Mission Control + * - Recent missions: open a card directly on its board * - * Keeps its data sources out of the dashboard tree so it works from any - * view (settings, integrations, agent files, etc.). + * Keeps its data sources out of the board tree so it works from any + * view (settings, integrations, agent files, etc.). The three list groups + * render from `command-palette-lists.tsx`. */ export function CommandPalette() { - const { t } = useTranslation(["shell", "dashboard"]); + const { t } = useTranslation("shell"); const open = useUIStore((s) => s.paletteOpen); const setOpen = useUIStore((s) => s.setPaletteOpen); useEffect(() => { if (open) analytics.track("command_palette_opened"); }, [open]); - const setViewMode = useUIStore((s) => s.setViewMode); - const openSettings = useUIStore((s) => s.openSettings); + const openTeamView = useUIStore((s) => s.openTeamView); const setActivityPanelId = useUIStore((s) => s.setActivityPanelId); - const setCheatsheetOpen = useUIStore((s) => s.setCheatsheetOpen); const agents = useAgentStore((s) => s.agents); const setCurrentAgent = useAgentStore((s) => s.setCurrent); + const teams = useTeams(); const workspaceId = useWorkspaceStore((s) => s.current?.id); - const { layout } = useSidebarLayout(workspaceId); + const layout = useSidebarLayoutValue(workspaceId); const orderedAgents = useMemo( () => flatSidebarOrder(agents, layout), [agents, layout], @@ -74,12 +66,13 @@ export function CommandPalette() { if (!convos) return []; return ( convos - // Archived missions live in the per-agent Archived tab, not the + // Archived missions live in the board's archived view, and + // guided-setup chats are never missions — neither belongs in the // quick-switcher's recent list. .filter( (c) => c.type === "activity" && - c.status !== "archived" && + c.status !== ARCHIVED_STATUS && !isSetupChatMode(c.agent), ) .slice() @@ -93,30 +86,38 @@ export function CommandPalette() { for (const a of agents) m[a.folderPath] = a.color; return m; }, [agents]); + const rosterByPath = useMemo(() => agentsByPath(agents), [agents]); const close = () => setOpen(false); + function jumpToTeam(teamId: string) { + openTeamView(teamId, "mission-control"); + close(); + } + function jumpToAgent(agentId: string) { const agent = agents.find((a) => a.id === agentId); if (!agent) return; setCurrentAgent(agent); - setViewMode(DEFAULT_TAB_ID); + // An agent has no screen of its own any more: "jump to Kai" means Kai's + // team board, filtered to Kai. + openAgentBoard(agent.id); close(); } function openMission(agentPath: string, missionId: string) { const agent = agents.find((a) => a.folderPath === agentPath); if (!agent) { - setViewMode("dashboard"); + openHome(); close(); return; } - // Same handoff `session-notifications.ts` uses: switch to the - // agent's activity tab, then publish the mission id via - // `activityPanelId`. BoardTab consumes it and selects the card, - // which opens the right panel. + // Same handoff `session-notifications.ts` uses: open the board the + // mission's card lives on (its agent's team, filtered to that agent), then + // publish the mission id via `activityPanelId`. The board on the glass + // consumes it and selects the card, which opens the right panel. setCurrentAgent(agent); - setViewMode("activity"); + openAgentBoard(agent.id); setActivityPanelId(missionId); close(); } @@ -127,19 +128,22 @@ export function CommandPalette() { // change runs, so focus lands on the right place. setTimeout(() => { const ui = useUIStore.getState(); - if (ui.viewMode === "dashboard") { - ui.onStartMission?.(); - } else if (useAgentStore.getState().current) { - if (ui.viewMode !== "activity") { - ui.setViewMode("activity"); - setTimeout(() => useUIStore.getState().onStartMission?.(), 50); - } else { - ui.onStartMission?.(); - } - } else { - ui.setViewMode("dashboard"); - setTimeout(() => useUIStore.getState().onStartMission?.(), 50); + // A team's board owns the handler already. The guard is two-part because + // the `team` view also renders Team Settings, Routines, Files and the + // no-agents empty state, none of which mounts a board — with no + // registered handler this has to fall through to the navigate-then-fire + // path instead of silently doing nothing. + if (isMissionBoardView(ui.viewMode) && ui.onStartMission) { + ui.onStartMission(); + return; } + // Anywhere else (Settings, Files, the Store): go to the board that owns + // the handler. The agent the user last worked with names the team board + // to land on; with none, home does. + const current = useAgentStore.getState().current; + if (current) openAgentBoard(current.id); + else openHome(); + setTimeout(() => useUIStore.getState().onStartMission?.(), 50); }, 30); } @@ -147,103 +151,23 @@ export function CommandPalette() { - + - {t("shell:palette.empty")} - - - - - {t("shell:palette.actions.newMission")} - {shortcutLabel("newMission")} - - { - setViewMode("dashboard"); - close(); - }} - value="action mission-control" - > - - {t("shell:palette.actions.missionControl")} - {shortcutLabel("missionControl")} - - { - setViewMode(STORE_VIEW_ID); - close(); - }} - value="action agent-store" - > - - {t("shell:sidebar.agentStore")} - - { - openSettings(null); - close(); - }} - value="action settings" - > - - {t("shell:palette.actions.settings")} - - { - close(); - setTimeout(() => setCheatsheetOpen(true), 30); - }} - value="action shortcuts" - > - - {t("shell:palette.actions.shortcuts")} - {shortcutLabel("cheatsheet")} - - + {t("palette.empty")} - {orderedAgents.length > 0 && ( - <> - - - {orderedAgents.map((agent) => ( - jumpToAgent(agent.id)} - > - - {agent.name} - - ))} - - - )} + - {recentMissions.length > 0 && ( - <> - - - {recentMissions.map((m) => ( - openMission(m.agent_path, m.id)} - > - -
    - {m.title} - - {m.agent_name} - -
    -
    - ))} -
    - - )} + + +
    ); diff --git a/app/src/components/context/context-slots.ts b/app/src/components/context/context-slots.ts new file mode 100644 index 000000000..afffb9507 --- /dev/null +++ b/app/src/components/context/context-slots.ts @@ -0,0 +1,53 @@ +import { useTranslation } from "react-i18next"; +import { + useSaveWorkspaceContext, + useWorkspaceContext, +} from "../../hooks/queries/use-workspace-context"; +import { useAgentStore } from "../../stores/agents"; +import type { InstructionsContentLabels } from "../agent/job-description-parts"; + +/** The two halves of the workspace's standing context, as stored on the wire. */ +export type ContextSlot = "workspace" | "user"; + +/** + * One context slot's editor state: the content, the save, and whether the + * agent-backed read has landed. + * + * Both files live at the OPEN AGENT's workspace root and its runtime reads them + * into the prompt (see `use-workspace-context`), which is why an agent path is + * the precondition rather than a workspace id. `ready` is false until one + * resolves, so the surface can show a frame instead of an editor over nothing. + */ +export function useContextSlot(slot: ContextSlot): { + ready: boolean; + content: string; + onSave: (next: string) => Promise; +} { + const agentPath = useAgentStore((s) => s.current?.folderPath); + const { data } = useWorkspaceContext(agentPath); + const save = useSaveWorkspaceContext(agentPath); + + return { + ready: !!agentPath && !!data, + content: data?.[slot] ?? "", + onSave: async (next: string) => { + await save.mutateAsync({ slot, content: next }); + }, + }; +} + +/** The editor copy for one slot, in the shared `InstructionsContent` shape. */ +export function useContextSlotLabels( + slot: ContextSlot, +): InstructionsContentLabels { + const { t } = useTranslation("context"); + return { + emptyTitle: t(`editor.${slot}.emptyTitle`), + emptyDescription: t(`editor.${slot}.emptyDescription`), + writeButton: t(`editor.${slot}.writeButton`), + helper: t(`editor.${slot}.helper`), + saving: t("editor.saving"), + saved: t("editor.saved"), + placeholder: t(`editor.${slot}.placeholder`), + }; +} diff --git a/app/src/components/dashboard.tsx b/app/src/components/dashboard.tsx deleted file mode 100644 index e3f26be3c..000000000 --- a/app/src/components/dashboard.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - Button, - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from "@houston-ai/core"; -import { Plus } from "lucide-react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useCanCreateAgents } from "../hooks/use-can-create-agents"; -import { useCapabilities } from "../hooks/use-capabilities"; -import { isMultiplayer } from "../lib/org-roles"; -import { useAgentStore } from "../stores/agents"; -import { useUIStore } from "../stores/ui"; -import { MentionsInbox, useMentionInbox } from "./board/mentions-inbox"; -import { MissionControlActive } from "./board/mission-control-active"; -import { MissionControlArchived } from "./board/mission-control-archived"; - -/** - * Which Mission Control surface is showing. A union rather than a pile of - * booleans: the three are mutually exclusive by construction, and "archived AND - * mentions" must never be representable. - */ -type MissionControlMode = "active" | "archived" | "mentions"; - -/** - * Mission Control: every agent's missions on one board. The active board, the - * cross-agent Archived view and the Mentions inbox are separate components that - * swap (not hide) so only the mounted one's hooks run. This view owns the mode - * + the no-agents empty state; all board wiring lives in ``. - */ -export function Dashboard() { - const { t } = useTranslation("dashboard"); - const agents = useAgentStore((s) => s.agents); - const setDialogOpen = useUIStore((s) => s.setCreateAgentDialogOpen); - const { canCreate: canCreateAgents } = useCanCreateAgents(); - const { capabilities } = useCapabilities(); - const [mode, setMode] = useState("active"); - // Mentions are a team surface: with nobody to mention you, the inbox is dead - // chrome. Single player never sees the control and can never land in the mode. - const mentionsEnabled = isMultiplayer(capabilities); - const { mentionCount } = useMentionInbox(agents); - const activeMode = mode === "mentions" && !mentionsEnabled ? "active" : mode; - - if (agents.length === 0) { - return ( -
    - - - {t("noAgents.title")} - {t("noAgents.description")} - - {canCreateAgents && ( - - )} - -
    - ); - } - - return ( -
    - {activeMode === "archived" ? ( - setMode("active")} - /> - ) : activeMode === "mentions" ? ( - setMode("active")} /> - ) : ( - setMode("archived")} - mentions={ - mentionsEnabled - ? { onShow: () => setMode("mentions"), count: mentionCount } - : undefined - } - /> - )} -
    - ); -} diff --git a/app/src/components/file-preview-dialog.tsx b/app/src/components/file-preview-dialog.tsx index a2d6761c4..55cfbd1bf 100644 --- a/app/src/components/file-preview-dialog.tsx +++ b/app/src/components/file-preview-dialog.tsx @@ -27,7 +27,7 @@ import { FilePreviewBody, type Loaded } from "./file-preview-body"; * Bytes come over the authenticated download route, so nothing here assumes a * local filesystem, and a file the Files grid already thumbnailed is served * from the shared byte cache (`lib/file-bytes-cache.ts`) instead of downloaded - * twice. Opened from the Files tab and from chat file surfaces (file cards, + * twice. Opened from the Files section and from chat file surfaces (file cards, * turn summaries, prose file pills) via `useOpenAgentFile`. */ @@ -39,7 +39,7 @@ interface Props { filePath: string | null; fileName: string; /** Key into the shared byte cache, from `sharedBytesKey(file)` when the - * opener holds the entry (the Files tab does). Omitted for anything the + * opener holds the entry (the Files section does). Omitted for anything the * grid could not have thumbnailed, which downloads directly rather than * parking an unbounded blob in memory. */ bytesCacheKey?: number; diff --git a/app/src/components/inbox/inbox-view.tsx b/app/src/components/inbox/inbox-view.tsx new file mode 100644 index 000000000..b2846bc61 --- /dev/null +++ b/app/src/components/inbox/inbox-view.tsx @@ -0,0 +1,126 @@ +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@houston-ai/core"; +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useUserProfiles } from "../../hooks/queries/use-user-profiles"; +import { useAgentStore } from "../../stores/agents"; +import { openMentionRow } from "../board/mention-row-nav"; +import type { MentionInboxRow } from "../board/mentions-inbox-model"; +import { + MentionsInboxRow, + MentionsInboxRowSkeleton, +} from "../board/mentions-inbox-row"; +import { + mentionerIds, + resolveMentionerName, + storedContributorNames, +} from "../board/mentions-inbox-view-model"; +import { useMentionInbox } from "../board/use-mention-inbox"; +import { PageContainer, PageHero } from "../shell/page-shell"; + +/** + * The Inbox: every mission where a teammate typed my name, newest first. A + * top-level screen of its own, so it reads the roster from the store rather + * than taking it as a prop. + * + * It is a TOP-LEVEL row for everyone, deliberately NOT capability-gated: it is + * the app's landing surface whenever no team has resolved yet, so it has to + * exist before anything else does. Single player simply lands on its empty + * state, which is the honest answer when nobody can mention you. + * + * Deliberately NOT a board, and so it wears no board chrome: there is no + * surface to go back to, no mode to pill and nothing to search, only "who + * pinged me, where, and how long ago". It is the canonical page chrome over a + * plain scrollable list of plane rows, each of which navigates to the mission's + * chat the same way a completion notification does. + * + * It carries NO Context door any more. What every agent knows about you is a + * rail row of its own now ("About me"), and what they know about the company + * lives in Admin: a quiet button in this masthead asked the user to find a door + * and then pick a tab before reaching either, which is one level of hide too + * many for the standing knowledge agents read before they act. + */ +export function InboxView() { + const { t, i18n } = useTranslation("dashboard"); + const agents = useAgentStore((s) => s.agents); + const { rows, conversations, isPending } = useMentionInbox(agents); + + const senderIds = useMemo(() => mentionerIds(rows), [rows]); + const { profiles } = useUserProfiles(senderIds); + const fallbackNames = useMemo( + () => storedContributorNames(conversations), + [conversations], + ); + const nameFor = useCallback( + (userId: string) => resolveMentionerName(userId, profiles, fallbackNames), + [profiles, fallbackNames], + ); + + const openRow = useCallback( + (row: MentionInboxRow) => openMentionRow(agents, conversations, row), + [agents, conversations], + ); + + return ( +
    + {/* The masthead is fixed and the list scrolls under it, so a long backlog + never pushes the screen's title off the top. `page-shell` sanctions + the split: the top container opens the page, the scrolling one closes + it. `px-3` matches the rows' own inset so the title sits on the same + left edge they do. */} + + + + + {isPending ? ( +
    + {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
    + ) : rows.length === 0 ? ( + + + {t("mentions.empty.title")} + + {t("mentions.empty.description")} + + + + ) : ( +
      + {rows.map((row) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + ); +} diff --git a/app/src/components/integrations-view/catalog-browse-pane.tsx b/app/src/components/integrations-view/catalog-browse-pane.tsx new file mode 100644 index 000000000..f02d25186 --- /dev/null +++ b/app/src/components/integrations-view/catalog-browse-pane.tsx @@ -0,0 +1,57 @@ +import type { + IntegrationConnection, + IntegrationToolkit, +} from "@houston-ai/engine-client"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import type { ConnectFlow } from "../integrations"; +import { matchesQuery } from "../integrations/browse-model"; +import { CatalogPane } from "./catalog-pane"; + +/** The Composio browse pane, including its late-connect query guard. */ +export function CatalogBrowsePane({ + catalog, + connections, + surface, + query, + setQuery, + category, + isLoading, + connectFlow, + onRemove, + children, +}: { + catalog: IntegrationToolkit[]; + connections: IntegrationConnection[]; + surface: string; + query: string; + setQuery: Dispatch>; + category: string; + isLoading: boolean; + connectFlow: ConnectFlow; + onRemove: (toolkit: string, connectionId?: string) => void; + children?: ReactNode; +}) { + return ( + + setQuery((currentQuery) => { + if (!currentQuery.trim()) return currentQuery; + const app = catalog.find((item) => item.slug === toolkit); + return app && matchesQuery(app, currentQuery.trim().toLowerCase()) + ? "" + : currentQuery; + }) + } + onRemove={onRemove} + > + {children} + + ); +} diff --git a/app/src/components/integrations-view/catalog-controls.tsx b/app/src/components/integrations-view/catalog-controls.tsx index ae7d2cba7..7b1f83731 100644 --- a/app/src/components/integrations-view/catalog-controls.tsx +++ b/app/src/components/integrations-view/catalog-controls.tsx @@ -11,6 +11,7 @@ import { UNCATEGORIZED, } from "../integrations"; import { FilterCombobox } from "../shell/filter-combobox"; +import { HeaderSearch } from "../shell/page-header/header-search"; /** * The catalog surface's ONE controls row — the shared search field + the house @@ -18,8 +19,8 @@ import { FilterCombobox } from "../shell/filter-combobox"; * "Other"). It sits ABOVE both sections of the {@link CatalogShell}; the surface * owns the `query` + `category` state and threads it through here, into the * installed-section filter, and into the discovery {@link CatalogPane}, so ONE - * query narrows everything. Shared verbatim by the global Integrations page and - * the per-agent Integrations tab so the controls never drift. + * query narrows everything. Owned by the global Integrations page; the controls + * live here so both of its catalog sections read from one row. */ export function CatalogControls({ catalog, @@ -28,6 +29,7 @@ export function CatalogControls({ onQueryChange, category, onCategoryChange, + variant, }: { catalog: IntegrationToolkit[]; connections: IntegrationConnection[]; @@ -36,6 +38,7 @@ export function CatalogControls({ /** A primary-category slug, the `UNCATEGORIZED` bucket, or the "all" sentinel. */ category: string; onCategoryChange: (value: string) => void; + variant: "strip" | "row"; }) { const { t } = useTranslation("integrations"); const categoryOptions = useMemo(() => { @@ -47,15 +50,18 @@ export function CatalogControls({ })); }, [catalog, connections, t]); + const inStrip = variant === "strip"; return ( -
    - +
    + + +
    ); diff --git a/app/src/components/integrations-view/catalog-mode-tabs.tsx b/app/src/components/integrations-view/catalog-mode-tabs.tsx deleted file mode 100644 index 4af1c9c91..000000000 --- a/app/src/components/integrations-view/catalog-mode-tabs.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - CatalogCount, - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@houston-ai/core"; -import type { - CustomIntegrationView, - IntegrationConnection, - IntegrationToolkit, -} from "@houston-ai/engine-client"; -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { useTranslation } from "react-i18next"; -import type { Agent } from "../../lib/types"; -import { - type ConnectFlow, - CustomIntegrationsSection, - type PermissionsFix, -} from "../integrations"; -import { matchesQuery } from "../integrations/browse-model"; -import { CatalogPane } from "./catalog-pane"; - -/** - * The PAGE-LEVEL source toggle (HOU-980 review): **Integrations** (the - * Composio world — search, its Installed strip, the browse catalog) vs - * **Custom integrations** (the API / MCP world — ITS installed list + Add), - * shared VERBATIM by the global Integrations page and the per-agent tab. - * One source at a time, each with its own "Installed" up top — the old - * layout showed custom integrations twice (strip tiles AND tab rows). - * When the host doesn't serve custom integrations (`customData` `null`, or - * still `undefined` while loading) the toggle chrome drops and the Composio - * surface renders bare; a FAILED custom list keeps the tab so the section - * can show its error + retry state. - */ -export function CatalogModeTabs({ - mode, - onModeChange, - catalogCount, - customData, - customListFailed = false, - agent, - tabActive, - children, -}: { - mode: string; - onModeChange: (value: string) => void; - /** The Integrations chip (connectable total; undefined while loading). */ - catalogCount: number | undefined; - /** `null` = host without custom integrations, `undefined` = still loading — - * both render the Composio surface without toggle chrome. */ - customData: CustomIntegrationView[] | null | undefined; - customListFailed?: boolean; - agent?: Agent; - /** Per-agent surface: whether that tab owns the visible agent screen - * (TabProps.isActive) — gates the setup chat's shared shell panel. */ - tabActive?: boolean; - /** The Composio-mode content (the surface's own CatalogShell). */ - children: ReactNode; -}) { - const { t } = useTranslation("integrations"); - if (customData == null && !customListFailed) return <>{children}; - return ( - - - - {t("home.tabs.catalog")} - {catalogCount != null && } - - - {t("home.tabs.custom")} - {customData != null && } - - - {children} - - - - - ); -} - -/** - * The Composio browse pane, built once for both surfaces (the blocks used to - * be copy-paste twins). A successful connect clears the shared query only - * when the landed app still matches it, so a late OAuth completion can never - * erase a newer search. The agent surface passes its `allowlist` + `lockedFix` - * (locked browse rows) and its disallowed-apps section as `children`; the - * global page passes none of them. - */ -export function CatalogBrowsePane({ - catalog, - connections, - surface, - query, - setQuery, - category, - isLoading, - connectFlow, - onRemove, - allowlist = null, - lockedFix, - children, -}: { - catalog: IntegrationToolkit[]; - connections: IntegrationConnection[]; - surface: string; - query: string; - setQuery: Dispatch>; - category: string; - isLoading: boolean; - connectFlow: ConnectFlow; - onRemove: (toolkit: string, connectionId?: string) => void; - allowlist?: string[] | null; - lockedFix?: PermissionsFix; - children?: ReactNode; -}) { - return ( - - setQuery((currentQuery) => { - if (!currentQuery.trim()) return currentQuery; - const app = catalog.find((item) => item.slug === toolkit); - return app && matchesQuery(app, currentQuery.trim().toLowerCase()) - ? "" - : currentQuery; - }) - } - onRemove={onRemove} - allowlist={allowlist} - lockedFix={lockedFix} - > - {children} - - ); -} diff --git a/app/src/components/integrations-view/catalog-pane.tsx b/app/src/components/integrations-view/catalog-pane.tsx index f09c84f21..53d2e13f9 100644 --- a/app/src/components/integrations-view/catalog-pane.tsx +++ b/app/src/components/integrations-view/catalog-pane.tsx @@ -3,14 +3,13 @@ import type { IntegrationToolkit, } from "@houston-ai/engine-client"; import type { ReactNode } from "react"; -import type { ConnectFlow, PermissionsFix } from "../integrations"; +import type { ConnectFlow } from "../integrations"; import { CatalogSkeleton } from "./catalog-skeletons"; import { CategoryCatalog } from "./category-catalog"; /** - * The Integrations tab of a catalog surface — shared VERBATIM by the global - * page and the per-agent Integrations tab: any surface-specific `children` (the - * agent tab's disallowed-apps section) over the grouped category catalog. It is + * The Integrations tab of a catalog surface: any surface-specific `children` + * over the grouped category catalog. It is * CONTROLLED: the surface owns the ONE search + category (its `controls` row * above BOTH sections) and threads them in as `query` + `category`, so the same * filter narrows this discovery area and the Installed strip together (the @@ -20,8 +19,7 @@ import { CategoryCatalog } from "./category-catalog"; * * There is NO recovery section at the top of the pane: an app whose connection * is pending or errored stays in its own category rows, wearing its status, and - * is finished or removed from there. On a Teams host `allowlist` renders blocked - * apps as locked rows. While the data settles it shows the + * is finished or removed from there. While the data settles it shows the * {@link CatalogSkeleton}, which mirrors the grouped catalog it replaces so * resolving costs no layout shift. */ @@ -35,14 +33,12 @@ export function CatalogPane({ connectFlow, onConnected, onRemove, - allowlist = null, - lockedFix, children, }: { catalog: IntegrationToolkit[]; connections: IntegrationConnection[]; - /** Which surface these rows belong to (the global page vs. one agent's tab), - * half of each row's connect-flow origin key. */ + /** Which surface these rows belong to, half of each row's connect-flow + * origin key. */ surface: string; /** The surface's shared search query (from its one controls row). */ query: string; @@ -54,11 +50,6 @@ export function CatalogPane({ onConnected?: (toolkit: string) => void; /** Disconnect an app's half-made connection (the app modal's Remove). */ onRemove: (toolkit: string) => void; - /** The Teams effective allowlist (`null` = unrestricted, no locks ever). */ - allowlist?: string[] | null; - /** Role-aware "Enable it in Permissions" resolver for locked rows (a viewer - * who can lift the ceiling); absent = the read-only member view. */ - lockedFix?: PermissionsFix; /** Surface-specific sections above the catalog. */ children?: ReactNode; }) { @@ -78,8 +69,6 @@ export function CatalogPane({ query={query} category={category} onRemove={onRemove} - allowlist={allowlist} - lockedFix={lockedFix} /> )}
    diff --git a/app/src/components/integrations-view/catalog-skeletons.tsx b/app/src/components/integrations-view/catalog-skeletons.tsx index af453ef3f..c7643eb9e 100644 --- a/app/src/components/integrations-view/catalog-skeletons.tsx +++ b/app/src/components/integrations-view/catalog-skeletons.tsx @@ -1,9 +1,8 @@ import { CatalogGrid, Skeleton } from "@houston-ai/core"; /** - * The catalog surfaces' loading language, in ONE place so the global - * Integrations page and the per-agent tab never disagree about what "still - * loading" looks like. + * The catalog surfaces' loading language, in ONE place so the page's Installed + * and Available sections never disagree about what "still loading" looks like. * * Every placeholder MIRRORS the real layout it stands in for, down to the row * padding, the 40px art tile, and the two text lines, so resolving swaps @@ -70,27 +69,3 @@ export function CatalogSkeleton() {
    ); } - -/** - * The WHOLE catalog surface while its queries settle: the controls row, the - * Installed section, and the Available section, in the `CatalogShell` order. - * The per-agent Integrations tab shows this instead of the boot `LoadingState` - * so an already-booted surface reloading its data reads the same as the global - * page (a full-page "Loading your integrations" for a section refresh was the - * loudest of the three loading languages this page used to speak). - */ -export function CatalogSurfaceSkeleton() { - return ( -
    - -
    - - -
    -
    - - -
    -
    - ); -} diff --git a/app/src/components/integrations-view/category-catalog.tsx b/app/src/components/integrations-view/category-catalog.tsx index 89160673d..8ad9a4367 100644 --- a/app/src/components/integrations-view/category-catalog.tsx +++ b/app/src/components/integrations-view/category-catalog.tsx @@ -4,11 +4,7 @@ import type { } from "@houston-ai/engine-client"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - CatalogLockedSection, - type ConnectFlow, - type PermissionsFix, -} from "../integrations"; +import type { ConnectFlow } from "../integrations"; import { AppInfoDialog } from "./app-info-dialog"; import { CatalogCategorySection } from "./catalog-category-section"; import { useCatalogSections } from "./use-catalog-sections"; @@ -38,10 +34,7 @@ import { useCatalogSections } from "./use-catalog-sections"; * the page never kills a poll. The spotlight REPEATS rows that also sit in a * category section, so each row carries an origin key and `useCatalogSections` * hands the expansion to the single copy the user pressed — the duplicate keeps - * only its `+` spinner. On a - * Teams host with an `allowlist` ceiling, apps outside it drop from the sections - * and surface as read-only LOCKED rows below (same query + category filter, so - * searching a blocked app finds its locked row, never a false empty state). + * only its `+` spinner. */ export function CategoryCatalog({ catalog, @@ -52,34 +45,26 @@ export function CategoryCatalog({ query, category, onRemove, - allowlist = null, - lockedFix, }: { catalog: IntegrationToolkit[]; connections: IntegrationConnection[]; connectFlow: ConnectFlow; onConnected?: (toolkit: string) => void; /** This catalog's half of every row's origin key — which surface the row - * belongs to (the global page vs. one agent's tab). */ + * belongs to. */ surface: string; query: string; /** The filter dropdown's pick: a primary-category slug or "all". */ category: string; /** Disconnect a broken connection (the modal's Remove). */ onRemove: (toolkit: string) => void; - /** The Teams effective allowlist (`null` = unrestricted, no locks ever). */ - allowlist?: string[] | null; - /** Role-aware "Enable it in Permissions" resolver for locked rows; absent = - * the read-only member view (ask-your-admin copy). */ - lockedFix?: PermissionsFix; }) { const { t } = useTranslation("integrations"); - const { visible, locked, owners, broken, expand } = useCatalogSections({ + const { visible, owners, broken, expand } = useCatalogSections({ catalog, connections, query, category, - allowlist, surface, origins: connectFlow.origins, }); @@ -94,7 +79,7 @@ export function CategoryCatalog({ return (
    - {visible.length === 0 && locked.length === 0 ? ( + {visible.length === 0 ? (

    {t("picker.noResults")}

    @@ -118,10 +103,6 @@ export function CategoryCatalog({
    )} - {locked.length > 0 && ( - - )} - void; + customData: CustomIntegrationView[] | null | undefined; + customListFailed: boolean; +}) { + const { t } = useTranslation("integrations"); + const items = [ + { + id: "catalog" as const, + // The identity lozenge wears the same mark the rail's Integrations row + // does, exactly as a team's lozenge wears its glyph: the door and the + // page agree on what this place looks like. No count chips up here — + // the catalog's size is already stated once, on the Available section + // heading, and a number repeated in the chrome is bookkeeping. + label: ( + <> + + {t("home.tabs.catalog")} + + ), + heading: true, + }, + ...(customModeAvailable(customData, customListFailed) + ? [{ id: "custom" as const, label: t("home.tabs.custom") }] + : []), + ]; + + return ( + + + + ); +} diff --git a/app/src/components/integrations-view/integrations-ready.tsx b/app/src/components/integrations-view/integrations-ready.tsx index 0d415d54d..339c5321a 100644 --- a/app/src/components/integrations-view/integrations-ready.tsx +++ b/app/src/components/integrations-view/integrations-ready.tsx @@ -1,82 +1,41 @@ import { CatalogShell } from "@houston-ai/core"; -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useDisconnectIntegration } from "../../hooks/queries"; import { - useCustomIntegrationsFor, - useCustomTransportAgentId, - useDisconnectIntegration, -} from "../../hooks/queries"; -import { - catalogHiddenToolkits, + type ConnectedApps, + CustomIntegrationsSection, INTEGRATION_PROVIDER, ReconnectBanner, - useConnectedApps, useConnectFlow, useConnectionSelection, } from "../integrations"; -import { PageHeader } from "../shell/page-shell"; +import { PageHeaderTools } from "../shell/page-header/page-header-tools"; +import { CatalogBrowsePane } from "./catalog-browse-pane"; import { CatalogControls } from "./catalog-controls"; -import { CatalogBrowsePane, CatalogModeTabs } from "./catalog-mode-tabs"; import { InstalledSkeleton } from "./catalog-skeletons"; import { ConnectedAppDialogs } from "./connected-app-dialogs"; import { InstalledStrip } from "./installed-strip"; -import { useCatalogSurface } from "./use-catalog-surface"; +import type { IntegrationsMode } from "./integrations-header"; +import type { CatalogSurface } from "./use-catalog-surface"; -interface IntegrationsReadyProps { - reconnectNotice: boolean; - dismissReconnect: () => Promise; -} - -/** - * The ready state of the global Integrations page (personal mode): a hero - * title + muted subtitle over the page-level source toggle - * ({@link CatalogModeTabs} — one source at a time, HOU-980 review): - * - * - **Integrations** (Composio): ONE controls row ({@link CatalogControls}) - * whose search + category narrow the Installed strip (active catalog - * connections only) AND the browse catalog together, via the shared - * {@link CatalogShell}. - * - **Custom integrations**: the API / MCP surface with its OWN installed - * list up top ({@link CustomIntegrationsSection}). - * - * When the host doesn't serve custom integrations the toggle chrome drops and - * the Composio surface renders bare. A catalog row opens the detail MODAL - * (`AppDetailDialog` — never a slideover): view + reconnect + disconnect for - * that personal connection. Which agents may use an app is managed in one - * place (the Permissions view), never here. The connect flow is bound here - * (connect-only) and handed to the catalog and the detail modal; its state is - * app-wide and shared, so closing any of them, switching modes, or leaving - * the page entirely never kills an in-flight OAuth poll, and a connect - * started from chat shows up on these rows. - * - * The catalog shows the FULL Houston catalog. Policy is per agent only (the - * org-wide app ceiling was removed), so the global page has no ceiling to - * apply and never locks a row — locked browse rows live only on the per-agent - * Integrations tab, keyed to that agent's ceiling. - */ export function IntegrationsReady({ reconnectNotice, dismissReconnect, -}: IntegrationsReadyProps) { + mode, + apps, + surface, +}: { + reconnectNotice: boolean; + dismissReconnect: () => Promise; + mode: IntegrationsMode; + apps: ConnectedApps; + surface: CatalogSurface; +}) { const { t } = useTranslation("integrations"); - const apps = useConnectedApps(); const connectFlow = useConnectFlow({}); const disconnect = useDisconnectIntegration(INTEGRATION_PROVIDER); - // Same transport (and query key) as the CustomIntegrationsSection inside the - // tab, so the chip and the tab body can never disagree — and the tab shows - // behind the hosted gateway, which proxies only the per-agent custom routes. - const customTransportAgentId = useCustomTransportAgentId(); - const custom = useCustomIntegrationsFor(customTransportAgentId); const selection = useConnectionSelection(apps); - - const surface = useCatalogSurface({ - active: apps.activeRows, - catalog: apps.catalogData, - connections: apps.connData, - }); const { - tab, - setTab, query, setQuery, category, @@ -87,89 +46,68 @@ export function IntegrationsReady({ availableCount, } = surface; - // The Integrations mode chip stays the UNFILTERED connectable total (what - // the mode browses): every app minus the ones that left for the Installed - // strip. An app whose connection never landed is still browsable here, so it - // counts. - const connectableCount = useMemo(() => { - const hidden = catalogHiddenToolkits(apps.connData); - return apps.catalogData.filter((tk) => !hidden.has(tk.slug)).length; - }, [apps.catalogData, apps.connData]); - return ( <> - 0 - ? t("home.descriptionCount", { count: apps.catalogData.length }) - : t("home.description") - } - className="mb-7" - /> - {reconnectNotice && (
    )} - - - } - installedTitle={t("home.installedTitle")} - installedCount={apps.isLoading ? undefined : installedCount} - installed={ - apps.isLoading ? ( - - ) : installedCount > 0 ? ( - // Omitted entirely (no heading) when the shared filter leaves - // nothing installed, so the section only ever renders with rows. - + ) : ( + <> + + {(inStrip) => ( + - ) : undefined - } - availableTitle={t("home.availableTitle")} - availableCount={apps.isLoading ? undefined : availableCount} - tabs={[ - { - value: "catalog", - label: t("home.tabs.catalog"), - content: ( - disconnect.mutate({ toolkit })} + )} + + + ) : installedCount > 0 ? ( + - ), - }, - ]} - /> - + ) : undefined + } + availableTitle={t("home.availableTitle")} + availableCount={apps.isLoading ? undefined : availableCount} + tabs={[ + { + value: "catalog", + label: t("home.tabs.catalog"), + content: ( + disconnect.mutate({ toolkit })} + /> + ), + }, + ]} + /> + + )} - - {gate.kind === "ready" ? ( - - ) : ( - <> - - {gate.kind === "loading" ? ( - - ) : gate.customAvailable ? ( -
    - - {gate.kind === "signin" ? ( - - ) : ( -

    - {t("custom.catalogUnavailable")} -

    - )} -
    - ) : gate.kind === "signin" ? ( - - ) : ( - - )} - - )} -
    -
    + +
    + +
    + + {/* The catalog column caps at its own natural width (two capped + cells) and centers in the wide page — headings and rows keep + one shared left edge, and the page's margin absorbs the rest, + split evenly, instead of piling up right of the grid. */} +
    + {gate.kind === "ready" ? ( + + ) : gate.kind === "loading" ? ( + + ) : gate.customAvailable && active === "custom" ? ( + + ) : gate.kind === "signin" ? ( + + ) : gate.customAvailable ? ( +

    + {t("custom.catalogUnavailable")} +

    + ) : ( + + )} +
    +
    +
    +
    +
    ); } diff --git a/app/src/components/integrations-view/use-catalog-sections.ts b/app/src/components/integrations-view/use-catalog-sections.ts index 127e37c8d..57d8f7f79 100644 --- a/app/src/components/integrations-view/use-catalog-sections.ts +++ b/app/src/components/integrations-view/use-catalog-sections.ts @@ -5,7 +5,7 @@ import type { import { useMemo, useState } from "react"; import { type BrokenConnection, - browseCatalogView, + browseCatalog, type CatalogSection, catalogHiddenToolkits, groupCatalogByCategory, @@ -20,8 +20,6 @@ import type { VisibleSection } from "./catalog-category-section"; export interface CatalogSections { /** The category sections on screen, each capped until the user expands it. */ visible: VisibleSection[]; - /** Apps a Teams ceiling blocks, shown as read-only locked rows below. */ - locked: IntegrationToolkit[]; /** slug -> the origin key of the ONE rendered row that owns its inline * connect state (the spotlight repeats rows; only one may expand). */ owners: Map; @@ -33,11 +31,10 @@ export interface CatalogSections { /** * The browse plane's derivation, lifted out of the view: which apps are - * connectable (a WORKING connection has left for the Installed strip, an - * admin-blocked one for the "Not allowed" section — a broken connection stays - * right here), how they group into capped category sections, which single copy - * of a repeated app owns the live connect state, and which apps wear a broken - * connection's status. + * connectable (a WORKING connection has left for the Installed strip — a broken + * connection stays right here), how they group into capped category sections, + * which single copy of a repeated app owns the live connect state, and which + * apps wear a broken connection's status. * * A fresh query OR category resets every section back to its capped preview * (changing category re-groups the sections, so a stale expansion no longer @@ -49,35 +46,24 @@ export function useCatalogSections(opts: { connections: IntegrationConnection[]; query: string; category: string; - allowlist: string[] | null; /** This catalog's half of every row's origin key. */ surface: string; /** slug -> the origin key the live flow was started from. */ origins: Record; }): CatalogSections { - const { catalog, connections, query, category, allowlist, surface, origins } = - opts; + const { catalog, connections, query, category, surface, origins } = opts; const hidden = useMemo( - () => catalogHiddenToolkits(connections, allowlist), - [connections, allowlist], + () => catalogHiddenToolkits(connections), + [connections], ); const broken = useMemo( () => partitionConnections(connections).broken, [connections], ); - // The ceiling splits the browse set BEFORE grouping: sections hold only the - // connectable apps; the blocked remainder renders as the locked strip below. - const { connectable, locked } = useMemo( - () => - browseCatalogView({ - catalog, - query, - category, - connected: hidden, - allowlist, - }), - [catalog, query, category, hidden, allowlist], + const connectable = useMemo( + () => browseCatalog({ catalog, query, category, connected: hidden }), + [catalog, query, category, hidden], ); const sections: CatalogSection[] = useMemo( () => @@ -131,7 +117,6 @@ export function useCatalogSections(opts: { return { visible, - locked, owners, broken, expand: (name) => setExpanded((prev) => new Set(prev).add(name)), diff --git a/app/src/components/integrations-view/use-catalog-surface.ts b/app/src/components/integrations-view/use-catalog-surface.ts index bce9eb5a5..4cdf2bddd 100644 --- a/app/src/components/integrations-view/use-catalog-surface.ts +++ b/app/src/components/integrations-view/use-catalog-surface.ts @@ -8,7 +8,7 @@ import { filterInstalledBy, type InstalledRow, } from "../../lib/installed-preview"; -import { browseCatalogView, catalogHiddenToolkits } from "../integrations"; +import { browseCatalog, catalogHiddenToolkits } from "../integrations"; /** The consolidated catalog surface's shared view state, ready for * {@link CatalogShell}. */ @@ -27,26 +27,22 @@ export interface CatalogSurface { /** How many installed rows the strip currently shows (the total at rest). */ installedCount: number; /** How many connectable apps match the shared filter (the Available header's - * count; the total at rest). Respects a Teams allowlist. */ + * count; the total at rest). */ availableCount: number; } /** - * Own the catalog surface's ONE controls row once: the tab + shared query + + * Own the catalog surface's ONE controls row once: the mode + shared query + * category state, the {@link filterInstalledBy} result feeding the Installed - * strip, and the connectable-match count feeding the Available header. Shared by - * the global Integrations page and the per-agent Integrations tab so the - * two-section wiring lives in one place; a parent that remounts per agent - * (`key={agent.id}`) gets naturally per-agent state. `allowlist` (`null` = - * unrestricted) only narrows the available count — locked apps never count. + * strip, and the connectable-match count feeding the Available header, so the + * two-section wiring lives in one place. */ export function useCatalogSurface(opts: { active: readonly InstalledRow[]; catalog: IntegrationToolkit[]; connections: IntegrationConnection[]; - allowlist?: string[] | null; }): CatalogSurface { - const { active, catalog, connections, allowlist = null } = opts; + const { active, catalog, connections } = opts; const [tab, setTab] = useState("catalog"); const [query, setQuery] = useState(""); const [category, setCategory] = useState("all"); @@ -60,10 +56,9 @@ export function useCatalogSurface(opts: { [active, catalog, query, category], ); const availableCount = useMemo(() => { - const connected = catalogHiddenToolkits(connections, allowlist); - return browseCatalogView({ catalog, query, category, connected, allowlist }) - .connectable.length; - }, [catalog, connections, query, category, allowlist]); + const connected = catalogHiddenToolkits(connections); + return browseCatalog({ catalog, query, category, connected }).length; + }, [catalog, connections, query, category]); return { tab, diff --git a/app/src/components/integrations/allowlist-editor.tsx b/app/src/components/integrations/allowlist-editor.tsx index 49264eaa1..25f1800d4 100644 --- a/app/src/components/integrations/allowlist-editor.tsx +++ b/app/src/components/integrations/allowlist-editor.tsx @@ -1,11 +1,11 @@ import { Switch } from "@houston-ai/core"; import type { IntegrationToolkit } from "@houston-ai/engine-client"; -import { useMemo, useState } from "react"; -import { AccessChoice } from "../tabs/agent-admin/access-choice.tsx"; +import { useId, useMemo, useState } from "react"; +import { AccessChoice } from "../agent/agent-admin/access-choice.tsx"; import { type AccessMode, ceilingMode, -} from "../tabs/agent-admin/agent-admin-row-values.ts"; +} from "../agent/agent-admin/agent-admin-row-values.ts"; import { AppCatalogGrid } from "./app-catalog-grid"; import { appDisplay } from "./app-display"; import { AppRow } from "./app-row"; @@ -65,6 +65,7 @@ export function AllowlistEditor({ copy, rowMeta, }: AllowlistEditorProps) { + const headingId = useId(); // View-only category filter shared by the allowed list + "Add apps" catalog. const [category, setCategory] = useState("all"); @@ -108,14 +109,16 @@ export function AllowlistEditor({ return (
    -

    {copy.question}

    +

    + {copy.question} +

    {copy.policyHelper}

    {readOnly && copy.readOnlyNote && (

    {copy.readOnlyNote}

    )} void; /** Apps to hide entirely (e.g. already-connected in the connect flow). */ excludeToolkits?: ReadonlySet; - /** - * The Teams effective allowlist. When set, apps outside it render as LOCKED - * rows below the connectable grid instead of vanishing; `null`/absent (single- - * player, or a Teams host with no ceiling) = no locks ever. - */ - allowlist?: string[] | null; /** The catalog is still fetching (show a loader, not a "no apps" message). */ loading?: boolean; /** Per-app trailing control (+ optional row click). Owns the row's action. */ @@ -47,18 +39,16 @@ interface AppCatalogGridProps { * The shared app-catalog shell: a control row (search box `flex-1` + the shared * {@link FilterCombobox} category picker trailing) above a paginated two-column * {@link AppRow} grid + load-more over the ~1000-app catalog. The per-row action - * (connect button, allow toggle, ...) is delegated via `renderRow`, so both the - * Integrations tab's connect browser and the manager's allowlist editor render - * the same layout without duplicating the shell markup. Category is controlled - * by the surface so the same selection also filters the connected / allowed - * lists that sit beside this grid. + * (allow toggle, ...) is delegated via `renderRow`, so a surface picking apps out + * of the catalog gets this layout without duplicating the shell markup. Category + * is controlled by the surface so the same selection also filters the lists that + * sit beside this grid. */ export function AppCatalogGrid({ catalog, category, onCategoryChange, excludeToolkits, - allowlist, loading, renderRow, }: AppCatalogGridProps) { @@ -73,45 +63,40 @@ export function AppCatalogGrid({ })), [catalog], ); - const { connectable, locked } = useMemo( + const apps = useMemo( () => - browseCatalogView({ + browseCatalog({ catalog, query: search, category, connected: excludeToolkits ?? new Set(), - allowlist: allowlist ?? null, }), - [catalog, search, category, excludeToolkits, allowlist], + [catalog, search, category, excludeToolkits], ); // A fresh result list (new search or category) collapses the page cap back to // the first page. Adjusting state during render (React's documented pattern) // keeps the reset in sync with the new list identity without a wasted paint. - // Only the connectable list paginates; the locked group is separately capped. const [visible, setVisible] = useState(BROWSE_PAGE_SIZE); - const [shownFor, setShownFor] = useState(connectable); - if (shownFor !== connectable) { - setShownFor(connectable); + const [shownFor, setShownFor] = useState(apps); + if (shownFor !== apps) { + setShownFor(apps); setVisible(BROWSE_PAGE_SIZE); } - const visibleApps = connectable.slice(0, visible); - const hasMore = visible < connectable.length; + const visibleApps = apps.slice(0, visible); + const hasMore = visible < apps.length; return (
    -
    - - setSearch(e.target.value)} - placeholder={t("picker.searchPlaceholder")} - className="h-9 w-full rounded-full border border-line bg-input pl-9 pr-3 text-sm text-ink placeholder:text-ink-muted focus:outline-none focus:ring-2 focus:ring-focus/20" - /> -
    + {categoryOptions.length > 0 && ( - {connectable.length === 0 && locked.length === 0 ? ( + {apps.length === 0 ? ( loading ? (
    @@ -139,39 +124,34 @@ export function AppCatalogGrid({ ) ) : ( <> - {connectable.length > 0 && ( - <> -
    - {visibleApps.map((tk) => { - const display = appDisplay(tk.slug, tk); - const row = renderRow(display, tk); - return ( - - ); +
    + {visibleApps.map((tk) => { + const display = appDisplay(tk.slug, tk); + const row = renderRow(display, tk); + return ( + + ); + })} +
    + {hasMore && ( +
    +
    - {hasMore && ( -
    - -
    - )} - + +
    )} - {locked.length > 0 && } )}
    diff --git a/app/src/components/integrations/blocked-ceiling.ts b/app/src/components/integrations/blocked-ceiling.ts deleted file mode 100644 index daabb486e..000000000 --- a/app/src/components/integrations/blocked-ceiling.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Role-aware signposting for a policy-BLOCKED app: the deep-link that would fix - * it for a viewer who can act. Policy is per agent only (the org-wide ceiling was - * removed as overengineering), so a blocked app is ALWAYS blocked by the agent's - * own ceiling and the fix is ALWAYS that agent's Permissions drill-in. One pure - * layer so the who-can-fix decision is unit-tested without React. DOM-free + - * dependency-free. - */ - -/** - * Resolve the "Enable it in Permissions" action for a blocked app, or - * `undefined` when the viewer cannot fix that app's ceiling (the member view, - * which keeps the ask-your-admin copy). The returned thunk performs the deep - * link into this agent's per-agent Permissions detail. Authority: the agent - * ceiling is agent-manager-editable AND only reachable by someone who can open - * the Permissions dashboard (`canManageAgent` folds both), so a plain member and - * a non-admin manager both fall through to `undefined`. - */ -export type PermissionsFix = (slug: string) => (() => void) | undefined; - -export function resolvePermissionsFix(opts: { - /** The viewer manages this agent AND can open the Permissions dashboard. */ - canManageAgent: boolean; - /** Deep-link to this agent's Permissions drill-in (agent-ceiling fix). */ - openAgentDetail: () => void; -}): PermissionsFix { - return () => (opts.canManageAgent ? opts.openAgentDetail : undefined); -} diff --git a/app/src/components/integrations/browse-model.ts b/app/src/components/integrations/browse-model.ts index 4317d9301..23d8d6743 100644 --- a/app/src/components/integrations/browse-model.ts +++ b/app/src/components/integrations/browse-model.ts @@ -1,8 +1,8 @@ import type { IntegrationToolkit } from "@houston-ai/engine-client"; /** - * The catalog-BROWSE pure layer: query filtering, A-Z ordering, Teams-allowlist - * partitioning, category metadata and the first-paint caps. Split from `model.ts` + * The catalog-BROWSE pure layer: query filtering, A-Z ordering, category + * metadata and the first-paint caps. Split from `model.ts` * (provider/support/poll) so every surface applies the same rules; the section * grouping + curated Featured spotlight live beside it in `browse-sections.ts`. * All pure. @@ -64,45 +64,6 @@ export function browseCatalog(opts: { return filtered.sort(byNameAsc); } -/** - * Max policy-blocked (locked) apps shown inline before the rest collapse into a - * "+N more" line, so a tiny Teams allowlist can't bury the connectable apps. - */ -export const LOCKED_PREVIEW_CAP = 8; - -/** Split by the Teams allowlist ceiling: `connectable` = may connect now, - * `locked` = ceiling BLOCKS (shown as locked rows, never connectable). Both A-Z. */ -export interface BrowseCatalogView { - connectable: IntegrationToolkit[]; - locked: IntegrationToolkit[]; -} - -/** - * Partition the browse catalog into the apps a member may connect and the apps a - * Teams allowlist ceiling BLOCKS, after {@link browseCatalog}'s category + search - * + connected-exclusion filter. `allowlist === null` means unrestricted (single- - * player, or a Teams host with no ceiling), so nothing is ever locked. Both lists - * keep the A-Z order. - */ -export function browseCatalogView(opts: { - catalog: IntegrationToolkit[]; - query: string; - category: string; - connected: ReadonlySet; - allowlist: string[] | null; -}): BrowseCatalogView { - const { allowlist, ...browse } = opts; - const results = browseCatalog(browse); - if (allowlist === null) return { connectable: results, locked: [] }; - const allowed = new Set(allowlist); - const connectable: IntegrationToolkit[] = []; - const locked: IntegrationToolkit[] = []; - for (const t of results) { - (allowed.has(t.slug) ? connectable : locked).push(t); - } - return { connectable, locked }; -} - /** Every category present in the catalog, sorted by display label. */ export function categoriesOf(catalog: IntegrationToolkit[]): string[] { const seen = new Set(); diff --git a/app/src/components/integrations/browse-sections.ts b/app/src/components/integrations/browse-sections.ts index 7c58084e1..2d1aac65d 100644 --- a/app/src/components/integrations/browse-sections.ts +++ b/app/src/components/integrations/browse-sections.ts @@ -105,7 +105,7 @@ export interface CatalogSection { * - **Narrowed to one category (`category` set)** — ANY-match: every app whose * `categories` CONTAINS that slug lands in that single section, so a * secondary-category app is findable under the filter and the rendered rows equal - * {@link browseCatalogView}'s `connectable` (the "Available" chip count). This + * {@link browseCatalog}'s result (the "Available" chip count). This * mirrors the installed side ({@link toolkitsInCategory}, also any-match). * * Apps sort A-Z within a section. Sections order MAINSTREAM-FIRST for our @@ -142,7 +142,7 @@ export function groupCatalogByCategory(opts: { let category: string; if (only) { // Narrowed: any-match into the single selected section, so rendered rows - // equal browseCatalogView/availableCount and secondary-category apps show. + // equal browseCatalog/availableCount and secondary-category apps show. if (!(t.categories ?? []).includes(only)) continue; category = only; } else { diff --git a/app/src/components/integrations/catalog-locked-section.tsx b/app/src/components/integrations/catalog-locked-section.tsx deleted file mode 100644 index b649eb740..000000000 --- a/app/src/components/integrations/catalog-locked-section.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { CatalogCount } from "@houston-ai/core"; -import type { IntegrationToolkit } from "@houston-ai/engine-client"; -import { Lock } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { appDisplay } from "./app-display"; -import { AppRow } from "./app-row"; -import type { PermissionsFix } from "./blocked-ceiling"; -import { LOCKED_PREVIEW_CAP } from "./browse-model"; -import { EnableInPermissionsButton } from "./enable-in-permissions-button"; - -interface CatalogLockedSectionProps { - /** Policy-blocked apps (already filtered + A-Z), rendered read-only. */ - locked: IntegrationToolkit[]; - /** - * Resolve the "Enable it in Permissions" deep-link for a blocked app, or - * `undefined` when the viewer can't lift that app's ceiling. When set (a viewer - * who CAN act), the row's ask-your-admin line is replaced by the CTA; when - * absent or `undefined` for a row (the member view), the ask-admin line stays. - */ - onEnable?: PermissionsFix; -} - -/** - * The policy-blocked apps in the browse catalog, shown as LOCKED rows instead of - * being hidden. So a member who searches for an app their admin hasn't enabled - * finds a locked row, with the ask-your-admin line visible at rest, rather than - * an empty list that reads as "Houston doesn't support it". Each row is - * non-interactive (a lock icon where Connect would sit, no click) and carries the - * ask-admin subtitle. The heading carries the total locked count and a subtitle - * naming this as an admin choice. Capped at {@link LOCKED_PREVIEW_CAP} with a "+N more" line - * so a tiny allowlist over the ~1000-app catalog can't flood past the connectable - * apps above. Only rendered on a Teams host with a real ceiling — the caller - * passes an empty `locked` list off Teams, so nothing shows. - */ -export function CatalogLockedSection({ - locked, - onEnable, -}: CatalogLockedSectionProps) { - const { t } = useTranslation("integrations"); - const shown = locked.slice(0, LOCKED_PREVIEW_CAP); - const overflow = locked.length - shown.length; - - return ( -
    -

    - - {t("locked.heading")} - -

    -

    {t("locked.subtitle")}

    -
    - {shown.map((tk) => { - const display = appDisplay(tk.slug, tk); - const fix = onEnable?.(tk.slug); - return ( - - ) : ( - - ) - } - /> - ); - })} -
    - {overflow > 0 && ( -

    - {t("locked.more", { count: overflow })} -

    - )} -
    - ); -} diff --git a/app/src/components/integrations/connected-apps-model.ts b/app/src/components/integrations/connected-apps-model.ts index f83208f91..ab956ddc8 100644 --- a/app/src/components/integrations/connected-apps-model.ts +++ b/app/src/components/integrations/connected-apps-model.ts @@ -2,7 +2,7 @@ import type { IntegrationConnection } from "@houston-ai/engine-client"; /** * Pure, DOM-free derivations for the connected-apps read-model both catalog - * surfaces share (the global Integrations page and the per-agent tab). Kept + * surfaces share (the global Integrations page and the in-chat connect cards). Kept * separate so the connection arithmetic is unit-tested in isolation. */ @@ -109,27 +109,16 @@ export function groupAccounts( /** * The toolkits the browse catalog must NOT offer, because they already have a * home elsewhere on the surface: a WORKING connection is an Installed strip - * row, and a connection the agent's Teams ceiling forbids is a "Not allowed" - * row. A pending or errored connection inside the ceiling is deliberately - * absent from this set — it stays in the catalog, in its own category section, - * where its `+` retries the connect. - * - * `allowlist === null` (single player, or Teams with no ceiling) blocks - * nothing, so the set is the working connections alone. + * row. A pending or errored connection is deliberately absent from this set — + * it stays in the catalog, in its own category section, where its `+` retries + * the connect. */ export function catalogHiddenToolkits( connections: IntegrationConnection[], - allowlist: string[] | null = null, ): Set { - const allowed = allowlist === null ? null : new Set(allowlist); const hidden = new Set(); for (const c of connections) { - if ( - c.status === "active" || - (allowed !== null && !allowed.has(c.toolkit)) - ) { - hidden.add(c.toolkit); - } + if (c.status === "active") hidden.add(c.toolkit); } return hidden; } diff --git a/app/src/components/integrations/custom-add-dialog.tsx b/app/src/components/integrations/custom-add-dialog.tsx index b845c43d0..b1623d1a9 100644 --- a/app/src/components/integrations/custom-add-dialog.tsx +++ b/app/src/components/integrations/custom-add-dialog.tsx @@ -56,7 +56,7 @@ function ChoiceCard({ /** * The "Add custom integration" dialog (HOU-980): a two-way fork, then the * chosen path. "Set up with your agent" hands off to the guided setup chat - * (the parent resolves the agent — the tab's agent, the workspace's only + * (the parent resolves the agent — the ambient agent, the workspace's only * agent, or the picker — and starts it); "Add manually" swaps the body for * the typed form ({@link CustomAddForm}: kind, URL + detect, name, * needs-a-key). The chat path LEADS visually because the product's audience diff --git a/app/src/components/integrations/custom-add-flow.tsx b/app/src/components/integrations/custom-add-flow.tsx index bdc32650c..9c74a36a2 100644 --- a/app/src/components/integrations/custom-add-flow.tsx +++ b/app/src/components/integrations/custom-add-flow.tsx @@ -11,7 +11,7 @@ interface CustomAddFlowProps { /** The add fork dialog's open state — the parent's Add button drives it. */ open: boolean; onOpenChange: (open: boolean) => void; - /** The AMBIENT agent (the per-agent tab): the guided chat starts with THIS + /** The AMBIENT agent (an agent-scoped caller): the guided chat starts with THIS * agent, no picker. Absent on the global page, where the workspace's only * agent resolves the target and only a multi-agent workspace asks. */ agent?: Agent; @@ -61,7 +61,7 @@ export function CustomAddFlow({ onOpenChange={onOpenChange} agentId={transportAgentId} onStartChat={() => { - // The ambient agent (per-agent tab), else the workspace's only + // The ambient agent, else the workspace's only // agent. Only a genuinely ambiguous workspace gets the picker: // asking "which agent?" when there is exactly one is a dead // question the #1171 flow already stopped asking. diff --git a/app/src/components/integrations/custom-controls.tsx b/app/src/components/integrations/custom-controls.tsx new file mode 100644 index 000000000..bca06a865 --- /dev/null +++ b/app/src/components/integrations/custom-controls.tsx @@ -0,0 +1,47 @@ +import { CatalogSearchField } from "@houston-ai/core"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { HeaderSearch } from "../shell/page-header/header-search"; + +/** + * The Custom mode's controls — its search + the Add button — in the same two + * forms the catalog mode's controls draw ({@link CatalogControls}): a compact + * strip cluster when the header holds the tools, the full-width row above the + * Installed card when it does not. The page header decides which is honest for + * the width; switching modes swaps like for like, so the search never appears + * for one source and vanishes for the other. + */ +export function CustomControls({ + query, + onQueryChange, + addButton, + variant, +}: { + query: string; + onQueryChange: (query: string) => void; + addButton: ReactNode; + variant: "strip" | "row"; +}) { + const { t } = useTranslation("integrations"); + const inStrip = variant === "strip"; + return ( +
    + + + + {addButton} +
    + ); +} diff --git a/app/src/components/integrations/custom-integration-dialogs.tsx b/app/src/components/integrations/custom-integration-dialogs.tsx index ce0f238c4..5d852db5d 100644 --- a/app/src/components/integrations/custom-integration-dialogs.tsx +++ b/app/src/components/integrations/custom-integration-dialogs.tsx @@ -52,7 +52,7 @@ export function useCustomSelection(): CustomSelection { /** * The dialog trio for one surface, driven by {@link useCustomSelection}. With * an `agentId` every read/write rides the per-agent routes (HOU-823), so the - * per-agent Integrations tab keeps working behind the hosted gateway (where + * per-agent transport keeps working behind the hosted gateway (where * the top-level custom routes 404). The detail card's own Enter/Update key and * Remove buttons chain into the sibling dialogs, closing the detail first so * one modal is on stage at a time. diff --git a/app/src/components/integrations/custom-integrations-section.tsx b/app/src/components/integrations/custom-integrations-section.tsx index d1d8b2dc2..bcfdc0208 100644 --- a/app/src/components/integrations/custom-integrations-section.tsx +++ b/app/src/components/integrations/custom-integrations-section.tsx @@ -1,4 +1,4 @@ -import { Button } from "@houston-ai/core"; +import { Button, CatalogGrid } from "@houston-ai/core"; import { Plus } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,11 +7,12 @@ import { useCustomTransportAgentId, useStartCustomOAuth, } from "../../hooks/queries"; -import type { Agent } from "../../lib/types"; import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; import { AgentPickerDialog } from "../agent-picker-dialog"; import { INTEGRATIONS_VIEW_ID } from "../integrations-view/id"; +import { PageHeaderTools } from "../shell/page-header/page-header-tools"; +import { CustomControls } from "./custom-controls"; import { CustomEmptyState, CustomLoadErrorState } from "./custom-empty-state"; import { CustomIntegrationDialogs, @@ -28,7 +29,7 @@ import { useIntegrationChatSetup } from "./use-integration-chat-setup"; * Custom integrations (API / MCP servers the app catalog doesn't offer). Two * variants, one body: `"section"` (default) is the standalone block with its * own heading, embedded by the page's non-ready states; `"tab"` is the body of - * the Custom integrations tab on the global page AND the per-agent tab. Hidden + * the Custom integrations tab on the global Integrations page. Hidden * ENTIRELY when the host does not support the feature (list → `null`) or * before the list resolves; otherwise always visible so the empty state can * invite creation. @@ -38,9 +39,8 @@ import { useIntegrationChatSetup } from "./use-integration-chat-setup"; * board use, so this page stays visible). The manual add form and its fork * dialog (custom-add-*.tsx) stay in the tree, deliberately UNWIRED: discovery * isn't deterministic enough yet to hand users a raw form, so the chat is the - * one entry point until it is. With an `agent` (the per-agent tab) the chat - * starts with THAT agent; without one it starts with the workspace's only - * agent, and only a multi-agent workspace asks which agent runs the interview + * one entry point until it is. The chat starts on the workspace's only agent; + * a multi-agent workspace asks which agent runs the interview * ({@link AgentPickerDialog}). Reads/writes ride the per-agent routes * (HOU-823) whenever a transport agent exists, so the surface keeps working * behind the hosted gateway (which proxies no top-level custom route). A @@ -51,25 +51,17 @@ import { useIntegrationChatSetup } from "./use-integration-chat-setup"; */ export function CustomIntegrationsSection({ variant = "section", - agent, - tabActive, }: { variant?: "section" | "tab"; - agent?: Agent; - /** Per-agent surface only: whether that tab owns the visible agent screen - * (TabProps.isActive). The global page derives visibility from `viewMode` - * itself. Needed because kept-alive views leave every section MOUNTED, and - * only the visible one may drive the shared shell chat panel. */ - tabActive?: boolean; }) { const { t } = useTranslation("integrations"); - const transportAgentId = useCustomTransportAgentId(agent?.id); + const transportAgentId = useCustomTransportAgentId(); const list = useCustomIntegrationsFor(transportAgentId); const agents = useAgentStore((s) => s.agents); const viewMode = useUIStore((s) => s.viewMode); - const surfaceActive = agent - ? (tabActive ?? false) - : viewMode === INTEGRATIONS_VIEW_ID; + // Kept-alive views leave every page MOUNTED, so only the page the user is + // actually looking at may drive the shared shell chat panel. + const surfaceActive = viewMode === INTEGRATIONS_VIEW_ID; const chatSetup = useIntegrationChatSetup(); const selection = useCustomSelection(); const signIn = useStartCustomOAuth(transportAgentId); @@ -77,11 +69,11 @@ export function CustomIntegrationsSection({ const [pickerOpen, setPickerOpen] = useState(false); const [query, setQuery] = useState(""); - // Straight to the setup chat — no fork dialog. The ambient agent (per-agent - // tab) or a single-agent workspace resolves the target immediately; only a - // multi-agent workspace on the global page needs the picker. + // Straight to the setup chat — no fork dialog. A single-agent workspace + // resolves the target immediately; only a multi-agent workspace needs the + // picker to ask who runs the interview. const startAdd = () => { - const target = agent ?? (agents.length === 1 ? agents[0] : undefined); + const target = agents.length === 1 ? agents[0] : undefined; if (target) void chatSetup.start(target); else setPickerOpen(true); }; @@ -129,7 +121,10 @@ export function CustomIntegrationsSection({ ); const rowsGrid = ( -
    + // The shared container-aware grid, not a viewport breakpoint: this list + // renders inside the centered catalog column, and a viewport rule would + // count the window's width instead of the column's. + {visible.map((integration) => ( selection.openRemove(i.slug)} /> ))} -
    + ); return ( @@ -163,7 +158,6 @@ export function CustomIntegrationsSection({ {chatSetup.open && activeAgent && ( - - {rowsGrid} - + + {(inStrip) => ( + + )} + + {rowsGrid} {visible.length === 0 && (

    {t("custom.noResults")}

    )} diff --git a/app/src/components/integrations/custom-section-chrome.tsx b/app/src/components/integrations/custom-section-chrome.tsx index 3bc28b0f5..82e0ac7c0 100644 --- a/app/src/components/integrations/custom-section-chrome.tsx +++ b/app/src/components/integrations/custom-section-chrome.tsx @@ -1,4 +1,4 @@ -import { CatalogSearchField, CatalogShell } from "@houston-ai/core"; +import { CatalogShell } from "@houston-ai/core"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { SectionHeader } from "./section-header"; @@ -32,24 +32,20 @@ export function CustomSectionChrome({ /** * The Custom MODE's layout (HOU-980 review): the same {@link CatalogShell} - * grammar as the Composio mode — one controls row (this mode's search + the - * Add button) over an **Installed** card holding the custom rows — so - * switching the page-level source toggle swaps like for like. No description - * line: the toggle names the surface and the Add dialog explains the rest. - * The caller renders the rows (`children`); with a live query matching - * nothing it passes `count` 0 and shows its own no-results line under the - * shell. + * grammar as the Composio mode — an **Installed** card holding the custom + * rows — so switching the page-level source toggle swaps like for like. Its + * controls (this mode's search + the Add button) are {@link CustomControls}, + * rendered by the section through the page header's tools portal exactly as + * the catalog mode's are, so they live in the strip when the width holds them + * and in a row above this card when it does not. No description line: the + * toggle names the surface and the Add dialog explains the rest. The caller + * renders the rows (`children`); with a live query matching nothing it passes + * `count` 0 and shows its own no-results line under the shell. */ export function CustomModeShell({ - query, - onQueryChange, - addButton, count, children, }: { - query: string; - onQueryChange: (query: string) => void; - addButton: ReactNode; /** How many rows currently show (matches while filtering, total at rest). */ count: number; children?: ReactNode; @@ -57,20 +53,7 @@ export function CustomModeShell({ const { t } = useTranslation("integrations"); return ( - - {addButton} -
    - } installedTitle={t("home.installedTitle")} - installedCount={count} installed={count > 0 ? children : undefined} tabs={[]} /> diff --git a/app/src/components/integrations/effective-access.ts b/app/src/components/integrations/effective-access.ts deleted file mode 100644 index 263aa6d52..000000000 --- a/app/src/components/integrations/effective-access.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * THE single answer to "can agent X use app Y right now, and if not why". One - * pure classifier so no surface re-derives the rule from scratch: the - * agent-integrations view model buckets every connection through this, and the - * exported states name each reason a user might see. - * - * Usability is now purely connection ∩ effective allowlist — the per-agent - * GRANTS layer is gone (permissions live in exactly one place, the Permissions - * view). Precedence is deliberate (first match wins): an admin ceiling outranks - * everything (a blocked app reads as "your admin turned this off", never "not - * connected"), then a missing connection, then usable. Toolkit matching is exact - * slug equality — slugs are already normalized lowercase across this codebase - * (see `splitByGrant`), so no case-folding here. DOM-free + dependency-free so - * it's trivially unit-testable. - */ - -/** Why an agent can or cannot use an app, as a discriminated union. */ -export type EffectiveAccess = - | { state: "usable" } - | { state: "notConnected" } - | { state: "blockedByAdmin" }; - -/** The bare state tag, for callers that only need the classification. */ -export type EffectiveAccessState = EffectiveAccess["state"]; - -export function effectiveAccess(input: { - toolkit: string; - /** Active-or-recovering connections of the acting user (any toolkit). */ - connections: readonly { toolkit: string }[]; - /** Effective allowlist (org ∩ agent); `null` = unrestricted. */ - allowlist: readonly string[] | null; -}): EffectiveAccess { - const { toolkit, connections, allowlist } = input; - if (allowlist !== null && !allowlist.includes(toolkit)) { - return { state: "blockedByAdmin" }; - } - if (!connections.some((c) => c.toolkit === toolkit)) { - return { state: "notConnected" }; - } - return { state: "usable" }; -} diff --git a/app/src/components/integrations/enable-in-permissions-button.tsx b/app/src/components/integrations/enable-in-permissions-button.tsx deleted file mode 100644 index 044c4ba92..000000000 --- a/app/src/components/integrations/enable-in-permissions-button.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ShieldCheck } from "lucide-react"; - -/** - * The role-aware blocked-state CTA: a small pill that deep-links a viewer who - * CAN lift a policy ceiling into the Admin Permissions area, replacing the - * ask-your-admin line members see. Presentational (label + click in), so the - * leaf sections that render it stay store-free; the surface that has the policy - * data owns the destination. Visible at rest (no hover gating). - */ -export function EnableInPermissionsButton({ - label, - onClick, -}: { - label: string; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/app/src/components/integrations/index.ts b/app/src/components/integrations/index.ts index a3911b58d..1f11482a8 100644 --- a/app/src/components/integrations/index.ts +++ b/app/src/components/integrations/index.ts @@ -9,20 +9,13 @@ export { } from "./app-display"; export { AppLogo } from "./app-logo"; export { AppRow } from "./app-row"; -export { - type PermissionsFix, - resolvePermissionsFix, -} from "./blocked-ceiling"; export { BROWSE_PAGE_SIZE, - type BrowseCatalogView, browseCatalog, - browseCatalogView, type CategoryListView, categoriesOf, categoryLabel, categoryListView, - LOCKED_PREVIEW_CAP, toolkitsInCategory, UNCATEGORIZED, } from "./browse-model"; @@ -33,7 +26,6 @@ export { MOST_USED, SECTION_PREVIEW_CAP, } from "./browse-sections"; -export { CatalogLockedSection } from "./catalog-locked-section"; export { CATEGORY_PRIORITY } from "./category-priority"; export { ConnectFlowInline, hasConnectState } from "./connect-flow-inline"; export { ConnectNoticeLine } from "./connect-notice-line"; @@ -61,7 +53,6 @@ export { useCustomSelection, } from "./custom-integration-dialogs"; export { CustomIntegrationsSection } from "./custom-integrations-section"; -export { EnableInPermissionsButton } from "./enable-in-permissions-button"; export { IntegrationBadges } from "./integration-badges"; export { IntegrationChips } from "./integration-chips"; export { IntegrationDisconnectDialog } from "./integration-disconnect-dialog"; @@ -71,7 +62,6 @@ export { POLL_MAX_ATTEMPTS, type PollOutcome, pollConnectionUntilActive, - splitByGrant, } from "./model"; export { SectionHeader } from "./section-header"; export { diff --git a/app/src/components/integrations/integration-badges.tsx b/app/src/components/integrations/integration-badges.tsx index 2e2171d97..96d68efa9 100644 --- a/app/src/components/integrations/integration-badges.tsx +++ b/app/src/components/integrations/integration-badges.tsx @@ -8,7 +8,7 @@ import { useToolkitBySlug } from "./use-toolkit-catalog"; * {@link IntegrationChips}, for detail surfaces that have room to spell the app * out (a store listing's detail dialog, a skill's edit modal). Names and logos * resolve through the Composio toolkit catalog (the same `appDisplay` path the - * Integrations tab uses) so a detail surface never shows a machine slug. While + * Integrations page uses) so a detail surface never shows a machine slug. While * the catalog hasn't loaded, or on a deployment with no integration provider * wired, `appDisplay` degrades to a favicon guess and the slug as its name. * diff --git a/app/src/components/integrations/integration-chips.tsx b/app/src/components/integrations/integration-chips.tsx index cc06333aa..a27613b29 100644 --- a/app/src/components/integrations/integration-chips.tsx +++ b/app/src/components/integrations/integration-chips.tsx @@ -12,12 +12,12 @@ interface Props { /** * A compact row of 16px toolkit logos: the apps a card's subject works with * (a first-party agent in the new-agent store, a skill in the picker / chat / - * installed strip). Purely informational: the user connects apps once in the - * Integrations tab (Composio), and every skill reaches them through the + * installed strip). Purely informational: the user connects apps once on the + * Integrations page (Composio), and every skill reaches them through the * integration tools. * * Logos resolve through the Composio toolkit catalog (the same `appDisplay` - * path as the Integrations tab), because the favicon guess alone breaks for + * path as the Integrations page), because the favicon guess alone breaks for * every slug that isn't literally a `.com` domain (googledocs, metaads, * perplexityai, ...). While the catalog hasn't loaded — or on a deployment * with no integration provider wired — `appDisplay` falls back to the guess, diff --git a/app/src/components/integrations/integration-setup-chat.tsx b/app/src/components/integrations/integration-setup-chat.tsx index f3065354c..3b6bc3ab3 100644 --- a/app/src/components/integrations/integration-setup-chat.tsx +++ b/app/src/components/integrations/integration-setup-chat.tsx @@ -1,17 +1,16 @@ import { Button } from "@houston-ai/core"; import type { Activity } from "@houston-ai/engine-client"; import { Loader2, X } from "lucide-react"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import type { Agent, AgentDefinition } from "../../lib/types"; +import type { Agent } from "../../lib/types"; +import { RoutineSetupChatBoard } from "../agent/routine-setup-chat-board"; import { useShellDetailPanel } from "../shell/use-shell-detail-panel"; -import { RoutineSetupChatBoard } from "../tabs/routine-setup-chat-board"; interface Props { /** The agent that owns this setup chat (resolved by the section's hook). */ agent: Agent; - agentDef: AgentDefinition | null; /** * The agent's live integration-setup draft, or null while it is still being * created (renders a calm loading state — never a dead screen). @@ -20,7 +19,7 @@ interface Props { /** * Whether the surface that mounted this chat owns the visible screen. The * custom section stays mounted inside kept-alive views (the global page AND - * every agent tab), and only the visible instance may drive the SHARED + * every mounted screen), and only the visible instance may drive the SHARED * shell panel — two portals into one container would double-mount the chat. */ active: boolean; @@ -34,9 +33,9 @@ interface Props { /** * The custom-integration setup chat, rendered into the big shell-level panel - * (`useShellDetailPanel`) — the EXACT right-hand panel the Activity board and + * (`useShellDetailPanel`) — the EXACT right-hand panel the mission board and * the routine chat open, so the Integrations page stays visible on the left - * while the agent runs the interview on the right (the Automations look; the + * while the agent runs the interview on the right (the Routines look; the * old inline-box embed buried the page under the chat). * * The guided chat is a real mission under the hood, but every board filters @@ -48,7 +47,6 @@ interface Props { */ export function IntegrationSetupChat({ agent, - agentDef, activity, active, onClose, @@ -89,19 +87,6 @@ export function IntegrationSetupChat({ return () => document.removeEventListener("keydown", onKeyDown); }, [active, onClose]); - // The Integrations page is global, so the catalog may not resolve a def for - // this agent's (fabricated) configId. RoutineSetupChatBoard needs a non-null - // def only for its agent-modes list, which a setup chat never uses — - // synthesize a minimal one so the send path still compiles and runs. - const resolvedDef = useMemo( - () => - agentDef ?? { - config: { id: agent.configId, name: agent.name, description: "" }, - source: "builtin", - }, - [agentDef, agent.configId, agent.name], - ); - // The panel div mounts only after setPanelOpen(true) lands — render nothing // for that frame (and always nothing while another surface owns the screen). if (!active || !panelContainer) return null; @@ -152,7 +137,6 @@ export function IntegrationSetupChat({
    ; -}): { granted: IntegrationConnection[]; available: IntegrationConnection[] } { - const granted: IntegrationConnection[] = []; - const available: IntegrationConnection[] = []; - for (const c of opts.connections) { - (opts.grants.has(c.toolkit) ? granted : available).push(c); - } - return { granted, available }; -} - /** * Outcome of the post-connect poll loop. `timeout` and `error` are first-class * results, NOT silent fall-throughs: the caller MUST surface them so an diff --git a/app/src/components/integrations/use-connect-flow.ts b/app/src/components/integrations/use-connect-flow.ts index 0e8357fd2..2522b0629 100644 --- a/app/src/components/integrations/use-connect-flow.ts +++ b/app/src/components/integrations/use-connect-flow.ts @@ -35,7 +35,7 @@ export type { ConnectNotice, ConnectStep } from "./connect-flow-run"; * The connect / reconnect hand-off. It binds this surface to the ONE shared * flow state (`stores/connect-flow.ts`) rather than owning a private copy, so: * - * - a connect started in chat is the SAME flow the Integrations tab renders, + * - a connect started in chat is the SAME flow the Integrations page renders, * and per-toolkit single-flight holds across every surface (a second caller * for the same app JOINS the running flow and observes its outcome); * - flows are PER TOOLKIT and genuinely concurrent — connecting Slack never diff --git a/app/src/components/integrations/use-integration-chat-setup.ts b/app/src/components/integrations/use-integration-chat-setup.ts index 11d1578c1..20ded7220 100644 --- a/app/src/components/integrations/use-integration-chat-setup.ts +++ b/app/src/components/integrations/use-integration-chat-setup.ts @@ -17,7 +17,6 @@ import { queryKeys } from "../../lib/query-keys"; import { tauriActivity, tauriConfig } from "../../lib/tauri"; import { DEFAULT_TURN_MODE } from "../../lib/turn-mode"; import type { Agent } from "../../lib/types"; -import { useAgentCatalogStore } from "../../stores/agent-catalog"; import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; @@ -42,7 +41,6 @@ export function useIntegrationChatSetup() { const openAgentId = useUIStore((s) => s.integrationSetupChatAgentId); const setOpenAgentId = useUIStore((s) => s.setIntegrationSetupChatAgentId); const agents = useAgentStore((s) => s.agents); - const getAgentDef = useAgentCatalogStore((s) => s.getById); const [pending, setPending] = useState(false); // The kickoff runs on a provider the user is actually signed into, never on // an agent-configured one they never connected (PRODUCT-1236). `null` = the @@ -78,9 +76,6 @@ export function useIntegrationChatSetup() { [openAgentId, agents], ); const activeAgent = openAgent ?? draftAgent; - const activeAgentDef = activeAgent - ? (getAgentDef(activeAgent.configId) ?? null) - : null; // The full Activity (session_key, pending_interaction) for the active agent's // draft — the cross-agent conversation row is too thin to render the chat. @@ -223,7 +218,6 @@ export function useIntegrationChatSetup() { return { activeAgent, - activeAgentDef, draftActivity, /** A live draft exists somewhere (drives the Continue-setup banner). */ hasDraft: draftConvo != null, diff --git a/app/src/components/mission-agent-filter.tsx b/app/src/components/mission-agent-filter.tsx deleted file mode 100644 index d48976809..000000000 --- a/app/src/components/mission-agent-filter.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@houston-ai/core"; -import { ChevronDown, ListFilter } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import type { Agent } from "../lib/types"; -import { AgentCardAvatar } from "./shell/agent-card-avatar"; - -/** - * Mission Control's filter-by-agent menu, sibling of {@link MissionPersonFilter} - * and shaped the same way: an "everything" default plus one row per agent, - * collapsing to the selected agent's avatar when a chat panel narrows the bar. - */ -export function MissionAgentFilter({ - agents, - filterPath, - onFilterPathChange, - collapsed, -}: { - agents: Agent[]; - /** Selected agent's folder path, or `""` for all agents. */ - filterPath: string; - onFilterPathChange: (path: string) => void; - collapsed: boolean; -}) { - const { t } = useTranslation("dashboard"); - const selectedAgent = agents.find((agent) => agent.folderPath === filterPath); - - return ( - - - {collapsed ? ( - - ) : ( - - )} - - - onFilterPathChange("")}> - {t("filter.allAgents")} - - {agents.map((agent) => ( - onFilterPathChange(agent.folderPath)} - className="gap-2" - > - - {agent.name} - - ))} - - - ); -} diff --git a/app/src/components/mission-control-create.ts b/app/src/components/mission-control-create.ts index 92783cd32..a430868d6 100644 --- a/app/src/components/mission-control-create.ts +++ b/app/src/components/mission-control-create.ts @@ -1,21 +1,20 @@ /** * Pure planning for a Mission Control "new mission" submit. * - * Mission Control creates conversations across agents, so the active agent - * and its default mode must be resolved before delegating to - * `useMissionControl.handleCreateConversation`. Kept pure (no React, no - * Tauri) so the routing that issue #328 regressed — a blank submit MUST - * produce a create request whenever an agent is active — is unit-testable. + * Mission Control creates conversations ACROSS agents, so the target agent has + * to be resolved (and its absence handled) before delegating to + * `useMissionControl.handleCreateConversation`. Kept pure (no React, no Tauri) + * so the routing that issue #328 regressed — a blank submit MUST produce a + * create request whenever an agent is active, and a loud `no-agent` when none + * is, never a silently cleared composer — stays unit-testable. */ -import type { Agent, AgentDefinition } from "../lib/types"; +import type { Agent } from "../lib/types"; export type NewMissionPlan = | { kind: "create"; agent: Agent; - agentMode?: string; - promptFile?: string; providerOverride: string; modelOverride: string; } @@ -23,20 +22,14 @@ export type NewMissionPlan = export function planNewMission(args: { activeAgent: Agent | null; - activeAgentDef: AgentDefinition | null; providerOverride: string; modelOverride: string; }): NewMissionPlan { - const { activeAgent, activeAgentDef, providerOverride, modelOverride } = args; + const { activeAgent, providerOverride, modelOverride } = args; if (!activeAgent) return { kind: "no-agent" }; - // A blank Mission Control mission runs the agent's default mode (the - // first entry), mirroring the per-agent BoardTab "New mission" button. - const defaultMode = activeAgentDef?.config.agents?.[0]; return { kind: "create", agent: activeAgent, - agentMode: defaultMode?.id, - promptFile: defaultMode?.promptFile, providerOverride, modelOverride, }; diff --git a/app/src/components/mission-control-toolbar.tsx b/app/src/components/mission-control-toolbar.tsx index 1a26aa294..cadcf584e 100644 --- a/app/src/components/mission-control-toolbar.tsx +++ b/app/src/components/mission-control-toolbar.tsx @@ -1,75 +1,117 @@ +import type { KanbanItem } from "@houston-ai/board"; +import { CatalogSearchField } from "@houston-ai/core"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { BoardBackButton } from "./board/board-back-button"; -import { MissionSearchInput } from "./mission-search-input"; -import { - MissionToolbarActions, - type MissionToolbarActionsProps, -} from "./mission-toolbar-actions"; +import type { Agent } from "../lib/types"; +import { MissionPersonFilter } from "./mission-person-filter"; +import { NewMissionButton } from "./new-mission-button"; +import { HeaderSearch } from "./shell/page-header/header-search"; /** - * The Mission Control bar: an optional back arrow, the mode's title, an - * optional search field, and the {@link MissionToolbarActions} cluster. Every - * Mission Control mode (active board, Archived, Mentions) renders this same - * bar and simply omits the callbacks it has nothing to do with. + * A board section's own tools: how it is narrowed, and the one thing you came + * here to do. It draws in two forms, and the CALLER never picks — the team + * chrome measures its strip and says which one is honest for the width + * (`team-chrome-tools.tsx`). + * + * - **`strip`**: the third zone of the one-row team strip, at the right edge. + * A compact search that grows when you use it, the person filter, the + * primary action. No padding of its own; the strip owns that. + * - **`row`**: the two-row fallback, unchanged from before the strip existed — + * a full-width bar under the chrome, search pinned left, cluster right. + * + * Identity and navigation are never here in either form: that is the crumb and + * the tabs. The archive is a TAB too, so this has no entry pill, no overflow + * menu and no back button to compete with its one filled control. */ -interface MissionControlToolbarProps extends MissionToolbarActionsProps { +interface MissionControlToolbarProps { + /** Which form to draw. The chrome decides; see the module comment. */ + variant: "strip" | "row"; + /** Visible board items (post agent-filter) — feeds the person-filter roster. + * Omitted by the Archived section, which has no attribution filter. */ + items?: KanbanItem[]; + /** Selected person for the attribution filter, or `null` for Everyone. */ + filterUserId?: string | null; + /** When set, renders the filter-by-person control (active board only). */ + onFilterUserIdChange?: (userId: string | null) => void; search?: string; isSearchingText?: boolean; - /** When set, renders the text-search field. Omitted by the Mentions inbox, - * which is a short chronological list with nothing to search. */ + /** When set, renders the text-search field. */ onSearchChange?: (value: string) => void; - /** When set, renders the labelled back button on the left (used by the - * Archived and Mentions views to return to the active board). */ - onBack?: () => void; - /** Whether the Archived view is showing -- picks the title line. */ - archivedActive?: boolean; + /** This SECTION's own agent filter capsule (the archive's). Rendered in the + * same slot the person filter takes on the active board, so both board + * sections read left to right as search, filter, primary action. */ + agentFilter?: ReactNode; + /** The section's primary action. Both board sections carry it, and both + * answer "whose task?" the same way (`board/new-mission-target.ts`). */ + newMission?: { + agents: Agent[]; + menuOpen: boolean; + onMenuOpenChange: (open: boolean) => void; + onPick: (agent: Agent) => void; + }; + /** Compact layout: a chat panel is open, so the board is narrow. Collapses + * the person filter to its avatar so the row stays on one line. */ + collapsed: boolean; } -export function MissionControlToolbar(props: MissionControlToolbarProps) { +export function MissionControlToolbar({ + variant, + items, + filterUserId, + onFilterUserIdChange, + search = "", + isSearchingText = false, + onSearchChange, + agentFilter, + newMission, + collapsed, +}: MissionControlToolbarProps) { const { t } = useTranslation("dashboard"); - const { - search = "", - isSearchingText = false, - onSearchChange, - onBack, - archivedActive = false, - mentionsActive = false, - } = props; + const strip = variant === "strip"; + + const searchField = onSearchChange && ( + + + + ); + + const cluster = ( + <> + {onFilterUserIdChange && ( + + )} + {agentFilter} + {newMission && } + + ); + + if (strip) { + return ( + <> + {searchField} + {cluster} + + ); + } return ( -
    -
    - {onBack && ( - - )} - {/* Truncates rather than holding its width: on a narrow window the way - HOME must survive, so the title gives up space before the back - button does. */} -

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

    -
    - {onSearchChange && ( - - )} - -
    -
    +
    + {searchField} +
    {cluster}
    ); } diff --git a/app/src/components/mission-person-filter.tsx b/app/src/components/mission-person-filter.tsx index 424bb885f..a5affe67b 100644 --- a/app/src/components/mission-person-filter.tsx +++ b/app/src/components/mission-person-filter.tsx @@ -83,6 +83,10 @@ export function MissionPersonFilter({ void; -} - -// Reused offscreen canvas for measuring placeholder text width. -let measureCanvas: HTMLCanvasElement | null = null; -function textWidth(text: string, font: string): number { - measureCanvas ??= document.createElement("canvas"); - const ctx = measureCanvas.getContext("2d"); - if (!ctx) return 0; - ctx.font = font; - return ctx.measureText(text).width; -} - -export function MissionSearchInput({ - value, - isSearchingText, - labels, - className, - onChange, -}: MissionSearchInputProps) { - const wrapperRef = useRef(null); - const inputRef = useRef(null); - // Visible placeholder: the full text, or the short one when it wouldn't fit. - const [placeholder, setPlaceholder] = useState(labels.placeholder); - const { placeholder: full, placeholderShort } = labels; - - // Layout effect (not useEffect) so the first measurement happens before paint - // — avoids a full→short flicker on mount when the input is already narrow. - useLayoutEffect(() => { - const el = wrapperRef.current?.querySelector("input"); - if (!el || !placeholderShort) { - setPlaceholder(full); - return; - } - const update = () => { - const cs = getComputedStyle(el); - const font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; - const available = Math.max( - 0, - el.clientWidth - - (parseFloat(cs.paddingLeft) || 0) - - (parseFloat(cs.paddingRight) || 0), - ); - // +4px so it switches just before the text would actually clip. - setPlaceholder( - textWidth(full, font) + 4 <= available ? full : placeholderShort, - ); - }; - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => observer.disconnect(); - }, [full, placeholderShort]); - - return ( -
    - - onChange(event.target.value)} - placeholder={placeholder} - aria-label={full} - autoComplete="off" - className="rounded-full border-line bg-transparent pl-9 pr-16 text-sm focus:bg-transparent" - /> - {isSearchingText && ( - - )} - {value && ( - { - onChange(""); - inputRef.current?.focus(); - }} - /> - )} -
    - ); -} diff --git a/app/src/components/mission-toolbar-actions.tsx b/app/src/components/mission-toolbar-actions.tsx deleted file mode 100644 index d74125547..000000000 --- a/app/src/components/mission-toolbar-actions.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { KanbanItem } from "@houston-ai/board"; -import { - Button, - cn, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@houston-ai/core"; -import { Archive, AtSign } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { shortcutLabel } from "../lib/shortcuts"; -import type { Agent } from "../lib/types"; -import { mentionCountLabel } from "./board/mentions-inbox-view-model"; -import { MissionAgentFilter } from "./mission-agent-filter"; -import { MissionPersonFilter } from "./mission-person-filter"; -import { HoustonLogo } from "./shell/experience-card"; - -/** - * The right-hand control cluster of the Mission Control bar: filter by agent, - * filter by person, the Mentions and Archived mode pills, and "New mission". - * - * Every control is opt-in by callback presence, so each Mission Control mode - * shows exactly the chrome it can act on: the active board takes all of them, - * the Archived view drops the person filter, and the Mentions inbox drops - * everything but its own pill. That also keeps the single-player desktop - * byte-identical to before — no `onToggleMentions`, no Mentions chrome. - */ -/** The Mentions-inbox entry point the toolbar renders (multiplayer only). */ -export interface MissionsToolbarMentions { - onShow: () => void; - /** Unread mention count shown on the control. */ - count: number; -} - -export interface MissionToolbarActionsProps { - agents: Agent[]; - /** Visible board items (post agent-filter) — feeds the person-filter roster. - * Omitted by the Archived toolbar, which has no attribution filter. */ - items?: KanbanItem[]; - filterPath?: string; - /** Selected person for the attribution filter, or `null` for Everyone. */ - filterUserId?: string | null; - /** When set, renders the filter-by-agent menu. Omitted by the Mentions - * inbox, whose rows already name their agent. */ - onFilterPathChange?: (path: string) => void; - /** When set, renders the filter-by-person control (active board only). */ - onFilterUserIdChange?: (userId: string | null) => void; - /** Opens the cross-agent Archived view. When omitted, no Archived control - * renders -- the Archived view itself leaves it out, because there its own - * labelled back button is the single way home (HOU-1043). */ - onShowArchived?: () => void; - /** Whether the Mentions inbox is currently showing (highlights the control - * and switches the title line). */ - mentionsActive?: boolean; - /** Toggle between the active board and the Mentions inbox. Multiplayer only: - * omit it and single player gains no Mentions chrome at all. */ - onToggleMentions?: () => void; - /** Unread mention count shown on the Mentions control. 0 renders no number. */ - mentionCount?: number; - /** "New mission" trigger. Present in both the active and archived toolbars. */ - onNewMission?: () => void; - /** Compact layout: a chat panel is open, so the board is narrow. Collapses - * the buttons to icons so the title stays on one line. */ - collapsed: boolean; -} - -export function MissionToolbarActions({ - agents, - items, - filterPath = "", - filterUserId, - onFilterPathChange, - onFilterUserIdChange, - onShowArchived, - mentionsActive = false, - onToggleMentions, - mentionCount = 0, - onNewMission, - collapsed, -}: MissionToolbarActionsProps) { - const { t } = useTranslation("dashboard"); - const hasMentionCount = mentionCount > 0; - const mentionsLabel = hasMentionCount - ? t("mentions.ariaCount", { count: mentionCount }) - : t("mentions.button"); - - return ( -
    - {onFilterPathChange && ( - - )} - {onFilterUserIdChange && ( - - )} - {onToggleMentions && ( - - - - - {collapsed && ( - {mentionsLabel} - )} - - )} - {onShowArchived && ( - - - - - {collapsed && ( - - {t("archived.button")} - - )} - - )} - {onNewMission && ( - - - - - - {collapsed ? t("empty.newMission") : shortcutLabel("newMission")} - - - )} -
    - ); -} diff --git a/app/src/components/new-mission-button.tsx b/app/src/components/new-mission-button.tsx new file mode 100644 index 000000000..c1408155d --- /dev/null +++ b/app/src/components/new-mission-button.tsx @@ -0,0 +1,90 @@ +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@houston-ai/core"; +import { SquarePen } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { shortcutLabel } from "../lib/shortcuts"; +import type { Agent } from "../lib/types"; +import { AgentCardAvatar } from "./shell/agent-card-avatar"; +import { tourAnchor } from "./shell/workspace-tour-steps.ts"; + +/** + * "New task" — a board section's PRIMARY action, and the only filled control + * on the screen. + * + * It sits in the section's own row-2 tools rather than on the team strip, + * because it belongs to the page and not to the frame. `size="sm"` puts it at + * exactly the strip's lozenge height (32px) with a full radius, so the right + * zone reads as one row of Safari capsules. It keeps its LABEL — an icon-only + * primary action would make the single most important control on the screen + * the one you have to hover to identify. + * + * **It answers "whose task?" in place.** A board pinned to one agent, or a + * team with one agent, opens that composer straight away and the menu never + * appears; an ambiguous board drops a menu of the board's agents right under + * the button (16px helmet + name, the same idiom as the agent filter capsule + * beside it). The rule is `board/new-mission-target.ts`; this component only + * draws it, and the OPEN state is the caller's so the keyboard shortcut walks + * exactly the same path a click does. + */ +export function NewMissionButton({ + agents, + menuOpen, + onMenuOpenChange, + onPick, +}: { + /** The board's agents — the menu's roster. */ + agents: Agent[]; + /** Controlled by the caller, which decides whether a menu is needed at all. */ + menuOpen: boolean; + onMenuOpenChange: (open: boolean) => void; + onPick: (agent: Agent) => void; +}) { + const { t } = useTranslation("dashboard"); + const label = t("empty.newMission"); + + return ( + + + + {/* The trigger's own click IS the request: the caller turns it into + a direct open or a menu, so there is never a frame where a menu + opens for a board that has nothing to ask. */} + + + + + + {shortcutLabel("newMission")} + + + + {agents.map((agent) => ( + onPick(agent)} + > + + {agent.name} + + ))} + + + ); +} diff --git a/app/src/components/onboarding/cloud-migration/done-followups.tsx b/app/src/components/onboarding/cloud-migration/done-followups.tsx index 10dbd1ef7..aa6b85efa 100644 --- a/app/src/components/onboarding/cloud-migration/done-followups.tsx +++ b/app/src/components/onboarding/cloud-migration/done-followups.tsx @@ -112,7 +112,7 @@ export function DoneCongrats({ onFinish }: { onFinish: () => void }) { * Step 2: the apps the legacy account had connected before, one row per * toolkit via the shared `` (real logo, real name, from the Composio * toolkit catalog). Connect is the REAL account-level OAuth hand-off — the - * same `useConnectFlow` the Integrations tab runs (mint link, open browser, + * same `useConnectFlow` the Integrations page runs (mint link, open browser, * poll until active, invalidate, toast failures) with no agent context. * * Connects run TRULY in parallel: each row's Connect is gated only on ITS OWN diff --git a/app/src/components/onboarding/personal-assistant-onboarding.tsx b/app/src/components/onboarding/personal-assistant-onboarding.tsx index 3803fd011..c4b72ef5a 100644 --- a/app/src/components/onboarding/personal-assistant-onboarding.tsx +++ b/app/src/components/onboarding/personal-assistant-onboarding.tsx @@ -138,9 +138,10 @@ export function PersonalAssistantOnboarding({ // workspace shell mounts with the tour overlay already up — no flicker. // The tour itself owns the final landing: its Routines step demos the // freshly-seeded Morning briefing, and its completion/skip callback lands on - // the Routines tab (see workspace-shell). We do NOT set `viewMode` here — the - // tour's first step immediately switches it, so a set here would only flash - // and be overwritten. Also clear the resume flag: this is a terminal path. + // the agent's Routines section (see workspace-tour-overlay). We do NOT set + // `viewMode` here — the tour's first step immediately switches it, so a set + // here would only flash and be overwritten. Also clear the resume flag: this + // is a terminal path. const finishOnboarding = () => { analytics.track("onboarding_completed", { mission: TUTORIAL_MISSION.id, diff --git a/app/src/components/onboarding/use-create-assistant.ts b/app/src/components/onboarding/use-create-assistant.ts index da8541766..bf31d0348 100644 --- a/app/src/components/onboarding/use-create-assistant.ts +++ b/app/src/components/onboarding/use-create-assistant.ts @@ -38,7 +38,7 @@ async function refreshAfterCreate( } // Persist the account timezone so the seeded morning-briefing routine fires at - // the user's local 7am, not the cloud pod's UTC 7am. The Routines-tab hook + // the user's local 7am, not the cloud pod's UTC 7am. The Routines hook // auto-seeds this too, but it never mounts during onboarding — so a user who // never opens Routines would otherwise have their first routine fire in UTC. // Shared helper with the hook; if-absent guarded, never overwrites an existing diff --git a/app/src/components/organization/admin-index.tsx b/app/src/components/organization/admin-index.tsx index e7e05f41b..316169b5f 100644 --- a/app/src/components/organization/admin-index.tsx +++ b/app/src/components/organization/admin-index.tsx @@ -1,7 +1,7 @@ -import { CreditCard, Gauge, History, Users } from "lucide-react"; +import { Building2, ChartColumn, CreditCard, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsRow } from "../settings/settings-row"; -import { PageContainer, PageHeader } from "../shell/page-shell"; +import { PageContainer, PageHero } from "../shell/page-shell"; import type { OrgTabId } from "./org-view-model"; interface AdminIndexProps { @@ -14,16 +14,19 @@ interface AdminIndexProps { /** * The Admin (Organization) landing index. Settings-page grammar - * (SettingsCard/SettingsRow): grouped rows with icon + title + one-line - * description + an at-a-glance value that drill into a detail screen, so a - * non-technical admin scans membership / insights / billing at a glance instead - * of reading an anonymous tab strip. + * (SettingsCard/SettingsRow): rows with icon + title + one-line description + an + * at-a-glance value that drill into a detail screen, so a non-technical admin + * scans the whole dashboard instead of reading an anonymous tab strip. + * + * Four sections: People (the roster and its invites), Billing (the team's plan + * and seats), Analytics (the activity feed, message usage, and time worked as + * lenses), and Company context (the standing knowledge every agent in this + * workspace starts a turn with). * * Presentational only: the shell owns loading/gating and passes the visible id - * set plus each row's value. Access CONTROL (who can use which agent, per-agent - * + org-wide ceilings) lives in the top-level Permissions view now — this - * dashboard keeps membership + insights + billing. Insights always renders; - * Billing only when it is in the visible set. + * set plus each row's value. Every row but Billing always renders; Billing only + * when it is in the visible set. Per-agent policy (who can use which agent, its + * ceilings) is NOT here — it is reached through each team's Manage agents page. */ export function AdminIndex({ visibleIds, @@ -35,7 +38,7 @@ export function AdminIndex({ return ( - - - onSelect("activity")} - /> - onSelect("usage")} - /> - - {showBilling && ( )} + + + onSelect("analytics")} + /> + onSelect("companyContext")} + /> +
    ); diff --git a/app/src/components/organization/admin-section-detail.tsx b/app/src/components/organization/admin-section-detail.tsx index 007cf79bd..2f7881cc9 100644 --- a/app/src/components/organization/admin-section-detail.tsx +++ b/app/src/components/organization/admin-section-detail.tsx @@ -1,27 +1,27 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { PageContainer, PageHeader } from "../shell/page-shell"; -import ActivityTab from "./activity-tab"; +import { PageContainer, PageHero } from "../shell/page-shell"; +import AnalyticsTab from "./analytics-tab"; import BillingTab from "./billing-tab"; +import CompanyContextTab from "./company-context-tab"; import MembersTab from "./members-tab"; import type { OrgTabId } from "./org-view-model"; import type { OrgTabProps, OrgViewContext } from "./organization-view"; -import UsageTab from "./usage-tab"; /** Each Organization section renders from the shared `{ ctx }` contract. */ const SECTION_COMPONENTS: Record ReactNode> = { people: MembersTab, - activity: ActivityTab, - usage: UsageTab, billing: BillingTab, + analytics: AnalyticsTab, + companyContext: CompanyContextTab, }; /** * A section's detail body inside the Admin dashboard: the section heading over - * its content. Every section renders from the shared `{ ctx }` contract (no - * drill-ins remain here — the member/agent access lenses moved to the - * Permissions view). Extracted from `organization-view.tsx` so the shell stays a + * its content. Every section renders from the shared `{ ctx }` contract (the + * activity feed and message usage are lenses INSIDE Analytics now, not sections + * of their own). Extracted from `organization-view.tsx` so the shell stays a * thin index/detail switch. */ export function AdminSectionDetail({ @@ -37,7 +37,7 @@ export function AdminSectionDetail({ const Section = SECTION_COMPONENTS[active]; return ( - + {ctx ? (
    ) : ( diff --git a/app/src/components/organization/analytics-tab.tsx b/app/src/components/organization/analytics-tab.tsx new file mode 100644 index 000000000..4138d173d --- /dev/null +++ b/app/src/components/organization/analytics-tab.tsx @@ -0,0 +1,61 @@ +import { Tabs, TabsList, TabsTrigger } from "@houston-ai/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useCapabilities } from "../../hooks/use-capabilities"; +import { ComputeSection } from "../time-worked/compute-section"; +import { showComputeSection } from "../time-worked/compute-usage-model"; +import ActivityTab from "./activity-tab"; +import type { OrgTabProps } from "./organization-view"; +import UsageTab from "./usage-tab"; + +/** The three questions Analytics answers, in sub-tab order. */ +type AnalyticsLens = "activity" | "usage" | "timeWorked"; + +const BASE_LENSES: readonly AnalyticsLens[] = ["activity", "usage"]; + +/** + * Organization > Analytics: everything measured about the org, behind one tab. + * Three LENSES as sub-tabs rather than stacked sections — Activity is a paged + * "Show more" feed, so anything under it would be buried below an unbounded + * list. Each lens is the existing surface verbatim, keeping its own data hook; + * only the SELECTED lens renders, so an unopened lens starts no read. + * + * Time worked appears only where the deployment meters compute + * (`capabilities.computeUsage`): omitting the sub-tab is what keeps + * `ComputeSection`'s query from firing elsewhere, and it means the lens can + * never open empty. + */ +export default function AnalyticsTab({ ctx }: OrgTabProps) { + const { t } = useTranslation("teams"); + const { capabilities } = useCapabilities(); + const [lens, setLens] = useState("activity"); + + const lenses: readonly AnalyticsLens[] = showComputeSection(capabilities) + ? [...BASE_LENSES, "timeWorked"] + : BASE_LENSES; + // Capabilities can resolve (or a space can change) under a selected lens; fall + // back to the lead lens rather than render nothing. + const active = lenses.includes(lens) ? lens : "activity"; + + return ( +
    + setLens(next as AnalyticsLens)} + className="self-start" + > + + {lenses.map((id) => ( + + {t(`org.tabs.${id}`)} + + ))} + + + + {active === "activity" && } + {active === "usage" && } + {active === "timeWorked" && } +
    + ); +} diff --git a/app/src/components/organization/company-context-tab.tsx b/app/src/components/organization/company-context-tab.tsx new file mode 100644 index 000000000..b81fb6a46 --- /dev/null +++ b/app/src/components/organization/company-context-tab.tsx @@ -0,0 +1,38 @@ +import { Spinner } from "@houston-ai/core"; +import { InstructionsContent } from "../agent/job-description-parts"; +import { useContextSlot, useContextSlotLabels } from "../context/context-slots"; +import type { OrgTabProps } from "./organization-view"; + +/** + * Organization > Company context: the standing knowledge every agent in this + * workspace starts a turn with. It is admin-owned org-wide copy, so it belongs + * on the Admin dashboard next to People and Billing rather than on a screen of + * its own; the per-user half of the same context lives with the user, not here. + * + * The wire is unchanged — `useContextSlot("workspace")` reads and writes the + * same blob it always did. `ready` is false until the agent-backed read lands, + * so the frame shows a spinner instead of an editor over nothing. + * + * Takes {@link OrgTabProps} for uniformity with every other section even though + * it reads nothing off the shared context. + */ +export default function CompanyContextTab(_props: OrgTabProps) { + const editor = useContextSlot("workspace"); + const labels = useContextSlotLabels("workspace"); + + if (!editor.ready) { + return ( +
    + +
    + ); + } + + return ( + + ); +} diff --git a/app/src/components/organization/id.ts b/app/src/components/organization/id.ts new file mode 100644 index 000000000..46c447d72 --- /dev/null +++ b/app/src/components/organization/id.ts @@ -0,0 +1,2 @@ +/** The Admin (Organization) top-level view id (rail destination, "Workspace" band). */ +export const ORGANIZATION_VIEW_ID = "organization"; diff --git a/app/src/components/organization/index.ts b/app/src/components/organization/index.ts index cd576bf2a..b9f26d5ac 100644 --- a/app/src/components/organization/index.ts +++ b/app/src/components/organization/index.ts @@ -1,3 +1,4 @@ +export { ORGANIZATION_VIEW_ID } from "./id"; export { AUDIT_PAGE_SIZE, canSeeOrganization, diff --git a/app/src/components/organization/org-nav-store.ts b/app/src/components/organization/org-nav-store.ts index 7991cbb15..4370c3ccb 100644 --- a/app/src/components/organization/org-nav-store.ts +++ b/app/src/components/organization/org-nav-store.ts @@ -8,18 +8,19 @@ import type { OrgTabId } from "./org-view-model.ts"; * it: the C8 team-status banner / trial pill (in the shell) sends the user to * the Billing tab. Rather than lift that state into the shared UI store (and * couple every consumer to it), this tiny colocated store carries the intent: - * the caller sets the request then calls `openSettings("organization")` (Admin is - * a Settings section since HOU-788, so that ONE store action replaces the old - * viewMode switch); `OrganizationView` consumes it (initial mount AND while - * already open) and clears it so a later plain nav to the dashboard lands on the - * default tab. + * the caller sets the request, then navigates with + * `setViewMode(ORGANIZATION_VIEW_ID)`. `OrganizationView` consumes it and clears + * it, so a later plain nav to the dashboard lands on the default tab. * - * 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. + * Admin is a KEPT-ALIVE top-level screen, so it does not remount per + * navigation: the view consumes the pin from an effect on this field, which + * fires on the first mount AND while the screen is already open (the same shape + * `team-view/team-settings-nav-store.ts` uses). A pin nothing consumes — the + * gates hide Admin, so the screen is never mounted — cannot mislead either: the + * caller that sets it is itself behind the team-space gate. * - * (Per-agent / permission deep links target Settings > Permissions via - * `usePermissionsNav`, not this store.) + * (Per-agent settings are opened directly by `lib/open-agent.ts`, which routes + * through Team Settings rather than pinning anything here.) */ interface OrgNavState { /** The tab to open on the next Organization render, or null for the default. */ diff --git a/app/src/components/organization/org-view-model.ts b/app/src/components/organization/org-view-model.ts index f4ec2d915..12b5912ba 100644 --- a/app/src/components/organization/org-view-model.ts +++ b/app/src/components/organization/org-view-model.ts @@ -1,5 +1,5 @@ import type { AuditEntry, Capabilities } from "@houston-ai/engine-client"; -import { canSeeMembers, hasSpaces } from "../../lib/org-roles.ts"; +import { canSeeMembers, isPersonalSpace } from "../../lib/org-roles.ts"; /** * Pure, DOM-free logic for the Organization dashboard (Teams v2 + C8 billing). @@ -8,32 +8,44 @@ import { canSeeMembers, hasSpaces } from "../../lib/org-roles.ts"; */ /** - * The sections of the Organization dashboard, in tab order. Permissions (who can - * use which agent, each agent's ceilings, the org-wide defaults) moved OUT to - * the top-level Permissions view; the Organization dashboard is now membership + - * insights + billing only. + * The four sections of the Admin (Organization) dashboard. Analytics is the one + * measurement section (activity feed, message usage, time worked as its lenses); + * Company context is the workspace half of the standing context every agent + * starts a turn with. Per-agent policy is NOT here: it is reached through each + * team's Manage agents page. */ -export type OrgTabId = "people" | "activity" | "usage" | "billing"; +export type OrgTabId = "people" | "billing" | "analytics" | "companyContext"; /** - * The always-present sections, in display order. `billing` (C8) is appended - * conditionally by {@link orgTabIds} only on a Spaces host, in a team space, for - * owner/admin. + * The always-present sections. `billing` (C8) is the only conditional one, added + * by {@link orgTabIds} on a Spaces host, in a team space, for owner/admin — see + * that function for the authoritative display order. */ export const ORG_TAB_IDS: readonly OrgTabId[] = [ "people", - "activity", - "usage", + "analytics", + "companyContext", ] as const; /** - * The dashboard's tab ids in display order: the fixed set, plus `billing` when - * `canSeeBillingTab` (in `lib/org-roles`) holds. Pure so the tab set is - * unit-tested without React; the view maps each id to its component + `t()` - * label. + * The dashboard's tab ids in display order, written out literally so the order + * reads off the source: People, then Billing when `canSeeBillingTab` (in + * `lib/billing-gates`) holds, then Analytics, then Company context. Pure so the + * tab set is unit-tested without React; the view maps each id to its component + + * `t()` label. + * + * Company context takes no gate of its own on purpose: the whole Admin view is + * mounted only behind {@link canSeeOrganization}, which is false on a personal + * space (`isPersonalSpace`), so "org spaces only" is already enforced one level + * up and a second branch here would be dead code. */ export function orgTabIds(gates: { billing: boolean }): readonly OrgTabId[] { - return [...ORG_TAB_IDS, ...(gates.billing ? (["billing"] as const) : [])]; + return [ + "people", + ...(gates.billing ? (["billing"] as const) : []), + "analytics", + "companyContext", + ]; } /** @@ -58,7 +70,7 @@ export function canSeeOrganization( caps: Capabilities | null | undefined, activeSpaceIsTeam: boolean, ): boolean { - if (hasSpaces(caps) && !activeSpaceIsTeam) return false; + if (isPersonalSpace(caps, activeSpaceIsTeam)) return false; return canSeeMembers(caps); } diff --git a/app/src/components/organization/organization-view.tsx b/app/src/components/organization/organization-view.tsx index d2fd506a0..c4f56e0fc 100644 --- a/app/src/components/organization/organization-view.tsx +++ b/app/src/components/organization/organization-view.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useOrg } from "../../hooks/queries"; import { useCapabilities } from "../../hooks/use-capabilities"; import { analytics } from "../../lib/analytics"; -import { canSeeBillingTab } from "../../lib/org-roles"; +import { canSeeBillingTab } from "../../lib/billing-gates"; import { isTeamWorkspace } from "../../lib/space-id"; import { useWorkspaceStore } from "../../stores/workspaces"; import { BackBarScreen } from "../shell/back-bar-screen"; @@ -33,32 +33,30 @@ export interface OrgTabProps { } /** - * The Admin (Organization) dashboard (Settings > Admin): membership + insights + - * billing. A shell only: it loads the org, builds the shared `OrgViewContext`, + * The Admin (Organization) dashboard: People, Billing, Analytics, Company + * context. A shell only: it loads the org, builds the shared `OrgViewContext`, * and switches between two screens in the settings-page grammar — * - * - INDEX (`active === null`): a landing of grouped, self-describing rows - * ({@link AdminIndex}) — People (membership), Insights (Activity, Usage), and - * Billing when in scope. - * - DETAIL (`active` set): a back bar + section heading + the section body. + * - INDEX (`active === null`): a landing of self-describing rows + * ({@link AdminIndex}) — People (membership), Billing when in scope, then + * Analytics (activity / usage / time worked) and Company context. + * - DETAIL (`active` set): a back bar + section heading + the section body. That + * bar is now the ONLY one on the page: as a TOP-LEVEL view in the rail's + * "Workspace" band, Admin owns the whole window and has no level above it. * - * Permission surfaces (who can use which agent, per-agent + org-wide ceilings) - * moved OUT to the Permissions section. Rendered ONLY when `canSeeOrganization` + * Permission surfaces (who can use which agent, per-agent ceilings) are NOT + * here: per-agent policy is discovered through each team's Manage agents page, + * in the team view's settings section. Mounted 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. + * personal one): the rail hides the row, the kept-alive screen is not even + * mounted, and `blockedTopLevelView` sends a stale `viewMode` home the moment + * the gates resolve against it. * - * 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 - * each section detail reuses the same bar one level down. + * Kept alive like every top-level screen, so it comes back on the section it was + * left on — and so the Billing deep link below has to land while the view is + * ALREADY open, not on a mount that never happens again. */ -export function OrganizationView({ - backLabel, - onBack, -}: { - backLabel: string; - onBack: () => void; -}) { +export function OrganizationView() { const { t } = useTranslation("teams"); const { data: org, isLoading } = useOrg(true); const { capabilities } = useCapabilities(); @@ -78,16 +76,22 @@ export function OrganizationView({ // index so the admin lands on the scannable overview, not a section body. const [active, setActive] = useState(null); - // One event per section detail opened (index → detail), keyed like the - // global view switches so a single tab_name breakdown covers everything. + // One event per section DETAIL opened (index → detail), keyed like the global + // view switches so a single tab_name breakdown covers everything. Landing on + // the view at all is the shell's `tab_opened` / `organization`, so this fires + // strictly below it and the two never double-count. useEffect(() => { if (active !== null) analytics.track("tab_opened", { tab_name: `org:${active}` }); }, [active]); - // Honor a deep link straight into a section's detail (the C8 team-status - // banner routes to Billing), then clear it so a later plain nav to the - // dashboard opens the index again. + // Honor a deep link straight into a section's detail — the C8 team-status + // banner is the one caller, and it asks for Billing — then clear it so a later + // plain nav to the dashboard opens the index again. (The create-team toast + // pins nothing: it wants the index, whose lead card is People.) This is an + // effect on the STORE field, not mount-time state, precisely because the + // screen is kept alive: it fires on the first mount AND while already open, + // the same way `team-settings.tsx` consumes its own one-shot pin. useEffect(() => { if (requestedTab === null) return; if (visibleIds.includes(requestedTab)) setActive(requestedTab); @@ -106,13 +110,13 @@ export function OrganizationView({ if (active === null) { return ( - +
    - +
    ); } diff --git a/app/src/components/organization/person-row.tsx b/app/src/components/organization/person-row.tsx new file mode 100644 index 000000000..6d5a3e5fa --- /dev/null +++ b/app/src/components/organization/person-row.tsx @@ -0,0 +1,81 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@houston-ai/core"; +import type { ReactNode } from "react"; +import { initialsFor } from "./people-tab-model.ts"; + +/** + * The ONE person row Houston lists people with: a face, a name (plus a quiet + * "you" marker on the caller's own row), an optional second identity line, and + * exactly ONE trailing control on the right. + * + * It exists because the shape had been forked byte-for-byte across surfaces — + * the team Members card and the Permissions agent People tab — and a fork is + * how two lists of people start reading differently. The shell owns the flat + * page language (transparent row, no card chrome, so the section's own rhythm + * carries the list) and the identity typography; it knows nothing about teams, + * agents or access levels, which is what keeps it reusable for the next list. + * + * The trailing control is the caller's `children`: a dropdown, a static label, + * whatever that surface's one decision is. A control that opens a menu should + * wear {@link personRowTriggerClass} so every people list's right edge is the + * same pill at every width. + */ +export function PersonRow({ + name, + avatarUrl, + isSelf, + selfLabel, + secondary, + children, +}: { + /** The primary identity line, and the source of the avatar initials. */ + name: string; + /** Resolved avatar photo, or null/undefined for initials-only. */ + avatarUrl?: string | null; + /** Marks this as the caller's own row. */ + isSelf?: boolean; + /** The translated "you" marker, rendered only when `isSelf`. */ + selfLabel?: string; + /** A second, muted identity line (an org role, a standing). Omitted keeps + * the identity block to a single line. */ + secondary?: ReactNode; + /** The row's one trailing control. */ + children?: ReactNode; +}) { + const primary = ( + <> + {name} + {isSelf && selfLabel && ( + {selfLabel} + )} + + ); + + return ( +
  • + + {avatarUrl && ( + + )} + {initialsFor(name)} + + {secondary === undefined ? ( +

    {primary}

    + ) : ( +
    +

    {primary}

    +

    {secondary}

    +
    + )} + {children} +
  • + ); +} + +/** + * The dropdown trigger a person row's trailing control wears: a pill that shows + * its current value at rest (never hover-gated), sized to the row's 32px rhythm + * and dimmed while a write on the row is in flight. Defined once here so the + * two people lists cannot drift apart one utility at a time. + */ +export const personRowTriggerClass = + "inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-line px-3 text-sm text-ink hover:bg-chip focus:outline-none focus:ring-2 focus:ring-focus/20 disabled:opacity-50"; diff --git a/app/src/components/permissions/agent-detail.tsx b/app/src/components/permissions/agent-detail.tsx index 2246b3a76..cdd5fabab 100644 --- a/app/src/components/permissions/agent-detail.tsx +++ b/app/src/components/permissions/agent-detail.tsx @@ -1,85 +1,117 @@ import { Button, HoustonAvatar, resolveAgentColor } from "@houston-ai/core"; -import type { OrgMember } from "@houston-ai/engine-client"; +import { UserPlus } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TAB_ID } from "../../agents/standard-tabs"; import { useCapabilities } from "../../hooks/use-capabilities"; +import { usePersonalSpace } from "../../hooks/use-personal-space"; import { isAgentManager } from "../../lib/agent-access"; +import { openAgentBoard } from "../../lib/open-agent"; import type { Agent } from "../../lib/types"; import { useAgentStore } from "../../stores/agents"; -import { useUIStore } from "../../stores/ui"; -import { PageContainer, PageHeader } from "../shell/page-shell"; -import { AgentPermissionsPanel } from "./agent-permissions-panel"; -import type { PermissionsAgentTab } from "./permissions-nav-store"; +import { agentShareSurface } from "../agent/agent-access-model"; +import { AgentShareSurfaces } from "../agent/agent-share-surfaces"; +import type { AgentSettingsSection } from "../agent-settings/agent-settings-nav.ts"; +import { AgentSettingsPage } from "../agent-settings/agent-settings-page"; +import { PageContainer, PageHero } from "../shell/page-shell"; /** - * Permissions agent detail: an org owner/admin manages ONE agent across three - * tabs — **People** (who can use it, at what level), **Integrations** (its app - * ceiling), and **AI Models** (its model ceiling). The whole product is agent- - * centric: pick an agent, then manage who reaches it and what it may reach. + * Permissions agent detail: an org owner/admin opens ONE agent on the canonical + * {@link AgentSettingsPage} — its Context (job description, learnings) and its + * Permissions (people, apps, AI models, skills) in one master-detail surface. + * The whole product is agent-centric: pick an agent, then manage who reaches it + * and what it may reach. * - * The manager-authority gate lives HERE, in the parent: the top-level drill-in is - * owner/admin-only, and a visible-but-not-manager admin gets a manager-only note - * instead of the editable panel. {@link isAgentManager}: owner → any org agent; - * admin → only agents where their effective `access === "manager"`. (The agent's - * Settings access rows reuse the same section components and show read-only to - * non-managers rather than hiding policy state.) + * Manager authority decides the FACE, not access: an admin who can see this + * agent but doesn't manage it gets the page `readOnly` — every section renders + * its non-manager face — rather than a dead-end note. {@link isAgentManager}: owner → any org agent; admin → only + * agents where their effective `access === "manager"`. The gateway is the real + * enforcer either way. * * The `agent` is resolved live from the store by the shell (by id, not a * snapshot), so a share mutation that reloads the store shows fresh data here. + * + * It also carries the agent's ONE Share affordance. That used to hang off the + * per-agent header, which no longer exists; this page is where an agent is + * addressed now, so the same {@link AgentShareSurfaces} wiring lives here — + * the manage dialog in a team space, the read-only "who has access" list for a + * member, and, in a PERSONAL space on a spaces host, the share-via-team flow + * that moves the agent into a team (personal spaces cannot be invited into, so + * that pipeline has no other door). */ export function AgentDetail({ agent, - members, - initialTab = "people", + initialSection = "people", + onSectionShown, }: { agent: Agent; - members: OrgMember[]; - /** Tab to open on first mount (a deep link may land on Integrations). */ - initialTab?: PermissionsAgentTab; + /** Section to open on first mount (a deep link may land on Apps). */ + initialSection?: AgentSettingsSection; + /** The section actually on screen, for the caller's analytics. */ + onSectionShown?: (section: AgentSettingsSection) => void; }) { const { t } = useTranslation("teams"); const { capabilities } = useCapabilities(); const setCurrentAgent = useAgentStore((s) => s.setCurrent); - const setViewMode = useUIStore((s) => s.setViewMode); const canManage = isAgentManager(capabilities, agent); + const personalSpace = usePersonalSpace(); + const [shareOpen, setShareOpen] = useState(false); + // The ONE reading of "am I in a personal space", shared with the rail and the + // Members card, rather than a second inline derivation off the workspace id. + const shareSurface = agentShareSurface(capabilities, agent, personalSpace); - // The old card behavior: leave Permissions and open the agent's chat. + // Leave the settings page and go where the agent WORKS: its team's Mission + // Control, filtered to this agent. const openAgent = () => { setCurrentAgent(agent); - setViewMode(DEFAULT_TAB_ID); + openAgentBoard(agent.id); }; return (
    - - {t("org.agentDetail.openAgent")} - +
    + {shareSurface !== "none" && ( + + )} + +
    } />
    - {canManage ? ( - - ) : ( -

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

    - )} + +
    ); } diff --git a/app/src/components/permissions/agent-grid.tsx b/app/src/components/permissions/agent-grid.tsx new file mode 100644 index 000000000..08b8e126b --- /dev/null +++ b/app/src/components/permissions/agent-grid.tsx @@ -0,0 +1,60 @@ +import { CatalogSectionHeader, resolveAgentColor } from "@houston-ai/core"; +import type { OrgMember } from "@houston-ai/engine-client"; +import type { ReactNode } from "react"; +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, + rowAction, + onOpenAgent, +}: { + agents: Agent[]; + /** Org roster used to name each agent's managers. Empty on single player. */ + members: OrgMember[]; + /** A per-row right-edge control the CALLER owns, or nothing. A team's Manage + * agents page hangs "Move to team" here; the org-wide Permissions list has + * no team to move within, so it passes none and its rows are unchanged. */ + rowAction?: (agent: Agent) => ReactNode; + 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/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/agent-row.tsx b/app/src/components/permissions/agent-row.tsx index 8a342f47f..fafac6446 100644 --- a/app/src/components/permissions/agent-row.tsx +++ b/app/src/components/permissions/agent-row.tsx @@ -1,5 +1,6 @@ import { CatalogRow, HoustonAvatar } from "@houston-ai/core"; import { ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; interface PermissionsAgentRowProps { name: string; @@ -9,6 +10,12 @@ interface PermissionsAgentRowProps { summary: string; /** Accessible label for the open action (already translated). */ openLabel: string; + /** An interactive right-edge control of the row's own (today: "Move to + * team", on a team's Manage agents page). It is a SIBLING of the row + * button, never nested in it, so its clicks are its own and never also open + * the agent. Absent everywhere else, which is why the row stays one target + * wherever it has nothing else to offer. */ + action?: ReactNode; onOpen: () => void; } @@ -24,6 +31,7 @@ export function PermissionsAgentRow({ color, summary, openLabel, + action, onOpen, }: PermissionsAgentRowProps) { return ( @@ -36,6 +44,7 @@ export function PermissionsAgentRow({ trailing={ } + action={action} /> ); } diff --git a/app/src/components/permissions/agents-list.tsx b/app/src/components/permissions/agents-list.tsx deleted file mode 100644 index 9e1f691fd..000000000 --- a/app/src/components/permissions/agents-list.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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"; - -/** - * The Permissions plane: the agents the caller can see (owner: every org - * agent; admin: the ones assigned to them — the agent list query already - * reflects this), as flat rows in the app's page language. Each row carries - * ONE plain-language summary line — who can use the agent, who manages it — - * and opens that agent's permission card (People | Integrations | AI Models). - * Fresh orgs get a "create your first agent" empty state. - * - * Deliberately NOT here: last-opened (dashboard information, not permission - * information) and the pinned model (one config fetch per row). - */ -export function AgentsList({ - members, - onOpenAgent, -}: { - members: OrgMember[]; - onOpenAgent: (agent: Agent) => void; -}) { - const { t } = useTranslation("teams"); - const agents = useAgentStore((s) => s.agents); - const setCreateAgentDialogOpen = useUIStore( - (s) => s.setCreateAgentDialogOpen, - ); - - if (agents.length === 0) { - return ( - - {t("agentsTab.empty.title")} - {t("agentsTab.empty.body")} - - - - - ); - } - - 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/index.ts b/app/src/components/permissions/index.ts deleted file mode 100644 index a8d9946d2..000000000 --- a/app/src/components/permissions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { usePermissionsNav } from "./permissions-nav-store"; -export { PermissionsView } from "./permissions-view"; diff --git a/app/src/components/permissions/permissions-nav-store.ts b/app/src/components/permissions/permissions-nav-store.ts deleted file mode 100644 index 855751918..000000000 --- a/app/src/components/permissions/permissions-nav-store.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { create } from "zustand"; - -/** Which tab of an agent's Permissions detail a deep link should open on. */ -export type PermissionsAgentTab = "people" | "integrations" | "models"; - -/** - * A one-shot request to open the Permissions view on a specific agent's detail, - * optionally on a specific tab. - * - * 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 - * 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. - * - * 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. */ - 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; - /** 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 }), -})); diff --git a/app/src/components/permissions/permissions-view.tsx b/app/src/components/permissions/permissions-view.tsx deleted file mode 100644 index a4f2dde00..000000000 --- a/app/src/components/permissions/permissions-view.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { 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 { 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"; - -/** - * 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. - * - * 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 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 - * the agent drill-in reuses the same bar one level down. - */ -export function PermissionsView({ - backLabel, - onBack, -}: { - backLabel: string; - onBack: () => void; -}) { - const { t } = useTranslation("teams"); - const { data: org } = useOrg(true); - const agents = useAgentStore((s) => s.agents); - - const requestedAgentId = usePermissionsNav((s) => s.requestedAgentId); - const requestedAgentTab = usePermissionsNav((s) => s.requestedAgentTab); - 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. - const [detail, setDetail] = useState<{ - agentId: string; - tab: PermissionsAgentTab; - } | 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) - analytics.track("tab_opened", { - tab_name: `permissions:${detail.tab}`, - agent_id: detail.agentId, - }); - }, [detail]); - - // 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. - useEffect(() => { - if (requestedAgentId === null) return; - setDetail({ - agentId: requestedAgentId, - tab: requestedAgentTab ?? "people", - }); - clearRequested(); - }, [requestedAgentId, requestedAgentTab, clearRequested]); - - const members = org?.members ?? []; - const detailAgent = - detail != null - ? (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. - if (detail && detailAgent) { - return ( - setDetail(null)} - > - - - ); - } - - return ( - - - - setDetail({ agentId: a.id, tab: "people" })} - /> - - - ); -} 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/portable/import-wizard.tsx b/app/src/components/portable/import-wizard.tsx index fb194c6a6..f00b7eace 100644 --- a/app/src/components/portable/import-wizard.tsx +++ b/app/src/components/portable/import-wizard.tsx @@ -36,7 +36,6 @@ import { invoke } from "@tauri-apps/api/core"; import { Check } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TAB_ID } from "../../agents/standard-tabs"; import { useProviderStatuses } from "../../hooks/use-provider-statuses"; import { AGENT_NAME_MAX_LENGTH, agentNameIssue } from "../../lib/agent-name"; import { finishAgentSetup } from "../../lib/agent-setup"; @@ -45,6 +44,7 @@ import { analytics } from "../../lib/analytics"; import { pickDefaultProviderModel } from "../../lib/default-provider-model"; import { getEngine } from "../../lib/engine"; import { genericErrorDescription } from "../../lib/error-report"; +import { openAgentBoard } from "../../lib/open-agent"; import { providerIsConnected } from "../../lib/provider-connection"; import { getDefaultModel } from "../../lib/providers"; import { tauriProvider, toAgent } from "../../lib/tauri"; @@ -301,7 +301,7 @@ export function ImportAgentWizard() { name: installed.agentName, }), }); - useUIStore.getState().setViewMode(DEFAULT_TAB_ID); + openAgentBoard(installed.agent.id); setOpen(false); reset(); void finishAgentSetup(installed.agentPath, { diff --git a/app/src/components/provider-browser/brand-mark.tsx b/app/src/components/provider-browser/brand-mark.tsx index a7d9148ca..2e5fb45ff 100644 --- a/app/src/components/provider-browser/brand-mark.tsx +++ b/app/src/components/provider-browser/brand-mark.tsx @@ -1,6 +1,6 @@ /** * The AI Models hub's colorful brand mark — its "candy store" counterpart to the - * Integrations tab's `AppLogo`. It renders the shared monochrome `ProviderGlyph` + * Integrations page's `AppLogo`. It renders the shared monochrome `ProviderGlyph` * boxless (no tile, no backing wash) and, when the brand has a curated accent, * tints the glyph via inherited `currentColor`. Chat surfaces (RowCard etc.) * intentionally stay monochrome by using `ProviderGlyph` directly; only the hub @@ -11,7 +11,7 @@ import { cn } from "@houston-ai/core"; import { providerBrandColor } from "../shell/provider-brand-colors.ts"; import { ProviderGlyph } from "../shell/provider-logos.tsx"; -// Full-bleed like the Integrations tab's `AppLogo` images — the glyph fills the +// Full-bleed like the Integrations page's `AppLogo` images — the glyph fills the // footprint so the brand mark reads big and bold, not shrunken inside a box. const MARK_SIZE = { sm: "size-6", diff --git a/app/src/components/provider-browser/provider-row.tsx b/app/src/components/provider-browser/provider-row.tsx index 1f3ba1b5f..6d174ae4d 100644 --- a/app/src/components/provider-browser/provider-row.tsx +++ b/app/src/components/provider-browser/provider-row.tsx @@ -1,6 +1,6 @@ /** * One provider CARD in the marketplace grid (Providers tab). A colorful, - * recognition-first card mirroring the Integrations tab's `AppRow`: a boxless + * recognition-first card mirroring the Integrations page's `AppRow`: a boxless * full-color brand mark + name (with an inline `LiveStatus` "Connected" dot * when connected) on the first line, then a secondary line leading with the * live model count in bold (`{N} models`), a middot, then diff --git a/app/src/components/settings/help-group.tsx b/app/src/components/settings/help-group.tsx deleted file mode 100644 index e9a38a629..000000000 --- a/app/src/components/settings/help-group.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * The Help group that leads the Settings index: the guided tour's entry point, - * moved here from the agent topbar so the header keeps only the controls people - * reach for constantly. - */ - -import { Compass } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { DEFAULT_TAB_ID } from "../../agents/standard-tabs"; -import { useUIStore } from "../../stores/ui"; -import { SettingsCard, SettingsRow } from "./settings-row"; - -export function HelpGroup() { - const { t } = useTranslation("settings"); - const setViewMode = useUIStore((s) => s.setViewMode); - const setUiTourActive = useUIStore((s) => s.setUiTourActive); - - /** - * Leave Settings BEFORE arming the tour: every anchor it spotlights lives in - * the workspace shell, and the overlay measures its first target the moment - * it mounts. Both writes are synchronous Zustand sets in one handler, so the - * tour mounts against the already-restored shell, never against Settings. - */ - function startTour() { - setViewMode(DEFAULT_TAB_ID); - setUiTourActive(true); - } - - return ( - - {/* The wrapper carries the `appTour` anchor the tour's own "replay the - tour" step spotlights — that step opens Settings to find it. */} -
    - -
    -
    - ); -} diff --git a/app/src/components/settings/identity-header-model.ts b/app/src/components/settings/identity-header-model.ts new file mode 100644 index 000000000..75f749686 --- /dev/null +++ b/app/src/components/settings/identity-header-model.ts @@ -0,0 +1,36 @@ +/** + * The identity header's resolved fields, derived from the ONE self-identity + * (`useMyProfile`) plus the session's email. A DOM-free module so the one rule + * that is not a straight pass-through — when the email line is worth drawing — + * is unit-tested under `node --test` without React. + */ +export interface IdentityHeaderFace { + /** Never blank: `resolveMyProfile` already falls back name > email > id. */ + name: string; + /** The second line, or `null` when there is nothing honest to put there. */ + email: string | null; + /** The photo, or `null` so the header renders initials instead. */ + avatarUrl: string | null; +} + +/** + * Resolve what the header draws. + * + * The email line is DROPPED when it is blank or when it is already the name: + * `resolveMyProfile` falls back to the email for anyone who never set a display + * name, and printing the same address twice, once bold and once muted, reads + * like a rendering bug rather than a fact about the account. + */ +export function identityHeaderFace(input: { + name: string; + email?: string | null; + avatarUrl?: string | null; +}): IdentityHeaderFace { + const name = input.name.trim(); + const email = (input.email ?? "").trim(); + return { + name, + email: email && email !== name ? email : null, + avatarUrl: input.avatarUrl ?? null, + }; +} diff --git a/app/src/components/settings/identity-header.tsx b/app/src/components/settings/identity-header.tsx new file mode 100644 index 000000000..990734fa1 --- /dev/null +++ b/app/src/components/settings/identity-header.tsx @@ -0,0 +1,79 @@ +import { initialsFor } from "@houston-ai/board"; +import { Avatar, AvatarFallback, AvatarImage, Button } from "@houston-ai/core"; +import { useTranslation } from "react-i18next"; +import { useMyProfile } from "../../hooks/use-my-profile"; +import { useSession } from "../../hooks/use-session"; +import { signOut } from "../../lib/auth"; +import { logger } from "../../lib/logger"; +import { identityHeaderFace } from "./identity-header-model"; + +/** + * Who you are signed in as, at the head of the Settings index: the face, the + * name, the email and the way out. + * + * This is the app's ONE identity control. The rail used to carry a second one — + * an avatar button whose menu offered Account settings, Send feedback and Sign + * out — but with Settings a permanent row in the rail's foot that menu was a + * second door onto this very page, so it is gone and its one irreplaceable item, + * Sign out, lives here. + * + * It renders on exactly the condition the old menu did: a resolved self-profile, + * which only exists with a session, which only exists on a deployment with an + * identity backend. Single-player desktop without auth draws nothing. + * + * Sign-out is the SAME `signOut` the old menu called (the purge path in + * `lib/sign-out.ts`); it surfaces its own failure on the auth-error bus, which + * the sign-in screen replacing this page renders, so the handler only keeps the + * rejection from floating and leaves a line for the bug report. + */ +export function SettingsIdentityHeader() { + const { t } = useTranslation("settings"); + const { data: session } = useSession(); + const profile = useMyProfile(); + + if (!profile) return null; + + const face = identityHeaderFace({ + name: profile.name, + email: session?.email, + avatarUrl: profile.avatarUrl, + }); + + return ( +
    + + {face.avatarUrl && ( + + )} + + {initialsFor(face.name)} + + +
    +

    {face.name}

    + {face.email && ( +

    {face.email}

    + )} +
    + +
    + ); +} diff --git a/app/src/components/settings/sections/account.tsx b/app/src/components/settings/sections/account.tsx deleted file mode 100644 index 03b11bbd2..000000000 --- a/app/src/components/settings/sections/account.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Button } from "@houston-ai/core"; -import { useTranslation } from "react-i18next"; -import { useMyProfile } from "../../../hooks/use-my-profile"; -import { useSession } from "../../../hooks/use-session"; -import { signOut } from "../../../lib/auth"; -import { isIdentityConfigured } from "../../../lib/identity"; -import { logger } from "../../../lib/logger"; -import { SettingsControlRow } from "../settings-row"; - -/** Up to two leading letters from the display name, for the no-photo fallback. */ -function initialsOf(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - const letters = parts.slice(0, 2).map((p) => p[0] ?? ""); - return letters.join("").toUpperCase() || "?"; -} - -export function AccountSection() { - const { t } = useTranslation("settings"); - const { data: session } = useSession(); - const profile = useMyProfile(); - - if (!isIdentityConfigured() || !session || !profile) return null; - - // Read the SAME resolved identity every other self-face uses: the display - // name + provider (Google/Microsoft) photo carried on the identity session. - // Read-only — avatar upload returns when the gateway profile store lands. - const displayName = profile.name; - const avatar = profile.avatarUrl; - - return ( - - ) : ( - - {initialsOf(displayName)} - - ) - } - title={displayName} - description={session.email || undefined} - > - - - ); -} - -export function useAccountAvailable() { - const { data: session } = useSession(); - return isIdentityConfigured() && !!session; -} diff --git a/app/src/components/settings/sections/migration.tsx b/app/src/components/settings/sections/migration.tsx index bae4a3753..c0d6e93f9 100644 --- a/app/src/components/settings/sections/migration.tsx +++ b/app/src/components/settings/sections/migration.tsx @@ -24,7 +24,7 @@ function readOutcome(userId: string): "done" | "skipped" | null { /** * The "Continue migration" section shows only when a re-run makes sense: a * hosted desktop build, legacy data still on THIS machine, and this machine's - * migration not already completed ("done"). Mirrors `useAccountAvailable` — a + * migration not already completed ("done"). Mirrors `useProfileAvailable` — a * hook the settings view calls to gate the row. The detect query shares its key * with the wizard gate's, so React Query dedupes the filesystem scan. Identity * (Firebase) has no client-writable user metadata, so the completed state is the diff --git a/app/src/components/settings/sections/profile.tsx b/app/src/components/settings/sections/profile.tsx index 0133407cc..361183a9b 100644 --- a/app/src/components/settings/sections/profile.tsx +++ b/app/src/components/settings/sections/profile.tsx @@ -19,7 +19,8 @@ const NAME_MAX_CHARS = 60; * Whether Settings shows the Profile section at all. Gated by DATA, never by a * flag: the section appears only once the profile READ succeeded, so a host * that 404s the route (`data === null`) or a read that failed hides an editor - * that could not save anyway. Mirrors {@link useAccountAvailable}. + * that could not save anyway. Mirrors {@link useMigrationAvailable} in shape: + * a hook the settings view calls to gate one row of the index. */ export function useProfileAvailable(): boolean { const { data: session } = useSession(); diff --git a/app/src/components/settings/sections/shortcuts.tsx b/app/src/components/settings/sections/shortcuts.tsx index 5baebe465..701aaf80d 100644 --- a/app/src/components/settings/sections/shortcuts.tsx +++ b/app/src/components/settings/sections/shortcuts.tsx @@ -12,8 +12,8 @@ interface Row { const NAV_ROWS: Row[] = [ { action: "palette", labelKey: "shell:cheatsheet.rows.palette" }, { - action: "missionControl", - labelKey: "shell:cheatsheet.rows.missionControl", + action: "inbox", + labelKey: "shell:cheatsheet.rows.inbox", }, { action: "newMission", labelKey: "shell:cheatsheet.rows.newMission" }, ]; diff --git a/app/src/components/settings/sections/workspace-context.tsx b/app/src/components/settings/sections/workspace-context.tsx deleted file mode 100644 index 7137697dd..000000000 --- a/app/src/components/settings/sections/workspace-context.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { - useSaveWorkspaceContext, - useWorkspaceContext, -} from "../../../hooks/queries/use-workspace-context"; -import { useAgentStore } from "../../../stores/agents"; -import { - InstructionsContent, - type InstructionsContentLabels, -} from "../../tabs/job-description-parts"; - -type Slot = "workspace" | "user"; - -function useSlotEditor(slot: Slot) { - // Stored on the open agent (see use-workspace-context): the files live at its - // workspace root and its runtime reads them into the prompt. - const agentPath = useAgentStore((s) => s.current?.folderPath); - const { data } = useWorkspaceContext(agentPath); - const save = useSaveWorkspaceContext(agentPath); - - const content = data?.[slot] ?? ""; - - const onSave = async (next: string) => { - await save.mutateAsync({ slot, content: next }); - }; - - return { ready: !!agentPath && !!data, content, onSave }; -} - -function useSlotLabels( - prefix: "workspaceContext" | "userContext", -): InstructionsContentLabels { - const { t } = useTranslation("settings"); - return { - emptyTitle: t(`${prefix}.emptyTitle`), - emptyDescription: t(`${prefix}.emptyDescription`), - writeButton: t(`${prefix}.writeButton`), - helper: t(`${prefix}.helper`), - saving: t(`${prefix}.saving`), - saved: t(`${prefix}.saved`), - placeholder: t(`${prefix}.placeholder`), - }; -} - -export function WorkspaceContextSection() { - const editor = useSlotEditor("workspace"); - const labels = useSlotLabels("workspaceContext"); - if (!editor.ready) return null; - return ( - - ); -} - -export function UserContextSection() { - const editor = useSlotEditor("user"); - const labels = useSlotLabels("userContext"); - if (!editor.ready) return null; - return ( - - ); -} diff --git a/app/src/components/settings/settings-index.tsx b/app/src/components/settings/settings-index.tsx index 67b3a1c37..f1f74204c 100644 --- a/app/src/components/settings/settings-index.tsx +++ b/app/src/components/settings/settings-index.tsx @@ -1,23 +1,10 @@ -import { - Bug, - Building2, - CircleUserRound, - CloudUpload, - FileText, - Keyboard, - ShieldCheck, - Timer, - User, -} from "lucide-react"; +import { Bug, CircleUserRound, CloudUpload, Keyboard } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useWorkspaceContext } from "../../hooks/queries/use-workspace-context"; import { genericErrorDescription } from "../../lib/error-report"; import type { SettingsSectionId } from "../../lib/settings-sections"; -import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; -import { PageContainer, PageHeader } from "../shell/page-shell"; -import { HelpGroup } from "./help-group"; -import { AccountSection } from "./sections/account"; +import { PageContainer, PageHero } from "../shell/page-shell"; +import { SettingsIdentityHeader } from "./identity-header"; import { AppearanceSection } from "./sections/appearance"; import { DangerSection } from "./sections/danger"; import { DeleteAccountSection } from "./sections/delete-account"; @@ -26,41 +13,39 @@ import { NotificationsSection } from "./sections/notifications"; import { SettingsCard, SettingsRow } from "./settings-row"; interface SettingsIndexProps { - accountAvailable: boolean; migrationAvailable: boolean; profileAvailable: boolean; - /** Deployment gate for the Time worked row (`capabilities.computeUsage`). */ - showTimeWorked: boolean; - /** Teams gate for the Admin + Permissions rows. */ - showOrganization: boolean; onSelect: (id: SettingsSectionId) => void; } /** - * The settings landing page. Simple settings (appearance, language, account, - * delete) are resolved inline as control rows; the heavier - * ones (context editors, shortcuts, bug report, and since HOU-788 Time worked, - * Permissions and Admin) are navigable rows that drill into their own screen. - * Account appears only when applicable; the Workspace and Team groups only when - * their Teams gate passes. Help leads the page: it is where the guided tour is - * started from since the topbar "Guide me" button moved in here. + * The settings landing page, and ONLY settings: the things every user adjusts + * about their own app. + * + * Everything that was not a setting has left. The guided tour starts from the + * Agent Store, the Context editors are one step from the Inbox, and Time worked, + * Admin and Permissions are screens of their own in the rail's "Workspace" band + * — none of them was a preference, and the index reads shorter for it. What is + * left is ONE general group everybody sees (identity, appearance, language, + * notifications, account, then the help-shaped rows that used to sit under a + * "Support" heading of their own), plus Danger. There is no role gate on this + * page at all any more. + * + * The page OPENS on the signed-in person: the rail's avatar menu was a second + * door onto this page and is gone, so identity is a header here rather than a + * row buried in the general group. Everything below it is a preference. + * + * Simple settings are resolved inline as control rows; the heavier ones + * (shortcuts, bug report) are navigable rows that drill into their own screen. */ export function SettingsIndex({ - accountAvailable, migrationAvailable, profileAvailable, - showTimeWorked, - showOrganization, onSelect, }: SettingsIndexProps) { const { t } = useTranslation("settings"); - const agentPath = useAgentStore((s) => s.current?.folderPath); - const { data: context } = useWorkspaceContext(agentPath); const addToast = useUIStore((s) => s.addToast); - const contextValue = (slot: "workspace" | "user") => - context?.[slot]?.trim() ? t("settings:index.values.set") : undefined; - async function handleVersionClick() { try { await navigator.clipboard.writeText(__APP_VERSION__); @@ -76,14 +61,14 @@ export function SettingsIndex({ return ( -
    - + {/* WorkspaceSection (rename) is deliberately not rendered: the @@ -99,62 +84,10 @@ export function SettingsIndex({ - {accountAvailable && } {/* The API-keys row is HIDDEN for now (HOU-806): the Agents API surface lives in the Routines tab. The section, its strings, and all plumbing remain — restore by re-adding this row (and the apiKeysAvailable gate from apiKeysSupported) when it returns. */} - - - {showTimeWorked && ( - - onSelect("timeWorked")} - /> - - )} - - {showOrganization && ( - - onSelect("organization")} - /> - onSelect("permissions")} - /> - - )} - - - onSelect("workspaceContext")} - /> - onSelect("userContext")} - /> - - - ; - if (active === "permissions") - return ; - if (active === "organization") - return ; - return ( - {active === "workspaceContext" ? ( - - ) : active === "userContext" ? ( - - ) : ( -
    - {active === "profile" && } - {/* The API-keys screen is HIDDEN from the index for now (HOU-806: - the Agents API surface lives in the Routines tab) — its nav row - is gone, so only a programmatic deep-link pin reaches it. The - section and its plumbing stay intact for when it returns. */} - {active === "apiKeys" && } - {active === "shortcuts" && } - {active === "reportBug" && } - {active === "migration" && } -
    - )} +
    + {active === "profile" && } + {/* The API-keys screen is HIDDEN from the index for now (HOU-806: the + Agents API surface lives in the Routines tab) — its nav row is gone, + so only a programmatic deep-link pin reaches it. The section and its + plumbing stay intact for when it returns. */} + {active === "apiKeys" && } + {active === "shortcuts" && } + {active === "reportBug" && } + {active === "migration" && } +
    ); } diff --git a/app/src/components/settings/settings-view.tsx b/app/src/components/settings/settings-view.tsx index f56c41b67..de53573a0 100644 --- a/app/src/components/settings/settings-view.tsx +++ b/app/src/components/settings/settings-view.tsx @@ -8,22 +8,14 @@ import { } from "@houston-ai/core"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useSurfaceGates } from "../../hooks/use-surface-gates"; import { analytics } from "../../lib/analytics"; -import { - parseSettingsSection, - settingsSectionGate, - settingsSectionNeedsWorkspace, -} from "../../lib/settings-sections"; +import { parseSettingsSection } from "../../lib/settings-sections"; import { workspaceGateState } from "../../lib/workspace-switch"; import { useUIStore } from "../../stores/ui"; import { useWorkspaceStore } from "../../stores/workspaces"; -import { BackBarScreen } from "../shell/back-bar-screen"; -import { useAccountAvailable } from "./sections/account"; import { useMigrationAvailable } from "./sections/migration"; import { useProfileAvailable } from "./sections/profile"; import { SettingsIndex } from "./settings-index"; -import { clearSettingsSectionPin } from "./settings-nav-pins"; import { SettingsSectionBody } from "./settings-section-body"; /** @@ -45,10 +37,8 @@ export function SettingsView() { const workspacesLoading = useWorkspaceStore((s) => s.loading); const workspaceLoadError = useWorkspaceStore((s) => s.loadError); const loadWorkspaces = useWorkspaceStore((s) => s.loadWorkspaces); - const accountAvailable = useAccountAvailable(); const profileAvailable = useProfileAvailable(); const migrationAvailable = useMigrationAvailable(); - const { showOrganization, showTimeWorked, ready } = useSurfaceGates(); // The open section lives in the UI store, not in local state: every surface // that navigates here goes through `openSettings`, so a deep link lands even // when Settings is ALREADY open (a toast action fired from inside Settings @@ -57,39 +47,14 @@ export function SettingsView() { const setActive = useUIStore((s) => s.setSettingsSection); const active = parseSettingsSection(useUIStore((s) => s.settingsSection)); - // A section this caller can't see (a stale deep-link, or a role/space change - // that hid Admin + Permissions while one was open) falls back to the index — - // the settings mirror of the shell's blocked top-level view reset. The - // decision WAITS for `ready`: capabilities are null while they load and every - // gate reads false then, so deciding early would dump an owner out of an open - // section on every team-space switch (which drops the capabilities query). - const sectionGate = - active === null - ? null - : settingsSectionGate(active, { - showOrganization, - showTimeWorked, - ready, - }); - const visible = sectionGate === "visible" ? active : null; - useEffect(() => { - if (active === null || sectionGate !== "blocked") return; - setActive(null); - // The section never rendered, so nothing consumed its one-shot deep-link - // pin. Drop it here or it hijacks the next legitimate open. - clearSettingsSectionPin(active); - }, [active, sectionGate, setActive]); - - // Every ORIGINAL section (and the index) reads the current workspace, so they - // sit behind the workspace gate. "No workspace yet" hides three genuinely + // The index and EVERY section read the current workspace, so the whole screen + // sits behind the workspace gate — no section opts out, because none of them + // is a team surface any more. "No workspace yet" hides three genuinely // different situations, and conflating them left a failed load spinning // forever (HOU-818): still loading is a spinner; a load that THREW blames the // connection and offers a retry; a load that succeeded with nothing is a dead // end the user can also only escape by retrying, but nothing is broken, so - // the copy must not accuse their network. The three MOVED surfaces (HOU-788) - // opt out — see `settingsSectionNeedsWorkspace`. - const needsWorkspace = - visible === null || settingsSectionNeedsWorkspace(visible); + // the copy must not accuse their network. const gate = workspaceGateState({ current: currentWorkspace, loading: workspacesLoading, @@ -97,18 +62,17 @@ export function SettingsView() { }); // One `tab_opened` per Settings surface actually reached, keyed like every - // other view switch (`settings` for the index, `settings:timeWorked` for a - // section) so a single tab_name breakdown keeps covering the surfaces that - // used to be top-level views. Settings owns this event outright — the shell's - // generic viewMode effect skips `settings`, so a deep link can no longer - // double-count — and a loading or error frame emits nothing, because nobody - // opened it. + // other view switch (`settings` for the index, `settings:profile` for a + // section) so a single tab_name breakdown covers both depths. Settings owns + // this event outright — the shell's generic viewMode effect skips `settings`, + // so a deep link can no longer double-count — and a loading or error frame + // emits nothing, because nobody opened it. const reached = - sectionGate === "loading" || (needsWorkspace && gate !== "ready") + gate !== "ready" ? null - : visible === null + : active === null ? "settings" - : `settings:${visible}`; + : `settings:${active}`; const lastReached = useRef(null); useEffect(() => { if (reached === null || lastReached.current === reached) return; @@ -116,22 +80,8 @@ export function SettingsView() { analytics.track("tab_opened", { tab_name: reached }); }, [reached]); - // A gated section whose capabilities are still in flight holds its place at - // its own depth: the same single back bar the section itself will render, so - // resolving swaps only the body. - if (sectionGate === "loading") { - return ( - setActive(null)} - > - - - ); - } - - if (needsWorkspace && gate === "loading") return ; - if (needsWorkspace && gate !== "ready") { + if (gate === "loading") return ; + if (gate !== "ready") { const copy = gate === "failed" ? { @@ -162,15 +112,12 @@ export function SettingsView() { ); } - if (visible === null) { + if (active === null) { return (
    @@ -179,7 +126,7 @@ export function SettingsView() { return ( setActive(null)} /> diff --git a/app/src/components/shell/agent-activity-summary-model.ts b/app/src/components/shell/agent-activity-summary-model.ts index 0d0deec31..8464af005 100644 --- a/app/src/components/shell/agent-activity-summary-model.ts +++ b/app/src/components/shell/agent-activity-summary-model.ts @@ -1,9 +1,5 @@ import { isSetupChatMode } from "../../lib/integration-chat-setup.ts"; -import type { ReadCursorStore } from "../../lib/read-cursors.ts"; -import { - countUnreadByAgentPath, - type UnreadConversationInput, -} from "../../lib/unread-model.ts"; +import type { UnreadConversationInput } from "../../lib/unread-model.ts"; export interface AgentActivitySummaryInput { id: string; @@ -24,16 +20,42 @@ export interface ActivityConversationSummaryInput export interface AgentActivitySummary { needsYouCount: number; runningCount: number; - /** Missions that moved since I last read them (HOU-945). Always 0 off - * multiplayer or without a signed-in user, so single-player / desktop - * renders no new chrome. */ - unreadCount: number; } -/** Everything the unread count needs: the reader's cursors and who they are. */ -export interface UnreadSummaryOptions { - store: ReadCursorStore; - selfId: string | null; +/** What a TEAM's header says on behalf of the agent rows folded under it. */ +export interface TeamActivityRollup { + /** The sum of its members' needs-you counts. A sum and not a member count: + * the badge answers "how much is waiting in here", which is the same + * question an agent row's badge answers, one level up. */ + needsYouCount: number; + /** The sum of its members' running missions. The rail DRAWS it as a ring + * (running or not — the same binary an agent row shows) and only ever SAYS + * the number, in the ring's accessible label. */ + runningCount: number; +} + +/** + * Roll a team's members up into the one line its header can carry. + * + * A folded team draws no agent rows, so everything they were signalling leaves + * the rail with them — and "collapse this team" must not mean "stop telling me + * my agents need something". It reads the SAME per-agent summaries the rows + * do, so a header can never disagree with the rows behind it, and an agent with + * no summary yet contributes nothing rather than a zero-shaped guess. + */ +export function teamActivityRollup( + agentIds: readonly string[], + summaries: Record, +): TeamActivityRollup { + let needsYouCount = 0; + let runningCount = 0; + for (const agentId of agentIds) { + const summary = summaries[agentId]; + if (!summary) continue; + needsYouCount += summary.needsYouCount; + runningCount += summary.runningCount; + } + return { needsYouCount, runningCount }; } /** One agent's board rows (`.houston/activity`), the summary-relevant bits. */ @@ -49,10 +71,6 @@ export interface ActivitySummaryInput { * as the sidebar fallback while the all-conversations aggregate has not * fetched for the current roster key (cold boot, pods still waking). * - * `unreadCount` is always 0 here, and cannot be anything else: a board row - * carries no attribution (no `created_by`, no `mentioned`), so there is nothing - * to decide relevance against. The aggregate path below owns the unread number, - * and this fallback simply reports none rather than guessing one. */ export function summarizeActivities( activities: ActivitySummaryInput[], @@ -60,7 +78,6 @@ export function summarizeActivities( const summary: AgentActivitySummary = { needsYouCount: 0, runningCount: 0, - unreadCount: 0, }; for (const activity of activities) { if (isSetupChatMode(activity.agent)) continue; @@ -76,19 +93,10 @@ export function summarizeActivities( /** * The sidebar's per-agent badge numbers. * - * `unread` is OPTIONAL, and its absence (or a null `selfId`) leaves every - * `unreadCount` at 0 — so single-player and desktop render exactly the chrome - * they render today, with no new dot to explain. That absence is how the gate - * is expressed: the sidebar hook omits the option entirely unless the - * deployment advertises `capabilities.multiplayer`. The counting itself is - * delegated to {@link countUnreadByAgentPath} rather than re-derived here: the - * badge, the Mentions inbox and the notifier must agree on what "unread" means, - * and they only can if there is one implementation of it. */ export function buildAgentActivitySummaries( agents: AgentActivitySummaryInput[], conversations: ActivityConversationSummaryInput[], - unread?: UnreadSummaryOptions, ): Record { const summaries: Record = {}; const agentIdByPath = new Map(); @@ -97,7 +105,6 @@ export function buildAgentActivitySummaries( summaries[agent.id] = { needsYouCount: 0, runningCount: 0, - unreadCount: 0, }; agentIdByPath.set(agent.folderPath, agent.id); } @@ -119,18 +126,5 @@ export function buildAgentActivitySummaries( } } - if (unread) { - const byPath = countUnreadByAgentPath( - conversations, - unread.store, - unread.selfId, - ); - for (const [agentPath, count] of Object.entries(byPath)) { - const agentId = agentIdByPath.get(agentPath); - const summary = agentId ? summaries[agentId] : undefined; - if (summary) summary.unreadCount = count; - } - } - return summaries; } diff --git a/app/src/components/shell/agent-sidebar-items.tsx b/app/src/components/shell/agent-sidebar-items.tsx index 65d2c4900..ef747b4da 100644 --- a/app/src/components/shell/agent-sidebar-items.tsx +++ b/app/src/components/shell/agent-sidebar-items.tsx @@ -1,49 +1,46 @@ -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"; -import { - AgentSidebarIcon, - NeedsYouChip, - UnreadDot, -} from "./agent-sidebar-status"; +import { AgentSidebarIcon } 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; - unreadLabel: (count: number) => string; - onChangeColor: (agentId: string, color: string) => void; - onShareAgent: (agentId: string) => void; - shareLabel: string; } +interface BuildAgentSidebarItemsArgs extends AgentItemArgs { + agents: Agent[]; +} + +/** + * The rail's agent rows: an avatar and a name — nothing else. + * + * **No needs-you count, no unread dot.** Any mark on a row that had something + * waiting turned the rail into a scoreboard: dots and numbers competed with the + * names for a 220px column, and a rail's job is to say what exists and where + * you are, not to keep score. The signals still exist where they can be acted + * on — the team's board, and the ROLLUP a folded team's header carries on + * behalf of rows that are not on screen at all. Names get the space back. + * + * **No "..." menu either.** An agent is renamed, recoloured, moved and deleted + * on its team's Manage agents page, which is also where it is configured, so + * there is exactly one door onto all of it. + * + * What survives is the one thing a row can say without asking to be read: the + * running ring around the avatar (motion, not a number). + */ export function buildAgentSidebarItems({ agents, summaries, runningLabel, - needsYouLabel, - unreadLabel, - onChangeColor, - onShareAgent, - shareLabel, }: BuildAgentSidebarItemsArgs): SidebarItem[] { return agents.map((agent) => { const summary = summaries[agent.id] ?? { needsYouCount: 0, runningCount: 0, - unreadCount: 0, }; - const hasRunning = summary.runningCount > 0; - const hasUnread = summary.unreadCount > 0; - const needsYou = summary.needsYouCount > 0; return { id: agent.id, @@ -51,75 +48,10 @@ export function buildAgentSidebarItems({ icon: ( 0} runningLabel={runningLabel(summary.runningCount)} /> ), - // Both signals can show at once, dot first: an agent with something - // urgent may ALSO have unread news, and hiding one behind the other - // would make the rail lie about what is waiting. Nothing to say leaves - // `trailing` undefined, exactly as before. - trailing: - hasUnread || needsYou ? ( - - {hasUnread ? ( - - ) : null} - {needsYou ? ( - - ) : null} - - ) : undefined, - menuContent: ( - <> - onChangeColor(agent.id, color)} - /> - onShareAgent(agent.id)}> - {shareLabel} - - - ), }; }); } - -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/agent-sidebar-status.tsx b/app/src/components/shell/agent-sidebar-status.tsx index 09c086ff1..37e066402 100644 --- a/app/src/components/shell/agent-sidebar-status.tsx +++ b/app/src/components/shell/agent-sidebar-status.tsx @@ -1,4 +1,32 @@ import { Badge, cn, HoustonAvatar, resolveAgentColor } from "@houston-ai/core"; +import type { ReactNode } from "react"; + +/** + * The rail's ONE "something is running here" treatment: a ring around whatever + * mark sits in the glyph column. An agent row wears it around its avatar; a + * FOLDED team's header wears it around the team's glyph, on behalf of the agent + * rows it is hiding. Same component, because a second ring drawn a hair + * differently would read as a second kind of running. + */ +export function RunningRing({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} interface AgentSidebarIconProps { color?: string; @@ -17,17 +45,7 @@ export function AgentSidebarIcon({ if (!running) return avatar; - return ( - - {avatar} - - ); + return {avatar}; } interface NeedsYouChipProps { @@ -49,21 +67,3 @@ export function NeedsYouChip({ count, label }: NeedsYouChipProps) { ); } - -/** The quiet unread signal: a small filled dot, deliberately NOT a count chip. - * `NeedsYouChip` means "act now"; this only means "there is something new here - * for you". Different weight, different shape, so the two never read alike. - * The count lives in the accessible label, where a screen reader and a hover - * can reach it without the rail turning into a wall of numbers. */ -export function UnreadDot({ label }: { label: string }) { - return ( - - - - ); -} diff --git a/app/src/components/shell/agent-warming-dialog.tsx b/app/src/components/shell/agent-warming-dialog.tsx index 7911901ef..ecaaa5a84 100644 --- a/app/src/components/shell/agent-warming-dialog.tsx +++ b/app/src/components/shell/agent-warming-dialog.tsx @@ -1,7 +1,7 @@ /** * "Your agent is still being created" notice (HOU-693). Opens when the user * triggers a write (save, create, update…) on an agent whose engine is still - * warming up — the tabs stay explorable, this dialog is the only blocker. + * warming up — every screen stays explorable, this dialog is the only blocker. * Mirrors the boot splash's helmet-in-motion so the wait reads as the same * continuous "getting ready" state. Closes itself if the agent turns ready * while it is open. diff --git a/app/src/components/shell/archived-board-button.tsx b/app/src/components/shell/archived-board-button.tsx deleted file mode 100644 index 56a3866a5..000000000 --- a/app/src/components/shell/archived-board-button.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Button } from "@houston-ai/core"; -import { Archive } from "lucide-react"; - -interface ArchivedBoardButtonProps { - /** Visible text on the pill, e.g. "Archived". */ - label: string; - onClick: () => void; -} - -/** - * The door to an agent's archived missions: a labelled pill floating over the - * board's bottom-right corner. It wears its name in TEXT rather than hiding it - * on a tooltip, because an unlabelled round icon read as decoration and nobody - * found the archive behind it (HOU-1043). - * - * Entry only. It disappears while the archived list is showing, where the - * header's back button is the single, obvious way out; a button that quietly - * changed meaning depending on an outline was the other half of that bug. - */ -export function ArchivedBoardButton({ - label, - onClick, -}: ArchivedBoardButtonProps) { - return ( - - ); -} diff --git a/app/src/components/shell/create-agent-team-dialog.tsx b/app/src/components/shell/create-agent-team-dialog.tsx new file mode 100644 index 000000000..271ca814b --- /dev/null +++ b/app/src/components/shell/create-agent-team-dialog.tsx @@ -0,0 +1,159 @@ +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Input, +} from "@houston-ai/core"; +import { SidebarGroupGlyph } from "@houston-ai/layout"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + useCreateAgentTeam, + useOrg, + useSetAgentTeamMemberOwner, +} from "../../hooks/queries"; +import type { UseSidebarLayout } from "../../hooks/use-sidebar-layout"; +import { useUIStore } from "../../stores/ui"; +import { AgentShareAddPeople } from "../agent/agent-share-add-people"; +import { TEAM_NAME_MAX_RUNES } from "../team-view/team-members-model"; +import { buildTeamIdentityChoices } from "./team-identity"; + +export function CreateAgentTeamDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; + serverBacked: boolean; + sidebar: UseSidebarLayout; +}) { + const { t } = useTranslation(["teams", "shell", "common"]); + const choices = useMemo(() => buildTeamIdentityChoices(t), [t]); + const create = useCreateAgentTeam(); + const addMember = useSetAgentTeamMemberOwner(); + const { data: org } = useOrg(props.serverBacked); + const [name, setName] = useState(""); + const [icon, setIcon] = useState(); + const [color, setColor] = useState(); + const [memberIds, setMemberIds] = useState([]); + const trimmed = name.trim(); + const tooLong = Array.from(trimmed).length > TEAM_NAME_MAX_RUNES; + + const close = () => { + props.onOpenChange(false); + setName(""); + setIcon(undefined); + setColor(undefined); + setMemberIds([]); + }; + const openTeamView = useUIStore((s) => s.openTeamView); + const submit = async () => { + if (!trimmed || tooLong || create.isPending) return; + let createdId: string | null = null; + if (props.serverBacked) { + const team = await create.mutateAsync({ name: trimmed, icon, color }); + for (const userId of memberIds) { + await addMember.mutateAsync({ teamId: team.id, userId, owner: false }); + } + createdId = team.id; + } else { + const id = props.sidebar.createGroup(trimmed); + if (id) props.sidebar.setGroupIdentity(id, { icon, color }); + createdId = id; + } + close(); + // You made a place; you land in it (the Linear grammar). Landing on the + // empty board is also what makes the next step obvious: add an agent. + if (createdId) openTeamView(createdId, "mission-control"); + }; + + return ( + + + + {t("teams:agentTeams.create.title")} + + setName(event.target.value)} + placeholder={t("teams:agentTeams.create.namePlaceholder")} + aria-label={t("teams:agentTeams.create.nameLabel")} + /> + {tooLong && ( +

    + {t("teams:agentTeams.create.tooLong", { max: TEAM_NAME_MAX_RUNES })} +

    + )} +
    +

    {choices.labels.icons}

    +
    + {choices.glyphs.map((choice) => ( + + ))} +
    +
    + {props.serverBacked && org?.members && ( +
    +

    + {t("teams:agentTeams.create.members")} +

    + !memberIds.includes(member.userId), + )} + onAdd={(member) => + setMemberIds((current) => [...current, member.userId]) + } + /> + {memberIds.length > 0 && ( +

    + {t("teams:agentTeams.create.membersSelected", { + count: memberIds.length, + })} +

    + )} +
    + )} +
    +

    {choices.labels.colors}

    +
    + {choices.colors.map((choice) => ( + + ))} +
    +
    +
    + + +
    +
    +
    + ); +} diff --git a/app/src/components/shell/create-team-dialog.tsx b/app/src/components/shell/create-team-dialog.tsx index 817693687..e0c30460b 100644 --- a/app/src/components/shell/create-team-dialog.tsx +++ b/app/src/components/shell/create-team-dialog.tsx @@ -11,10 +11,12 @@ import { import { type FormEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useCreateTeam } from "../../hooks/queries/use-orgs"; +import { openHome } from "../../lib/home-nav"; import { orgSlugFromWorkspaceId } from "../../lib/space-id"; import { useAgentStore } from "../../stores/agents"; import { useUIStore } from "../../stores/ui"; import { useWorkspaceStore } from "../../stores/workspaces"; +import { ORGANIZATION_VIEW_ID } from "../organization/id"; import { MAX_TEAM_NAME_LENGTH, validateTeamName } from "./create-team-model"; interface Props { @@ -42,7 +44,6 @@ export function CreateTeamDialog({ open, onOpenChange }: Props) { const loadAgents = useAgentStore((s) => s.loadAgents); const addToast = useUIStore((s) => s.addToast); const setViewMode = useUIStore((s) => s.setViewMode); - const openSettings = useUIStore((s) => s.openSettings); const [name, setName] = useState(""); // Start every open with a clean field; reset on close so a reopen after a @@ -80,22 +81,26 @@ export function CreateTeamDialog({ open, onOpenChange }: Props) { // from Settings (or any non-home view, or an agent that belonged to // the old space and the reload above just dropped), the view would // stay put and the whole create reads as a silent failure. Land them - // in the new team's home — the same "dashboard" the shell resets a - // blocked view to — so the switch is visible. The toast's Invite - // action then takes them on to Admin from home. - setViewMode("dashboard"); + // on home — the same place the shell sends a blocked view — so the + // switch is visible. The new space's teams may not have resolved yet, + // in which case home IS the Inbox and the shell's boot rule moves + // them on to the first team's Mission Control the moment it lands; + // that composition is deliberate. The toast's Invite action then + // takes them on to Admin from home. + openHome(); } - // Point the user at the next step: the Admin dashboard's People card, - // now guaranteed visible because the active space is the fresh team. - // The switch (setCurrent) already happened above, so the org view is - // reachable the moment they click. + // Point the user at the next step: the Admin screen's People card, now + // guaranteed visible because the active space is the fresh team. The + // switch (setCurrent) already happened above, so `showOrganization` has + // resolved true and the kept-alive Admin screen is mounted by the time + // they click. addToast({ title: t("teams:createTeam.successTitle", { name: org.name }), description: t("teams:createTeam.successBody"), variant: "success", action: { label: t("teams:createTeam.successAction"), - onClick: () => openSettings("organization"), + onClick: () => setViewMode(ORGANIZATION_VIEW_ID), }, }); onOpenChange(false); diff --git a/app/src/components/shell/create-workspace-dialog.tsx b/app/src/components/shell/create-workspace-dialog.tsx index 80fad9156..da20480c5 100644 --- a/app/src/components/shell/create-workspace-dialog.tsx +++ b/app/src/components/shell/create-workspace-dialog.tsx @@ -6,13 +6,18 @@ import { } from "@houston-ai/core"; import { type FormEvent, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { DEFAULT_TAB_ID } from "../../agents/standard-tabs"; +import { useMoveAgentToTeam } from "../../hooks/queries"; +import { useCapabilities } from "../../hooks/use-capabilities"; import { useProviderStatuses } from "../../hooks/use-provider-statuses"; +import { useSidebarLayout } from "../../hooks/use-sidebar-layout"; import { AGENT_NAME_MAX_LENGTH, agentNameIssue } from "../../lib/agent-name"; import { isAgentNameConflictError } from "../../lib/agent-name-conflict"; import { finishAgentSetup } from "../../lib/agent-setup"; import { startAgentSetupMission } from "../../lib/agent-setup-mission"; import { pickDefaultProviderModel } from "../../lib/default-provider-model"; +import { newAgentPlacement } from "../../lib/new-agent-placement"; +import { openAgentBoard } from "../../lib/open-agent"; +import { hasAgentTeams } from "../../lib/org-roles"; import { providerIsConnected } from "../../lib/provider-connection"; import { tauriProvider } from "../../lib/tauri"; import { useAgentCatalogStore } from "../../stores/agent-catalog"; @@ -27,12 +32,16 @@ type Step = 1 | 2; export function CreateAgentDialog() { const { t } = useTranslation(["shell", "agents"]); const open = useUIStore((s) => s.createAgentDialogOpen); + const targetTeamId = useUIStore((s) => s.createAgentTeamId); const setOpen = useUIStore((s) => s.setCreateAgentDialogOpen); - const uiTourActive = useUIStore((s) => s.uiTourActive); const agentDefs = useAgentCatalogStore((s) => s.agents); const existingAgents = useAgentStore((s) => s.agents); const createAgent = useAgentStore((s) => s.create); const currentWorkspace = useWorkspaceStore((s) => s.current); + const { capabilities } = useCapabilities(); + const serverBacked = hasAgentTeams(capabilities); + const sidebar = useSidebarLayout(currentWorkspace?.id); + const moveToTeam = useMoveAgentToTeam(); const [step, setStep] = useState(1); const [name, setName] = useState(""); @@ -147,12 +156,24 @@ export function CreateAgentDialog() { setCreating(false); return; } + const placement = newAgentPlacement(targetTeamId, serverBacked); + if (placement.kind === "server") { + await moveToTeam.mutateAsync({ + agentId: created.id, + teamId: placement.teamId, + }); + } else if (placement.kind === "local") { + sidebar.moveItem(created.id, { + groupId: placement.groupId, + beforeItemId: null, + }); + } // Reveal the agent NOW. The provider/model write and setup mission dispatch // to the agent's engine, which on the hosted profile is a pod still // cold-starting — awaiting them here would re-block the dialog for the whole // pod warm-up (HOU-649). The agent already exists and is current; finish its // setup in the background. Each surfaces its own error toast on failure. - useUIStore.getState().setViewMode(DEFAULT_TAB_ID); + openAgentBoard(created.id); void finishAgentSetup(agentPath, { ...kickoffPin, routine: null }); // Auto-start the agent's self-setup mission in the normal shell: it // introduces itself and interviews the user, persisting what they say into @@ -182,11 +203,6 @@ export function CreateAgentDialog() { onOpenChange={(o) => { if (!o) handleClose(); }} - // Modal mode applies pointer-events:none to everything outside the - // dialog. While the tour is on, that would block the tour's own - // Next/Back buttons (rendered outside DialogContent). Drop modality - // for the tour and let the tour's overlay own the focus instead. - modal={!uiTourActive} > { - if (uiTourActive) e.preventDefault(); - }} - onEscapeKeyDown={(e) => { - if (uiTourActive) e.preventDefault(); - }} > {step === 1 ? ( <> diff --git a/app/src/components/shell/detail-panel-owners.ts b/app/src/components/shell/detail-panel-owners.ts index 304b5df8c..62cf253d6 100644 --- a/app/src/components/shell/detail-panel-owners.ts +++ b/app/src/components/shell/detail-panel-owners.ts @@ -1,18 +1,18 @@ /** * Ownership bookkeeping for the ONE shell-level detail panel. * - * Every surface that renders the panel (the Activity board, the Routines chat, - * the per-agent and Mission Control Archived lists, the skill / integration - * setup chats) portals into the SAME container, and all of them stay MOUNTED - * while hidden — agent tabs are only CSS-hidden, top-level screens are kept - * alive. A single shared "panel is open" boolean therefore has last-writer-wins - * semantics with no writer left to correct it: the tab the user navigates AWAY - * from keeps its `true` on the flag while it stops portaling anything in, and - * the shell paints an empty card next to the board (PRODUCT-1229). + * Every surface that renders the panel (the mission boards, the Routines chat, + * the Archived lists, the skill / integration setup chats) portals into the + * SAME container, and all of them stay MOUNTED while hidden — top-level screens + * are kept alive. A single shared "panel is open" boolean therefore has + * last-writer-wins semantics with no writer left to correct it: the screen the + * user navigates AWAY from keeps its `true` on the flag while it stops portaling + * anything in, and the shell paints an empty card next to the board + * (PRODUCT-1229). * * A claim SET fixes both halves at once. A surface that leaves releases only * its own id, so it can never clobber the surface the user just navigated to - * (the reason the Routines tab used to skip the release entirely), and the + * (the reason the Routines surface used to skip the release entirely), and the * panel is open exactly while at least one surface is actually rendering it. */ diff --git a/app/src/components/shell/experience-renderer.tsx b/app/src/components/shell/experience-renderer.tsx deleted file mode 100644 index 20f8157ff..000000000 --- a/app/src/components/shell/experience-renderer.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Spinner } from "@houston-ai/core"; -import { Suspense } from "react"; -import { visibleAgentTabs } from "../../agents/standard-tabs"; -import { resolveTabComponent } from "../../agents/tab-resolver"; -import { useCapabilities } from "../../hooks/use-capabilities"; -import type { Agent, AgentDefinition } from "../../lib/types"; - -interface AgentRendererProps { - agentDef: AgentDefinition; - agent: Agent; - activeTabId: string; -} - -export function AgentRenderer({ - agentDef, - agent, - activeTabId, -}: AgentRendererProps) { - const { capabilities } = useCapabilities(); - return ( -
    - {visibleAgentTabs(capabilities, agent).map((tab) => { - const TabComponent = resolveTabComponent(tab); - const isActive = tab.id === activeTabId; - return ( -
    - - -
    - } - > - - -
    - ); - })} -
    - ); -} diff --git a/app/src/components/shell/feedback-dialog.tsx b/app/src/components/shell/feedback-dialog.tsx deleted file mode 100644 index 99b804240..000000000 --- a/app/src/components/shell/feedback-dialog.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@houston-ai/core"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { reportBug } from "../../lib/bug-report"; -import { getCurrentUserEmail } from "../../lib/current-user"; -import { genericErrorDescription } from "../../lib/error-report"; -import { useUIStore } from "../../stores/ui"; - -/** - * Voluntary feedback dialog. The companion to Sentry's auto-capture: crashes - * land in Sentry on their own, this is for the things Sentry can't see — - * UX confusion, missing features, ideas, soft errors that didn't throw. - * - * Submits to the same Linear-bug-report Tauri command the previous "Report - * bug" toast action used, but now with the user's typed message in the - * payload (bug-report.ts → BugReportPayload.user_message → format.rs leads - * the issue title + description with it). - */ -interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function FeedbackDialog({ open, onOpenChange }: Props) { - const { t } = useTranslation("shell"); - const [message, setMessage] = useState(""); - const [sending, setSending] = useState(false); - const addToast = useUIStore((s) => s.addToast); - - const handleSend = async () => { - const trimmed = message.trim(); - if (!trimmed) return; - setSending(true); - try { - const issueId = await reportBug({ - command: "user_feedback", - error: "(user-submitted feedback, not an error)", - timestamp: new Date().toISOString(), - appVersion: __APP_VERSION__, - userEmail: getCurrentUserEmail(), - userMessage: trimmed, - }); - addToast({ - title: t("feedback.successTitle"), - description: issueId - ? t("feedback.successWithId", { id: issueId }) - : t("feedback.successNoId"), - variant: "success", - }); - setMessage(""); - onOpenChange(false); - } catch (err) { - addToast({ - title: t("feedback.errorTitle"), - description: genericErrorDescription("user_feedback", err), - variant: "error", - }); - } finally { - setSending(false); - } - }; - - return ( - - - - {t("feedback.title")} - {t("feedback.description")} - -