From 597fd63b6177337c873798221cc6b5538e9ee966 Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 2 Sep 2026 23:55:43 -0700 Subject: [PATCH 1/3] feat(ui): add streamlined navigation foundation (#12746) --- packages/shared/src/feature-catalog.ts | 8 + packages/shared/src/types/instance.ts | 6 + .../shared/src/validators/instance.test.ts | 7 + packages/shared/src/validators/instance.ts | 1 + .../instance-settings-routes.test.ts | 18 + .../instance-settings-service.test.ts | 10 + server/src/services/instance-settings.ts | 2 + tests/e2e/apps-dark-mode-shots.spec.ts | 2 +- tests/e2e/sidebar-takeover.spec.ts | 113 +-- .../AgentContextualSidebar.test.tsx | 58 ++ ui/src/components/AgentContextualSidebar.tsx | 131 ++++ .../AppConnectionSidebar.production.tsx | 154 ++++ ui/src/components/AppsSidebar.production.tsx | 92 +++ ui/src/components/AppsSidebar.test.tsx | 13 +- ui/src/components/AppsSidebar.tsx | 61 +- .../components/BreadcrumbBar.production.tsx | 160 ++++ ui/src/components/BreadcrumbBar.test.tsx | 167 ++++- ui/src/components/BreadcrumbBar.tsx | 155 ++-- .../CompanySettingsSidebar.production.tsx | 214 ++++++ .../CompanySettingsSidebar.test.tsx | 4 +- ui/src/components/CompanySettingsSidebar.tsx | 25 +- .../ContextualSidebarFrame.test.tsx | 90 +++ ui/src/components/ContextualSidebarFrame.tsx | 70 ++ ui/src/components/Layout.test.tsx | 3 + ui/src/components/Layout.tsx | 2 +- .../RequestCollapsedSidebar.test.tsx | 22 +- ui/src/components/RequestCollapsedSidebar.tsx | 12 +- .../RoutineContextualSidebar.test.tsx | 116 +++ .../components/RoutineContextualSidebar.tsx | 140 ++++ .../SecondarySidebar.production.tsx | 39 + ui/src/components/SecondarySidebar.tsx | 19 +- ui/src/components/Sidebar.production.tsx | 251 +++++++ ui/src/components/Sidebar.test.tsx | 209 +++--- ui/src/components/Sidebar.tsx | 146 ++-- .../SidebarAccountMenu.production.tsx | 286 ++++++++ ui/src/components/SidebarAccountMenu.test.tsx | 25 + ui/src/components/SidebarAccountMenu.tsx | 15 +- .../components/SidebarAgents.production.tsx | 683 ++++++++++++++++++ ui/src/components/SidebarAgents.tsx | 2 +- .../SidebarCompanyMenu.production.tsx | 513 +++++++++++++ ui/src/components/SidebarCompanyMenu.test.tsx | 5 + ui/src/components/SidebarCompanyMenu.tsx | 11 +- .../components/SidebarNavItem.production.tsx | 226 ++++++ ui/src/components/SidebarNavItem.test.tsx | 36 +- ui/src/components/SidebarNavItem.tsx | 81 ++- ui/src/components/SidebarProjects.tsx | 4 +- ui/src/components/SidebarRecentTasks.test.tsx | 188 +++++ ui/src/components/SidebarRecentTasks.tsx | 69 ++ ui/src/components/SidebarShell.production.tsx | 244 +++++++ ui/src/components/SidebarShell.test.tsx | 21 + ui/src/components/SidebarShell.tsx | 52 +- .../SidebarStarredProjects.production.tsx | 218 ++++++ ui/src/components/SidebarStarredProjects.tsx | 4 +- .../SkillsContextualSidebar.test.tsx | 115 +++ ui/src/components/SkillsContextualSidebar.tsx | 79 ++ .../components/contextual-sidebar-styles.ts | 15 + ui/src/context/BreadcrumbContext.tsx | 15 + ui/src/context/SidebarContext.test.tsx | 279 ++----- ui/src/context/SidebarContext.tsx | 137 +--- ui/src/hooks/useRecentTasks.ts | 112 +++ ui/src/hooks/useStreamlinedUiEnabled.test.tsx | 82 +++ ui/src/hooks/useStreamlinedUiEnabled.ts | 44 ++ ui/src/index.css | 102 ++- ui/src/lib/queryKeys.test.ts | 14 + ui/src/lib/queryKeys.ts | 4 + ui/src/lib/recent-tasks.test.ts | 94 +++ ui/src/lib/recent-tasks.ts | 155 ++++ ui/src/lib/shell-navigation.test.ts | 85 +++ ui/src/lib/shell-navigation.ts | 106 +++ .../InstanceExperimentalSettings.test.tsx | 32 +- ui/src/pages/InstanceExperimentalSettings.tsx | 13 + ui/src/pages/agent-detail-navigation.test.ts | 39 + ui/src/pages/agent-detail-navigation.ts | 76 ++ ui/src/pages/audit/audit-navigation.test.ts | 41 ++ ui/src/pages/audit/audit-navigation.ts | 64 ++ ui/src/pages/skills/skills-navigation.test.ts | 45 ++ ui/src/pages/skills/skills-navigation.ts | 41 ++ 77 files changed, 6102 insertions(+), 890 deletions(-) create mode 100644 ui/src/components/AgentContextualSidebar.test.tsx create mode 100644 ui/src/components/AgentContextualSidebar.tsx create mode 100644 ui/src/components/AppConnectionSidebar.production.tsx create mode 100644 ui/src/components/AppsSidebar.production.tsx create mode 100644 ui/src/components/BreadcrumbBar.production.tsx create mode 100644 ui/src/components/CompanySettingsSidebar.production.tsx create mode 100644 ui/src/components/ContextualSidebarFrame.test.tsx create mode 100644 ui/src/components/ContextualSidebarFrame.tsx create mode 100644 ui/src/components/RoutineContextualSidebar.test.tsx create mode 100644 ui/src/components/RoutineContextualSidebar.tsx create mode 100644 ui/src/components/SecondarySidebar.production.tsx create mode 100644 ui/src/components/Sidebar.production.tsx create mode 100644 ui/src/components/SidebarAccountMenu.production.tsx create mode 100644 ui/src/components/SidebarAgents.production.tsx create mode 100644 ui/src/components/SidebarCompanyMenu.production.tsx create mode 100644 ui/src/components/SidebarNavItem.production.tsx create mode 100644 ui/src/components/SidebarRecentTasks.test.tsx create mode 100644 ui/src/components/SidebarRecentTasks.tsx create mode 100644 ui/src/components/SidebarShell.production.tsx create mode 100644 ui/src/components/SidebarStarredProjects.production.tsx create mode 100644 ui/src/components/SkillsContextualSidebar.test.tsx create mode 100644 ui/src/components/SkillsContextualSidebar.tsx create mode 100644 ui/src/components/contextual-sidebar-styles.ts create mode 100644 ui/src/hooks/useRecentTasks.ts create mode 100644 ui/src/hooks/useStreamlinedUiEnabled.test.tsx create mode 100644 ui/src/hooks/useStreamlinedUiEnabled.ts create mode 100644 ui/src/lib/recent-tasks.test.ts create mode 100644 ui/src/lib/recent-tasks.ts create mode 100644 ui/src/lib/shell-navigation.test.ts create mode 100644 ui/src/lib/shell-navigation.ts create mode 100644 ui/src/pages/agent-detail-navigation.test.ts create mode 100644 ui/src/pages/agent-detail-navigation.ts create mode 100644 ui/src/pages/audit/audit-navigation.test.ts create mode 100644 ui/src/pages/audit/audit-navigation.ts create mode 100644 ui/src/pages/skills/skills-navigation.test.ts create mode 100644 ui/src/pages/skills/skills-navigation.ts diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 66a71b46b5f..bd934c1703c 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -81,6 +81,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("defaults the streamlined UI on and accepts an explicit patch", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableStreamlinedUi).toBe(true); + expect( + patchInstanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: false }), + ).toEqual({ enableStreamlinedUi: false }); + }); + it("defaults the server info debug view off", () => { const settings = instanceExperimentalSettingsSchema.parse({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 6a392d3f0dd..a23c3615743 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -45,6 +45,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableManagedSandboxOnly: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), + enableStreamlinedUi: z.boolean().default(true), // Deprecated compatibility key. Apps is a standard product surface and is // always enabled; this remains accepted so older stored rows and managed // configs continue to load during upgrades. diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 455fbeb4fc0..4c5bc175797 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -270,6 +270,24 @@ describe("instance settings routes", () => { .expect(404); }); + it("accepts the instance-wide Streamlined UI preference", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableStreamlinedUi: false }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableStreamlinedUi: false, + }); + }); + it("strips server-managed worktree run execution fields before updating experimental settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 569fb6685b3..475a2fdd5a8 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,6 +29,7 @@ describe("instance settings service", () => { enableManagedSandboxOnly: false, enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: true, + enableStreamlinedUi: true, enableApps: true, enableConferenceRoomChat: false, enableClassicTaskInterface: false, @@ -59,6 +60,15 @@ describe("instance settings service", () => { }); }); + it("defaults streamlined UI on without inheriting the retired navigation preference", () => { + expect(normalizeExperimentalSettings(undefined).enableStreamlinedUi).toBe(true); + expect(normalizeExperimentalSettings({}).enableStreamlinedUi).toBe(true); + expect( + normalizeExperimentalSettings({ enableStreamlinedLeftNavigation: false }).enableStreamlinedUi, + ).toBe(true); + expect(normalizeExperimentalSettings({ enableStreamlinedUi: false }).enableStreamlinedUi).toBe(false); + }); + it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true); expect(normalizeExperimentalSettings({}).enableApps).toBe(true); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index d590dde57e1..b6954556128 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -224,6 +224,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, + enableStreamlinedUi: parsed.data.enableStreamlinedUi ?? true, // Apps graduated from Experimental. Ignore historical off values while // continuing to accept the compatibility key in stored settings. enableApps: true, @@ -262,6 +263,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, + enableStreamlinedUi: true, enableApps: true, enablePipelines: false, enableCases: false, diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index f1a5282569c..bbc1de02f7b 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -161,7 +161,7 @@ test.describe.serial("dark-mode Apps surfaces", () => { await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0); - await expect(page.locator('a[href$="/activity"]', { hasText: "Activity" })).toBeVisible(); + await expect(page.locator('a[href$="/activity"]', { hasText: "Audit" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. await expect(page.locator('a[href$="/apps"]', { hasText: "Connectors" })).toBeVisible(); diff --git a/tests/e2e/sidebar-takeover.spec.ts b/tests/e2e/sidebar-takeover.spec.ts index ffbfa746ba8..af55e6fff86 100644 --- a/tests/e2e/sidebar-takeover.spec.ts +++ b/tests/e2e/sidebar-takeover.spec.ts @@ -1,37 +1,20 @@ import { test, expect, request as pwRequest, type APIRequestContext } from "@playwright/test"; /** - * E2E: Sidebar takeover model (PAP-10695). + * E2E: contextual sidebar companion model. * - * Takeover routes (company settings, plugin `routeSidebar`) no longer *replace* - * the main app sidebar. Instead the host collapses the app `` to its - * 64px rail (still peek-able) and renders the contextual sidebar in a second - * pane → `[ app rail ][ secondary ~240px ][ content ]`. + * Contextual routes render their navigation beside the stable global sidebar. + * The global company navigation and account menu remain available, and leaving + * the surface restores the user's global sidebar preference. * - * These specs assert the rail + secondary pane coexist on a company settings - * route, and that an explicit user pin (expanded) wins over the route-driven - * collapse (pin precedence). - * - * The plugin `routeSidebar` half of this behavior shares the exact same Layout - * code path (one `secondarySidebar`/`hasSecondarySidebar` resolver drives both - * company-settings and plugin routes) and is covered by the unit tests in - * `ui/src/components/Layout.test.tsx`. A live plugin-route e2e requires the - * `plugin-llm-wiki` plugin to be installed in the throwaway e2e instance, which - * is out of scope for this default local_trusted run; visual QA of both panes - * is delegated to the QA child issue. + * Plugin route sidebars share the same Layout path. A live plugin-route test + * requires a plugin fixture, so that branch remains covered by Layout tests. */ const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199); const BASE_URL = `http://127.0.0.1:${PORT}`; const COMPANY_NAME_PREFIX = "E2E-SidebarTakeover"; const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; - -// The sidebar header's "Collapse sidebar" toggle only renders when the app -// sidebar is expanded (pinned, desktop, not takeover-locked); in the collapsed -// rail and on takeover routes it is hidden. Its presence/absence is therefore -// a stable proxy for the app sidebar's collapsed state (see Sidebar.tsx). -// (Previously "Open search", but the header search icon moved into the nav, -// where it renders in the rail too.) const APP_SIDEBAR_EXPANDED_MARKER = "Collapse sidebar"; async function createCompany(board: APIRequestContext): Promise<{ id: string; prefix: string }> { @@ -53,7 +36,7 @@ async function createCompany(board: APIRequestContext): Promise<{ id: string; pr }; } -test.describe("Sidebar takeover (collapse + secondary pane)", () => { +test.describe("Contextual sidebar companion", () => { let board: APIRequestContext; let companyId: string; let prefix: string; @@ -71,93 +54,59 @@ test.describe("Sidebar takeover (collapse + secondary pane)", () => { }); test.beforeEach(async ({ page }) => { - // Start each test from a clean (unpinned) sidebar state so the route-driven - // collapse is the only thing acting on it. await page.addInitScript((key) => { window.localStorage.removeItem(key); + window.sessionStorage.clear(); }, COLLAPSED_STORAGE_KEY); }); - test("collapses the app sidebar to its rail and shows the settings sidebar beside it", async ({ page }) => { + test("shows Settings beside the global navigation", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - // The contextual (secondary) pane is present... - const secondary = page.locator("[data-secondary-sidebar]"); - await expect(secondary).toBeVisible(); - await expect(secondary).toHaveCount(1); + const contextual = page.locator('[data-contextual-sidebar="settings"]'); + await expect(contextual).toBeVisible(); + await expect(contextual).toHaveCount(1); + await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(1); - // ...and it is ~240px wide (w-60), distinct from the 64px app rail. - const secondaryBox = await secondary.boundingBox(); - expect(secondaryBox).not.toBeNull(); - expect(secondaryBox!.width).toBeGreaterThan(180); + await expect(contextual.getByRole("link", { name: "General" })).toBeVisible(); + await expect(contextual.getByText("Environments", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Back from Settings" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Open account menu" })).toBeVisible(); - // The app sidebar is NOT replaced — its company nav still renders... await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); - - // ...but it is collapsed to its rail: the expanded-only "Open search" - // header control is hidden. await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); }); - test("renders the secondary pane nav labels at full width despite the app rail collapse", async ({ page }) => { - // Regression (PAP-10700): the secondary pane is 240px wide, but its - // SidebarNavItem children read the *global* collapsed state and used to - // render icon-only (label `w-0 text-transparent`), making the settings nav - // unreadable in the default takeover state. The pane must force full labels. + test("renders contextual labels at full width", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - const secondary = page.locator("[data-secondary-sidebar]"); - await expect(secondary).toBeVisible(); - - // App sidebar is collapsed to its rail (default unpinned takeover state)... - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); - - // ...yet a settings nav label renders at its full text width, not clipped to - // zero. "Environments" is unique to the company-settings nav. - const envLabel = secondary.getByText("Environments", { exact: true }); + const contextual = page.locator('[data-contextual-sidebar="settings"]'); + const envLabel = contextual.getByText("Environments", { exact: true }); await expect(envLabel).toBeVisible(); const labelBox = await envLabel.boundingBox(); expect(labelBox).not.toBeNull(); expect(labelBox!.width).toBeGreaterThan(20); }); - test("settings force-collapse overrides an expanded pin without mutating it", async ({ page }) => { - // User has pinned the sidebar expanded ("0"). Company settings is a hard - // secondary-sidebar takeover route, so forceCollapsed wins while the route is - // active (force > pin > route request > default) but must not mutate the pin. - await page.addInitScript( - ({ key }) => { - window.localStorage.setItem(key, "0"); - }, - { key: COLLAPSED_STORAGE_KEY }, - ); - + test("keeps the retired collapse control absent across contextual navigation", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - - // Secondary pane still shows on the takeover route. - await expect(page.locator("[data-secondary-sidebar]")).toBeVisible(); - - // The app sidebar is hard-collapsed despite the stored expanded pin. + await expect(page.locator('[data-contextual-sidebar="settings"]')).toBeVisible(); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); await page.goto(`/${prefix}/dashboard`); - // Leaving the takeover route clears the force and restores the user's - // persisted expanded pin. - await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible(); + await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); }); - test("leaving the takeover route removes the secondary pane and restores the sidebar", async ({ page }) => { + test("uses Dashboard as the safe fallback for a direct Settings link", async ({ page }) => { await page.goto(`/${prefix}/company/settings`); - await expect(page.locator("[data-secondary-sidebar]")).toBeVisible(); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toHaveCount(0); + await page.getByRole("button", { name: "Back from Settings" }).click(); - // Navigate to a plain (non-takeover) route. - await page.goto(`/${prefix}/dashboard`); - - // No secondary pane, and the app sidebar is no longer force-collapsed. - await expect(page.locator("[data-secondary-sidebar]")).toHaveCount(0); - await expect(page.getByLabel(APP_SIDEBAR_EXPANDED_MARKER)).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/${prefix}/dashboard$`)); + await expect(page.locator("[data-contextual-sidebar]")).toHaveCount(0); + await expect(page.getByRole("link", { name: "Dashboard" })).toBeVisible(); }); }); diff --git a/ui/src/components/AgentContextualSidebar.test.tsx b/ui/src/components/AgentContextualSidebar.test.tsx new file mode 100644 index 00000000000..f95068b2a36 --- /dev/null +++ b/ui/src/components/AgentContextualSidebar.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { AgentContextualSidebar } from "./AgentContextualSidebar"; + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("./ContextualSidebarFrame", () => ({ + ContextualSidebarFrame: ({ + title, + showHeader, + className, + children, + }: { + title: string; + showHeader?: boolean; + className?: string; + children: React.ReactNode; + }) => ( + + ), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: ({ to, label }: { to: string; label: string }) => {label}, +})); + +describe("AgentContextualSidebar", () => { + it("renders local definition/runtime/governance links and scoped Audit links", () => { + const queryClient = new QueryClient(); + const markup = renderToStaticMarkup( + + + + + , + ); + + expect(markup).toContain("Codex Coder"); + expect(markup).toContain('data-show-header="false"'); + expect(markup).toContain("border-r border-border bg-background"); + expect(markup).toContain('data-slot="contextual-sidebar-nav"'); + expect(markup).toContain('href="/agents/codexcoder/overview"'); + expect(markup).toContain('href="/agents/codexcoder/permissions"'); + expect(markup).toContain('href="/agents/codexcoder/api-keys"'); + expect(markup).toContain('href="/activity?mode=agents&agentId=agent-1"'); + expect(markup).toContain('href="/activity/runs?agentId=agent-1"'); + expect(markup).toContain("Harness / Runtime"); + expect(markup).toContain("Permissions / Trust"); + }); +}); diff --git a/ui/src/components/AgentContextualSidebar.tsx b/ui/src/components/AgentContextualSidebar.tsx new file mode 100644 index 00000000000..41b7489e666 --- /dev/null +++ b/ui/src/components/AgentContextualSidebar.tsx @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + BadgeDollarSign, + BookOpenText, + History, + KeyRound, + Library, + PlayCircle, + ReceiptText, + Settings2, + ShieldCheck, + Sparkles, + Wrench, +} from "lucide-react"; +import { agentsApi } from "@/api/agents"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; +import { SidebarNavItem } from "./SidebarNavItem"; +import { contextualSidebarStyles } from "./contextual-sidebar-styles"; +import { + AGENT_DETAIL_NAVIGATION, + agentDetailHref, + agentScopedAuditHref, + type AgentLocalDetailView, +} from "@/pages/agent-detail-navigation"; + +const localIcons = { + overview: Sparkles, + instructions: BookOpenText, + skills: Library, + runtime: Settings2, + secrets: ShieldCheck, + tools: Wrench, + permissions: ShieldCheck, + "api-keys": KeyRound, + revisions: History, +} satisfies Record; + +const auditItems = [ + { section: "activity", label: "Activity", icon: Activity }, + { section: "runs", label: "Runs", icon: PlayCircle }, + { section: "costs", label: "Costs", icon: ReceiptText }, + { section: "budgets", label: "Budgets", icon: BadgeDollarSign }, +] as const; + +export function AgentContextualSidebar({ + agentRef, + agentId, + agentName, +}: { + agentRef: string; + agentId?: string; + agentName?: string; +}) { + const { selectedCompanyId } = useCompany(); + const shouldResolveAgent = !agentId || !agentName; + const { data: resolvedAgent } = useQuery({ + queryKey: [...queryKeys.agents.detail(agentRef), selectedCompanyId ?? null, "contextual-sidebar"], + queryFn: () => agentsApi.get(agentRef, selectedCompanyId ?? undefined), + enabled: shouldResolveAgent && Boolean(agentRef && selectedCompanyId), + }); + const resolvedId = agentId ?? resolvedAgent?.id; + const resolvedName = agentName ?? resolvedAgent?.name ?? "Agent"; + + return ( + + + + ); +} diff --git a/ui/src/components/AppConnectionSidebar.production.tsx b/ui/src/components/AppConnectionSidebar.production.tsx new file mode 100644 index 00000000000..8c2cb02e663 --- /dev/null +++ b/ui/src/components/AppConnectionSidebar.production.tsx @@ -0,0 +1,154 @@ +import { ChevronLeft } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { toolsApi } from "@/api/tools"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + APP_TABS, + CONNECTED_ONLY_APP_TABS, + appApplicationTabHref, + appTabHref, + type AppTabKey, +} from "@/pages/apps/app-tabs"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { + appDefinitionLogoUrl, + appDefinitionName, + appDefinitionSlug, + type AppGalleryDisplayEntry, +} from "@/pages/apps/app-definition-display"; +import { SidebarNavItem } from "./SidebarNavItem.production"; + +type AppDetailSidebarProps = + | { kind: "connection"; connectionId: string } + | { kind: "application"; applicationId: string }; + +export function AppDetailSidebar(props: AppDetailSidebarProps) { + const { selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const connectionQuery = useQuery({ + queryKey: queryKeys.tools.connection(props.kind === "connection" ? props.connectionId : "__none__"), + queryFn: () => toolsApi.getConnection(props.kind === "connection" ? props.connectionId : ""), + enabled: props.kind === "connection" && !!props.connectionId, + }); + const applicationsQuery = useQuery({ + queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listApplications(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const connectionsQuery = useQuery({ + queryKey: queryKeys.tools.connections(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listConnections(selectedCompanyId!), + enabled: props.kind === "application" && !!selectedCompanyId, + }); + const galleryQuery = useQuery({ + queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listGallery(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const attentionQuery = useQuery({ + queryKey: queryKeys.apps.attention(selectedCompanyId ?? "__none__"), + queryFn: () => toolsApi.listAppsAttention(selectedCompanyId!), + enabled: !!selectedCompanyId, + refetchInterval: 30_000, + }); + + const connection = connectionQuery.data; + const application = props.kind === "application" + ? (applicationsQuery.data?.applications ?? []).find((app) => app.id === props.applicationId) + : null; + const appConnections = props.kind === "application" + ? (connectionsQuery.data?.connections ?? []).filter((candidate) => candidate.applicationId === props.applicationId) + : []; + const previousConnection = latestArchivedConnection(appConnections); + const appName = connection ? humanizeConnectionDisplayName(connection) : application?.name ?? "App"; + const logoEntry = galleryEntryFor( + (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], + connection, + application ?? undefined, + ); + const reviewConnectionId = connection?.id ?? previousConnection?.id ?? null; + const attentionItem = reviewConnectionId + ? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId) + : null; + const reviewCount = + (attentionItem?.pendingActionRequestCount ?? 0) + + ((attentionItem?.quarantinedCatalogEntryCount ?? 0) > 0 ? 1 : 0); + + return ( + + ); +} + +function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string { + return props.kind === "connection" + ? appTabHref(props.connectionId, tab) + : appApplicationTabHref(props.applicationId, tab); +} + +function galleryEntryFor( + apps: AppGalleryDisplayEntry[], + connection: ToolConnection | undefined, + application: ToolApplication | undefined, +): AppGalleryDisplayEntry | null { + if (application?.applicationKey) { + const keyed = apps.find((app) => appDefinitionSlug(app) === application.applicationKey); + if (keyed) return keyed; + } + const name = (connection?.name ?? application?.name)?.toLowerCase(); + if (!name) return null; + return apps.find((app) => appDefinitionName(app).toLowerCase() === name) ?? + apps.find((app) => appDefinitionSlug(app) === name) ?? + null; +} + +function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null { + const archived = connections.filter((connection) => connection.status === "archived"); + if (archived.length === 0) return null; + return archived.reduce((latest, connection) => { + const latestTime = new Date(latest.updatedAt ?? latest.createdAt ?? 0).getTime(); + const connectionTime = new Date(connection.updatedAt ?? connection.createdAt ?? 0).getTime(); + return connectionTime > latestTime ? connection : latest; + }); +} diff --git a/ui/src/components/AppsSidebar.production.tsx b/ui/src/components/AppsSidebar.production.tsx new file mode 100644 index 00000000000..d3f09d355dc --- /dev/null +++ b/ui/src/components/AppsSidebar.production.tsx @@ -0,0 +1,92 @@ +import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; +import { Link } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; +import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; +import { useReviewCount } from "@/pages/apps/useReviewCount"; +import { SidebarNavItem } from "./SidebarNavItem.production"; + +/** + * Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA + * PAP-13254 / U3). + * + * ← Back · APPS: Browse / Review (n) + * DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity + * + * "Browse" is the store and "Review" holds decisions waiting on the user's + * OK. Connection management lives with the Developer tools. + * "Needs attention" is no longer a door: health/error triage folds into + * Connections as a status filter + banner, so approvals are never buried + * behind an error label. The Developer section was folded in from the retired + * ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a + * one-line caption frames who it's for (Finding A). "Run your own" and "Paste a + * config" moved out of the sidebar into rows on the Connect-an-app page + * (PAP-10922). + */ +export function AppsSidebar() { + const { selectedCompany } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + const reviewCount = useReviewCount(); + const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); + const developerTabs = DEVELOPER_TABS.filter( + (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, + ); + + return ( + + ); +} diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx index 84ea1cabf78..2f5c5fc29db 100644 --- a/ui/src/components/AppsSidebar.test.tsx +++ b/ui/src/components/AppsSidebar.test.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppsSidebar } from "./AppsSidebar"; +import { contextualSidebarStyles } from "./contextual-sidebar-styles"; const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockToolsApi = vi.hoisted(() => ({ @@ -97,7 +98,7 @@ describe("AppsSidebar", () => { vi.clearAllMocks(); }); - it("renders the consolidated connector and review doors without retired developer links", async () => { + it("renders the consolidated connector and review doors without a redundant contextual heading", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -112,7 +113,7 @@ describe("AppsSidebar", () => { }); await flushReact(); - expect(container.textContent).toContain("Apps"); + expect(container.textContent).not.toContain("Apps"); expect(container.textContent).not.toContain("Developer"); expect(container.textContent).not.toContain("Advanced setup for developers"); expect(container.textContent).not.toContain("Most teams"); @@ -152,6 +153,14 @@ describe("AppsSidebar", () => { expect(sidebarNavItemMock).not.toHaveBeenCalledWith( expect.objectContaining({ to: "/apps/advanced/audit" }), ); + expect(container.querySelector('[data-slot="contextual-sidebar-nav"]')?.className).toBe( + contextualSidebarStyles.nav, + ); + expect( + Array.from(container.querySelectorAll('[data-slot="contextual-sidebar-group"]')).every( + (group) => group.className === contextualSidebarStyles.group, + ), + ).toBe(true); await act(async () => { root.unmount(); diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index 4e48641984f..269eed56902 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -1,25 +1,18 @@ -import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; -import { Link } from "@/lib/router"; -import { useCompany } from "@/context/CompanyContext"; -import { useSidebar } from "@/context/SidebarContext"; +import { Store, ShieldQuestion } from "lucide-react"; import { DEVELOPER_TABS, advancedTabHref, isExperimentalToolTab } from "@/pages/tools/tool-tabs"; import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled"; import { useReviewCount } from "@/pages/apps/useReviewCount"; import { SidebarNavItem } from "./SidebarNavItem"; +import { contextualSidebarStyles } from "./contextual-sidebar-styles"; /** * Secondary sidebar for the Apps area. * - * ← Back · APPS: Connectors / Review (n) - * - * The landing page combines connector discovery, account management, and - * connection health. Review keeps governed actions waiting on the user's OK. - * Advanced developer surfaces remain hidden unless one is explicitly enabled. + * Connectors combines discovery, account management, and connection health. + * Review keeps governed actions waiting on the user's approval. Advanced + * developer surfaces remain hidden unless one is explicitly enabled. */ export function AppsSidebar() { - const { selectedCompany } = useCompany(); - const { isMobile, setSidebarOpen } = useSidebar(); - const reviewCount = useReviewCount(); const { enabled: smokeLabEnabled } = useSmokeLabEnabled(); const developerTabs = DEVELOPER_TABS.filter((tab) => { @@ -31,28 +24,12 @@ export function AppsSidebar() { return ( diff --git a/ui/src/components/BreadcrumbBar.production.tsx b/ui/src/components/BreadcrumbBar.production.tsx new file mode 100644 index 00000000000..3bb3407efe0 --- /dev/null +++ b/ui/src/components/BreadcrumbBar.production.tsx @@ -0,0 +1,160 @@ +import { Link } from "@/lib/router"; +import { Menu } from "lucide-react"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useSidebar } from "../context/SidebarContext"; +import { useCompany } from "../context/CompanyContext"; +import { Button } from "@/components/ui/button"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Fragment, useMemo } from "react"; +import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; +import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; + +type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; + +/** Task identifier rendered in gray monospace between the glyph and the title. */ +function CrumbIdentifier({ identifier }: { identifier?: string }) { + if (!identifier) return null; + return {identifier}; +} + +function GlobalToolbar({ context }: { context: GlobalToolbarContext }) { + const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId }); + const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId }); + return ( +
+ {slots.length > 0 ? ( + + ) : null} + {launchers.length > 0 ? ( + + ) : null} +
+ ); +} + +export function BreadcrumbBar() { + const { breadcrumbs, mobileToolbar } = useBreadcrumbs(); + const { toggleSidebar, isMobile } = useSidebar(); + const { selectedCompanyId, selectedCompany } = useCompany(); + + const globalToolbarSlotContext = useMemo( + () => ({ + companyId: selectedCompanyId ?? null, + companyPrefix: selectedCompany?.issuePrefix ?? null, + }), + [selectedCompanyId, selectedCompany?.issuePrefix], + ); + + const globalToolbarSlots = ; + + if (isMobile && mobileToolbar) { + return ( +
+ {mobileToolbar} +
+ ); + } + + if (breadcrumbs.length === 0) { + return ( +
+ {globalToolbarSlots} +
+ ); + } + + const menuButton = isMobile && ( + + ); + + // Single breadcrumb = page title (uppercase) + if (breadcrumbs.length === 1) { + return ( +
+ {menuButton} +
+ {breadcrumbs[0].leading || breadcrumbs[0].identifier ? ( +

+ {breadcrumbs[0].leading && ( + {breadcrumbs[0].leading} + )} + + {breadcrumbs[0].label} +

+ ) : ( +

+ {breadcrumbs[0].label} +

+ )} +
+ {globalToolbarSlots} +
+ ); + } + + // Multiple breadcrumbs = breadcrumb trail + return ( +
+ {menuButton} +
+ + + {breadcrumbs.map((crumb, i) => { + const isLast = i === breadcrumbs.length - 1; + return ( + + {i > 0 && } + + {isLast || !crumb.href ? ( + crumb.leading || crumb.identifier ? ( + + {crumb.leading && ( + {crumb.leading} + )} + + {crumb.label} + + ) : ( + {crumb.label} + ) + ) : ( + + {crumb.leading || crumb.identifier ? ( + + {crumb.leading && ( + {crumb.leading} + )} + + {crumb.label} + + ) : ( + {crumb.label} + )} + + )} + + + ); + })} + + +
+ {globalToolbarSlots} +
+ ); +} diff --git a/ui/src/components/BreadcrumbBar.test.tsx b/ui/src/components/BreadcrumbBar.test.tsx index 373f7429050..e002a40539d 100644 --- a/ui/src/components/BreadcrumbBar.test.tsx +++ b/ui/src/components/BreadcrumbBar.test.tsx @@ -7,17 +7,26 @@ import { BreadcrumbProvider, useBreadcrumbs } from "../context/BreadcrumbContext import { BreadcrumbBar } from "./BreadcrumbBar"; vi.mock("@/lib/router", () => ({ - Link: ({ children, to }: { children: ReactNode; to: string }) => ( - {children} + Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => ( + {children} ), })); vi.mock("../context/SidebarContext", () => ({ - useSidebar: () => ({ isMobile: false, toggleSidebar: vi.fn() }), + useSidebar: () => ({ + collapsed: false, + isMobile: false, + toggleCollapsed: vi.fn(), + toggleSidebar: vi.fn(), + }), })); vi.mock("../context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { issuePrefix: "PAP" } }), + useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { issuePrefix: "TES" } }), +})); + +vi.mock("../context/PanelContext", () => ({ + usePanel: () => ({ panelVisible: true, togglePanelVisible: vi.fn() }), })); vi.mock("@/plugins/slots", () => ({ @@ -30,25 +39,59 @@ vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null, })); -function TaskBreadcrumbs({ onOpen }: { onOpen: () => void }) { - const { setBreadcrumbs, setBreadcrumbToolbar } = useBreadcrumbs(); +function TaskBreadcrumbs({ + onOpen, + panelControl, + taskDetailLayout = false, + identifier = "PAP-16679", +}: { + onOpen?: () => void; + panelControl?: { open: boolean; onToggle: () => void }; + taskDetailLayout?: boolean; + identifier?: string; +}) { + const { + setBreadcrumbs, + setBreadcrumbToolbar, + setBreadcrumbPanelControl, + } = useBreadcrumbs(); useEffect(() => { setBreadcrumbs([ - { label: "Tasks", href: "/tasks" }, - { label: "Keep the panel launcher available", identifier: "PAP-16679" }, + { label: "Tasks", href: "/issues" }, + { + label: "Hire your first engineer and create a hiring plan", + identifier, + leading: "status", + }, ]); setBreadcrumbToolbar( - , + onOpen ? ( + + ) : null, ); - return () => setBreadcrumbToolbar(null); - }, [onOpen, setBreadcrumbToolbar, setBreadcrumbs]); + setBreadcrumbPanelControl(panelControl ?? null); + return () => { + setBreadcrumbToolbar(null); + setBreadcrumbPanelControl(null); + }; + }, [ + identifier, + onOpen, + panelControl, + setBreadcrumbPanelControl, + setBreadcrumbToolbar, + setBreadcrumbs, + ]); - return ; + return ; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + describe("BreadcrumbBar", () => { let container: HTMLDivElement; let root: Root; @@ -78,9 +121,103 @@ describe("BreadcrumbBar", () => { 'button[aria-label="Show task side panel"]', ); expect(launcher).not.toBeNull(); - expect(launcher?.closest(".h-12")?.textContent).toContain("PAP-16679"); + expect(launcher?.closest(".h-\\(--sz-60px\\)")?.textContent).toContain("PAP-16679"); act(() => launcher?.click()); expect(onOpen).toHaveBeenCalledOnce(); }); + + it("appends the task identifier to the task title in the task-detail header", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + const identifier = container.querySelector('[data-slot="task-title-identifier"]'); + expect(identifier).toBeTruthy(); + expect(identifier?.className).not.toContain("absolute"); + expect(identifier?.closest(".relative")?.className).toContain("border-b"); + expect(identifier?.closest(".relative")?.className).toContain("border-border"); + expect(identifier?.closest(".relative")?.className).toContain("h-(--sz-60px)"); + + const title = Array.from(container.querySelectorAll("span")) + .find((element) => element.textContent === "Hire your first engineer and create a hiring plan"); + const tasksLink = container.querySelector('a[href="/issues"]'); + const header = tasksLink?.closest(".relative"); + expect(header?.classList).toContain("px-4"); + expect(header?.classList).toContain("md:px-6"); + expect(header?.classList).not.toContain("px-3"); + expect(tasksLink?.classList).toContain("font-semibold"); + expect(tasksLink?.classList).toContain("tracking-wider"); + expect(tasksLink?.classList).toContain("text-muted-foreground"); + expect(tasksLink?.classList).toContain("hover:text-foreground"); + expect(tasksLink?.classList).not.toContain("font-bold"); + expect(title?.className).toContain("truncate"); + expect(title?.nextElementSibling).toBe(identifier); + expect(identifier?.textContent).toBe("TES-1"); + }); + + it("styles the root crumb as an uppercase muted header on every detail view", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + const rootCrumb = container.querySelector('a[href="/issues"]'); + expect(rootCrumb?.classList).toContain("font-semibold"); + expect(rootCrumb?.classList).toContain("uppercase"); + expect(rootCrumb?.classList).toContain("tracking-wider"); + expect(rootCrumb?.classList).toContain("text-muted-foreground"); + expect(rootCrumb?.classList).toContain("hover:text-foreground"); + }); + + it("routes the single task-detail panel button through a page override", async () => { + const onToggle = vi.fn(); + const panelControl = { open: false, onToggle }; + await act(async () => { + root.render( + + + , + ); + }); + + const launcher = container.querySelector( + 'button[aria-label="Show properties"]', + ); + expect(launcher).not.toBeNull(); + expect(container.querySelector('button[aria-label="Hide properties"]')).toBeNull(); + + act(() => launcher?.click()); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("omits the retired sidebar control and keeps the properties control", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + const leftControl = container.querySelector( + 'button[aria-label="Collapse sidebar"]', + ); + const rightControl = container.querySelector( + 'button[aria-label="Hide properties"]', + ); + + expect(leftControl).toBeNull(); + expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); + expect(rightControl?.className).toContain("size-9"); + expect(rightControl?.className).not.toContain("rounded-none"); + expect(rightControl?.className).not.toContain("h-full"); + }); }); diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index b29d24cc893..751bdc7fdd7 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -1,8 +1,9 @@ import { Link } from "@/lib/router"; -import { Menu } from "lucide-react"; +import { Menu, PanelRightClose, PanelRightOpen } from "lucide-react"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useSidebar } from "../context/SidebarContext"; import { useCompany } from "../context/CompanyContext"; +import { usePanel } from "../context/PanelContext"; import { Button } from "@/components/ui/button"; import { Breadcrumb, @@ -15,13 +16,18 @@ import { import { Fragment, useMemo, type ReactNode } from "react"; import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; +import { cn } from "../lib/utils"; type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; -/** Task identifier rendered in gray monospace between the glyph and the title. */ +/** Task identifier rendered in gray monospace beside its breadcrumb label. */ function CrumbIdentifier({ identifier }: { identifier?: string }) { if (!identifier) return null; - return {identifier}; + return ( + + {identifier} + + ); } function GlobalToolbar({ @@ -46,10 +52,18 @@ function GlobalToolbar({ ); } -export function BreadcrumbBar() { - const { breadcrumbs, breadcrumbToolbar, mobileToolbar } = useBreadcrumbs(); +export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) { + const { + breadcrumbs, + breadcrumbToolbar, + breadcrumbPanelControl, + mobileToolbar, + } = useBreadcrumbs(); const { toggleSidebar, isMobile } = useSidebar(); + const { panelVisible, togglePanelVisible } = usePanel(); const { selectedCompanyId, selectedCompany } = useCompany(); + const taskPanelOpen = breadcrumbPanelControl?.open ?? panelVisible; + const toggleTaskPanel = breadcrumbPanelControl?.onToggle ?? togglePanelVisible; const globalToolbarSlotContext = useMemo( () => ({ @@ -68,7 +82,7 @@ export function BreadcrumbBar() { if (isMobile && mobileToolbar) { return ( -
+
{mobileToolbar}
); @@ -76,7 +90,7 @@ export function BreadcrumbBar() { if (breadcrumbs.length === 0) { return ( -
+
{globalToolbarSlots}
); @@ -94,10 +108,72 @@ export function BreadcrumbBar() { ); + const breadcrumbTrail = ( +
+ + + {breadcrumbs.map((crumb, i) => { + const isLast = i === breadcrumbs.length - 1; + return ( + + {i > 0 && } + + {isLast || !crumb.href ? ( + crumb.leading || crumb.identifier ? ( + + {crumb.leading && ( + {crumb.leading} + )} + {!taskDetailLayout ? : null} + {crumb.label} + {taskDetailLayout && isLast ? : null} + + ) : ( + {crumb.label} + ) + ) : ( + + {crumb.leading || crumb.identifier ? ( + + {crumb.leading && ( + {crumb.leading} + )} + {!taskDetailLayout ? : null} + {crumb.label} + {taskDetailLayout && isLast ? : null} + + ) : ( + + {crumb.label} + + )} + + )} + + + ); + })} + + +
+ ); + // Single breadcrumb = page title (uppercase) if (breadcrumbs.length === 1) { return ( -
+
{menuButton}
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? ( @@ -121,52 +197,27 @@ export function BreadcrumbBar() { // Multiple breadcrumbs = breadcrumb trail return ( -
+
{menuButton} -
- - - {breadcrumbs.map((crumb, i) => { - const isLast = i === breadcrumbs.length - 1; - return ( - - {i > 0 && } - - {isLast || !crumb.href ? ( - crumb.leading || crumb.identifier ? ( - - {crumb.leading && ( - {crumb.leading} - )} - - {crumb.label} - - ) : ( - {crumb.label} - ) - ) : ( - - {crumb.leading || crumb.identifier ? ( - - {crumb.leading && ( - {crumb.leading} - )} - - {crumb.label} - - ) : ( - {crumb.label} - )} - - )} - - - ); - })} - - -
+ {breadcrumbTrail} {globalToolbarSlots} + {taskDetailLayout ? ( + + ) : null}
); } diff --git a/ui/src/components/CompanySettingsSidebar.production.tsx b/ui/src/components/CompanySettingsSidebar.production.tsx new file mode 100644 index 00000000000..4080d1afff6 --- /dev/null +++ b/ui/src/components/CompanySettingsSidebar.production.tsx @@ -0,0 +1,214 @@ +import { useQuery } from "@tanstack/react-query"; +import { + ChevronLeft, + Clock3, + Cpu, + Download, + FlaskConical, + KeyRound, + MailPlus, + MonitorCog, + Puzzle, + Shield, + SlidersHorizontal, + Upload, + UserRoundPen, + Users, +} from "lucide-react"; +import type { PluginRecord } from "@paperclipai/shared"; +import { sidebarBadgesApi } from "@/api/sidebarBadges"; +import { pluginsApi } from "@/api/plugins"; +import { ApiError } from "@/api/client"; +import { Link, NavLink } from "@/lib/router"; +import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; +import { usePluginSlots } from "@/plugins/slots"; +import { SidebarNavItem } from "./SidebarNavItem.production"; + +/** + * Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin + * settings page, so a sidebar entry would lead nowhere useful. Filter them out + * here. Plugins that mix a sandbox provider with other contributions still + * appear. + */ +function isSandboxProviderOnly(plugin: PluginRecord): boolean { + const drivers = plugin.manifestJson.environmentDrivers ?? []; + if (drivers.length === 0) return false; + return drivers.every((d) => d.kind === "sandbox_provider"); +} + +export function CompanySettingsSidebar() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + const { hidden: hiddenSettings } = useHiddenSettings(); + const showPage = (pageKey: string) => !hiddenSettings.has(pageKey); + const showPlugins = showPage("instance.plugins"); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // nav entry is hidden rather than dead-ending. Export stays available. + const isCloud = Boolean(useCloudInstance()); + const { slots: companySettingsPluginSlots } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); + const { data: badges } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.sidebarBadges(selectedCompanyId) + : ["sidebar-badges", "__disabled__"] as const, + queryFn: async () => { + try { + return await sidebarBadgesApi.get(selectedCompanyId!); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + return null; + } + throw error; + } + }, + enabled: !!selectedCompanyId, + retry: false, + refetchInterval: 15_000, + }); + const { data: plugins } = useQuery({ + queryKey: queryKeys.plugins.all, + queryFn: () => pluginsApi.list(), + // The listing only feeds the per-plugin subtree below; skip it when the + // operator hides the Plugins surface. + enabled: showPlugins, + }); + const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin)); + + return ( + + ); +} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 96c15512608..0fde10d0cef 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -16,6 +16,7 @@ const mockPluginsApi = vi.hoisted(() => ({ const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ + useNavigate: () => vi.fn(), Link: ({ children, to, @@ -41,7 +42,7 @@ vi.mock("@/lib/router", () => ({ vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", - selectedCompany: { id: "company-1", name: "Paperclip" }, + selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" }, }), })); @@ -53,6 +54,7 @@ vi.mock("@/context/SidebarContext", () => ({ })); vi.mock("./SidebarNavItem", () => ({ + SidebarNavExpandedProvider: ({ children }: { children: React.ReactNode }) => children, SidebarNavItem: (props: { to: string; label: string; diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index f7c1977602b..43887f56db5 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { - ChevronLeft, Cpu, Download, FlaskConical, @@ -17,16 +16,16 @@ import type { PluginRecord } from "@paperclipai/shared"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { pluginsApi } from "@/api/plugins"; import { ApiError } from "@/api/client"; -import { Link, NavLink } from "@/lib/router"; +import { NavLink } from "@/lib/router"; import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; -import { useSidebar } from "@/context/SidebarContext"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; +import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; /** * Sandbox-provider-only plugins (e.g. E2B, exe.dev, Modal) have no per-plugin @@ -41,8 +40,7 @@ function isSandboxProviderOnly(plugin: PluginRecord): boolean { } export function CompanySettingsSidebar() { - const { selectedCompany, selectedCompanyId } = useCompany(); - const { isMobile, setSidebarOpen } = useSidebar(); + const { selectedCompanyId } = useCompany(); const { hidden: hiddenSettings } = useHiddenSettings(); const showPage = (pageKey: string) => !hiddenSettings.has(pageKey); const showPlugins = showPage("instance.plugins"); @@ -82,20 +80,7 @@ export function CompanySettingsSidebar() { const sidebarPlugins = (plugins ?? []).filter((plugin) => !isSandboxProviderOnly(plugin)); return ( - + ); } diff --git a/ui/src/components/ContextualSidebarFrame.test.tsx b/ui/src/components/ContextualSidebarFrame.test.tsx new file mode 100644 index 00000000000..c196847551c --- /dev/null +++ b/ui/src/components/ContextualSidebarFrame.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Settings } from "lucide-react"; +import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; +import { rememberContextualSidebarOrigin } from "@/lib/shell-navigation"; + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate })); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: { name: "Paperclip", issuePrefix: "PAP" } }), +})); +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn(), collapsed: true, peeking: false }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("ContextualSidebarFrame", () => { + let container: HTMLDivElement; + + beforeEach(() => { + window.sessionStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + vi.clearAllMocks(); + }); + + function render() { + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + return root; + } + + it("renders the contextual identity and uses a safe fallback", () => { + const root = render(); + expect(container.textContent).toContain("Paperclip"); + expect(container.textContent).toContain("Settings"); + + act(() => { + container.querySelector('button[aria-label="Back from Settings"]')?.click(); + }); + expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + act(() => root.unmount()); + }); + + it("returns to the same-company route that opened the contextual surface", () => { + rememberContextualSidebarOrigin({ + surface: "settings", + companyPrefix: "PAP", + previousPathname: "/PAP/issues/task-1", + }); + const root = render(); + + act(() => { + container.querySelector('button[aria-label="Back from Settings"]')?.click(); + }); + expect(mockNavigate).toHaveBeenCalledWith("/PAP/issues/task-1"); + act(() => root.unmount()); + }); + + it("can omit contextual identity when the breadcrumb already provides it", () => { + const root = createRoot(container); + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).toBe("Skill links"); + expect(container.querySelector('[aria-label="Back from Skills"]')).toBeNull(); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/ContextualSidebarFrame.tsx b/ui/src/components/ContextualSidebarFrame.tsx new file mode 100644 index 00000000000..f7a48968a0f --- /dev/null +++ b/ui/src/components/ContextualSidebarFrame.tsx @@ -0,0 +1,70 @@ +import { ChevronLeft, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useNavigate } from "@/lib/router"; +import { + readContextualSidebarOrigin, + type ContextualSidebarSurface, +} from "@/lib/shell-navigation"; +import { cn } from "@/lib/utils"; +import { SidebarNavExpandedProvider } from "./SidebarNavItem"; + +export function ContextualSidebarFrame({ + surface, + title, + icon: Icon, + fallbackTo = "/dashboard", + showHeader = true, + className, + children, +}: { + surface: ContextualSidebarSurface; + title: string; + icon?: LucideIcon; + fallbackTo?: string; + showHeader?: boolean; + className?: string; + children: ReactNode; +}) { + const navigate = useNavigate(); + const { selectedCompany } = useCompany(); + const { isMobile, setSidebarOpen } = useSidebar(); + + function goBack() { + navigate(readContextualSidebarOrigin({ + surface, + companyPrefix: selectedCompany?.issuePrefix, + fallbackTo, + })); + if (isMobile) setSidebarOpen(false); + } + + return ( + + + + ); +} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 5596289aeaf..bd04975db98 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -586,6 +586,9 @@ describe("Layout", () => { expect(container.textContent).toContain("Apps sidebar"); expect(container.textContent).toContain("Main company nav"); expect(mockSetForceCollapsed).toHaveBeenCalledWith(true); + const secondaryRail = container.querySelector("[data-secondary-sidebar]"); + expect(secondaryRail?.classList.contains("w-60")).toBe(true); + expect(secondaryRail?.classList.contains("shrink-0")).toBe(true); await act(async () => { root.unmount(); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 898f715955f..43004ed114b 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -643,7 +643,7 @@ export function Layout() { )} {!isMobile && hasSecondarySidebar ? ( - {secondarySidebar} + {secondarySidebar} ) : null}
diff --git a/ui/src/components/RequestCollapsedSidebar.test.tsx b/ui/src/components/RequestCollapsedSidebar.test.tsx index 0e1d0339e35..dc065e96636 100644 --- a/ui/src/components/RequestCollapsedSidebar.test.tsx +++ b/ui/src/components/RequestCollapsedSidebar.test.tsx @@ -9,8 +9,6 @@ import { RequestCollapsedSidebar } from "./RequestCollapsedSidebar"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -const COLLAPSED_STORAGE_KEY = "paperclip.sidebar.collapsed"; - let capturedValue: ReturnType | null = null; function Capture() { @@ -82,25 +80,24 @@ describe("RequestCollapsedSidebar", () => { localStorage.clear(); }); - it("requests collapsed while mounted when there is no user pin", async () => { + it("records the legacy route request without collapsing the global navigation", async () => { active = await render(true); expect(capturedValue?.routeRequestsCollapsed).toBe(true); - expect(capturedValue?.collapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); }); - it("lets an explicit user pin override the route request", async () => { + it("keeps the global navigation expanded when old pin APIs are called", async () => { active = await render(true); - expect(capturedValue?.collapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); - // User explicitly pins expanded — must win over the route's request. - flushSync(() => capturedValue?.setCollapsed(false)); + flushSync(() => capturedValue?.setCollapsed(true)); expect(capturedValue?.routeRequestsCollapsed).toBe(true); expect(capturedValue?.collapsed).toBe(false); }); it("clears the request on unmount, restoring the global default", async () => { active = await render(true); - expect(capturedValue?.collapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); // Navigate away: the route (and its ) unmounts. flushSync(() => active!.root.render()); @@ -109,15 +106,14 @@ describe("RequestCollapsedSidebar", () => { expect(capturedValue?.collapsed).toBe(false); }); - it("keeps a user pin after navigating away (pin persists, request cleared)", async () => { + it("clears the request without persisting a retired collapsed pin", async () => { active = await render(true); flushSync(() => capturedValue?.setCollapsed(true)); - expect(localStorage.getItem(COLLAPSED_STORAGE_KEY)).toBe("1"); + expect(localStorage.getItem("paperclip.sidebar.collapsed")).toBeNull(); flushSync(() => active!.root.render()); await flushReact(); - // Route request gone, but the explicit collapsed pin still applies. expect(capturedValue?.routeRequestsCollapsed).toBe(false); - expect(capturedValue?.collapsed).toBe(true); + expect(capturedValue?.collapsed).toBe(false); }); }); diff --git a/ui/src/components/RequestCollapsedSidebar.tsx b/ui/src/components/RequestCollapsedSidebar.tsx index 259de29a4ea..7d2d7f67d13 100644 --- a/ui/src/components/RequestCollapsedSidebar.tsx +++ b/ui/src/components/RequestCollapsedSidebar.tsx @@ -2,14 +2,10 @@ import { useEffect } from "react"; import { useSidebar } from "../context/SidebarContext"; /** - * Effect-only component (renders nothing). While mounted, it asks the app - * sidebar to default to **collapsed** (still peek-able) — the generic - * "this route brings its own sidebar, keep the app one out of the way" case. - * - * Precedence is enforced by {@link SidebarContext}: an explicit user pin - * (collapsed *or* expanded) always wins over this route request. When the - * component unmounts — e.g. navigating away from the route that rendered it — - * the cleanup clears the request and the global default is restored. + * Back-compat effect for routes that previously requested the app sidebar's + * retired icon rail. The request remains observable to older integrations, + * but {@link SidebarContext} keeps the global navigation expanded. Cleanup + * still clears the request when the route unmounts. * * Drop it anywhere inside a route's element tree: * diff --git a/ui/src/components/RoutineContextualSidebar.test.tsx b/ui/src/components/RoutineContextualSidebar.test.tsx new file mode 100644 index 00000000000..a58163a50d0 --- /dev/null +++ b/ui/src/components/RoutineContextualSidebar.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + RoutineContextualSidebar, + resolveRoutineDetailDestination, + routineActivityAuditHref, + routineDetailHref, + routineRunsAuditHref, +} from "./RoutineContextualSidebar"; + +vi.mock("@/lib/router", () => ({ + useParams: () => ({ routineId: "routine-route" }), +})); + +vi.mock("@/api/routines", () => ({ + routinesApi: { get: vi.fn() }, +})); + +vi.mock("./ContextualSidebarFrame", () => ({ + ContextualSidebarFrame: ({ + surface, + title, + fallbackTo, + showHeader, + className, + children, + }: { + surface: string; + title: string; + fallbackTo: string; + showHeader?: boolean; + className?: string; + children: ReactNode; + }) => ( + + ), +})); + +vi.mock("./SidebarNavItem", () => ({ + SidebarNavItem: ({ to, label }: { to: string; label: string }) => {label}, +})); + +describe("routine contextual navigation", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + }); + + it("builds stable detail and scoped Audit destinations", () => { + expect(routineDetailHref("routine-1")).toBe("/routines/routine-1/overview"); + expect(routineDetailHref("routine-1", "triggers")).toBe("/routines/routine-1/triggers"); + expect(routineRunsAuditHref("routine-1")).toBe( + "/activity/runs?entityType=routine&entityId=routine-1", + ); + expect(routineActivityAuditHref("routine-1")).toBe( + "/activity?entityType=routine&entityId=routine-1", + ); + }); + + it("defaults and redirects legacy operation routes without remembering a prior section", () => { + expect(resolveRoutineDetailDestination({ routineId: "routine-1" })) + .toBe("/routines/routine-1/overview"); + expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "unknown" })) + .toBe("/routines/routine-1/overview"); + expect(resolveRoutineDetailDestination({ routineId: "routine-1", section: "runs" })) + .toBe("/activity/runs?entityType=routine&entityId=routine-1"); + expect(resolveRoutineDetailDestination({ routineId: "routine-1", legacyTab: "activity" })) + .toBe("/activity?entityType=routine&entityId=routine-1"); + }); + + it("renders the routine contextual sidebar with configuration and Audit links", () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => root.render( + + + , + )); + + const frame = container.querySelector("aside"); + expect(frame?.getAttribute("data-surface")).toBe("routine"); + expect(frame?.getAttribute("data-title")).toBe("Weekly release review"); + expect(frame?.getAttribute("data-fallback")).toBe("/routines"); + expect(frame?.getAttribute("data-show-header")).toBe("false"); + expect(frame?.classList).toContain("bg-background"); + expect(frame?.classList).toContain("border-r"); + expect(container.querySelector('a[href="/routines/routine-1/overview"]')?.textContent).toBe("Overview"); + expect(container.querySelector('a[href="/routines/routine-1/triggers"]')?.textContent).toBe("Schedule"); + expect(container.querySelector('a[href="/activity/runs?entityType=routine&entityId=routine-1"]')) + .not.toBeNull(); + expect(container.querySelector('a[href="/activity?entityType=routine&entityId=routine-1"]')) + .not.toBeNull(); + expect(container.textContent).not.toContain("History"); + }); +}); diff --git a/ui/src/components/RoutineContextualSidebar.tsx b/ui/src/components/RoutineContextualSidebar.tsx new file mode 100644 index 00000000000..46e80fd9ba8 --- /dev/null +++ b/ui/src/components/RoutineContextualSidebar.tsx @@ -0,0 +1,140 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + Braces, + CalendarClock, + KeyRound, + LayoutDashboard, + Play, + Repeat, + Send, + type LucideIcon, +} from "lucide-react"; +import { routinesApi } from "@/api/routines"; +import { queryKeys } from "@/lib/queryKeys"; +import { useParams } from "@/lib/router"; +import { + auditSectionHref, + routineAuditHref, +} from "@/pages/audit/audit-navigation"; +import { ContextualSidebarFrame } from "./ContextualSidebarFrame"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export const ROUTINE_DETAIL_VIEWS = [ + "overview", + "triggers", + "variables", + "delivery", + "secrets", + "history", +] as const; + +export type RoutineDetailView = (typeof ROUTINE_DETAIL_VIEWS)[number]; + +export type RoutineContextualNavItem = { + view: Exclude; + label: string; + icon: LucideIcon; +}; + +export const ROUTINE_CONTEXTUAL_NAV_ITEMS: readonly RoutineContextualNavItem[] = [ + { view: "overview", label: "Overview", icon: LayoutDashboard }, + { view: "triggers", label: "Schedule", icon: CalendarClock }, + { view: "variables", label: "Variables", icon: Braces }, + { view: "delivery", label: "Delivery", icon: Send }, + { view: "secrets", label: "Secrets", icon: KeyRound }, +]; + +export function isRoutineDetailView(value: string | null | undefined): value is RoutineDetailView { + return ROUTINE_DETAIL_VIEWS.includes(value as RoutineDetailView); +} + +export function routineDetailHref(routineId: string, view: RoutineDetailView = "overview") { + return `/routines/${routineId}/${view}`; +} + +export function routineRunsAuditHref(routineId: string) { + return auditSectionHref("runs", { + entityType: "routine", + entityId: routineId, + }); +} + +export function routineActivityAuditHref(routineId: string) { + return routineAuditHref(routineId); +} + +/** + * Canonical landing/legacy resolver used by the page and available to the + * shell. Detail configuration stays local; immutable operational history + * lives in scoped Audit. + */ +export function resolveRoutineDetailDestination(input: { + routineId: string; + section?: string | null; + legacyTab?: string | null; +}) { + const requested = input.legacyTab ?? input.section; + if (requested === "runs") return routineRunsAuditHref(input.routineId); + if (requested === "activity") return routineActivityAuditHref(input.routineId); + if (isRoutineDetailView(requested)) return routineDetailHref(input.routineId, requested); + return routineDetailHref(input.routineId, "overview"); +} + +export function RoutineContextualSidebar({ + routineId: routineIdProp, + title, +}: { + routineId?: string; + title?: string; +} = {}) { + const params = useParams<{ routineId?: string }>(); + const routineId = routineIdProp ?? params.routineId ?? ""; + const { data: routine } = useQuery({ + queryKey: queryKeys.routines.detail(routineId), + queryFn: () => routinesApi.get(routineId), + enabled: Boolean(routineId && !title), + }); + const frameTitle = title ?? routine?.title ?? "Routine"; + + return ( + + + + ); +} diff --git a/ui/src/components/SecondarySidebar.production.tsx b/ui/src/components/SecondarySidebar.production.tsx new file mode 100644 index 00000000000..ae295bf59d3 --- /dev/null +++ b/ui/src/components/SecondarySidebar.production.tsx @@ -0,0 +1,39 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { SidebarNavExpandedProvider } from "./SidebarNavItem.production"; + +/** + * Fixed-width contextual pane that sits between the collapsed app rail and the + * main content on takeover routes (company settings, plugin `routeSidebar`). + * + * The takeover model (PAP-10695) no longer *replaces* the app ``: + * the host collapses it to its 64px rail (still peek-able) and renders the + * contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`. + * + * It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a + * right border and its own vertical scroll — so callers just drop the + * contextual nav (or a plugin slot mount) inside as `children`. + * + * Because the pane is always 240px wide it forces the full-label presentation + * on any `SidebarNavItem` inside it, so the contextual nav stays readable even + * while the app sidebar is collapsed to its rail (PAP-10700). + */ +export function SecondarySidebar({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/src/components/SecondarySidebar.tsx b/ui/src/components/SecondarySidebar.tsx index 20b41c17dcb..a8cb196fbc7 100644 --- a/ui/src/components/SecondarySidebar.tsx +++ b/ui/src/components/SecondarySidebar.tsx @@ -3,20 +3,9 @@ import { cn } from "@/lib/utils"; import { SidebarNavExpandedProvider } from "./SidebarNavItem"; /** - * Fixed-width contextual pane that sits between the collapsed app rail and the - * main content on takeover routes (company settings, plugin `routeSidebar`). - * - * The takeover model (PAP-10695) no longer *replaces* the app ``: - * the host collapses it to its 64px rail (still peek-able) and renders the - * contextual sidebar here, yielding `[ rail 64px ][ secondary ~240px ][ content ]`. - * - * It is a dumb container — fixed `w-60`, full-height, non-shrinking, with a - * right border and its own vertical scroll — so callers just drop the - * contextual nav (or a plugin slot mount) inside as `children`. - * - * Because the pane is always 240px wide it forces the full-label presentation - * on any `SidebarNavItem` inside it, so the contextual nav stays readable even - * while the app sidebar is collapsed to its rail (PAP-10700). + * Content adapter for a contextual sidebar. Depending on the route, Layout + * either places it inside the primary SidebarShell or mounts it as an adjacent + * secondary rail. */ export function SecondarySidebar({ children, @@ -29,7 +18,7 @@ export function SecondarySidebar({
diff --git a/ui/src/components/Sidebar.production.tsx b/ui/src/components/Sidebar.production.tsx new file mode 100644 index 00000000000..771a142767a --- /dev/null +++ b/ui/src/components/Sidebar.production.tsx @@ -0,0 +1,251 @@ +import { + Inbox, + ListChecks, + CircleDot, + Target, + LayoutDashboard, + DollarSign, + History, + Search, + SquarePen, + Network, + Boxes, + Repeat, + Layers, + GitBranch, + Package, + Settings, + FolderOpen, + AppWindow, + MessagesSquare, + GanttChartSquare, + LayoutGrid, +} from "lucide-react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { SidebarSection } from "./SidebarSection"; +import { SidebarNavItem } from "./SidebarNavItem.production"; +import { SidebarAgents } from "./SidebarAgents.production"; +import { SidebarProjects } from "./SidebarProjects"; +import { SidebarStarredProjects } from "./SidebarStarredProjects.production"; +import { useDialogActions } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { useSidebar } from "../context/SidebarContext"; +import { attentionApi } from "../api/attention"; +import { heartbeatsApi } from "../api/heartbeats"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { queryKeys } from "../lib/queryKeys"; +import { attentionBadgeCount } from "../lib/attention"; +import { useInboxBadge } from "../hooks/useInboxBadge"; +import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; +import { PluginSlotOutlet } from "@/plugins/slots"; +import { PluginLauncherOutlet } from "@/plugins/launchers"; +import { SidebarCompanyMenu } from "./SidebarCompanyMenu.production"; + +export function Sidebar() { + const { openNewIssue } = useDialogActions(); + // Every labeled section is collapsible (session-scoped, default open) — + // one policy across static nav groups and the data-driven sections. + const [workOpen, setWorkOpen] = useState(true); + const [companyOpen, setCompanyOpen] = useState(true); + const { selectedCompanyId, selectedCompany } = useCompany(); + const { collapsed, peeking } = useSidebar(); + const rail = collapsed && !peeking; + const inboxBadge = useInboxBadge(selectedCompanyId); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!); + const sharedLiveRuns = useSharedPollingQuery({ + companyId: selectedCompanyId, + resourceKey: "live-runs", + queryKey: liveRunsQueryKey, + enabled: !!selectedCompanyId, + // Event-sourced via LiveUpdatesProvider (GitHub issue 9627) + reconnect reconcile — no + // interval poll needed. Polling here also re-armed React Query's timer on + // every live-event cache write, a major source of steady-state churn. + refetchInterval: false, + leaderOnly: true, + }); + const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({ + queryKey: liveRunsQueryKey, + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!), + enabled: sharedLiveRuns.enabled, + refetchInterval: sharedLiveRuns.refetchInterval, + }); + usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); + const liveRunCount = liveRuns?.length ?? 0; + const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; + const showApps = experimentalSettings?.enableApps === true; + const showPipelines = experimentalSettings?.enablePipelines === true; + const showStatusCards = experimentalSettings?.enableStatusCards === true; + const goalsLinkPending = experimentalSettings === undefined; + const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true; + // Decisions (attention home) is an experimental surface (PAP-13481): the nav + // item is hidden entirely until the flag is enabled (same no-flash pattern as + // showWorkspacesLink — it defaults hidden, so no placeholder is needed). + const showDecisions = experimentalSettings?.enableDecisions === true; + const { data: attentionFeed } = useQuery({ + queryKey: queryKeys.attention(selectedCompanyId!), + queryFn: () => attentionApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && showDecisions, + refetchInterval: 60_000, + }); + const attentionCount = attentionBadgeCount(attentionFeed); + const showCases = experimentalSettings?.enableCases === true; + // Streamlined left navigation (top-level Projects link + starred children) is + // now the standard product sidebar (PAP-12472). The former experimental + // opt-out was retired; classic per-project collapsible mode is no longer + // user-selectable. Kept as a constant so the classic branch below stays as a + // documented reference until it is fully removed. Routes are unaffected. + const streamlined = true; + // Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item + // is a new surface, hidden entirely while the flag is off (same no-flash + // pattern as showWorkspacesLink above). + const conferenceRoomChatEnabled = experimentalSettings?.enableConferenceRoomChat === true; + + const pluginContext = { + companyId: selectedCompanyId, + companyPrefix: selectedCompany?.issuePrefix ?? null, + }; + + return ( + + ); +} diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index d13dc1aa6be..f075408a3ea 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -100,18 +100,24 @@ vi.mock("./SidebarCompanyMenu", () => ({ vi.mock("./SidebarAgents", () => ({ SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => ( -
+
+ Active agents +
), })); vi.mock("./SidebarProjects", () => ({ - SidebarProjects: () =>
Projects collapsible
, + SidebarProjects: () =>
Classic projects
, })); vi.mock("./SidebarStarredProjects", () => ({ SidebarStarredProjects: () =>
, })); +vi.mock("./SidebarRecentTasks", () => ({ + SidebarRecentTasks: () =>
Recent Tasks
, +})); + async function flushReact() { for (let index = 0; index < 5; index += 1) { await Promise.resolve(); @@ -150,6 +156,7 @@ describe("Sidebar", () => { mockAttentionApi.list.mockResolvedValue({ items: [] }); mockSidebar.isMobile = false; mockSidebar.collapsed = false; + mockSidebar.collapseLocked = false; mockSidebar.peeking = false; }); @@ -159,6 +166,21 @@ describe("Sidebar", () => { vi.clearAllMocks(); }); + it("keeps the default sidebar edge borderless", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const sidebar = container.querySelector("aside"); + expect(sidebar?.classList).not.toContain("border-r"); + expect(sidebar?.classList).not.toContain("border-border"); + expect(sidebar?.classList).toContain("bg-border/50"); + expect(sidebar?.classList).toContain("dark:bg-muted"); + + flushSync(() => { + root.unmount(); + }); + }); + it("shows Search as a nav item instead of a header icon", async () => { // The header's spare width goes to the workspace name (which otherwise // truncates at ~78px), so search lives in the nav list — still @@ -201,7 +223,7 @@ describe("Sidebar", () => { }); }); - it("streamlined (flag ON): keeps Task wording, top-level Projects link, no per-project collapsible", async () => { + it("uses the simplified work navigation with one Agents destination", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, @@ -217,34 +239,35 @@ describe("Sidebar", () => { const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects"); expect(projectsLink?.getAttribute("href")).toBe("/projects"); - - expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull(); - expect( - container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), - ).toBe("true"); + const agentLinks = [...container.querySelectorAll('a[href="/agents"]')]; + expect(agentLinks).toHaveLength(1); + expect([...container.querySelectorAll('a[href="/activity"]')]).toHaveLength(1); + expect(navLabels).toContain("Audit"); + expect(navLabels).not.toContain("Settings"); + expect(navLabels).not.toContain("Activity"); + expect(navLabels).not.toContain("Costs"); + expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).not.toBeNull(); flushSync(() => { root.unmount(); }); }); - it("defaults to streamlined navigation while experimental settings are loading", async () => { + it("keeps the simplified navigation while experimental settings are loading", async () => { mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); const root = await renderSidebar(); const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim()); expect(navLabels).toContain("Projects"); - expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull(); - expect( - container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), - ).toBe("true"); + expect(navLabels).toContain("Agents"); + expect(container.textContent).not.toContain("Organization"); flushSync(() => { root.unmount(); }); }); - it("streamlined is now standard: a stale enableStreamlinedLeftNavigation=false opt-out is ignored", async () => { + it("ignores the retired streamlined navigation opt-out", async () => { // PAP-12472 retired the experimental opt-out; the streamlined sidebar is the // only path, so an old `false` setting no longer restores classic mode. mockInstanceSettingsApi.getExperimental.mockResolvedValue({ @@ -257,11 +280,44 @@ describe("Sidebar", () => { expect(navLabels).toContain("Tasks"); // Top-level Projects link + starred children stay, per-project collapsible gone. expect(navLabels).toContain("Projects"); - expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull(); expect(container.querySelector('[data-testid="sidebar-starred-projects"]')).not.toBeNull(); - expect( - container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"), - ).toBe("true"); + expect(navLabels).toContain("Agents"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("restores legacy agent and organization navigation when Streamlined UI is off", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableStreamlinedUi: false, + enableApps: true, + }); + const root = await renderSidebar(); + + const labels = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim()); + expect(container.querySelector('[data-testid="sidebar-recent-tasks"]')).toBeNull(); + expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined")).toBe("undefined"); + expect(container.textContent).toContain("Organization"); + expect(labels).toEqual(expect.arrayContaining(["Org", "Apps", "Timeline", "Costs", "Activity", "Settings"])); + expect(labels).not.toContain("Audit"); + expect(labels).not.toContain("Projects"); + expect(container.querySelector('a[href="/agents"]')).toBeNull(); + expect(container.querySelector("aside")?.classList).toContain("border-r"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("locks the legacy collapse control while a secondary sidebar forces the rail", async () => { + mockSidebar.collapseLocked = true; + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false }); + const root = await renderSidebar(); + + expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); flushSync(() => { root.unmount(); @@ -336,25 +392,25 @@ describe("Sidebar", () => { }); }); - it("shows Skills directly below Artifacts in Work", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + it("groups and orders the streamlined Work and Org navigation", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableApps: true, + }); const root = await renderSidebar(); - const artifactsLink = [...container.querySelectorAll("a")].find( - (anchor) => anchor.textContent === "Artifacts", - ); - expect(artifactsLink?.getAttribute("href")).toBe("/artifacts"); - - const navText = container.querySelector("nav")?.textContent ?? ""; - expect(navText).toContain("Artifacts"); - expect(navText).toContain("Skills"); - expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Skills")); - const sections = [...container.querySelectorAll("nav > div")]; const workSection = sections.find((section) => section.textContent?.startsWith("Work")); - const companySection = sections.find((section) => section.textContent?.startsWith("Organization")); - expect(workSection?.textContent).toContain("Skills"); - expect(companySection?.textContent).not.toContain("Skills"); + const orgSection = sections.find((section) => section.textContent?.startsWith("Org")); + const labels = (section: Element | undefined) => [...(section?.querySelectorAll("a") ?? [])] + .map((anchor) => anchor.textContent?.trim()); + + expect(labels(workSection)).toEqual(["Tasks", "Projects", "Routines", "Artifacts"]); + expect(labels(orgSection)).toEqual(["Agents", "Skills", "Apps", "Audit"]); + expect(sections.indexOf(workSection!)).toBeLessThan(sections.indexOf(orgSection!)); + expect( + workSection?.querySelector('a[href="/issues"] svg')?.classList.contains("lucide-circle-check"), + ).toBe(true); flushSync(() => { root.unmount(); @@ -398,25 +454,22 @@ describe("Sidebar", () => { expect(link?.getAttribute("href")).toBe("/goals"); const navText = container.querySelector("nav")?.textContent ?? ""; - expect(navText.indexOf("Goals")).toBeLessThan(navText.indexOf("Artifacts")); + expect(navText.indexOf("Artifacts")).toBeLessThan(navText.indexOf("Goals")); flushSync(() => { root.unmount(); }); }); - it("places Timeline in the Company section", async () => { + it("keeps Timeline out of the global navigation", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); const root = await renderSidebar(); const sections = [...container.querySelectorAll("nav > div")]; const workSection = sections.find((section) => section.textContent?.startsWith("Work")); - const companySection = sections.find((section) => section.textContent?.startsWith("Organization")); + expect(workSection?.textContent).toContain("Projects"); expect(workSection?.textContent).not.toContain("Timeline"); - expect(companySection?.textContent).toContain("Timeline"); - - const timelineLink = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Timeline"); - expect(timelineLink?.getAttribute("href")).toBe("/timeline"); + expect(container.querySelector('a[href="/timeline"]')).toBeNull(); flushSync(() => { root.unmount(); @@ -479,7 +532,7 @@ describe("Sidebar", () => { }); }); - it("always shows Apps at the bottom of the Work section", async () => { + it("always shows Apps in the Org section", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); const root = await renderSidebar(); @@ -487,10 +540,10 @@ describe("Sidebar", () => { const link = links.find((anchor) => anchor.textContent === "Apps"); expect(link?.getAttribute("href")).toBe("/apps"); expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan( - links.findIndex((anchor) => anchor.textContent === "Projects"), + links.findIndex((anchor) => anchor.textContent === "Skills"), ); expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan( - links.findIndex((anchor) => anchor.textContent === "Org"), + links.findIndex((anchor) => anchor.textContent === "Audit"), ); flushSync(() => { @@ -536,80 +589,14 @@ describe("Sidebar", () => { }); }); - it("header toggle collapses an expanded sidebar (aria-expanded reflects state)", async () => { + it("does not render a global navigation collapse affordance", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); const root = await renderSidebar(); - const toggle = container.querySelector('button[aria-label="Collapse sidebar"]'); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute("aria-expanded")).toBe("true"); - - flushSync(() => { - toggle?.click(); - }); - expect(mockSidebar.toggleCollapsed).toHaveBeenCalledTimes(1); - - flushSync(() => { - root.unmount(); - }); - }); - - it("hides the expand/collapse toggle while a secondary sidebar locks the rail", async () => { - // A secondary sidebar forces the rail; the user must not be able to expand - // the primary while it is shown (PAP-10694). - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); - mockSidebar.collapseLocked = true; - const root = await renderSidebar(); - expect(container.querySelector('button[aria-label="Collapse sidebar"]')).toBeNull(); expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); - - mockSidebar.collapseLocked = false; - flushSync(() => { - root.unmount(); - }); - }); - - it("keeps the collapsed rail top bar to just the company logo (no clipped toggle)", async () => { - // In the narrow rail the collapse toggle doesn't fit beside the logo and - // would overflow/clip, shoving the logo out of the icon column (PAP-10676), so - // it is dropped in the rail. Expansion stays reachable via hover-peek + Pin - // and Cmd/Ctrl+B. The toggle returns as soon as the panel is expanded or - // peeking (covered by the other top-bar tests). - mockSidebar.collapsed = true; - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); - const root = await renderSidebar(); - - expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); - // The company menu (company switcher / logo) is still present in the rail. + expect(container.querySelector('button[aria-label="Keep sidebar expanded"]')).toBeNull(); expect(container.textContent).toContain("Company menu"); - // Search survives the rail as an icon-only nav item — the old - // header icon was dropped entirely here, leaving the rail with no visible - // search affordance. - const railSearchLink = [...container.querySelectorAll("nav a")] - .find((anchor) => anchor.getAttribute("href") === "/search"); - expect(railSearchLink).toBeTruthy(); - - flushSync(() => { - root.unmount(); - }); - }); - - it("peek header shows a pin that promotes the peek to pinned-expanded", async () => { - mockSidebar.collapsed = true; - mockSidebar.peeking = true; - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); - const root = await renderSidebar(); - - // The collapse toggle is replaced by the pin while peeking. - expect(container.querySelector('button[aria-label="Expand sidebar"]')).toBeNull(); - const pin = container.querySelector('button[aria-label="Keep sidebar expanded"]'); - expect(pin).not.toBeNull(); - - flushSync(() => { - pin?.click(); - }); - expect(mockSidebar.setCollapsed).toHaveBeenCalledWith(false); flushSync(() => { root.unmount(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 2281375062c..298fdd96d00 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { Inbox, ListChecks, - CircleDot, + CircleCheck, Target, LayoutDashboard, DollarSign, @@ -16,13 +16,11 @@ import { Package, Settings, FolderOpen, - PanelLeftClose, - PanelLeftOpen, - Pin, AppWindow, MessagesSquare, GanttChartSquare, LayoutGrid, + Users, } from "lucide-react"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -31,6 +29,7 @@ import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarAgents } from "./SidebarAgents"; import { SidebarProjects } from "./SidebarProjects"; import { SidebarStarredProjects } from "./SidebarStarredProjects"; +import { SidebarRecentTasks } from "./SidebarRecentTasks"; import { useDialogActions } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; @@ -40,8 +39,8 @@ import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { attentionBadgeCount } from "../lib/attention"; import { useInboxBadge } from "../hooks/useInboxBadge"; +import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling"; -import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { PluginSlotOutlet } from "@/plugins/slots"; @@ -53,9 +52,10 @@ export function Sidebar() { // Every labeled section is collapsible (session-scoped, default open) — // one policy across static nav groups and the data-driven sections. const [workOpen, setWorkOpen] = useState(true); - const [companyOpen, setCompanyOpen] = useState(true); + const [organizationOpen, setOrganizationOpen] = useState(true); const { selectedCompanyId, selectedCompany } = useCompany(); - const { isMobile, collapsed, collapseLocked, peeking, toggleCollapsed, setCollapsed } = useSidebar(); + const { collapsed, peeking } = useSidebar(); + const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const rail = collapsed && !peeking; const inboxBadge = useInboxBadge(selectedCompanyId); const { data: experimentalSettings } = useQuery({ @@ -82,6 +82,9 @@ export function Sidebar() { }); usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const liveRunCount = liveRuns?.length ?? 0; + const liveIssueIds = new Set( + (liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []), + ); const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; const showPipelines = experimentalSettings?.enablePipelines === true; const showStatusCards = experimentalSettings?.enableStatusCards === true; @@ -99,12 +102,6 @@ export function Sidebar() { }); const attentionCount = attentionBadgeCount(attentionFeed); const showCases = experimentalSettings?.enableCases === true; - // Streamlined left navigation (top-level Projects link + starred children) is - // now the standard product sidebar (PAP-12472). The former experimental - // opt-out was retired; classic per-project collapsible mode is no longer - // user-selectable. Kept as a constant so the classic branch below stays as a - // documented reference until it is fully removed. Routes are unaffected. - const streamlined = true; // Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item // is a new surface, hidden entirely while the flag is off (same no-flash // pattern as showWorkspacesLink above). @@ -116,55 +113,21 @@ export function Sidebar() { }; return ( -
+ {recentRuns.length === 0 ? ( +

+ No runs yet. Run the routine now or wait for its schedule. +

+ ) : ( +
+ {recentRuns.map((run) => run.linkedIssue ? ( + + + {formatRoutineTimestamp(run.triggeredAt)} + + )} + /> + ) : ( +
+ + {run.trigger?.label ?? "Routine run"} + {formatRoutineTimestamp(run.triggeredAt)} +
+ ))} +
+ )} + + +
+ ); +} diff --git a/ui/src/components/onboarding/PillGuy.tsx b/ui/src/components/onboarding/PillGuy.tsx index 588cfac87fe..73a01b44cc6 100644 --- a/ui/src/components/onboarding/PillGuy.tsx +++ b/ui/src/components/onboarding/PillGuy.tsx @@ -34,8 +34,8 @@ function DormantPill() { {/* Closed eyes: the same rounded rects as the open pair, flattened. */} - - + + - - + + @@ -57,9 +57,9 @@ function AlivePill() { return ( - - - + + + - - + + diff --git a/ui/src/components/routine-sections/editable-sections.production.tsx b/ui/src/components/routine-sections/editable-sections.production.tsx new file mode 100644 index 00000000000..49e79c2522c --- /dev/null +++ b/ui/src/components/routine-sections/editable-sections.production.tsx @@ -0,0 +1,859 @@ +import { useEffect, useMemo, useState } from "react"; +import { + ArrowRight, + Braces, + Clock3, + Edit3, + KeyRound, + Play, + Plus, + X, +} from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RadioCardGroup } from "@/components/ui/radio-card"; +import { cn } from "@/lib/utils"; +import { nextCronFires, previewFirePolicies } from "../../lib/cron-fires"; +import { timeAgo } from "../../lib/timeAgo"; +import { EmptyState } from "../EmptyState"; +import { InlineEntitySelector } from "../InlineEntitySelector"; +import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "../IssueDocumentAnnotations"; +import { AgentIcon } from "../AgentIconPicker"; +import { MarkdownEditor } from "../MarkdownEditor"; +import { ScheduleEditor, getScheduleCronValidation } from "../ScheduleEditor"; +import { RoutineVariablesEditor, RoutineVariablesHint } from "../RoutineVariablesEditor"; +import { RoutineTriggerCard } from "../RoutineTriggerCard"; +import { EnvironmentVariablesEditor } from "../environment-variables-editor"; +import { createDefaultNewTrigger, useRoutineDetail } from "./context"; +import type { EnvBinding, RoutineDetail as RoutineDetailType } from "@paperclipai/shared"; + +const concurrencyPolicyOptions = [ + { + value: "coalesce_if_active", + title: "Coalesce if active", + description: "Keep one follow-up run queued while an active run is still working.", + }, + { + value: "always_enqueue", + title: "Always enqueue", + description: "Queue every trigger occurrence, even if several runs stack up.", + }, + { + value: "skip_if_active", + title: "Skip if active", + description: "Drop overlapping trigger occurrences while the routine is already active.", + }, +]; + +const catchUpPolicyOptions = [ + { + value: "skip_missed", + title: "Skip missed", + description: "Ignore schedule windows that were missed while paused.", + }, + { + value: "enqueue_missed_with_cap", + title: "Enqueue missed with cap", + description: "Catch up missed schedule windows after recovery; sub-hourly schedules are combined into one catch-up run, slower schedules replay each missed window up to a cap.", + }, +]; + +const activityGatePolicyOptions = [ + { + value: "always", + title: "Run on every scheduled tick", + description: "Fire on the schedule no matter what — the default behavior.", + }, + { + value: "require_external_activity", + title: "Skip when there's been no activity since the last run", + description: + "On a scheduled tick, only run if something happened since the last run that finished. Lets a watcher-style routine stay asleep while the system is settled instead of burning tokens.", + }, +]; + +const activityGateScopeOptions = [ + { + value: "company", + title: "Company-wide", + description: "Any activity across the company counts as a reason to run.", + }, + { + value: "project", + title: "This project", + description: "Only activity in the routine's project counts as a reason to run.", + }, +]; + +const triggerKinds = ["schedule", "webhook"]; +const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; +const signingModeDescriptions: Record = { + bearer: "Expect a shared bearer token in the Authorization header.", + hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.", + github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).", + none: "No authentication — the webhook URL itself acts as a shared secret.", +}; +const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]); + +export function OverviewSection({ + defaultDescriptionAnnotationsOpen = false, +}: { + defaultDescriptionAnnotationsOpen?: boolean; +} = {}) { + const ctx = useRoutineDetail(); + const { + routine, + editDraft, + setEditDraft, + assigneeOptions, + projectOptions, + recentAssigneeIds, + recentProjectIds, + agentById, + projectById, + currentAssignee, + currentProject, + mentionOptions, + assigneeSelectorRef, + projectSelectorRef, + descriptionEditorRef, + routineRuns, + activity, + saveRoutine, + saveConflict, + isSectionDirty, + navigateToSection, + } = ctx; + const [descriptionAnnotationsOpen, setDescriptionAnnotationsOpen] = useState(defaultDescriptionAnnotationsOpen); + + const activeTriggers = routine.triggers.length; + const nextFire = useMemo(() => { + const upcoming = routine.triggers + .filter((trigger) => trigger.kind === "schedule" && trigger.nextRunAt) + .map((trigger) => new Date(trigger.nextRunAt as Date)) + .sort((a, b) => a.getTime() - b.getTime())[0]; + return upcoming ? upcoming.toLocaleString() : null; + }, [routine.triggers]); + const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0; + const lastRun = (routineRuns ?? [])[0] ?? null; + const recentActivity = (activity ?? []).slice(0, 5); + + return ( +
+ {/* Assignment row */} +
+
+ For + + setEditDraft((current) => ({ ...current, assigneeAgentId })) + } + onConfirm={() => { + if (editDraft.projectId) { + descriptionEditorRef.current?.focus(); + } else { + projectSelectorRef.current?.focus(); + } + }} + renderTriggerValue={(option) => + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( + {option.label} + ) + ) : ( + Responsible + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const assignee = agentById.get(option.id); + return ( + <> + {assignee ? ( + + ) : null} + {option.label} + + ); + }} + /> + in + setEditDraft((current) => ({ ...current, projectId }))} + onConfirm={() => descriptionEditorRef.current?.focus()} + renderTriggerValue={(option) => + option && currentProject ? ( + <> + + {option.label} + + ) : ( + Project + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const project = projectById.get(option.id); + return ( + <> + + {option.label} + + ); + }} + /> +
+
+ + {!routine.assigneeAgentId ? ( +
+ Default agent required. This routine can stay as a draft and still run manually, but + automation stays paused until you assign a default agent. +
+ ) : null} + + {/* Instructions */} +
+
+ {routine.descriptionDocument ? ( + setDescriptionAnnotationsOpen((open) => !open)} + /> + ) : null} +
+ {routine.descriptionDocument ? ( + + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-(--sz-120px) text-sm leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + + ) : ( + setEditDraft((current) => ({ ...current, description }))} + placeholder="Add instructions..." + bordered={false} + contentClassName="min-h-(--sz-120px) text-sm leading-7" + mentions={mentionOptions} + onSubmit={() => { + if (!saveRoutine.isPending && editDraft.title.trim()) { + saveRoutine.mutate(); + } + }} + /> + )} +
+ + {/* Variables peek */} +
+ + setEditDraft((current) => ({ ...current, variables }))} + /> +
+ + {/* Summary cards */} +
+ navigateToSection("triggers")} + ariaLabel={`${activeTriggers} triggers. Open triggers.`} + /> + navigateToSection("secrets")} + ariaLabel={`${boundSecrets} secrets bound. Open secrets.`} + /> + navigateToSection("runs")} + ariaLabel={lastRun ? `Last run ${lastRun.status}. Open runs.` : "No runs. Open runs."} + /> +
+ + {/* Recent activity */} +
+

+ Recent activity +

+ {recentActivity.length === 0 ? ( +

No activity yet.

+ ) : ( +
+ {recentActivity.map((event) => ( +
+ + {event.action} + + + {event.details && Object.keys(event.details).length > 0 + ? Object.keys(event.details).slice(0, 3).join(" · ") + : ""} + + {timeAgo(event.createdAt)} +
+ ))} + +
+ )} +
+
+ ); +} + +function SummaryCard({ + icon: Icon, + label, + value, + hint, + to, + ariaLabel, +}: { + icon: typeof Clock3; + label: string; + value: string; + hint: string; + to: () => void; + ariaLabel: string; +}) { + return ( + + ); +} + +export function TriggersSection() { + const ctx = useRoutineDetail(); + const { routine, newTrigger, setNewTrigger, createTrigger, updateTrigger, deleteTrigger, rotateTrigger } = ctx; + const [addOpen, setAddOpen] = useState(false); + const [newScheduleEditorValid, setNewScheduleEditorValid] = useState(true); + const newScheduleValidation = useMemo( + () => newTrigger.kind === "schedule" ? getScheduleCronValidation(newTrigger.cronExpression) : null, + [newTrigger.cronExpression, newTrigger.kind], + ); + const addDisabled = + createTrigger.isPending || + (newScheduleValidation ? !newScheduleValidation.valid || !newScheduleEditorValid : false); + + useEffect(() => { + if (newTrigger.kind !== "schedule") setNewScheduleEditorValid(true); + }, [newTrigger.kind]); + + return ( +
+ {/* Add-trigger drawer header (§3.2) */} +
+

+ {routine.triggers.length === 0 + ? "No triggers yet" + : `${routine.triggers.length} trigger${routine.triggers.length === 1 ? "" : "s"}`} +

+ +
+ + {/* Add trigger form — expand-on-click drawer */} + {addOpen ? ( +
+

Add trigger

+
+
+ + +
+ {newTrigger.kind === "schedule" && ( +
+ + + setNewTrigger((current) => ({ ...current, cronExpression })) + } + onValidityChange={setNewScheduleEditorValid} + /> +
+ )} + {newTrigger.kind === "webhook" && ( + <> +
+ + +

+ {signingModeDescriptions[newTrigger.signingMode]} +

+
+ {!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && ( +
+ + + setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value })) + } + /> +
+ )} + + )} +
+
+ + +
+
+ ) : null} + + {/* Existing triggers */} + {routine.triggers.length === 0 ? ( + setAddOpen(true)} + /> + ) : ( +
+ {routine.triggers.map((trigger) => ( + updateTrigger.mutate({ id, patch })} + onRotate={(id) => rotateTrigger.mutate(id)} + onDelete={(id) => deleteTrigger.mutate(id)} + /> + ))} +
+ )} +
+ ); +} + +export function VariablesSection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, navigateToSection } = ctx; + const hasVariables = editDraft.variables.length > 0; + + return ( +
+
+ + Variables are auto-detected from {"{{placeholders}}"} in + the title & instructions. The variable name is read-only — rename by editing the + placeholder. + + +
+ + {hasVariables ? ( + setEditDraft((current) => ({ ...current, variables }))} + /> + ) : ( + navigateToSection("overview")} + /> + )} +
+ ); +} + +export function SecretsSection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, availableSecrets, createSecret, secretMessage, copySecretValue } = ctx; + + // Project/company-scoped secrets that already see real usage, surfaced as + // quick-bind chips (§3.4). Ranked by reference count then recency. + const recentlyUsedSecrets = useMemo( + () => + [...availableSecrets] + .filter((secret) => secret.status === "active") + .sort((a, b) => { + const refDelta = (b.referenceCount ?? 0) - (a.referenceCount ?? 0); + if (refDelta !== 0) return refDelta; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }) + .slice(0, 8), + [availableSecrets], + ); + + return ( +
+
+ Routine secrets apply to every task this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* names are reserved. +
+ + {secretMessage ? ( +
+
+

{secretMessage.title}

+

+ Save this now. Paperclip will not show the secret value again. +

+
+
+ {secretMessage.entries.map((entry, index) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ ) : null} + + } + secrets={availableSecrets} + recentlyUsedSecrets={recentlyUsedSecrets} + onCreateSecret={async (name, value) => createSecret.mutateAsync({ name, value })} + onChange={(env) => setEditDraft((current) => ({ ...current, env: env ?? null }))} + /> +
+ ); +} + +export function DeliverySection() { + const ctx = useRoutineDetail(); + const { editDraft, setEditDraft, routine } = ctx; + + // The activity gate only affects schedule ticks (webhook/manual/API fires are + // themselves activity and always run), so the control is only meaningful for + // routines that have a schedule trigger. Disable — rather than hide — it + // elsewhere so the capability stays discoverable. + const hasScheduleTrigger = routine.triggers.some((trigger) => trigger.kind === "schedule"); + const gateEnabled = editDraft.activityGatePolicy === "require_external_activity"; + + return ( +
+
+

+ Concurrency +

+ + setEditDraft((current) => ({ ...current, concurrencyPolicy })) + } + options={concurrencyPolicyOptions} + /> +
+
+

+ Catch-up +

+ + setEditDraft((current) => ({ ...current, catchUpPolicy })) + } + options={catchUpPolicyOptions} + /> +
+
+

+ Advanced run policy +

+ + setEditDraft((current) => ({ ...current, activityGatePolicy })) + } + options={activityGatePolicyOptions} + disabled={!hasScheduleTrigger} + /> + {!hasScheduleTrigger ? ( +

+ Add a schedule trigger to gate runs on activity. Webhook, manual, and API fires always + run. +

+ ) : gateEnabled ? ( +
+ + + setEditDraft((current) => ({ ...current, activityGateScope })) + } + options={activityGateScopeOptions} + /> +
+ ) : null} +
+ +
+ ); +} + +const dispositionToneClass: Record = { + queued: "text-emerald-600 dark:text-emerald-400", + coalesced: "text-amber-600 dark:text-amber-400", + skipped: "text-muted-foreground", +}; + +/** + * "Next 5 fires" preview (§3.5) — the strongest "what does this policy mean?" + * surface. Picks the soonest-firing schedule trigger, computes its next fires + * client-side, and annotates each with how the chosen concurrency policy would + * treat it. + */ +function NextFiresPreview({ + triggers, + concurrencyPolicy, +}: { + triggers: RoutineDetailType["triggers"]; + concurrencyPolicy: string; +}) { + const preview = useMemo(() => { + const schedule = triggers + .filter((trigger) => trigger.kind === "schedule" && trigger.enabled && trigger.cronExpression) + .map((trigger) => { + const fires = nextCronFires(trigger.cronExpression, 5, { + timeZone: trigger.timezone ?? "UTC", + }); + return { trigger, fires }; + }) + .filter((entry) => entry.fires.length > 0) + .sort((a, b) => a.fires[0]!.getTime() - b.fires[0]!.getTime())[0]; + if (!schedule) return null; + return { + timeZone: schedule.trigger.timezone ?? "UTC", + entries: previewFirePolicies(schedule.fires, concurrencyPolicy), + }; + }, [triggers, concurrencyPolicy]); + + return ( +
+

+ Next 5 fires +

+ {preview ? ( + <> +
+ {preview.entries.map((entry, index) => ( +
+ · + {formatFireTime(entry.at, preview.timeZone)} + + + {entry.label} + + {entry.note ? ( + ({entry.note}) + ) : null} +
+ ))} +
+

+ Preview assumes the previous run is still in flight when the next fires. Times shown in{" "} + {preview.timeZone}. +

+ + ) : ( +

+ No enabled schedule trigger to preview. Add a schedule in Triggers to see how this policy + treats upcoming fires. +

+ )} +
+ ); +} + +function formatFireTime(date: Date, timeZone: string): string { + try { + return new Intl.DateTimeFormat(undefined, { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .format(date) + .replace(",", ""); + } catch { + return date.toISOString(); + } +} diff --git a/ui/src/components/routine-sections/editable-sections.tsx b/ui/src/components/routine-sections/editable-sections.tsx index a80ff3d4baa..4321a3ec445 100644 --- a/ui/src/components/routine-sections/editable-sections.tsx +++ b/ui/src/components/routine-sections/editable-sections.tsx @@ -4,7 +4,6 @@ import { Braces, Clock3, Edit3, - KeyRound, Play, Plus, X, @@ -144,7 +143,6 @@ export function OverviewSection({ .sort((a, b) => a.getTime() - b.getTime())[0]; return upcoming ? upcoming.toLocaleString() : null; }, [routine.triggers]); - const boundSecrets = editDraft.env ? Object.keys(editDraft.env).length : 0; const lastRun = (routineRuns ?? [])[0] ?? null; const recentActivity = (activity ?? []).slice(0, 5); @@ -320,7 +318,7 @@ export function OverviewSection({
{/* Summary cards */} -
+
navigateToSection("triggers")} ariaLabel={`${activeTriggers} triggers. Open triggers.`} /> - navigateToSection("secrets")} - ariaLabel={`${boundSecrets} secrets bound. Open secrets.`} - /> ({ }, })); +vi.mock("../components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -279,6 +283,13 @@ describe("PromptsTab instruction editor", () => { await flushReact(); } + async function selectInstructionMode(mode: "Read" | "Edit" | "Raw") { + await act(async () => { + buttonByText(container, mode.toLowerCase()).click(); + }); + await flushReact(); + } + it("uses server markdown metadata for extensionless files and saves MarkdownEditor drafts", async () => { const summary = makeSummary("AGENTS", "AGENTS", { language: "markdown", @@ -289,6 +300,13 @@ describe("PromptsTab instruction editor", () => { { AGENTS: makeDetail(summary, "# Current") }, ); + await waitFor(() => { + expect(container.querySelector('[data-testid="markdown-body"]')?.textContent).toBe("# Current"); + }); + await selectInstructionMode("Raw"); + expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent?.trim()).toBe("# Current"); + await selectInstructionMode("Edit"); + const editor = await waitFor(() => { const candidate = container.querySelector('[data-testid="markdown-editor"]'); expect(candidate).not.toBeNull(); @@ -330,6 +348,7 @@ describe("PromptsTab instruction editor", () => { { "AGENTS.md": makeDetail(summary, "# Current") }, { onDirtyChange }, ); + await selectInstructionMode("Edit"); const editorProps = await waitFor(() => { const latest = markdownEditorRenderMock.mock.calls.at(-1)?.[0] as @@ -364,6 +383,7 @@ describe("PromptsTab instruction editor", () => { onCancelActionChange: (next) => { cancelAction = next; }, }, ); + await selectInstructionMode("Edit"); const editor = await waitFor(() => { const candidate = container.querySelector('[data-testid="markdown-editor"]'); @@ -401,6 +421,7 @@ describe("PromptsTab instruction editor", () => { { "settings.json": makeDetail(summary, "{\n \"ok\": true\n}") }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('textarea[placeholder="File contents"]')).not.toBeNull(); }); @@ -417,6 +438,7 @@ describe("PromptsTab instruction editor", () => { buttonByText(container, "Create").click(); }); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('[data-testid="markdown-editor"]')).not.toBeNull(); }); @@ -433,6 +455,7 @@ describe("PromptsTab instruction editor", () => { { "FALLBACK.md": makeDetail(summary, "# Fallback", { markdown: undefined }) }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector("[data-testid=\"markdown-editor\"]")).not.toBeNull(); expect(markdownEditorRenderMock).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -451,6 +474,7 @@ describe("PromptsTab instruction editor", () => { { "NOTES.md": makeDetail(summary, "raw instructions") }, ); + await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('[data-testid="markdown-editor"]')).toBeNull(); expect(container.querySelector('textarea[placeholder="File contents"]')?.value).toBe("raw instructions"); diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx new file mode 100644 index 00000000000..6bc2a0ce0e4 --- /dev/null +++ b/ui/src/pages/AgentDetail.production.tsx @@ -0,0 +1,4516 @@ +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { useParams, useNavigate, Link, Navigate, useBeforeUnload, type NavigateFunction } from "@/lib/router"; +import { useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { + agentsApi, + type AgentKey, + type ClaudeLoginResult, + type AgentPermissionUpdate, +} from "../api/agents"; +import { builtInAgentsApi, type BuiltInManagedResourceKind } from "../api/builtInAgents"; +import { companySkillsApi } from "../api/companySkills"; +import { budgetsApi } from "../api/budgets"; +import { heartbeatsApi } from "../api/heartbeats"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { ApiError } from "../api/client"; +import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts"; +import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; +import { activityApi } from "../api/activity"; +import { accessApi } from "../api/access"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { usePanel } from "../context/PanelContext"; +import { useSidebar } from "../context/SidebarContext"; +import { useCompany } from "../context/CompanyContext"; +import { useToastActions } from "../context/ToastContext"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; +import { copyTextToClipboard } from "../lib/clipboard"; +import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab"; +import { AgentConfigForm } from "../components/AgentConfigForm"; +import { PageTabBar } from "../components/PageTabBar"; +import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; +import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils"; +import { MarkdownEditor } from "../components/MarkdownEditor"; +import { assetsApi } from "../api/assets"; +import { toolsApi } from "../api/tools"; +import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; +import { StatusBadge } from "../components/StatusBadge"; +import { MarkdownBody } from "../components/MarkdownBody"; +import { CopyText } from "../components/CopyText"; +import { EntityRow } from "../components/EntityRow"; +import { StatusGlyph } from "../components/StatusGlyph"; +import { MembershipAction } from "../components/MembershipAction"; +import { StarToggle } from "../components/StarToggle"; +import { Identity } from "../components/Identity"; +import { AuditFeed } from "./audit/AuditFeed.production"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { AgentActionButtons } from "../components/AgentActionButtons"; +import { InlineBanner } from "../components/InlineBanner"; +import { BuiltInBundlePanel } from "../components/BuiltInBundlePanel"; +import { ConfigureBuiltInAgentModal } from "../components/ConfigureBuiltInAgentModal"; +import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; +import { TrustPresetSection } from "../components/TrustPresetSection"; +import { FileTree, buildFileTree } from "../components/FileTree"; +import { ScrollToBottom } from "../components/ScrollToBottom"; +import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout"; +import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge"; +import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; +import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; +import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; +import { cn } from "../lib/utils"; +import { describeRunRetryState } from "../lib/runRetryState"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + CheckCircle2, + XCircle, + Clock, + Timer, + Loader2, + Slash, + RotateCcw, + Plus, + Key, + Eye, + EyeOff, + Copy, + ChevronRight, + ChevronDown, + ArrowLeft, + HelpCircle, + FolderOpen, + AlertTriangle, +} from "lucide-react"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Input } from "@/components/ui/input"; +import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; +import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView"; +import { AgentToolsTab } from "./AgentToolsTab"; +import { + appendCapped, + LIVE_TRANSCRIPT_RENDER_LIMIT, + MAX_LIVE_EVENTS, + MAX_LIVE_LOG_LINES, +} from "../lib/live-log-buffer"; +import { + isUuidLike, + type Agent, + type AgentDetail as AgentDetailRecord, + type BudgetPolicySummary, + type HeartbeatRun, + type HeartbeatRunEvent, + type AgentRuntimeState, + type LiveEvent, + type WorkspaceOperation, + isResponsibleUserDenialCode, + responsibleUserLabel, +} from "@paperclipai/shared"; +import { ResponsibleUserDenialNotice } from "../components/ResponsibleUserDenialNotice"; +import { RunWorkspaceRecoverySurface } from "../components/RunWorkspaceRecoverySurface"; +import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui"; +import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; +import { agentRouteRef } from "../lib/utils"; +import { + isStarred, + resourceMembershipState, + useResourceMembershipMutation, + useResourceMemberships, +} from "../hooks/useResourceMemberships"; +import { Badge } from "@/components/ui/badge"; + +const runStatusIcons: Record = { + succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, + failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" }, + running: { icon: Loader2, color: "text-blue-600 dark:text-blue-400" }, // Gallery feedback r1: running = status blue, not cyan. + queued: { icon: Clock, color: "text-yellow-600 dark:text-yellow-400" }, + scheduled_retry: { icon: Clock, color: "text-sky-600 dark:text-sky-400" }, + timed_out: { icon: Timer, color: "text-orange-600 dark:text-orange-400" }, + cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" }, +}; + +const RUN_LOG_PAGE_BYTES = 256_000; + +const REDACTED_ENV_VALUE = "***REDACTED***"; +const SECRET_ENV_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; +const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; + +function formatOrgChainHealthPath(agent: AgentDetailRecord) { + return agent.orgChainHealth?.fullChain + .map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`) + .join(" -> ") ?? agent.name; +} + +function redactPathText(value: string, censorUsernameInLogs: boolean) { + return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs }); +} + +function redactPathValue(value: T, censorUsernameInLogs: boolean): T { + return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs }); +} + +function redactCommandText(value: string, censorUsernameInLogs: boolean): string { + return redactPathText(redactCommandSecretText(value, REDACTED_ENV_VALUE), censorUsernameInLogs); +} + +function shouldRedactSecretValue(key: string, value: unknown): boolean { + if (SECRET_ENV_KEY_RE.test(key)) return true; + if (typeof value !== "string") return false; + return JWT_VALUE_RE.test(value); +} + +function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boolean): string { + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + (value as { type?: unknown }).type === "secret_ref" + ) { + return "***SECRET_REF***"; + } + if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE; + if (value === null || value === undefined) return ""; + if (typeof value === "string" && COMMAND_ENV_KEY_RE.test(key)) return redactCommandText(value, censorUsernameInLogs); + if (typeof value === "string") return redactPathText(value, censorUsernameInLogs); + try { + return JSON.stringify(redactPathValue(value, censorUsernameInLogs)); + } catch { + return redactPathText(String(value), censorUsernameInLogs); + } +} + +function isMarkdown(pathValue: string) { + return pathValue.toLowerCase().endsWith(".md"); +} + +function shouldUseMarkdownInstructionsEditor(input: { + selectedFileExists: boolean; + selectedPath: string; + detail?: { markdown?: boolean } | null; + summary?: { markdown?: boolean } | null; +}) { + const metadataMarkdown = input.detail?.markdown ?? input.summary?.markdown; + if (typeof metadataMarkdown === "boolean") return metadataMarkdown; + return isMarkdown(input.selectedPath); +} + +function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string { + const env = asRecord(envValue); + if (!env) return ""; + + const keys = Object.keys(env); + if (keys.length === 0) return ""; + + return keys + .sort() + .map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`) + .join("\n"); +} + +const sourceLabels: Record = { + timer: "Timer", + assignment: "Assignment", + on_demand: "On-demand", + automation: "Automation", +}; + +const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; +type ScrollContainer = Window | HTMLElement; + +function isWindowContainer(container: ScrollContainer): container is Window { + return container === window; +} + +function isElementScrollContainer(element: HTMLElement): boolean { + const overflowY = window.getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; +} + +function findScrollContainer(anchor: HTMLElement | null): ScrollContainer { + let parent = anchor?.parentElement ?? null; + while (parent) { + if (isElementScrollContainer(parent)) return parent; + parent = parent.parentElement; + } + return window; +} + +function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + const viewportBottom = window.scrollY + window.innerHeight; + return { + scrollHeight: pageHeight, + distanceFromBottom: Math.max(0, pageHeight - viewportBottom), + }; + } + + const viewportBottom = container.scrollTop + container.clientHeight; + return { + scrollHeight: container.scrollHeight, + distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom), + }; +} + +function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + window.scrollTo({ top: pageHeight, behavior }); + return; + } + + container.scrollTo({ top: container.scrollHeight, behavior }); +} + +type AgentDetailView = "dashboard" | "instructions" | "configuration" | "secrets" | "skills" | "tools" | "runs" | "audit" | "budget"; + +export const AGENT_DETAIL_TABS: ReadonlyArray<{ value: AgentDetailView; label: string }> = [ + { value: "dashboard", label: "Dashboard" }, + { value: "instructions", label: "Instructions" }, + { value: "skills", label: "Skills" }, + { value: "configuration", label: "Configuration" }, + { value: "secrets", label: "Secrets" }, + { value: "tools", label: "Tools" }, + { value: "runs", label: "Runs" }, + { value: "audit", label: "Audit" }, + { value: "budget", label: "Budget" }, +]; + +export const DISCARD_AGENT_CONFIG_CHANGES_MESSAGE = "Discard unsaved agent configuration changes?"; + +export function confirmAgentConfigNavigation( + dirty: boolean, + confirm: (message: string) => boolean = (message) => + typeof window === "undefined" || window.confirm(message), +): boolean { + return !dirty || confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE); +} + +export function agentConfigHistoryRestoreDelta(currentIndex: unknown, nextIndex: unknown): number | null { + if (typeof currentIndex !== "number" || typeof nextIndex !== "number") return null; + const delta = currentIndex - nextIndex; + return delta === 0 ? null : delta; +} + +export function restoreAgentConfigHistoryEntry( + history: Pick, + currentEntry: { index: unknown; state: unknown; url: string }, + nextIndex: unknown, +): boolean { + const restoreDelta = agentConfigHistoryRestoreDelta(currentEntry.index, nextIndex); + if (restoreDelta === null) { + // Some legacy URL-cleanup paths erased React Router's history index. A + // fresh copy of the guarded entry is the only safe way to return without + // letting Router consume the unindexed destination and discard the form. + history.pushState(currentEntry.state, "", currentEntry.url); + return false; + } + + history.go(restoreDelta); + return true; +} + +export function parseAgentDetailView(value: string | null): AgentDetailView { + if (value === "instructions" || value === "prompts") return "instructions"; + if (value === "configure" || value === "configuration") return "configuration"; + if (value === "secrets") return "secrets"; + if (value === "skills") return "skills"; + if (value === "tools") return "tools"; + if (value === "budget") return "budget"; + if (value === "audit") return "audit"; + if (value === "runs") return value; + return "dashboard"; +} + +function usageNumber(usage: Record | null, ...keys: string[]) { + if (!usage) return 0; + for (const key of keys) { + const value = usage[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return 0; +} + +function setsEqual(left: Set, right: Set) { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + +function runMetrics(run: HeartbeatRun) { + const usage = (run.usageJson ?? null) as Record | null; + const result = (run.resultJson ?? null) as Record | null; + const input = usageNumber(usage, "inputTokens", "input_tokens"); + const output = usageNumber(usage, "outputTokens", "output_tokens"); + const cached = usageNumber( + usage, + "cachedInputTokens", + "cached_input_tokens", + "cache_read_input_tokens", + ); + const cost = + visibleRunCostUsd(usage, result); + const provider = asNonEmptyString(usage?.provider) ?? null; + const model = asNonEmptyString(usage?.model) ?? null; + return { + input, + output, + cached, + cost, + totalTokens: input + output, + provider, + model, + }; +} + +export type RunLogChunk = { + ts: string; + stream: "stdout" | "stderr" | "system"; + chunk: string; +}; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildHeartbeatProgressLogLine( + payload: Record, + fallbackTimestamp: string, +): RunLogChunk | null { + const message = asNonEmptyString(payload.message); + if (!message) return null; + const phase = asNonEmptyString(payload.phase); + const ts = asNonEmptyString(payload.updatedAt) ?? fallbackTimestamp; + const chunk = phase ? `[${phase}] ${message}` : message; + return { ts, stream: "system", chunk }; +} + +export function heartbeatProgressLogLineKey(line: RunLogChunk): string { + return `${line.ts}\u0000${line.stream}\u0000${line.chunk}`; +} + +export function shouldPollRunShellLog(status: HeartbeatRun["status"]): boolean { + return status === "running"; +} + +export function runDetailRefetchIntervalMs(status: HeartbeatRun["status"]): 5000 | 15000 | false { + if (status === "queued") return 5000; + if (status === "running") return 15000; + return false; +} + +export function RunInvocationCard({ + payload, + censorUsernameInLogs, +}: { + payload: Record; + censorUsernameInLogs: boolean; +}) { + const rawCommandLine = [ + typeof payload.command === "string" ? payload.command : null, + ...(Array.isArray(payload.commandArgs) + ? payload.commandArgs.filter((value): value is string => typeof value === "string") + : []), + ] + .filter((value): value is string => Boolean(value)) + .join(" "); + const commandLine = rawCommandLine ? redactCommandText(rawCommandLine, censorUsernameInLogs) : ""; + + const hasAdvancedDetails = + commandLine.length > 0 + || (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0) + || payload.prompt !== undefined + || payload.context !== undefined + || payload.env !== undefined; + + return ( +
+
Invocation
+ {typeof payload.adapterType === "string" && ( +
Adapter: {payload.adapterType}
+ )} + {typeof payload.cwd === "string" && ( +
Working dir: {payload.cwd}
+ )} + {hasAdvancedDetails && ( + + + + Details + + + {commandLine && ( +
+ Command: + {commandLine} +
+ )} + {Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && ( +
+
Command notes
+
    + {payload.commandNotes + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((note, idx) => ( +
  • + {note} +
  • + ))} +
+
+ )} + {payload.prompt !== undefined && ( +
+
Prompt
+
+                  {typeof payload.prompt === "string"
+                    ? redactPathText(payload.prompt, censorUsernameInLogs)
+                    : JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
+                
+
+ )} + {payload.context !== undefined && ( +
+
Context
+
+                  {JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
+                
+
+ )} + {payload.env !== undefined && ( +
+
Environment
+
+                  {formatEnvForDisplay(payload.env, censorUsernameInLogs)}
+                
+
+ )} +
+
+ )} +
+ ); +} + +function parseStoredLogContent(content: string): RunLogChunk[] { + const parsed: RunLogChunk[] = []; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const raw = JSON.parse(trimmed) as { ts?: unknown; stream?: unknown; chunk?: unknown }; + const stream = + raw.stream === "stderr" || raw.stream === "system" ? raw.stream : "stdout"; + const chunk = typeof raw.chunk === "string" ? raw.chunk : ""; + const ts = typeof raw.ts === "string" ? raw.ts : new Date().toISOString(); + if (!chunk) continue; + parsed.push({ ts, stream, chunk }); + } catch { + // Ignore malformed log lines. + } + } + return parsed; +} + +function workspaceOperationPhaseLabel(phase: WorkspaceOperation["phase"]) { + switch (phase) { + case "worktree_prepare": + return "Worktree setup"; + case "workspace_config_freshness": + return "Config freshness"; + case "workspace_provision": + return "Provision"; + case "workspace_teardown": + return "Teardown"; + case "worktree_cleanup": + return "Worktree cleanup"; + default: + return phase; + } +} + +function workspaceOperationStatusTone(status: WorkspaceOperation["status"]) { + switch (status) { + case "succeeded": + return "border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300"; + case "failed": + return "border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300"; + case "running": + return "border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300"; + case "skipped": + return "border-yellow-500/20 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"; + default: + return "border-border bg-muted/40 text-muted-foreground"; + } +} + +function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation["status"] }) { + return ( + + {status.replace("_", " ")} + + ); +} + +function WorkspaceOperationLogViewer({ + operation, + censorUsernameInLogs, +}: { + operation: WorkspaceOperation; + censorUsernameInLogs: boolean; +}) { + const [open, setOpen] = useState(false); + const { data: logData, isLoading, error } = useQuery({ + queryKey: ["workspace-operation-log", operation.id], + queryFn: () => heartbeatsApi.workspaceOperationLog(operation.id), + enabled: open && Boolean(operation.logRef), + refetchInterval: open && operation.status === "running" ? 2000 : false, + }); + + const chunks = useMemo( + () => (logData?.content ? parseStoredLogContent(logData.content) : []), + [logData?.content], + ); + + return ( +
+ + {open && ( +
+ {isLoading &&
Loading log...
} + {error && ( +
+ {error instanceof Error ? error.message : "Failed to load workspace operation log"} +
+ )} + {!isLoading && !error && chunks.length === 0 && ( +
No persisted log lines.
+ )} + {chunks.length > 0 && ( +
+ {chunks.map((chunk, index) => ( +
+ + {new Date(chunk.ts).toLocaleTimeString("en-US", { hour12: false })} + + + [{chunk.stream}] + + {redactPathText(chunk.chunk, censorUsernameInLogs)} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +function WorkspaceOperationsSection({ + operations, + censorUsernameInLogs, +}: { + operations: WorkspaceOperation[]; + censorUsernameInLogs: boolean; +}) { + if (operations.length === 0) return null; + + return ( +
+
+ Workspace ({operations.length}) +
+
+ {operations.map((operation) => { + const metadata = asRecord(operation.metadata); + return ( +
+
+
{workspaceOperationPhaseLabel(operation.phase)}
+ +
+ {relativeTime(operation.startedAt)} + {operation.finishedAt && ` to ${relativeTime(operation.finishedAt)}`} +
+
+ {operation.command && ( +
+ Command: + {operation.command} +
+ )} + {operation.cwd && ( +
+ Working dir: + {operation.cwd} +
+ )} + {(asNonEmptyString(metadata?.branchName) + || asNonEmptyString(metadata?.baseRef) + || asNonEmptyString(metadata?.worktreePath) + || asNonEmptyString(metadata?.repoRoot) + || asNonEmptyString(metadata?.cleanupAction)) && ( +
+ {asNonEmptyString(metadata?.branchName) && ( +
Branch: {metadata?.branchName as string}
+ )} + {asNonEmptyString(metadata?.baseRef) && ( +
Base ref: {metadata?.baseRef as string}
+ )} + {asNonEmptyString(metadata?.worktreePath) && ( +
Worktree: {metadata?.worktreePath as string}
+ )} + {asNonEmptyString(metadata?.repoRoot) && ( +
Repo root: {metadata?.repoRoot as string}
+ )} + {asNonEmptyString(metadata?.cleanupAction) && ( +
Cleanup: {metadata?.cleanupAction as string}
+ )} +
+ )} + {typeof metadata?.created === "boolean" && ( +
+ {metadata.created ? "Created by this run" : "Reused existing workspace"} +
+ )} + {operation.stderrExcerpt && operation.stderrExcerpt.trim() && ( +
+
stderr excerpt
+
+                    {redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
+                  
+
+ )} + {operation.stdoutExcerpt && operation.stdoutExcerpt.trim() && ( +
+
stdout excerpt
+
+                    {redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
+                  
+
+ )} + {operation.logRef && ( + + )} +
+ ); + })} +
+
+ ); +} + +export function AgentDetail() { + const { companyPrefix, agentId, tab: urlTab, runId: urlRunId } = useParams<{ + companyPrefix?: string; + agentId: string; + tab?: string; + runId?: string; + }>(); + const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { closePanel } = usePanel(); + const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const [actionError, setActionError] = useState(null); + const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState>(() => new Set()); + const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null); + const needsDashboardData = activeView === "dashboard"; + const needsRunData = activeView === "runs" || Boolean(urlRunId); + const shouldLoadHeartbeats = needsDashboardData || needsRunData; + const [configDirty, setConfigDirty] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const saveConfigActionRef = useRef<(() => void) | null>(null); + const cancelConfigActionRef = useRef<(() => void) | null>(null); + const { isMobile } = useSidebar(); + const routeAgentRef = agentId ?? ""; + const routeCompanyId = useMemo(() => { + if (!companyPrefix) return null; + const requestedPrefix = companyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; + }, [companies, companyPrefix]); + const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; + const canFetchAgent = routeAgentRef.length > 0 && (isUuidLike(routeAgentRef) || Boolean(lookupCompanyId)); + const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []); + const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []); + const prepareAgentNavigation = useCallback(() => { + return confirmAgentConfigNavigation(configDirty); + }, [configDirty]); + + const { data: agent, isLoading, error } = useQuery({ + queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null], + queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId), + enabled: canFetchAgent, + }); + const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; + const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; + const agentLookupRef = agent?.id ?? routeAgentRef; + const resolvedAgentId = agent?.id ?? null; + const membershipsQuery = useResourceMemberships(resolvedCompanyId); + const membershipMutation = useResourceMembershipMutation(resolvedCompanyId); + const agentMembershipState = resolvedAgentId + ? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId) + : "joined"; + + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: !!resolvedCompanyId, + }); + const builtInAgentsEnabled = experimentalSettings?.enableBuiltInAgents === true; + const { data: builtInStates } = useQuery({ + queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!), + queryFn: () => builtInAgentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId && builtInAgentsEnabled, + }); + const builtInState = builtInAgentsEnabled + ? builtInStates?.find((entry) => entry.agentId === resolvedAgentId) ?? null + : null; + const builtInFeatureLabel = builtInState + ? builtInState.definition.featureKeys + .map((key) => key.charAt(0).toUpperCase() + key.slice(1)) + .join(", ") + : ""; + const invalidateBuiltIn = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(resolvedCompanyId!) }); + if (resolvedAgentId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(resolvedAgentId) }); + } + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + }, [queryClient, resolvedCompanyId, resolvedAgentId, routeAgentRef]); + + const resetBuiltIn = useMutation({ + mutationFn: () => builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key), + onSuccess: invalidateBuiltIn, + }); + + const [showBuiltInConfigure, setShowBuiltInConfigure] = useState(false); + const resetBuiltInResource = useMutation({ + mutationFn: (kind: BuiltInManagedResourceKind) => + builtInAgentsApi.reset(resolvedCompanyId!, builtInState!.definition.key, [kind]), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to update bundle resource"); + }, + }); + const runBuiltInRoutine = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to run built-in routine"); + }, + }); + const enableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule"); + }, + }); + const disableBuiltInSchedule = useMutation({ + mutationFn: (routineKey: string) => + builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey), + onSuccess: invalidateBuiltIn, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule"); + }, + }); + const builtInRoutineActionPending = + runBuiltInRoutine.isPending + ? "run" + : enableBuiltInSchedule.isPending + ? "enable" + : disableBuiltInSchedule.isPending + ? "disable" + : null; + + const { data: runtimeState } = useQuery({ + queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef), + queryFn: () => agentsApi.runtimeState(resolvedAgentId!, resolvedCompanyId ?? undefined), + enabled: Boolean(resolvedAgentId) && needsDashboardData, + }); + + const { data: heartbeats } = useQuery({ + queryKey: queryKeys.heartbeats(resolvedCompanyId!, agent?.id ?? undefined), + queryFn: () => heartbeatsApi.list(resolvedCompanyId!, agent?.id ?? undefined), + enabled: !!resolvedCompanyId && !!agent?.id && shouldLoadHeartbeats, + }); + + const { data: allIssues } = useQuery({ + queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }), + enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData, + }); + + const { data: allAgents } = useQuery({ + queryKey: queryKeys.agents.list(resolvedCompanyId!), + queryFn: () => agentsApi.list(resolvedCompanyId!), + enabled: !!resolvedCompanyId && needsDashboardData, + }); + + const { data: budgetOverview } = useQuery({ + queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"), + queryFn: () => budgetsApi.overview(resolvedCompanyId!), + enabled: !!resolvedCompanyId, + refetchInterval: 30_000, + staleTime: 5_000, + }); + + const assignedIssues = (allIssues ?? []) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); + const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); + const agentBudgetSummary = useMemo(() => { + const matched = budgetOverview?.policies.find( + (policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef), + ); + if (matched) return matched; + const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0; + const spentMonthlyCents = agent?.spentMonthlyCents ?? 0; + return { + policyId: "", + companyId: resolvedCompanyId ?? "", + scopeType: "agent", + scopeId: agent?.id ?? routeAgentRef, + scopeName: agent?.name ?? "Agent", + metric: "billed_cents", + windowKind: "calendar_month_utc", + amount: budgetMonthlyCents, + observedAmount: spentMonthlyCents, + remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents), + utilizationPercent: + budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0, + warnPercent: 80, + hardStopEnabled: true, + notifyEnabled: true, + isActive: budgetMonthlyCents > 0, + status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok", + paused: agent?.status === "paused", + pauseReason: agent?.pauseReason ?? null, + windowStart: new Date(), + windowEnd: new Date(), + } satisfies BudgetPolicySummary; + }, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]); + const mobileLiveRun = useMemo( + () => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null, + [heartbeats], + ); + + useEffect(() => { + if (!agent) return; + if (urlRunId) { + if (routeAgentRef !== canonicalAgentRef) { + navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); + } + return; + } + const canonicalTab = + activeView === "instructions" + ? "instructions" + : activeView === "configuration" + ? "configuration" + : activeView === "secrets" + ? "secrets" + : activeView === "skills" + ? "skills" + : activeView === "tools" + ? "tools" + : activeView === "runs" + ? "runs" + : activeView === "audit" + ? "audit" + : activeView === "budget" + ? "budget" + : "dashboard"; + if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { + navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); + return; + } + }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, navigate]); + + useEffect(() => { + if (!agent?.companyId || agent.companyId === selectedCompanyId) return; + setSelectedCompanyId(agent.companyId, { source: "route_sync" }); + }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); + + // Invoke / pause / resume / terminate / duplicate / reset live in the shared + // AgentActionButtons component. The detail header keeps only "approve" here, + // which is surfaced via the pending-approval banner below. + const agentAction = useMutation({ + mutationFn: async (action: "approve") => { + if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); + if (action === "approve") { + return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined); + } + }, + onSuccess: () => { + setActionError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + if (agent?.id) { + queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) }); + } + } + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Action failed"); + }, + }); + + const budgetMutation = useMutation({ + mutationFn: (amount: number) => + budgetsApi.upsertPolicy(resolvedCompanyId!, { + scopeType: "agent", + scopeId: agent?.id ?? routeAgentRef, + amount, + windowKind: "calendar_month_utc", + }), + onSuccess: () => { + if (!resolvedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) }); + }, + }); + + const updateIcon = useMutation({ + mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + } + }, + }); + + const updatePermissions = useMutation({ + mutationFn: (permissions: AgentPermissionUpdate) => + agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined), + onSuccess: () => { + setActionError(null); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) }); + if (resolvedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) }); + } + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Failed to update permissions"); + }, + }); + + useEffect(() => { + const crumbs: { label: string; href?: string }[] = [ + { label: "Agents", href: "/agents" }, + ]; + const agentName = agent?.name ?? routeAgentRef ?? "Agent"; + if (activeView === "dashboard" && !urlRunId) { + crumbs.push({ label: agentName }); + } else { + crumbs.push({ label: agentName, href: `/agents/${canonicalAgentRef}/dashboard` }); + if (urlRunId) { + crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` }); + crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); + } else if (activeView === "instructions") { + crumbs.push({ label: "Instructions" }); + } else if (activeView === "configuration") { + crumbs.push({ label: "Configuration" }); + } else if (activeView === "secrets") { + crumbs.push({ label: "Secrets" }); + // } else if (activeView === "skills") { // TODO: bring back later + // crumbs.push({ label: "Skills" }); + } else if (activeView === "tools") { + crumbs.push({ label: "Tools" }); + } else if (activeView === "runs") { + crumbs.push({ label: "Runs" }); + } else if (activeView === "budget") { + crumbs.push({ label: "Budget" }); + } else { + crumbs.push({ label: "Dashboard" }); + } + } + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, agent, routeAgentRef, canonicalAgentRef, activeView, urlRunId]); + + useEffect(() => { + closePanel(); + return () => closePanel(); + }, [closePanel]); + + useEffect(() => { + if (!resolvedAgentId || agentMembershipState !== "joined") return; + setDismissedLeftAgentIds((current) => { + if (!current.has(resolvedAgentId)) return current; + const next = new Set(current); + next.delete(resolvedAgentId); + return next; + }); + }, [resolvedAgentId, agentMembershipState]); + + useBeforeUnload( + useCallback((event) => { + if (!configDirty) return; + event.preventDefault(); + event.returnValue = ""; + }, [configDirty]), + ); + + useEffect(() => { + if (!configDirty) return; + + function handleDocumentClick(event: MouseEvent) { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return; + } + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + if (anchor.target && anchor.target !== "_self") return; + + const nextUrl = new URL(anchor.href, window.location.href); + const currentUrl = new URL(window.location.href); + if (nextUrl.origin !== currentUrl.origin) return; + if ( + nextUrl.pathname === currentUrl.pathname && + nextUrl.search === currentUrl.search && + nextUrl.hash === currentUrl.hash + ) { + return; + } + if (prepareAgentNavigation()) return; + event.preventDefault(); + event.stopPropagation(); + } + + document.addEventListener("click", handleDocumentClick, true); + return () => document.removeEventListener("click", handleDocumentClick, true); + }, [configDirty, prepareAgentNavigation]); + + useEffect(() => { + if (!configDirty) return; + + // BrowserRouter updates after popstate. Run first in the capture phase so a + // rejected Back/Forward navigation can be restored before React Router + // consumes it and unmounts the route-backed form. + const currentEntry = { + index: window.history.state?.idx, + state: window.history.state, + url: window.location.href, + }; + let restoring = false; + + function handlePopState(event: PopStateEvent) { + if (restoring) { + restoring = false; + return; + } + + if (prepareAgentNavigation()) return; + + event.stopImmediatePropagation(); + restoring = restoreAgentConfigHistoryEntry(window.history, currentEntry, event.state?.idx); + } + + window.addEventListener("popstate", handlePopState, true); + return () => window.removeEventListener("popstate", handlePopState, true); + }, [configDirty, prepareAgentNavigation]); + + if (isLoading) return ; + if (error) return

{error.message}

; + if (!agent) return null; + if (!urlRunId && !urlTab) { + return ; + } + const isPendingApproval = agent.status === "pending_approval"; + const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain"; + const pausedEscalationWarning = !hasInvalidOrgChain ? agent.orgChainHealth?.escalationWarning ?? null : null; + const showConfigActionBar = ( + activeView === "configuration" || activeView === "instructions" || activeView === "secrets" + ) && (configDirty || configSaving); + const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id); + const agentMembershipPending = + membershipMutation.isPending && + membershipMutation.variables?.resourceType === "agent" && + membershipMutation.variables.resourceId === agent.id; + const agentStarred = isStarred(membershipsQuery.data, "agent", agent.id); + const agentStarPending = agentMembershipPending && membershipMutation.variables?.starred !== undefined; + const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined; + + function handleAgentTabChange(value: string) { + if (value === activeView || !prepareAgentNavigation()) return; + navigate(`/agents/${canonicalAgentRef}/${value}`); + } + + return ( +
+ {showLeftAgentNotice ? ( +
+

+ You left this agent. It no longer appears in your sidebar. +

+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + state: "joined", + })} + onLeave={() => membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + state: "left", + })} + /> + +
+ ) : null} + {pausedEscalationWarning ? ( +
+ +
+

Escalation path is paused

+

{pausedEscalationWarning}

+
+
+ ) : null} + {hasInvalidOrgChain ? ( +
+ +
+

Invalid reporting chain

+

+ {agent.name} cannot accept tasks or start runs until its reporting chain is repaired. +

+

+ {formatOrgChainHealthPath(agent)} +

+ {agent.orgChainHealth?.repairGuidance ? ( +

{agent.orgChainHealth.repairGuidance}

+ ) : ( +

+ Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree. +

+ )} +
+
+ ) : null} + {/* Header */} +
+
+ updateIcon.mutate(icon)} + > + + +
+
+

{agent.name}

+
+

+ {roleLabels[agent.role] ?? agent.role} + {agent.title ? ` - ${agent.title}` : ""} +

+
+
+
+ membershipMutation.mutate({ + resourceType: "agent", + resourceId: agent.id, + resourceName: agent.name, + starred: next, + })} + /> + navigate("/agents/all", { replace: true })} + hideTerminate={Boolean(builtInState)} + pauseConfirm={ + builtInState + ? { + title: `Pause the ${builtInState.definition.displayName}?`, + description: ( + <> + {builtInFeatureLabel} depends on this agent. While paused,{" "} + {builtInFeatureLabel.toLowerCase()} generation is skipped and the{" "} + {builtInFeatureLabel} page shows a warning. + + ), + } + : undefined + } + > + {mobileLiveRun && ( + + + + + + Live + + )} + +
+
+ + {builtInState && ( + resetBuiltIn.mutate()} + disabled={resetBuiltIn.isPending} + > + {resetBuiltIn.isPending ? "Resetting…" : "Reset to defaults"} + + } + > + Ships with Paperclip and powers {builtInFeatureLabel}. Configure it like + any agent — model, instructions, budget. It can be paused but not deleted; pausing it + pauses {builtInFeatureLabel}. + + )} + + {builtInState?.definition.bundle && ( + setShowBuiltInConfigure(true)} + onResetResource={(kind) => resetBuiltInResource.mutate(kind)} + onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)} + onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)} + onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)} + resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null} + routineActionPending={builtInRoutineActionPending} + /> + )} + + {builtInState && resolvedCompanyId && ( + { + setShowBuiltInConfigure(false); + invalidateBuiltIn(); + }} + /> + )} + + {!urlRunId && ( + + + + )} + + {actionError &&

{actionError}

} + {isPendingApproval && ( +
+ This agent is pending board approval and cannot be invoked yet. + +
+ )} + + {/* Floating Save/Cancel (desktop) */} + {!isMobile && showConfigActionBar && ( +
+
+ + +
+
+ )} + + {/* Mobile bottom Save/Cancel bar */} + {isMobile && showConfigActionBar && ( +
+
+ + +
+
+ )} + + {/* View content */} + {activeView === "dashboard" && ( + + )} + + {activeView === "instructions" && ( + + )} + + {activeView === "configuration" && ( + + )} + + {activeView === "secrets" && ( +
+ +
+ )} + + {activeView === "skills" && ( + + )} + + {activeView === "tools" && resolvedCompanyId && ( + + )} + + {activeView === "runs" && ( + + )} + + {activeView === "audit" && resolvedCompanyId ? ( + + ) : null} + + {activeView === "budget" && resolvedCompanyId ? ( +
+ budgetMutation.mutate(amount)} + variant="plain" + /> +
+ ) : null} +
+ ); +} + +/* ---- Helper components ---- */ + +function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export type LatestRunIssue = { id: string; title: string; status: string; identifier?: string | null }; + +/** + * The id of the issue a run works on, read from its context snapshot. Newer + * snapshots use `issueId`; older ones use `taskId`. Returns undefined for pure + * timer heartbeats that carry no task reference. + */ +export function getRunSnapshotIssueId( + run: Pick, +): string | undefined { + const ctx = run.contextSnapshot as Record | null; + const issueId = ctx?.issueId ?? ctx?.taskId; + return issueId ? String(issueId) : undefined; +} + +/** + * Resolve the Live Run section's two navigation destinations and the task (if + * any) the run works on. The run→task link lives in the run's context snapshot + * (`issueId`, falling back to `taskId` for older snapshots); the `HeartbeatRun` + * itself doesn't carry the issue id. The heading always links to the run detail + * page; the running row links to the task detail page when the snapshot resolves + * to a known issue, otherwise falls back to the run detail page (pure timer + * heartbeats or an issue that can't be resolved). + */ +export function resolveLatestRunNavigation( + run: Pick, + agentId: string, + issuesById: Map, +): { task: LatestRunIssue | undefined; runHref: string; rowHref: string } { + const issueId = getRunSnapshotIssueId(run); + const task = issueId ? issuesById.get(issueId) : undefined; + const runHref = `/agents/${agentId}/runs/${run.id}`; + const rowHref = task ? `/issues/${task.identifier ?? task.id}` : runHref; + return { task, runHref, rowHref }; +} + +function LatestRunCard({ + runs, + agentId, + issuesById, +}: { + runs: HeartbeatRun[]; + agentId: string; + issuesById: Map; +}) { + const sorted = useMemo( + () => + [...runs].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ), + [runs] + ); + + const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued"); + const run = liveRun ?? sorted[0]; + + // The assigned-issues list this card resolves against is bounded (server page + // limit), so a live run can reference a valid issue that isn't on the loaded + // page. When the snapshot points at an issue we don't already have, fetch it + // directly so the running row always links to the task rather than falling + // back to run metadata. `enabled` keeps this a no-op for the common case. + const snapshotIssueId = run ? getRunSnapshotIssueId(run) : undefined; + const needsFallbackFetch = !!snapshotIssueId && !issuesById.has(snapshotIssueId); + const { data: fallbackIssue } = useQuery({ + queryKey: queryKeys.issues.detail(snapshotIssueId ?? "__none__"), + queryFn: () => issuesApi.get(snapshotIssueId as string), + enabled: needsFallbackFetch, + staleTime: 30_000, + }); + + const summaryRaw = run + ? run.resultJson + ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") + : run.error ?? "" + : ""; + + // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines + const summary = useMemo(() => { + if (!summaryRaw) return ""; + const lines = summaryRaw + .replace(/^#{1,6}\s+/gm, "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l)); + const excerpt: string[] = []; + let chars = 0; + for (const line of lines) { + if (excerpt.length >= 3 || chars + line.length > 280) break; + excerpt.push(line); + chars += line.length; + } + return excerpt.join(" "); + }, [summaryRaw]); + + if (!run) return null; + + const isLive = run.status === "running" || run.status === "queued"; + // Fold any directly-fetched fallback issue into the lookup, keyed by the same + // snapshot id used to resolve the row so it hits regardless of id-vs-slug. + const effectiveIssuesById = + fallbackIssue && snapshotIssueId + ? new Map(issuesById).set(snapshotIssueId, { + id: fallbackIssue.id, + title: fallbackIssue.title, + status: fallbackIssue.status, + identifier: fallbackIssue.identifier, + }) + : issuesById; + const { task, runHref, rowHref } = resolveLatestRunNavigation(run, agentId, effectiveIssuesById); + const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; + const StatusIcon = statusInfo.icon; + + return ( +
+
+ +

+ {isLive && ( + + + + + )} + {isLive ? "Live Run" : "Latest Run"} + + · {run.id.slice(0, 8)} + +

+ +
+ + +
+ + + {task ? ( + <> + + + {task.identifier ?? task.id.slice(0, 8)} + + {task.title} + + ) : ( + <> + {run.id.slice(0, 8)} + + {sourceLabels[run.invocationSource] ?? run.invocationSource} + + + )} + {relativeTime(run.createdAt)} +
+ + {summary && ( +
+ {summary} +
+ )} + +
+ ); +} + +/* ---- Agent Overview (main single-page view) ---- */ + +function AgentOverview({ + agent, + runs, + assignedIssues, + runtimeState, + agentId, + agentRouteId, +}: { + agent: AgentDetailRecord; + runs: HeartbeatRun[]; + assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[]; + runtimeState?: AgentRuntimeState; + agentId: string; + agentRouteId: string; +}) { + const issuesById = useMemo(() => { + const map = new Map(); + for (const issue of assignedIssues) map.set(issue.id, issue); + return map; + }, [assignedIssues]); + + return ( +
+ {/* Latest Run */} + + + {/* Charts */} +
+ + + + {/* PAP-411: "Tasks by Priority" chart hidden behind SHOW_TASK_PRIORITY_UI. */} + {SHOW_TASK_PRIORITY_UI && ( + + + + )} + + + + + + +
+ + {/* Recent Issues */} +
+
+

Recent Tasks

+ + See All → + +
+ {assignedIssues.length === 0 ? ( +

No recent tasks.

+ ) : ( +
+ {assignedIssues.slice(0, 10).map((issue) => ( + } + /> + ))} + {assignedIssues.length > 10 && ( +
+ +{assignedIssues.length - 10} more tasks +
+ )} +
+ )} +
+ + {/* Costs */} +
+

Costs

+ +
+
+ ); +} + +/* ---- Costs Section (inline) ---- */ + +function CostsSection({ + runtimeState, + runs, +}: { + runtimeState?: AgentRuntimeState; + runs: HeartbeatRun[]; +}) { + const runsWithCost = runs + .filter((r) => { + const metrics = runMetrics(r); + return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return ( +
+ {runtimeState && ( +
+
+
+ Input tokens + {formatTokens(runtimeState.totalInputTokens)} +
+
+ Output tokens + {formatTokens(runtimeState.totalOutputTokens)} +
+
+ Cached tokens + {formatTokens(runtimeState.totalCachedInputTokens)} +
+
+ Total cost + {formatCents(runtimeState.totalCostCents)} +
+
+
+ )} + {runsWithCost.length > 0 && ( +
+ + + + + + + + + + + + {runsWithCost.slice(0, 10).map((run) => { + const metrics = runMetrics(run); + return ( + + + + + + + + ); + })} + +
DateRunInputOutputCost
{formatDate(run.createdAt)}{run.id.slice(0, 8)}{formatTokens(metrics.input)}{formatTokens(metrics.output)} + {metrics.cost > 0 + ? `$${metrics.cost.toFixed(4)}` + : "-" + } +
+
+ )} +
+ ); +} + +/* ---- Agent Configure Page ---- */ + +/** + * Agent detail URLs use a name-derived key, so updates that change the agent's + * name (a rename or a config-revision rollback) can invalidate the reference + * currently in the URL. When that happens, refetching the old reference would + * 404 with "Agent not found". Instead, drop the stale cached queries and + * replace the URL with the new canonical reference. Returns true when a + * redirect happened. + */ +export function syncAgentRouteAfterRename( + queryClient: QueryClient, + navigate: NavigateFunction, + previous: { id: string; urlKey?: string | null; name?: string | null }, + updated: { id: string; urlKey?: string | null; name?: string | null }, + tab: string, +): boolean { + const previousRef = agentRouteRef(previous); + const nextRef = agentRouteRef(updated); + if (nextRef === previousRef) return false; + queryClient.removeQueries({ queryKey: queryKeys.agents.detail(previousRef) }); + navigate(`/agents/${nextRef}/${tab}`, { replace: true }); + return true; +} + +function AgentConfigurePage({ + agent, + agentId, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, + updatePermissions, +}: { + agent: AgentDetailRecord; + agentId: string; + companyId?: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { tab: urlTab } = useParams<{ tab?: string }>(); + const [revisionsOpen, setRevisionsOpen] = useState(false); + + const { data: configRevisions } = useQuery({ + queryKey: queryKeys.agents.configRevisions(agent.id), + queryFn: () => agentsApi.listConfigRevisions(agent.id, companyId), + }); + + const rollbackConfig = useMutation({ + mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId, companyId), + onSuccess: (updated) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? "configuration")) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + } + }, + }); + + return ( +
+ +
+

API Keys

+ +
+ + {/* Configuration Revisions — collapsible at the bottom */} +
+ + {revisionsOpen && ( +
+ {(configRevisions ?? []).length === 0 ? ( +

No configuration revisions yet.

+ ) : ( +
+ {(configRevisions ?? []).slice(0, 10).map((revision) => ( +
+
+
+ {revision.id.slice(0, 8)} + · + {formatDate(revision.createdAt)} + · + {revision.source} +
+ +
+

+ Changed:{" "} + {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} +

+
+ ))} +
+ )} +
+ )} +
+
+ ); +} + +/* ---- Configuration Tab ---- */ + +function ConfigurationTab({ + agent, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, + updatePermissions, + hidePromptTemplate, + hideInstructionsFile, + content = "configuration", +}: { + agent: AgentDetailRecord; + companyId?: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; + updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; + hidePromptTemplate?: boolean; + hideInstructionsFile?: boolean; + content?: "configuration" | "secrets"; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { tab: urlTab } = useParams<{ tab?: string }>(); + const { pushToast } = useToastActions(); + const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); + const lastAgentRef = useRef(agent); + + const { data: adapterModels } = useQuery({ + queryKey: + companyId + ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + : ["agents", "none", "adapter-models", agent.adapterType], + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + enabled: Boolean(companyId) && content === "configuration", + }); + + const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review"; + + const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({ + queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"], + queryFn: () => projectsApi.list(companyId!), + enabled: Boolean(companyId && lowTrustSelected) && content === "configuration", + }); + + const { data: boundaryIssues, isLoading: boundaryIssuesLoading } = useQuery({ + queryKey: companyId + ? [...queryKeys.issues.list(companyId), "low-trust-boundary-candidates"] + : ["issues", "__low-trust-disabled"], + queryFn: () => issuesApi.list(companyId!, { limit: 100, sortField: "updated", sortDir: "desc" }), + enabled: Boolean(companyId && lowTrustSelected) && content === "configuration", + }); + + const updateAgent = useMutation({ + mutationFn: (data: Record) => agentsApi.update(agent.id, data, companyId), + onMutate: () => { + setAwaitingRefreshAfterSave(true); + }, + onSuccess: (updated) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agent.companyId) }); + if (!syncAgentRouteAfterRename(queryClient, navigate, agent, updated, urlTab ?? content)) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + } + pushToast({ title: "Agent saved", tone: "success" }); + }, + onError: (err) => { + setAwaitingRefreshAfterSave(false); + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Could not save agent"; + pushToast({ title: "Save failed", body: message, tone: "error" }); + }, + }); + + useEffect(() => { + if (awaitingRefreshAfterSave && agent !== lastAgentRef.current) { + setAwaitingRefreshAfterSave(false); + } + lastAgentRef.current = agent; + }, [agent, awaitingRefreshAfterSave]); + const isConfigSaving = updateAgent.isPending || awaitingRefreshAfterSave; + + useEffect(() => { + onSavingChange(isConfigSaving); + }, [onSavingChange, isConfigSaving]); + + const canCreateAgents = Boolean(agent.permissions?.canCreateAgents); + const canCreateSkills = agent.permissions?.canCreateSkills !== false; + const canAssignTasks = Boolean(agent.access?.canAssignTasks); + const taskAssignSource = agent.access?.taskAssignSource ?? "none"; + const taskAssignLocked = agent.role === "ceo" || canCreateAgents; + const taskAssignHint = + taskAssignSource === "ceo_role" + ? "Enabled automatically for CEO agents." + : taskAssignSource === "agent_creator" + ? "Enabled automatically while this agent can create new agents." + : taskAssignSource === "explicit_grant" + ? "Enabled via explicit company permission grant." + : taskAssignSource === "simple_default" + ? "Enabled by simple company-wide task assignment defaults." + : "Disabled unless explicitly granted."; + + return ( +
+ updateAgent.mutateAsync(patch)} + isSaving={isConfigSaving} + adapterModels={adapterModels} + onDirtyChange={onDirtyChange} + onSaveActionChange={onSaveActionChange} + onCancelActionChange={onCancelActionChange} + hideInlineSave + hidePromptTemplate={hidePromptTemplate} + hideInstructionsFile={hideInstructionsFile} + content={content} + sectionLayout="cards" + /> + {content === "configuration" ? ( +

+ Saved adapter config affects the next run. Active runs keep the config they started with, and config changes may start a fresh adapter session. +

+ ) : null} + + {content === "configuration" ? ({ + id: project.id, + label: project.name, + }))} + issueCandidates={(boundaryIssues ?? []).map((issue) => ({ + id: issue.id, + label: `${issue.identifier ?? issue.id.slice(0, 8)} · ${issue.title}`, + }))} + candidatesLoading={boundaryProjectsLoading || boundaryIssuesLoading} + onChange={(nextPermissions) => + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills, + canAssignTasks, + ...buildPermissionsForTrustPreset(nextPermissions, nextPermissions.trustPreset === "low_trust_review" ? "low_trust_review" : "standard"), + }) + } + /> : null} + + {content === "configuration" ?
+

Permissions

+
+
+
+
Can create new agents
+

+ Lets this agent create or hire agents. This also grants task assignment authority. +

+
+ + updatePermissions.mutate({ + canCreateAgents: !canCreateAgents, + canCreateSkills, + canAssignTasks: !canCreateAgents ? true : canAssignTasks, + }) + } + disabled={updatePermissions.isPending} + /> +
+
+
+
Can create/import skills
+

+ Lets this agent install, import, create, and scan company skills without creating agents. +

+
+ + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills: !canCreateSkills, + canAssignTasks, + }) + } + disabled={updatePermissions.isPending} + /> +
+
+
+
Can assign tasks
+

+ {taskAssignHint} +

+
+ + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills, + canAssignTasks: !canAssignTasks, + }) + } + disabled={updatePermissions.isPending || taskAssignLocked} + /> +
+
+
: null} +
+ ); +} + +/* ---- Prompts Tab ---- */ + +export function PromptsTab({ + agent, + companyId, + onDirtyChange, + onSaveActionChange, + onCancelActionChange, + onSavingChange, +}: { + agent: Agent; + companyId?: string; + onDirtyChange: (dirty: boolean) => void; + onSaveActionChange: (save: (() => void) | null) => void; + onCancelActionChange: (cancel: (() => void) | null) => void; + onSavingChange: (saving: boolean) => void; +}) { + const queryClient = useQueryClient(); + const { selectedCompanyId } = useCompany(); + const { isMobile } = useSidebar(); + const [selectedFile, setSelectedFile] = useState("AGENTS.md"); + const [showFilePanel, setShowFilePanel] = useState(false); + const [draft, setDraft] = useState(null); + const [bundleDraft, setBundleDraft] = useState<{ + mode: "managed" | "external"; + rootPath: string; + entryFile: string; + } | null>(null); + const [newFilePath, setNewFilePath] = useState(""); + const [showNewFileInput, setShowNewFileInput] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [filePanelWidth, setFilePanelWidth] = useState(260); + const [instructionPaneWidth, setInstructionPaneWidth] = useState(null); + const containerRef = useRef(null); + const [awaitingRefresh, setAwaitingRefresh] = useState(false); + const lastFileVersionRef = useRef(null); + const externalBundleRef = useRef<{ + rootPath: string; + entryFile: string; + selectedFile: string; + } | null>(null); + + useEffect(() => { + setSelectedFile("AGENTS.md"); + setShowFilePanel(false); + setDraft(null); + setBundleDraft(null); + setNewFilePath(""); + setShowNewFileInput(false); + setPendingFiles([]); + setExpandedDirs(new Set()); + setAwaitingRefresh(false); + lastFileVersionRef.current = null; + externalBundleRef.current = null; + }, [agent.id]); + + const getCapabilities = useAdapterCapabilities(); + const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle; + + const { data: bundle, isLoading: bundleLoading } = useQuery({ + queryKey: queryKeys.agents.instructionsBundle(agent.id), + queryFn: () => agentsApi.instructionsBundle(agent.id, companyId), + enabled: Boolean(companyId && isLocal), + }); + + const persistedMode = bundle?.mode ?? "managed"; + const persistedRootPath = persistedMode === "managed" + ? (bundle?.managedRootPath ?? bundle?.rootPath ?? "") + : (bundle?.rootPath ?? ""); + const currentMode = bundleDraft?.mode ?? persistedMode; + const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md"; + const currentRootPath = bundleDraft?.rootPath ?? persistedRootPath; + const fileOptions = useMemo( + () => bundle?.files.map((file) => file.path) ?? [], + [bundle], + ); + const bundleMatchesDraft = Boolean( + bundle && + currentMode === persistedMode && + currentEntryFile === bundle.entryFile && + currentRootPath === persistedRootPath, + ); + const visibleFilePaths = useMemo( + () => bundleMatchesDraft + ? [...new Set([currentEntryFile, ...fileOptions, ...pendingFiles])] + : [currentEntryFile, ...pendingFiles], + [bundleMatchesDraft, currentEntryFile, fileOptions, pendingFiles], + ); + const fileTree = useMemo( + () => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))), + [visibleFilePaths], + ); + const selectedOrEntryFile = selectedFile || currentEntryFile; + const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile); + const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null; + + const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({ + queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile), + queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId), + enabled: Boolean(companyId && isLocal && selectedFileExists), + }); + + const updateBundle = useMutation({ + mutationFn: (data: { + mode?: "managed" | "external"; + rootPath?: string | null; + entryFile?: string; + clearLegacyPromptTemplate?: boolean; + }) => agentsApi.updateInstructionsBundle(agent.id, data, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const saveFile = useMutation({ + mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) => + agentsApi.saveInstructionsFile(agent.id, data, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: (_, variables) => { + setPendingFiles((prev) => prev.filter((f) => f !== variables.path)); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const deleteFile = useMutation({ + mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId), + onMutate: () => setAwaitingRefresh(true), + onSuccess: (_, relativePath) => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); + }, + onError: () => setAwaitingRefresh(false), + }); + + const uploadMarkdownImage = useMutation({ + mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to upload images"); + return assetsApi.uploadImage(selectedCompanyId, file, namespace); + }, + }); + + useEffect(() => { + if (!bundle) return; + if (!bundleMatchesDraft) { + if (selectedFile !== currentEntryFile) setSelectedFile(currentEntryFile); + return; + } + const availablePaths = bundle.files.map((file) => file.path); + if (availablePaths.length === 0) { + if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile); + return; + } + if (!availablePaths.includes(selectedFile) && selectedFile !== currentEntryFile && !pendingFiles.includes(selectedFile)) { + setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!); + } + }, [bundle, bundleMatchesDraft, currentEntryFile, pendingFiles, selectedFile]); + + useEffect(() => { + const nextExpanded = new Set(); + for (const filePath of visibleFilePaths) { + const parts = filePath.split("/"); + let currentPath = ""; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!; + nextExpanded.add(currentPath); + } + } + setExpandedDirs((current) => (setsEqual(current, nextExpanded) ? current : nextExpanded)); + }, [visibleFilePaths]); + + useEffect(() => { + if (isMobile) { + setInstructionPaneWidth(null); + return; + } + const element = containerRef.current; + if (!element) return; + + const updateWidth = () => setInstructionPaneWidth(element.getBoundingClientRect().width); + updateWidth(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setInstructionPaneWidth(entry.contentRect.width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [bundleLoading, isMobile, visibleFilePaths.length]); + + useEffect(() => { + const versionKey = selectedFileExists && selectedFileDetail + ? `${selectedFileDetail.path}:${selectedFileDetail.content}` + : `draft:${currentMode}:${currentRootPath}:${selectedOrEntryFile}`; + if (awaitingRefresh) { + setAwaitingRefresh(false); + setBundleDraft(null); + setDraft(null); + lastFileVersionRef.current = versionKey; + return; + } + if (lastFileVersionRef.current !== versionKey) { + setDraft(null); + lastFileVersionRef.current = versionKey; + } + }, [awaitingRefresh, currentMode, currentRootPath, selectedFileDetail, selectedFileExists, selectedOrEntryFile]); + + useEffect(() => { + if (!bundle) return; + setBundleDraft((current) => { + if (current) return current; + return { + mode: persistedMode, + rootPath: persistedRootPath, + entryFile: bundle.entryFile, + }; + }); + }, [bundle, persistedMode, persistedRootPath]); + + useEffect(() => { + if (!bundle || currentMode !== "external") return; + externalBundleRef.current = { + rootPath: currentRootPath, + entryFile: currentEntryFile, + selectedFile: selectedOrEntryFile, + }; + }, [bundle, currentEntryFile, currentMode, currentRootPath, selectedOrEntryFile]); + + const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : ""; + const displayValue = draft ?? currentContent; + const useMarkdownEditor = shouldUseMarkdownInstructionsEditor({ + selectedFileExists, + selectedPath: selectedOrEntryFile, + detail: selectedFileDetail, + summary: selectedFileSummary, + }); + const bundleDirty = Boolean( + bundleDraft && + ( + bundleDraft.mode !== persistedMode || + bundleDraft.rootPath !== persistedRootPath || + bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md") + ), + ); + const fileDirty = draft !== null && draft !== currentContent; + const isDirty = bundleDirty || fileDirty; + const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh; + + useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); + useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); + + useEffect(() => { + onSaveActionChange(isDirty ? () => { + const save = async () => { + const shouldClearLegacy = + Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive); + if (bundleDirty && bundleDraft) { + await updateBundle.mutateAsync({ + mode: bundleDraft.mode, + rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null, + entryFile: bundleDraft.entryFile, + }); + } + if (fileDirty) { + await saveFile.mutateAsync({ + path: selectedOrEntryFile, + content: displayValue, + clearLegacyPromptTemplate: shouldClearLegacy, + }); + } + }; + void save().catch(() => undefined); + } : null); + }, [ + bundle, + bundleDirty, + bundleDraft, + displayValue, + fileDirty, + isDirty, + onSaveActionChange, + saveFile, + selectedOrEntryFile, + updateBundle, + ]); + + useEffect(() => { + onCancelActionChange(isDirty ? () => { + setDraft(null); + if (bundle) { + setBundleDraft({ + mode: persistedMode, + rootPath: persistedRootPath, + entryFile: bundle.entryFile, + }); + } + } : null); + }, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]); + + const handleSeparatorDrag = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = filePanelWidth; + const onMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const next = Math.max(180, Math.min(500, startWidth + delta)); + setFilePanelWidth(next); + }; + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, [filePanelWidth]); + + const instructionsSideBySide = + !isMobile && instructionPaneWidth !== null && instructionPaneWidth >= filePanelWidth + 520; + + if (!isLocal) { + return ( +
+

+ Instructions bundles are only available for local adapters. +

+
+ ); + } + + if (bundleLoading && !bundle) { + return ; + } + + return ( +
+ {(bundle?.warnings ?? []).length > 0 && ( +
+ {(bundle?.warnings ?? []).map((warning) => ( +
+ {warning} +
+ ))} +
+ )} +

+ Saved instructions affect the next run. Active runs keep the instructions they started with, and instruction changes may start a fresh adapter session. +

+ + + + + Advanced + + + +
+ + + +
+
+
+
+ +
+
+
+

Files

+
+ {!showNewFileInput && ( + + )} + {isMobile && ( + + )} +
+
+ {showNewFileInput && ( +
+ setNewFilePath(event.target.value)} + placeholder="TOOLS.md" + className="font-mono text-sm" + autoFocus + onKeyDown={(event) => { + if (event.key === "Escape") { + setShowNewFileInput(false); + setNewFilePath(""); + } + }} + /> +
+ + +
+
+ )} + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(dirPath)) next.delete(dirPath); + else next.add(dirPath); + return next; + })} + onSelectFile={(filePath) => { + setSelectedFile(filePath); + if (!fileOptions.includes(filePath)) setDraft(""); + if (isMobile) setShowFilePanel(false); + }} + onToggleCheck={() => {}} + showCheckboxes={false} + wrapLabels + renderFileExtra={(node) => { + const file = bundle?.files.find((entry) => entry.path === node.path); + if (!file) return null; + if (file.deprecated) { + return ( + + + + virtual file + + + + Legacy inline prompt — this deprecated virtual file preserves the old promptTemplate content + + + ); + } + return ( + + {file.isEntryFile ? "entry" : `${file.size}b`} + + ); + }} + /> +
+ + {/* Draggable separator */} + {instructionsSideBySide && ( +
+ )} + +
+
+
+ {isMobile && ( + + )} +
+

{selectedOrEntryFile}

+

+ {selectedFileExists + ? selectedFileSummary?.deprecated + ? "Deprecated virtual file" + : `${selectedFileDetail?.language ?? "text"} file` + : "New file in this bundle"} +

+
+
+
+ {!fileLoading && ( + + + + )} + {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && ( + + )} +
+
+ + {selectedFileExists && fileLoading && !selectedFileDetail ? ( + + ) : useMarkdownEditor ? ( + setDraft(value ?? "")} + placeholder="# Agent instructions" + className="min-w-0 overflow-hidden" + contentClassName="min-h-(--sz-420px) max-w-full break-words text-sm leading-7" + imageUploadHandler={async (file) => { + const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`; + const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); + return asset.contentPath; + }} + /> + ) : ( +