From 9dfdeaad8662d9cd73a93490d09516877ef8a276 Mon Sep 17 00:00:00 2001 From: Tonio Date: Wed, 11 Nov 2026 17:23:59 -0800 Subject: [PATCH] Refine agent workspaces and organization charts (#20973) Refine agent details, identity editing, responsive controls, and organization charts with three card tiers, clearer save feedback, and stable filtering. Guard instruction-file renames and root configuration updates in one transaction through the agent lifecycle integration, preserving conflict checks, rollback, and configuration history. Co-Authored-By: Paperclip --- DESIGN.md | 44 +- docs/api/agents.md | 11 + packages/shared/src/agent-appearance.test.ts | 11 +- packages/shared/src/agent-appearance.ts | 5 +- packages/shared/src/cliplab/static.ts | 10 +- packages/shared/src/validators/agent.ts | 1 + scripts/check-module-boundaries.mjs | 1 + scripts/storybook-agent-avatar-assets.mjs | 13 +- server/src/__tests__/agent-avatars.test.ts | 20 +- .../__tests__/agent-file-durability.test.ts | 57 +- .../agent-instructions-routes.test.ts | 24 +- server/src/routes/agent-avatars.ts | 7 +- server/src/routes/agents.ts | 11 + server/src/routes/openapi.ts | 5 +- server/src/services/agent-avatar-worker.ts | 2 +- server/src/services/agent-avatars.ts | 7 +- server/src/services/agent-file-store.ts | 55 +- .../runner-e2e/source-sdk-public-inputs.json | 8 +- ui/src/api/agents.ts | 2 + ui/src/components/AgentActionButtons.tsx | 7 +- ui/src/components/AgentAvatar.test.tsx | 2 +- ui/src/components/AgentAvatar.tsx | 9 +- ui/src/components/AgentCharacter.test.tsx | 54 ++ ui/src/components/AgentCharacter.tsx | 33 +- .../AgentConfigForm.render.test.tsx | 49 ++ ui/src/components/AgentConfigForm.tsx | 30 +- ui/src/components/AgentsToolbar.tsx | 14 +- ui/src/components/AppsSidebar.tsx | 13 +- ui/src/components/IssueRow.tsx | 1 + ui/src/components/Layout.test.tsx | 14 + ui/src/components/Layout.tsx | 3 +- ui/src/components/OrgChartControls.tsx | 23 + ui/src/components/TrustPresetSection.tsx | 2 +- .../components/agent-detail/AgentPanels.tsx | 3 +- .../agents/AgentBulkRoster.test.tsx | 4 +- ui/src/components/agents/AgentBulkRoster.tsx | 4 +- .../components/agents/AgentRosterFilters.tsx | 16 +- .../artifacts/v1/ArtifactEmptyGraphic.tsx | 4 +- .../components/artifacts/v1/ArtifactSheet.tsx | 6 +- ui/src/index.css | 206 +++++++- ui/src/lib/agent-character-slot.ts | 31 +- .../pages/AgentDetail.instructions.test.tsx | 85 ++- ui/src/pages/AgentDetail.tsx | 303 ++++++----- ui/src/pages/AgentOverview.test.tsx | 11 +- ui/src/pages/AgentRunnerUpgrade.test.tsx | 30 +- ui/src/pages/Agents.test.tsx | 92 +++- ui/src/pages/Agents.tsx | 53 +- ui/src/pages/Inbox.tsx | 1 + ui/src/pages/OrgChart.test.tsx | 179 ++++++- ui/src/pages/OrgChart.tsx | 485 ++++++++++-------- ui/src/pages/org-chart/layout.test.ts | 102 ++++ ui/src/pages/org-chart/layout.ts | 150 ++++++ ui/src/pages/org-chart/task-preview.ts | 19 + ui/src/pages/org-chart/tasks.ts | 17 + .../fixtures/agent-avatar-url.test.ts | 1 + ui/storybook/fixtures/agent-avatar-url.ts | 3 +- 56 files changed, 1864 insertions(+), 489 deletions(-) create mode 100644 ui/src/components/AgentCharacter.test.tsx create mode 100644 ui/src/components/OrgChartControls.tsx create mode 100644 ui/src/pages/org-chart/layout.test.ts create mode 100644 ui/src/pages/org-chart/layout.ts create mode 100644 ui/src/pages/org-chart/task-preview.ts create mode 100644 ui/src/pages/org-chart/tasks.ts diff --git a/DESIGN.md b/DESIGN.md index 3fffb292159..901784c7c4e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1014,7 +1014,7 @@ Further consolidation proposals, pending visual review: Projects uses the large artifact stack with a dark grey Paperclip mark on an off-white image front. The empty-state action is “Create project”. Artwork, message and primary action have 20px gaps (`gap-5`). Hovering or keyboard-focusing the action -fans the same artwork as hovering the graphic. Artifacts uses that stack for +fans the artwork; hovering the empty-state graphic itself does not animate it. Single-type empty illustrations use only their icon, without lettering. Actual artifact hover behavior is unchanged. Artifacts uses that stack for Tasks and the corresponding single full-size artifact for each other type filter. Agent lists give chat and status their own vertically centered cells. Chat has a @@ -1090,17 +1090,15 @@ through the offline tokens. ### Agent overview and Identity editing -Agent headers lead with the 24px custom title and crown on one line, followed by “harness • name.” The harness logo and label link to the agent’s Harness / Runtime subtab with hover and keyboard-focus feedback. The logo has a 4px gap before its label; Chat and New task share the default 40px button height. Header spacing is reduced by 4px at each vertical edge. +Agent headers lead with the 24px agent name and crown on one line, followed by “harness • custom title.” The harness logo and label link to the agent’s Harness / Runtime subtab with hover and keyboard-focus feedback. The logo has a 4px gap before its label; Chat and New task share the default 40px button height. Header spacing is reduced by 4px at each vertical edge. The character renders in a 102px square (20% smaller than 128px, rounded to a whole pixel) with an 8px gap before the title, without CSS transform scaling. Reporting relationships live in Profile; its creation date shares the header metadata position with the Skills count. Empty Direct reports sections are hidden. The overview character tracks the page cursor, including paused -agents, while respecting reduced-motion and custom-avatar fallbacks. Chat sits -beside the text-only New task action. Recent tasks applies its raised surface to -the table, leaving the section heading on the page background. +agents, while respecting reduced-motion and custom-avatar fallbacks. The outlined Chat action shows its icon and label beside the primary New task action, which leads with a Paperclip icon. Recent tasks applies its raised surface to +the table, leaving the section heading on the page background. When no tasks exist, three clearly labelled suggestions open the task composer with their text and the current agent preselected; clicking a suggestion does not create or run a task. Suggested rows use dashed dividers. Identity panels share 24px insets and gutters from the token layer. Advanced -management contains the external-profile controls. New files accept a portable -basename (letters, numbers, hyphens and underscores) with a fixed `.md` suffix. +management contains the external-profile controls. Add file immediately opens an italic, subdued Untitled.md draft in the preview, with its filename focused. Filename edits update the selected list row live and save with the content; the fixed `.md` suffix stays subdued. Names accept letters, numbers, hyphens and underscores. The selected row keeps its muted highlight. The root file has a disabled trash control with “This file is required” help. Clicking file content enters editing without creating a dirty draft. The Identity save footer slides from the viewport bottom only for unsaved changes or a save in progress; undoing or discarding changes hides it. Successful saves swap the @@ -1121,5 +1119,35 @@ Radix’s standard 700ms hover delay on each entry, without the instant-tooltip provider or warm-up shortcut; keyboard focus retains immediate accessible help. Task-list titles use the remaining row width before truncating. Empty recovery, -external-status, and metadata slots do not reserve space; populated details and +external-status, and metadata slots do not reserve space. Archive reserves width only on the hovered or keyboard-focused row, with token-driven width and opacity transitions; populated details and trailing actions retain their existing layout. + +Org charts adapt team packing to the viewport using three fixed tiers: root leads with reports, all other leads with reports, and individual contributors. Leads use two portrait sizes; contributors keep a compact horizontal layout. Card tiers do not inflate to consume empty space. Small orgs (up to four agents) keep extra canvas padding and a 1× automatic zoom cap; Fit centers the result below the toolbar. Leadership cards use the badge treatment: a centered inset slot, name/role/status heading with a working star control, a large character centered in the remaining space, and a full-width divided footer. Tier 1 is 280×368px and tier 2 is 232×320px. Their characters fill the available area and follow the cursor; a dedicated eight-canvas pool bounds GPU work, and offscreen, reduced-motion, custom-avatar, and failed-render states retain static fallbacks. Other surfaces keep the single-hero animation budget. Main-lead names use 24px type, team-lead names use 19px, and their titles use 13px; card edge insets remain generous. Individual contributor names span the full card width; their characters sit at the bottom right, beside left-aligned role and model/effort details. Contributor details use 11px type with 14px leading, and each block wraps to at most two lines before truncating. The character sits 6px from the bottom and right inner edges, half the 12px text inset. Contributor cards request a fixed upward, slightly turned portrait from the existing avatar renderer, with high-resolution images for crisp zooming. Other avatar surfaces retain the default front view. Broad teams wrap into rows, while +individual contributors reporting alongside leaders stay in their own column. +Right-angle reporting lines use separate ports, horizontal tracks, and column lanes through the space between cards. The chart extends to the edges of the main collection area beneath a floating toolbar. Camera controls appear immediately before New Agent in chart view and fade in/out across view changes. Their space stays reserved in both views; no width or position transition accompanies the fade. Hidden controls are inert and removed from the accessibility tree. The main content keeps a stable scrollbar gutter in both views so toolbar alignment does not shift. Model labels +omit provider prefixes; the harness icon provides that context with a 4px gap. +Configured effort is subdued and sits below the model in leadership footers; contributor model/effort details remain inline with a shared two-line limit. Filtered-out cards and reporting lines with either endpoint filtered out fade to 12% opacity; all cards remain navigable. +Unknown model or effort values leave no fallback text. Card geometry is +defined in the CSS token layer and shared with hierarchy placement. Pan, zoom, +and Fit remain available when a large organization needs closer inspection. + +Task layering and its toggle are temporarily hidden on product surfaces, including the demo URL. When enabled for exploration, the chart-specific Tasks toggle overlays currently running task titles and muted +dashed links to the agents executing them. A shared task appears once with one +link per working agent. Task data loads only while the layer is enabled and +refreshes every 15 seconds; queued and completed runs are excluded. Task labels +link to their task from a separate top rail. Dashed associations are an overlay masked around card faces; toggling the layer preserves every card position and the current camera. The top rail scrolls horizontally for larger task sets. Empty and failed +loads are explicit. This layer shows execution associations, not inferred +assignments from the reporting hierarchy. + +Agent collection filters sit beside the list/chart toggle and persist across +views. The roster identity header carries the visible agent count; metadata +columns follow Status, Harness, Default model, Connection. Harness labels are +text only, and model text uses the same typography as harness labels. +Chart filters and search preserve hierarchy placement: nonmatching cards dim +but remain clickable and keyboard accessible. + +Below the mobile shell breakpoint (768px), agent header actions sit beneath the +identity row, file management chips sit below filenames, runner upgrade actions +sit below full-width copy, and runtime/trust panels stack. Task toolbar square +controls occupy a second row. Connector Browse/Review links become top segments +on their mobile collection surfaces; desktop navigation and layouts stay unchanged. diff --git a/docs/api/agents.md b/docs/api/agents.md index 3fc63221c33..7c5fb5f9bfa 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -165,3 +165,14 @@ POST /api/agents/{agentId}/config-revisions/{revisionId}/rollback ``` View and roll back agent configuration changes. + +### Rename an instruction file + +Use `PUT /api/agents/{id}/instructions-bundle/file` with `path`, `renameTo`, +`content`, and the source file's current `baseHash`. Use `null` only when the +configured root file is missing; a concurrently created root returns `409`. Renames require the same +instruction configuration permission as file writes. The managed file store +checks the source hash and destination under the agent write lock, then updates +the file and the configured root path together. A stale source or occupied +name returns `409`. External bundles must be migrated to managed storage before +using this operation. A successful response contains the renamed file detail. diff --git a/packages/shared/src/agent-appearance.test.ts b/packages/shared/src/agent-appearance.test.ts index 95cb8b22a18..709173240aa 100644 --- a/packages/shared/src/agent-appearance.test.ts +++ b/packages/shared/src/agent-appearance.test.ts @@ -17,7 +17,7 @@ describe("agent appearance", () => { it("uses a private asset URL for custom avatars while retaining the fallback palette", () => { const appearance = { ...appearanceForPalette("deep-tide"), customAvatarAssetId: "22222222-2222-4222-8222-222222222222" }; expect(resolveAgentAppearance(appearance)).toEqual(appearance); - expect(agentAvatarUrl(appearance, 128, 2)).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); + expect(agentAvatarUrl(appearance, 128, 2, "rest", false, "transparent", "portrait")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); expect(agentAppearanceSchema.safeParse({ ...appearance, customAvatarAssetId: "https://example.test/image" }).success).toBe(false); }); it("renders without a browser and preserves logical-size detail at high density", () => { @@ -34,6 +34,15 @@ describe("agent appearance", () => { expect(renderAgentSvg(appearance, 48, 1)).toContain('id="agent-candle-light"'); expect(renderAgentSvg(appearance, 24, 2)).toBe(eyes); }); + it("renders a deterministic portrait angle with its own URL and keeps front as the default", () => { + const appearance = appearanceForPalette("coral-mint"); + expect(agentAvatarUrl(appearance, 256, 2, "rest", false, "transparent", "portrait")).toBe("/api/agent-avatars/cap-v1/coral-mint/rest.png?size=256&scale=2&view=portrait"); + const portrait = renderAgentSvg(appearance, 256, 2, "rest", false, "portrait"); + expect(portrait).toContain('width="512"'); + expect(portrait).toBe(renderAgentSvg(appearance, 256, 2, "rest", false, "portrait")); + expect(portrait).not.toBe(renderAgentSvg(appearance, 256, 2)); + expect(renderAgentSvg(appearance, 256, 2)).toBe(renderAgentSvg(appearance, 256, 2, "rest", false, "front")); + }); it("uses a separate URL for opaque Slack exports without changing ordinary portraits", () => { const appearance = appearanceForPalette("cherry-pop"); expect(agentAvatarUrl(appearance, 512, 1, "rest", false, "paperclip-dark")).toBe("/api/agent-avatars/cap-v1/cherry-pop/rest.png?size=512&scale=1&background=paperclip-dark"); diff --git a/packages/shared/src/agent-appearance.ts b/packages/shared/src/agent-appearance.ts index d45525d26e5..e37f130eec7 100644 --- a/packages/shared/src/agent-appearance.ts +++ b/packages/shared/src/agent-appearance.ts @@ -5,6 +5,7 @@ export type AgentPaletteId = typeof AGENT_PALETTE_IDS[number]; export type CharacterPaletteId = AgentPaletteId | "muted-dream"; export const AGENT_AVATAR_SIZES = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512] as const; export type AgentAvatarSize = typeof AGENT_AVATAR_SIZES[number]; +export type AgentAvatarView = "front" | "portrait"; export type AgentAvatarBackground = "transparent" | "paperclip-dark"; /** sRGB PNG equivalent of index.css .dark --background: oklch(0.205 0 0). */ export const PAPERCLIP_DARK_AVATAR_BACKGROUND = "#171717"; @@ -40,9 +41,9 @@ export function resolveAgentAppearance(appearance: unknown, id = "agent"): Agent const parsed = agentAppearanceSchema.safeParse(appearance); return parsed.success ? parsed.data : legacyAgentAppearance(id); } -export function agentAvatarUrl(appearance: AgentAppearance, size: AgentAvatarSize = 512, scale: 1 | 2 = 1, pose: CharacterState = "rest", muted = false, background: AgentAvatarBackground = "transparent"): string { +export function agentAvatarUrl(appearance: AgentAppearance, size: AgentAvatarSize = 512, scale: 1 | 2 = 1, pose: CharacterState = "rest", muted = false, background: AgentAvatarBackground = "transparent", view: AgentAvatarView = "front"): string { if (appearance.customAvatarAssetId) return `/api/assets/${appearance.customAvatarAssetId}/content`; - return `/api/agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}.png?size=${size}&scale=${scale}${background === "paperclip-dark" ? "&background=paperclip-dark" : ""}`; + return `/api/agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}.png?size=${size}&scale=${scale}${background === "paperclip-dark" ? "&background=paperclip-dark" : ""}${view === "portrait" ? "&view=portrait" : ""}`; } export function characterStateForAgent(status: string): CharacterState { if (status === "running") return "working"; diff --git a/packages/shared/src/cliplab/static.ts b/packages/shared/src/cliplab/static.ts index 14d3fb11401..179dae48eb0 100644 --- a/packages/shared/src/cliplab/static.ts +++ b/packages/shared/src/cliplab/static.ts @@ -1,15 +1,17 @@ -import type { AgentAppearance, AgentAvatarSize, CharacterState } from "../agent-appearance.js"; +import type { AgentAppearance, AgentAvatarSize, AgentAvatarView, CharacterState } from "../agent-appearance.js"; import { characterDefinition, characterStill } from "./definition.js"; import { CharacterRenderer } from "./renderer.js"; import { snapshotSvg } from "./svg-snapshot.js"; /** Same geometry/face projection as the live character, without DOM or WebGL. */ -export function renderAgentSvg(appearance: AgentAppearance, size: AgentAvatarSize, scale: 1 | 2, state: CharacterState = "rest", muted = false): string { +export function renderAgentSvg(appearance: AgentAppearance, size: AgentAvatarSize, scale: 1 | 2, state: CharacterState = "rest", muted = false, view: AgentAvatarView = "front"): string { const definition = characterDefinition(appearance, muted); const renderer = new CharacterRenderer(null, { width: size * scale, height: size * scale, displaySize: size }); try { - renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false }, - characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } }); + renderer.render({ ...definition.character, trueFront: view === "front", lockPosition: true, followCursor: false, followRotation: false }, + characterStill(definition, state), view === "portrait" + ? { rotation: { x: -12, y: -20, z: -4 }, zoom: 1.12 } + : { rotation: { x: 0, y: 0, z: 0 } }); return snapshotSvg(renderer.snapshotScene(), "agent-"); } finally { renderer.dispose(); } } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index a2a58891196..13ba04317b1 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -37,6 +37,7 @@ export type UpdateAgentInstructionsBundle = z.infer { const pathname = new URL(req.url ?? "/", "http://storybook.local").pathname; - const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])(-paperclip-dark-v1)?\.png$/.exec(pathname); + const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])(-paperclip-dark-v1)?(-portrait-v1)?\.png$/.exec(pathname); if (!match) return next(); void (async () => { - const [, palette, pose, sizeText, scaleText, darkBackground] = match; + const [, palette, pose, sizeText, scaleText, darkBackground, portraitView] = match; const { pool, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = await renderer(); const size = Number(sizeText); const scale = Number(scaleText); @@ -33,7 +33,7 @@ export function storybookAgentAvatarAssets() { } if (!images.has(pathname)) { const appearance = appearanceForPalette(muted ? AGENT_PALETTE_IDS[0] : palette); - images.set(pathname, pool.render({ appearance, size, scale, pose, muted, background: darkBackground ? "paperclip-dark" : "transparent" }).catch((error) => { + images.set(pathname, pool.render({ appearance, size, scale, pose, muted, background: darkBackground ? "paperclip-dark" : "transparent", view: portraitView ? "portrait" : "front" }).catch((error) => { images.delete(pathname); throw error; })); @@ -60,6 +60,9 @@ export function storybookAgentAvatarAssets() { (muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette => CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size => [1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted })))))); + // Org cards use high-resolution, angled portraits at two logical sizes. + requests.push(...[false, true].flatMap(muted => (muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette => + [128, 256].flatMap(size => [1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose: "rest", muted, view: "portrait" })))))); // Slack exports use this one opaque preset; ordinary UI avatars stay clear. requests.push(...AGENT_PALETTE_IDS.map(palette => ({ appearance: appearanceForPalette(palette), size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }))); const pool = createAgentAvatarPool(2); @@ -70,8 +73,8 @@ export function storybookAgentAvatarAssets() { while (next < requests.length) { const request = requests[next++]; const source = await pool.render(request); - const { appearance, size, scale, pose, muted, background } = request; - const fileName = agentAvatarUrl(appearance, size, scale, pose, muted, background).slice(2); + const { appearance, size, scale, pose, muted, background, view } = request; + const fileName = agentAvatarUrl(appearance, size, scale, pose, muted, background, view).slice(2); this.emitFile({ type: "asset", fileName, source }); images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale }); } diff --git a/server/src/__tests__/agent-avatars.test.ts b/server/src/__tests__/agent-avatars.test.ts index 7469c18f515..6108e8452c1 100644 --- a/server/src/__tests__/agent-avatars.test.ts +++ b/server/src/__tests__/agent-avatars.test.ts @@ -114,6 +114,24 @@ describe("on-demand agent avatars", () => { const png = await pool.render(request); expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true }); }, 20_000); + it("serves angled portraits separately while keeping the default front cache intact", async () => { + const service = createAgentAvatarService(await storage()); cleanups.push(() => service.close()); + const url = await serve(service); + const front = await fetch(url + "?size=256&scale=2"); + const portrait = await fetch(url + "?size=256&scale=2&view=portrait"); + expect(portrait.status).toBe(200); + expect(portrait.headers.get("etag")).not.toBe(front.headers.get("etag")); + await front.arrayBuffer(); + const bytes = Buffer.from(await portrait.arrayBuffer()); + expect(await sharp(bytes).metadata()).toMatchObject({ width: 512, height: 512, format: "png", hasAlpha: true }); + const explicitFront = await fetch(url + "?size=256&scale=2&view=front"); + expect(explicitFront.headers.get("etag")).toBe(front.headers.get("etag")); + await explicitFront.arrayBuffer(); + const cached = await fetch(url + "?size=256&scale=2&view=portrait", { headers: { "If-None-Match": portrait.headers.get("etag")! } }); + expect(cached.status).toBe(304); + expect(avatarCacheKey({ ...request, view: "portrait" })).not.toBe(avatarCacheKey(request)); + expect(avatarCacheKey({ ...request, view: "front" })).toBe(avatarCacheKey(request)); + }, 20_000); it("preserves transparent background pixels in ordinary 512px avatars", async () => { const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close()); const png = await pool.render({ ...request, size: 512, scale: 1 }); @@ -155,7 +173,7 @@ describe("on-demand agent avatars", () => { await first.arrayBuffer(); const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } }); expect(second.status).toBe(304); - for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32"]) { + for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32", "?view=invalid"]) { const invalid = await fetch(url + suffix); expect(invalid.status).toBe(400); await invalid.text(); } expect(render).toHaveBeenCalledTimes(1); diff --git a/server/src/__tests__/agent-file-durability.test.ts b/server/src/__tests__/agent-file-durability.test.ts index c20e29ac96d..ddb9d890d2e 100644 --- a/server/src/__tests__/agent-file-durability.test.ts +++ b/server/src/__tests__/agent-file-durability.test.ts @@ -6,7 +6,7 @@ import { Readable } from "node:stream"; import { setTimeout as delay } from "node:timers/promises"; import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, agentFileRoots, agentInstructionWorkingCopies, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db"; +import { agents, agentConfigRevisions, agentFileRoots, agentInstructionWorkingCopies, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, createDb, closeFileStorageDb } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js"; import { invalidateAgentFileProjection, persistDurableAgentFiles } from "../services/agent-file-durability.js"; @@ -89,6 +89,61 @@ describe("durable agent file authority", () => { root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "File owner", adapterConfig: {} }); } + it("renames the active entry and edited content in the same committed file snapshot", async () => { + await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor()); + await store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Edited"), baseHash: fileHash(Buffer.from("Original")) }, actor()); + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(agent.adapterConfig.instructionsEntryFile).toBe("ROOT.md"); + const [revision] = await db.select().from(agentConfigRevisions).where(eq(agentConfigRevisions.agentId, agentId)); + expect(revision).toMatchObject({ source: "instructions_rename", createdByUserId: userId, + afterConfig: { adapterConfig: { instructionsEntryFile: "ROOT.md" } } }); + await replaceFilesystem(); + expect(await store().read(companyId, agentId, "ROOT.md", actor())).toEqual(Buffer.from("Edited")); + expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toBeNull(); + }); + + it("creates a renamed missing root without replacing a concurrently created source", async () => { + await store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("New root"), baseHash: null }, actor()); + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(agent.adapterConfig.instructionsEntryFile).toBe("ROOT.md"); + expect(await store().read(companyId, agentId, "ROOT.md", actor())).toEqual(Buffer.from("New root")); + await expect(store().rename({ companyId, agentId, path: "ROOT.md", renameTo: "OTHER.md", bytes: Buffer.from("Stale draft"), baseHash: null }, actor())).rejects.toMatchObject({ status: 409 }); + expect(await store().read(companyId, agentId, "OTHER.md", actor())).toBeNull(); + }); + + it("rejects a rename that waits behind a concurrent source edit", async () => { + await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor()); + let enter!: () => void, release!: () => void; + const entered = new Promise(resolve => { enter = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const writer = store().locked(companyId, agentId, actor(), true, async (_tx, _agent, folder) => { + enter(); + await released; + await materializeInstructionBytes(folder, "AGENTS.md", Buffer.from("Concurrent edit")); + }); + await entered; + const rename = store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Original"), baseHash: fileHash(Buffer.from("Original")) }, actor()); + const rejected = expect(rename).rejects.toMatchObject({ status: 409 }); + release(); + await writer; + await rejected; + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(agent.adapterConfig.instructionsEntryFile).toBeUndefined(); + expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Concurrent edit")); + expect(await store().read(companyId, agentId, "ROOT.md", actor())).toBeNull(); + }); + + it("preserves the active root and source when a rename cannot persist its content", async () => { + await store().write({ companyId, agentId, path: "AGENTS.md", bytes: Buffer.from("Original"), baseHash: null }, actor()); + objects.rejectWrites = true; + await expect(store().rename({ companyId, agentId, path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Edited"), baseHash: fileHash(Buffer.from("Original")) }, actor())).rejects.toThrow("fixture object-store outage"); + objects.rejectWrites = false; + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(agent.adapterConfig.instructionsEntryFile).toBeUndefined(); + expect(await store().read(companyId, agentId, "AGENTS.md", actor())).toEqual(Buffer.from("Original")); + expect(await store().read(companyId, agentId, "ROOT.md", actor())).toBeNull(); + }); + it("reads instruction compatibility from the committed head after deleting the entire home", async () => { await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original")); await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 794e724fd82..3528c4bba20 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -31,9 +31,10 @@ const mockInstructionWorkingCopies = vi.hoisted(() => ({ list: vi.fn(), resolve: vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies })); const mockDownloadAgentFile = vi.hoisted(() => vi.fn()); +const mockRenameAgentFile = vi.hoisted(() => vi.fn()); vi.mock("../services/agent-file-store.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, agentFileStore: (...args: Parameters) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile }) }; + return { ...actual, agentFileStore: (...args: Parameters) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile, rename: mockRenameAgentFile }) }; }); const mockInstructionRevisions = vi.hoisted(() => ({ readCurrent: vi.fn(), commit: vi.fn(), restore: vi.fn(), history: vi.fn(), readRevision: vi.fn(), diff: vi.fn(), materializeCurrent: vi.fn() })); @@ -581,6 +582,27 @@ describe("agent instructions bundle routes", () => { ); }); + it("renames through the file store with the authenticated actor and source hash", async () => { + mockRenameAgentFile.mockResolvedValue(undefined); + const res = await requestApp(await createApp(), baseUrl => request(baseUrl) + .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file") + .send({ path: "AGENTS.md", renameTo: "ROOT.md", content: "Updated", baseHash: "a".repeat(64) })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockRenameAgentFile).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", + path: "AGENTS.md", renameTo: "ROOT.md", bytes: Buffer.from("Updated"), baseHash: "a".repeat(64) }, expect.objectContaining({ type: "board" })); + expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1" }), "ROOT.md"); + expect(mockInstructionRevisions.commit).not.toHaveBeenCalled(); + expect(mockAgentService.update).not.toHaveBeenCalled(); + }); + + it("requires a source hash before renaming", async () => { + const res = await requestApp(await createApp(), baseUrl => request(baseUrl) + .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file") + .send({ path: "AGENTS.md", renameTo: "ROOT.md", content: "Updated" })); + expect(res.status).toBe(422); + expect(mockRenameAgentFile).not.toHaveBeenCalled(); + }); + it("commits entry bytes through the canonical service with the authenticated actor", async () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") diff --git a/server/src/routes/agent-avatars.ts b/server/src/routes/agent-avatars.ts index 609771689f0..975c2cd3b8e 100644 --- a/server/src/routes/agent-avatars.ts +++ b/server/src/routes/agent-avatars.ts @@ -13,6 +13,7 @@ const requestSchema = z.object({ pose: z.enum(CHARACTER_STATES), size: z.string().regex(/^\d+$/).default("512").transform(Number).refine(n => (AGENT_AVATAR_SIZES as readonly number[]).includes(n)), scale: z.enum(["1", "2"]).default("1"), + view: z.enum(["front", "portrait"]).default("front"), background: z.enum(["transparent", "paperclip-dark"]).default("transparent"), }).strict(); @@ -30,14 +31,14 @@ export function agentAvatarRoutes(injected?: ReturnType { const file = String(req.params.file); const parsed = requestSchema.safeParse({ ...req.query, version: req.params.version, palette: req.params.palette, pose: file.endsWith(".png") ? file.slice(0, -4) : file }); - if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => !["size", "scale", "background"].includes(key))) { + if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => !["size", "scale", "background", "view"].includes(key))) { res.setHeader("Cache-Control", "no-store"); res.status(400).json({ error: "Unsupported avatar version, palette, pose, size, or scale" }); return; } - const { palette, pose, size, scale, background } = parsed.data; + const { palette, pose, size, scale, background, view } = parsed.data; try { service ??= createAgentAvatarService(createStorageProviderFromConfig(loadConfig())); - const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2, background }, req.ip || req.socket.remoteAddress || "unknown"); + const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2, background, view }, req.ip || req.socket.remoteAddress || "unknown"); res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); res.setHeader("ETag", etag); res.setHeader("X-Content-Type-Options", "nosniff"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ad61bd17428..ac4b5f90d5d 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -5312,6 +5312,17 @@ export function agentRoutes( const id = req.params.id as string; const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); if (!existing) return; + if (req.body.renameTo !== undefined) { + await assertCanManageInstructionsPath(req, existing); + assertExternalInstructionsAdmin(req, existing); + if (req.body.baseHash === undefined) throw unprocessable("Read the file and supply baseHash before renaming it"); + await agentFiles.rename({ companyId: existing.companyId, agentId: existing.id, + path: req.body.path, renameTo: req.body.renameTo, bytes: Buffer.from(req.body.content, "utf8"), baseHash: req.body.baseHash }, req.actor); + const updated = await svc.getById(existing.id); + if (!updated) throw notFound("Agent not found"); + res.json(await instructions.readFile(updated, req.body.renameTo)); + return; + } const entryFile = deriveBundleState(existing).entryFile; if (instructionPath(req.body.path) === entryFile) { assertExternalInstructionsAdmin(req, existing); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5aab5b9265f..e642f0f287d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3238,7 +3238,7 @@ registry.registerPath({ path: "/api/agent-avatars/{version}/{palette}/{file}", tags: ["agents"], summary: "Render or retrieve a public preset agent portrait", - description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. Background defaults to transparent; paperclip-dark supplies the opaque Paperclip dark-mode background for Slack exports. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.", + description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. View defaults to front; portrait tilts the character upward and slightly to the side for larger card artwork. Background defaults to transparent; paperclip-dark supplies the opaque Paperclip dark-mode background for Slack exports. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.", request: { params: z.object({ version: z.literal("cap-v1"), @@ -3249,6 +3249,7 @@ registry.registerPath({ size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"), scale: z.enum(["1", "2"]).optional().default("1"), background: z.enum(["transparent", "paperclip-dark"]).optional().default("transparent"), + view: z.enum(["front", "portrait"]).optional().default("front"), }).strict(), }, responses: { @@ -3956,7 +3957,7 @@ registry.registerPath({ method: "put", path: "/api/agents/{id}/instructions-bundle/file", tags: ["agents"], - summary: "Upsert agent instructions file", + summary: "Save or rename a managed agent instructions file with a source hash guard", request: { params: z.object({ id: z.string() }), body: jsonBody(upsertAgentInstructionsFileSchema), diff --git a/server/src/services/agent-avatar-worker.ts b/server/src/services/agent-avatar-worker.ts index 13609a51148..9ebceb79a74 100644 --- a/server/src/services/agent-avatar-worker.ts +++ b/server/src/services/agent-avatar-worker.ts @@ -6,7 +6,7 @@ import type { AgentAvatarRequest } from "./agent-avatars.js"; parentPort!.on("message", async (request: AgentAvatarRequest) => { try { - const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted); + const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted, request.view); const image = sharp(Buffer.from(svg)); if (request.background === "paperclip-dark") image.flatten({ background: PAPERCLIP_DARK_AVATAR_BACKGROUND }); const png = await image.png().toBuffer(); diff --git a/server/src/services/agent-avatars.ts b/server/src/services/agent-avatars.ts index d82bb544770..2b114f706ae 100644 --- a/server/src/services/agent-avatars.ts +++ b/server/src/services/agent-avatars.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import type { AgentAppearance, AgentAvatarSize, AgentAvatarBackground, CharacterState } from "@paperclipai/shared"; +import type { AgentAppearance, AgentAvatarSize, AgentAvatarBackground, AgentAvatarView, CharacterState } from "@paperclipai/shared"; import type { StorageProvider } from "../storage/types.js"; import { createInviteRateLimiter } from "./invite-rate-limit.js"; import { createAgentAvatarPool } from "./agent-avatar-pool.js"; @@ -11,10 +11,11 @@ export interface AgentAvatarRequest { pose: CharacterState; muted: boolean; background?: AgentAvatarBackground; + view?: AgentAvatarView; } export function avatarCacheKey(request: AgentAvatarRequest) { - const { appearance, size, scale, pose, muted, background } = request; - return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}${background === "paperclip-dark" ? "-paperclip-dark-v1" : ""}.png`; + const { appearance, size, scale, pose, muted, background, view } = request; + return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}${background === "paperclip-dark" ? "-paperclip-dark-v1" : ""}${view === "portrait" ? "-portrait-v1" : ""}.png`; } export class AvatarAdmissionError extends Error { constructor(public readonly retryAfterSeconds: number) { super("Too many cold avatar requests"); } diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts index 4581a423acb..0d5c3672040 100644 --- a/server/src/services/agent-file-store.ts +++ b/server/src/services/agent-file-store.ts @@ -1,3 +1,4 @@ +import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js"; import { currentPluginInteractionOrigin } from "./plugin-interaction-context.js"; import { extractAgentMentionIds } from "@paperclipai/shared"; import { assertAgentInteraction } from "./agent-interaction-policy.js"; @@ -192,7 +193,7 @@ export async function isAgentFileSaveRecorded(tx: Tx, agent: Agent, relative: st export function agentFileStore(db: Db) { db = fileStorageDb(db); async function locked(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean, - fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise, texts: string[] = []) { + fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor, onRollback: (restore: () => Promise) => void) => Promise, texts: string[] = []) { return db.transaction(async tx => { if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null, operation: "configure", targetAgentId: agentId, lockAgentIds: texts.flatMap(extractAgentMentionIds), lock: "update" }); @@ -201,9 +202,15 @@ export function agentFileStore(db: Db) { const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent); const root = await adoptAgentFiles(tx, agent); if (write) await invalidateAgentFileProjection(root); - const result = await fn(tx, agent, root, bound); - if (write) await persistDurableAgentFiles(tx, agent, root); - return result; + let restore: (() => Promise) | undefined; + try { + const result = await fn(tx, agent, root, bound, callback => { restore = callback; }); + if (write) await persistDurableAgentFiles(tx, agent, root); + return result; + } catch (error) { + await restore?.(); + throw error; + } }); } async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record) { @@ -231,6 +238,46 @@ export function agentFileStore(db: Db) { }, read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))), + rename: (input: { companyId: string; agentId: string; path: string; renameTo: string; bytes: Buffer; baseHash: string | null }, actor: AuthorizationActor) => + locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound, onRollback) => { + const source = agentFilePath(input.path); + const target = agentFilePath(input.renameTo); + if (source === target) throw unprocessable("Choose a different file name"); + instructionBytes(input.bytes); + const previous = await inspectAgentFile(root, source); + const isRoot = deriveBundleState(agent).entryFile === source; + if ((!previous && !isRoot) || (previous?.hash ?? null) !== input.baseHash) { + throw conflict("This file changed since it was read. Reload before renaming.", { code: "AGENT_FILE_CONFLICT", path: source, currentHash: previous?.hash ?? null }); + } + if (previous && !previous.bytes) throw unprocessable("This file is too large to rename in the editor"); + const snapshot = await scanAgentFiles(root); + if ([...snapshot.entries].some(name => name.toLowerCase() === target.toLowerCase())) throw conflict("A file with this name already exists"); + const newEntries = target.split("/").map((_part, i, parts) => parts.slice(0, i + 1).join("/")).filter(name => !snapshot.entries.has(name)).length; + assertDirectorySize(snapshot.size - (previous?.size ?? 0) + input.bytes.length, snapshot.entries.size - (previous ? 1 : 0) + newEntries); + await assertAgentInteraction(tx, { companyId: input.companyId, actorAgentId: bound.type === "agent" ? bound.agentId : null, + operation: "mention", texts: [input.bytes.toString("utf8")] }); + const sourcePath = await assertInstructionPathSafe(root, source); + const targetPath = await assertInstructionPathSafe(root, target); + onRollback(async () => { + if (previous?.bytes) await materializeInstructionBytes(root, source, previous.bytes); + await fs.rm(targetPath, { force: true }); + await invalidateAgentFileProjection(root); + }); + await materializeInstructionBytes(root, target, input.bytes); + if (previous) await fs.unlink(sourcePath); + if (isRoot) { + await updateAgentConfigurationInTransaction(tx as unknown as Db, agent.id, { + adapterConfig: { ...agent.adapterConfig, instructionsBundleMode: "managed", + instructionsRootPath: root, instructionsEntryFile: target, instructionsFilePath: path.join(root, target) }, + }, { allowPendingApprovalConfigUpdate: true, recordRevision: { + createdByAgentId: bound.type === "agent" ? bound.agentId : null, + createdByUserId: bound.type === "board" ? bound.userId : null, + source: "instructions_rename", + } }); + } + await audit(tx, agent, bound, { path: source, contentHash: null, renamedTo: target }); + await audit(tx, agent, bound, { path: target, contentHash: fileHash(input.bytes), created: true, renamedFrom: source }); + }, [input.bytes.toString("utf8")]), write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) => locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => { const relative = agentFilePath(input.path); diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index b438afbecc3..d5d5c0cd2c9 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -44,8 +44,8 @@ "packages/shared/src/adapter-auth-session.ts": "1bde62818a6e2a127ceb6e947882ded311798892", "packages/shared/src/adapter-type.ts": "5af29dfc580ef18d53404b679cbe32a53587098e", "packages/shared/src/adapter-types.test.ts": "49646cb8f85f86cb45d9dd0efda6b84ed0599341", - "packages/shared/src/agent-appearance.test.ts": "95cb8b22a18bd68932d618bb97ddde24d0f8ee23", - "packages/shared/src/agent-appearance.ts": "d45525d26e5899afe673aa4ff52ddc1b6cf67b11", + "packages/shared/src/agent-appearance.test.ts": "709173240aad4df58d5d6fb0f4d94bef36ac3911", + "packages/shared/src/agent-appearance.ts": "e37f130eec7bb08efd4ed6d6a64812f2478c4320", "packages/shared/src/agent-avatar-upload.ts": "7146d88adf363161494bddca0f1074c1aa4d7b52", "packages/shared/src/agent-commentary.test.ts": "d8eb09c29f30023705de111639054e35d98bc5bf", "packages/shared/src/agent-configuration-resources.ts": "a71afc1c1a4a617f2fbbf0fc5aed4f462e9aacf1", @@ -201,7 +201,7 @@ "packages/shared/src/cliplab/particles.ts": "8ee5c63d1b8fc52367171b5fb6b63b3b3c554df5", "packages/shared/src/cliplab/renderer.ts": "47a1154009e4071740204962615af43078b14862", "packages/shared/src/cliplab/runtime.ts": "a197f0f0eb28d7b06e1b956fa2feb1e396a35a8d", - "packages/shared/src/cliplab/static.ts": "14d3fb114014ff4b30c18936133e132ea8b90c78", + "packages/shared/src/cliplab/static.ts": "179dae48eb00e0818c2be0a3203910b54c62e640", "packages/shared/src/cliplab/svg-canvas.ts": "2b1bb631749524487d05a61df435da7effb5d607", "packages/shared/src/cliplab/svg-path.ts": "69ab5fc506a0ad9965837ddb41ecc245b2df1916", "packages/shared/src/cliplab/svg-snapshot.ts": "f1ef8e5d8e4d69db0d1cdcf80ba9b54a7ad3d0c7", @@ -439,7 +439,7 @@ "packages/shared/src/validators/adapter-skills.ts": "ae43f7b180f632d5f54dfa821b8dc5ef6c6aa53d", "packages/shared/src/validators/agent-commentary.ts": "8242c5b28a77131aefdc00c873ad84c6c6736e4b", "packages/shared/src/validators/agent.test.ts": "d38767b0c1fc1439871855ed55dca3c8c7b645c6", - "packages/shared/src/validators/agent.ts": "a2a58891196726db069934fbec96407877ea9d4b", + "packages/shared/src/validators/agent.ts": "13ba04317b11c4281b7a2133a34630b4ad446dfd", "packages/shared/src/validators/app-definition.ts": "9fb660249eaf49a71adeab222cdb29a6db41ce28", "packages/shared/src/validators/approval.test.ts": "051d29b27400fdd1e311466587b276264cf072e4", "packages/shared/src/validators/approval.ts": "c1c074dc2f06d46adae0df8fa236d43c7aa01a25", diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b0fce9d8a8c..b9a0b343f96 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -193,6 +193,8 @@ export const agentsApi = { data: { path: string; content: string; baseRevisionId?: string | null; baseHash?: string | null; clearLegacyPromptTemplate?: boolean }, companyId?: string, ) => api.put(agentPath(id, companyId, "/instructions-bundle/file"), data), + renameInstructionsFile: (id: string, data: { path: string; renameTo: string; content: string; baseHash: string | null }, companyId?: string) => + api.put(agentPath(id, companyId, "/instructions-bundle/file"), data), instructionCandidates: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/instructions-bundle/candidates")), resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) => diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 5211b82e903..3fe9514a580 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -163,6 +163,7 @@ export function AgentActionButtons({ size = "sm", assignLabel = "Assign Task", showAssignIcon = true, + assignIcon: AssignIcon = Plus, showChat = false, runLabel = "Run now", showStatus = true, @@ -187,6 +188,7 @@ export function AgentActionButtons({ size?: "sm" | "default"; assignLabel?: string; showAssignIcon?: boolean; + assignIcon?: typeof Plus; showChat?: boolean; runLabel?: string; showStatus?: boolean; @@ -524,9 +526,10 @@ export function AgentActionButtons({ )} - {showChat && } {needsSetup ? ( )} diff --git a/ui/src/components/AgentAvatar.test.tsx b/ui/src/components/AgentAvatar.test.tsx index c4d5f9ac731..596e158fa1b 100644 --- a/ui/src/components/AgentAvatar.test.tsx +++ b/ui/src/components/AgentAvatar.test.tsx @@ -13,7 +13,7 @@ it("shows the uploaded image in small and animated-character surfaces, then retu const container = document.createElement("div"); const root = createRoot(container); try { - await act(async () => root.render(<>)); + await act(async () => root.render(<>)); for (const image of container.querySelectorAll("img")) { expect(image.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); expect(image.hasAttribute("srcset")).toBe(false); diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx index e4ef1278355..6015ae5c918 100644 --- a/ui/src/components/AgentAvatar.tsx +++ b/ui/src/components/AgentAvatar.tsx @@ -1,6 +1,6 @@ import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { useState } from "react"; -import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared"; +import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type AgentAvatarView, type CharacterState } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; import { deriveInitials } from "./Identity"; import { useAvatarInitials } from "./ui/avatar-initials"; @@ -17,19 +17,20 @@ export interface AgentAvatarProps { name?: string; label?: string; pose?: CharacterState; + view?: AgentAvatarView; muted?: boolean; className?: string; } -export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = "rest", muted = false, className }: AgentAvatarProps) { +export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = "rest", view = "front", muted = false, className }: AgentAvatarProps) { const identity = resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id); - const src = agentAvatarUrl(identity, size, 1, pose, muted); + const src = agentAvatarUrl(identity, size, 1, pose, muted, "transparent", view); const [failedUrl, setFailedUrl] = useState(null); const initials = useAvatarInitials(deriveInitials(name ?? agent?.name ?? "Agent")); return ( {failedUrl === src ? {initials} : - setFailedUrl(src)} />} ); diff --git a/ui/src/components/AgentCharacter.test.tsx b/ui/src/components/AgentCharacter.test.tsx new file mode 100644 index 00000000000..8418931daed --- /dev/null +++ b/ui/src/components/AgentCharacter.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { AgentCharacter } from "./AgentCharacter"; + +const createMock = vi.hoisted(() => vi.fn()); +vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter: createMock })); +vi.mock("@paperclipai/shared/cliplab/definition", () => ({ characterDefinition: vi.fn(), animationId: (state: string) => state })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +let container: HTMLDivElement; +let root: ReturnType; +let media: MediaQueryList; +let observers: Array<(visible: boolean) => void>; +beforeEach(() => { + observers = []; + media = Object.assign(new EventTarget(), { matches: false }) as unknown as MediaQueryList; + vi.stubGlobal("matchMedia", () => media); + vi.stubGlobal("IntersectionObserver", class { + constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { observers.push(visible => callback([{ isIntersecting: visible }])); } + observe() {} disconnect() {} + }); + createMock.mockReset().mockImplementation((host: HTMLElement) => { + const canvas = document.createElement("canvas"); host.appendChild(canvas); + return { destroy: vi.fn(() => canvas.remove()), setDefinition: vi.fn(), setAnimation: vi.fn() }; + }); + container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); +async function settle() { await act(async () => { await vi.dynamicImportSettled(); }); } +async function reveal() { await act(async () => observers.forEach(notify => notify(true))); await settle(); } + +it("animates visible org leaders together, bounds GPU contexts, and hands off offscreen slots", async () => { + await act(async () => root.render(<>{Array.from({ length: 12 }, (_, index) => )})); + await reveal(); + expect(container.querySelectorAll("canvas")).toHaveLength(8); + expect(createMock).toHaveBeenCalledTimes(8); + for (const call of createMock.mock.calls) expect(call[2]).toMatchObject({ trackingScope: "page", followCursor: true }); + await act(async () => observers[0](false)); await settle(); + expect(container.querySelectorAll("canvas")).toHaveLength(8); + expect(createMock).toHaveBeenCalledTimes(9); + // Reduced motion disposes every live renderer and reveals the static images. + await act(async () => { Object.assign(media, { matches: true }); media.dispatchEvent(new Event("change")); }); + expect(container.querySelectorAll("canvas")).toHaveLength(0); + expect(container.querySelectorAll(".invisible")).toHaveLength(0); +}); + +it("retains one hero outside the org chart and ignores custom avatars", async () => { + await act(async () => root.render(<>)); + await reveal(); + expect(container.querySelectorAll("canvas")).toHaveLength(1); + expect(createMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('img[src="/api/assets/22222222-2222-4222-8222-222222222222/content"]')).not.toBeNull(); +}); diff --git a/ui/src/components/AgentCharacter.tsx b/ui/src/components/AgentCharacter.tsx index 7b267333220..f0349a9f3bc 100644 --- a/ui/src/components/AgentCharacter.tsx +++ b/ui/src/components/AgentCharacter.tsx @@ -1,11 +1,18 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react"; import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; -import { characterSlot } from "@/lib/agent-character-slot"; +import { characterSlot, orgChartCharacterSlots } from "@/lib/agent-character-slot"; import { AgentAvatar, avatarSizeClasses, type AgentAvatarProps } from "./AgentAvatar"; import type { createCharacter } from "@paperclipai/shared/cliplab/runtime"; type Player = ReturnType; +let characterModules: Promise<[typeof import("@paperclipai/shared/cliplab/runtime"), typeof import("@paperclipai/shared/cliplab/definition")]> | undefined; +function loadCharacterModules() { + return characterModules ??= Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/definition")]).catch(error => { + characterModules = undefined; + throw error; + }); +} export interface AgentCharacterProps extends Omit { state?: CharacterState; motion?: "auto" | "still"; @@ -13,12 +20,16 @@ export interface AgentCharacterProps extends Omit { trackingScope?: "region" | "page"; followCursor?: boolean; followRotation?: boolean; + /** Org leadership cards may animate together, within a bounded canvas budget. */ + animationGroup?: "hero" | "org-chart"; } -export function AgentCharacter({ agent, appearance, size = 256, state = "idle", muted = false, motion = "auto", trackingRegion, trackingScope = "region", followCursor = true, followRotation = true, className, label, name }: AgentCharacterProps) { +export function AgentCharacter({ agent, appearance, size = 256, state = "idle", muted = false, motion = "auto", trackingRegion, trackingScope = "region", followCursor = true, followRotation = true, animationGroup = "hero", className, label, name }: AgentCharacterProps) { const identity = useMemo(() => resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id), [appearance, agent?.appearance, agent?.id]); const root = useRef(null), host = useRef(null), player = useRef(null); const slotId = useRef(Symbol("agent-character")); - const owner = useSyncExternalStore(characterSlot.subscribe, characterSlot.getSnapshot, () => null); + const slots = animationGroup === "org-chart" ? orgChartCharacterSlots : characterSlot; + const owners = useSyncExternalStore(slots.subscribe, slots.getSnapshot, slots.getSnapshot); + const ownsSlot = owners.has(slotId.current); const [visible, setVisible] = useState(false), [reduced, setReduced] = useState(true), [failed, setFailed] = useState(false), [ready, setReady] = useState(false); const active = !identity.customAvatarAssetId && visible && !reduced && !failed && motion === "auto" && state !== "rest"; useEffect(() => { @@ -30,15 +41,15 @@ export function AgentCharacter({ agent, appearance, size = 256, state = "idle", return () => { observer.disconnect(); media.removeEventListener("change", change); }; }, []); useEffect(() => { - if (active && owner === null) characterSlot.acquire(slotId.current); - if (!active) characterSlot.release(slotId.current); - }, [active, owner]); - useEffect(() => () => characterSlot.release(slotId.current), []); + if (active && !ownsSlot) slots.acquire(slotId.current); + if (!active) slots.release(slotId.current); + }, [active, owners, ownsSlot, slots]); + useEffect(() => () => slots.release(slotId.current), [slots]); useEffect(() => { - if (!active || owner !== slotId.current) return; + if (!active || !ownsSlot) return; let disposed = false; setReady(false); - void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/definition")]).then(([runtime, library]) => { + void loadCharacterModules().then(([runtime, library]) => { if (disposed || !host.current) return; player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), { animation: library.animationId(state), trackingRegion: trackingRegion?.current ?? root.current ?? undefined, @@ -47,10 +58,10 @@ export function AgentCharacter({ agent, appearance, size = 256, state = "idle", setReady(true); }).catch(() => { if (!disposed) setFailed(true); }); return () => { disposed = true; player.current?.destroy(); player.current = null; setReady(false); }; - }, [active, owner, trackingRegion, trackingScope, size, followCursor, followRotation]); + }, [active, ownsSlot, trackingRegion, trackingScope, size, followCursor, followRotation]); useEffect(() => { if (!player.current) return; - void import("@paperclipai/shared/cliplab/definition").then(library => { + void loadCharacterModules().then(([, library]) => { player.current?.setDefinition(library.characterDefinition(identity, muted)); player.current?.setAnimation(library.animationId(state)); }); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 112339a907e..c6954b204b9 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -17,6 +17,7 @@ import { defaultCreateValues } from "./agent-config-defaults"; import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload"; import { ApiError } from "../api/client"; import { aiConnectionsApi } from "../api/ai-connections"; +import * as ConnectionField from "./ai-connections/AiConnectionField"; import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields"; import type { AdapterConfigFieldsProps } from "../adapters/types"; import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; @@ -1220,6 +1221,54 @@ describe("AgentConfigForm environment selector", () => { expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); }); + it("clears dirty state when a runtime toggle is restored", async () => { + const dirty = vi.fn(); + const result = await renderForm([], {}, { content: "configuration", onDirtyChange: dirty }); + roots.push(result.root); + const toggle = result.container.querySelector('[role="switch"]')!; + expect(toggle).toBeTruthy(); + await act(async () => toggle.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(true); + await act(async () => toggle.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(false); + }); + + it.each([{}, { enabled: false, maxAttempts: 4, delayMs: 3000 }])("clears dirty state when continuation settings are restored (%j)", async saved => { + const dirty = vi.fn(); + const result = await renderForm([], { runtimeConfig: { heartbeat: { maxTurnContinuation: saved } } }, { onDirtyChange: dirty }); + roots.push(result.root); + await clickByText(result.container, "Advanced Run Policy"); + const toggle = result.container.querySelector('[aria-label="Continue after max-turn stop"]')!; + expect(toggle).toBeTruthy(); + await act(async () => toggle.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(true); + await act(async () => toggle.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(false); + }); + + it("clears dirty state when the saved account binding is restored", async () => { + const binding = { mode: "responsible_user" as const, provider: "openai" as const, method: "api_key" as const }; + const field = vi.spyOn(ConnectionField, "AiConnectionField").mockImplementation(({ onChange }) => <> + + + ); + try { + const dirty = vi.fn(); + const result = await renderForm([], { runtimeConfig: { aiConnection: binding } }, { onDirtyChange: dirty }); + roots.push(result.root); + await act(async () => findButton(result.container, "Other account")!.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(true); + await act(async () => findButton(result.container, "Original account")!.click()); + await flushReact(); + expect(dirty).toHaveBeenLastCalledWith(false); + } finally { field.mockRestore(); } + }); + it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => { const dirty = vi.fn(); let save: (() => void) | null = null; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index e9b2a373462..776423e864d 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -542,8 +542,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime"; + const displayedOriginals = new Map(); + /** Read effective value: overlay if dirty, else original */ function eff(group: RecordOverlayGroup, field: string, original: T): T { + displayedOriginals.set(`${group}.${field}`, original); const o = overlay[group]; if (field in o) return o[field] as T; return original; @@ -551,10 +554,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /** Mark field dirty in overlay */ function mark(group: RecordOverlayGroup, field: string, value: unknown) { - setOverlay((prev) => ({ - ...prev, - [group]: { ...prev[group], [field]: value }, - })); + const key = `${group}.${field}`; + setOverlay((prev) => { + const next = { ...prev[group] }; + if (displayedOriginals.has(key) && overlayValuesEqual(value, displayedOriginals.get(key))) { + delete next[field]; + } else { + next[field] = value; + } + return { ...prev, [group]: next }; + }); } function flushEnvironmentDraft() { @@ -629,6 +638,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record) : {}; const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record) : {}; + displayedOriginals.set("runtime.runtimeConfig", { ...runtimeConfig, aiConnection: runtimeConfig.aiConnection }); const adapterType = isCreate ? props.values.adapterType @@ -1510,7 +1520,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); - const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation); + const savedContinuation = asObject(heartbeat.maxTurnContinuation); + const continuationDefaults = { + ...savedContinuation, + enabled: asBoolean(savedContinuation.enabled, true), + maxAttempts: clampInteger(asFiniteNumber(savedContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), + delayMs: clampInteger(asFiniteNumber(savedContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000, + }; + const maxTurnContinuation = isCreate ? asObject(effectiveHeartbeat.maxTurnContinuation) + : eff("heartbeat", "maxTurnContinuation", continuationDefaults); const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true); const maxTurnContinuationMaxAttempts = clampInteger( asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), @@ -1610,7 +1628,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{showRunnerUpgrade && !runnerUpgraded ? (
-
+

Upgrade to Paperclip Runner

diff --git a/ui/src/components/AgentsToolbar.tsx b/ui/src/components/AgentsToolbar.tsx index a94ae0c6b9e..40255be05e3 100644 --- a/ui/src/components/AgentsToolbar.tsx +++ b/ui/src/components/AgentsToolbar.tsx @@ -1,6 +1,8 @@ +import type { ReactNode, Ref } from "react"; import { List, Network, Plus } from "lucide-react"; import { ToolbarSearch } from "./ToolbarSearch"; import { Button } from "./ui/button"; +import { OrgChartControls, type OrgChartActions } from "./OrgChartControls"; export interface AgentsToolbarProps { query: string; @@ -9,10 +11,13 @@ export interface AgentsToolbarProps { onViewChange: (value: "list" | "org") => void; hideViewToggle?: boolean; onNewAgent: () => void; + filters?: ReactNode; + orgControlsRef?: Ref; + orgActions?: OrgChartActions; } /** Shared roster search, view controls and actions for the page and stories. */ -export function AgentsToolbar({ query, onQueryChange, view, onViewChange, hideViewToggle, onNewAgent }: AgentsToolbarProps) { +export function AgentsToolbar({ query, onQueryChange, view, onViewChange, hideViewToggle, onNewAgent, filters, orgControlsRef, orgActions }: AgentsToolbarProps) { return

@@ -20,7 +25,14 @@ export function AgentsToolbar({ query, onQueryChange, view, onViewChange, hideVi
} + {filters}
+ {orgActions && ( +
+
+
+ )}
diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx index 2cbc7de1be1..d6bcf310691 100644 --- a/ui/src/components/AppsSidebar.tsx +++ b/ui/src/components/AppsSidebar.tsx @@ -1,4 +1,4 @@ -import { useLocation } from "@/lib/router"; +import { NavLink, useLocation } from "@/lib/router"; import { ChatDetailSidebar } from "./chat/ChatDetailSidebar"; import { ChatSetupSidebar } from "./chat/ChatSetupNavigation"; import { Store, ShieldQuestion } from "lucide-react"; @@ -79,3 +79,14 @@ export function AppsSidebar() { ); } + +/** Mobile collection navigation replaces the desktop Browse/Review rail. */ +export function AppsMobileNav() { + const reviewCount = useReviewCount(); + const segmentClass = ({ isActive }: { isActive: boolean }) => + `flex min-w-0 flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm no-underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isActive ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"}`; + return ; +} diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index be83fa8d49e..bb0d1748755 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -108,6 +108,7 @@ export function InboxArchiveButton({ + + +
+ {children} +
+ ); +} diff --git a/ui/src/components/TrustPresetSection.tsx b/ui/src/components/TrustPresetSection.tsx index 85de0657966..7e9f2bcf033 100644 --- a/ui/src/components/TrustPresetSection.tsx +++ b/ui/src/components/TrustPresetSection.tsx @@ -242,7 +242,7 @@ export function TrustPresetSection({ if (layout === "cards") { return (
-
+
{(["low_trust_review", "standard"] as const).map((value) => { const selected = preset === value; diff --git a/ui/src/components/agent-detail/AgentPanels.tsx b/ui/src/components/agent-detail/AgentPanels.tsx index 320b30091af..189a8243469 100644 --- a/ui/src/components/agent-detail/AgentPanels.tsx +++ b/ui/src/components/agent-detail/AgentPanels.tsx @@ -89,13 +89,14 @@ export function AgentMetricTile({ ); } -export function AgentChip({ tone = "neutral", mono, children }: { tone?: "neutral" | "danger"; mono?: boolean; children: ReactNode }) { +export function AgentChip({ tone = "neutral", mono, children, className }: { tone?: "neutral" | "danger"; mono?: boolean; children: ReactNode; className?: string }) { return ( {children} diff --git a/ui/src/components/agents/AgentBulkRoster.test.tsx b/ui/src/components/agents/AgentBulkRoster.test.tsx index c60893589fd..25f20864c4e 100644 --- a/ui/src/components/agents/AgentBulkRoster.test.tsx +++ b/ui/src/components/agents/AgentBulkRoster.test.tsx @@ -131,7 +131,7 @@ describe("pending agent roster display", () => { const row = render({ setupAdapterPending: true, setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } }); expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([ - "Google Antigravity", "Not configured", "Not available yet", + "Google Antigravity", "Not available yet", "Not configured", ]); const checkbox = row.querySelector('[role="checkbox"]'); expect(checkbox).not.toBeNull(); @@ -142,7 +142,7 @@ describe("pending agent roster display", () => { it("preserves ordinary process display without a saved setup choice", () => { const row = render({ setupAdapterPending: true }); expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([ - "Process", "Managed externally", "Harness default", + "Process", "Harness default", "Managed externally", ]); const checkbox = row.querySelector('[role="checkbox"]'); expect(checkbox).not.toBeNull(); diff --git a/ui/src/components/agents/AgentBulkRoster.tsx b/ui/src/components/agents/AgentBulkRoster.tsx index 208bc733d7d..f7cde6866d1 100644 --- a/ui/src/components/agents/AgentBulkRoster.tsx +++ b/ui/src/components/agents/AgentBulkRoster.tsx @@ -88,7 +88,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
- {renderChat && } + {renderChat && }{visible.map(agent => { const metadata = rosterIndex.byAgentId.get(agent.id)!; @@ -98,8 +98,8 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent, {renderChat && } + - ; })}
AgentChatStatusHarnessConnectionDefault model{visible.length} {visible.length === 1 ? "agent" : "agents"}ChatStatusHarnessDefault modelConnection
{renderChat(agent)}{renderStatus ? renderStatus(agent) : } {metadata.harness.label}{metadata.model.label} {metadata.connection.label}{metadata.model.label}
diff --git a/ui/src/components/agents/AgentRosterFilters.tsx b/ui/src/components/agents/AgentRosterFilters.tsx index e8568c40132..1bcc4ab4756 100644 --- a/ui/src/components/agents/AgentRosterFilters.tsx +++ b/ui/src/components/agents/AgentRosterFilters.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Filter } from "lucide-react"; import type { Agent } from "@paperclipai/shared"; @@ -20,29 +20,31 @@ export function useAgentRosterFilters(companyId: string | null, agents: Agent[] const accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId!), enabled: !!companyId }); const index = useMemo(() => buildAgentRosterIndex(agents, accounts.data), [agents, accounts.data]); const activeCount = agentRosterFilterFields.filter(field => filters[field]).length; + const matches = useCallback((agent: Agent) => matchesAgentRosterFilters(index.byAgentId.get(agent.id), filters), [index, filters]); return { filters, options: index.options, activeCount, onChange: (next: FilterState) => setSelection({ companyId, filters: next }), - matches: (agent: Agent) => matchesAgentRosterFilters(index.byAgentId.get(agent.id), filters), + matches, connectionError: accounts.error, retryConnections: () => void accounts.refetch(), }; } -export function AgentRosterFilters({ filters, options, onChange, activeCount, count, connectionError, retryConnections }: { +export function AgentRosterFilters({ filters, options, onChange, activeCount, count, connectionError, retryConnections, compact = false }: { filters: FilterState; options: Record; onChange: (filters: FilterState) => void; activeCount: number; count: number; + compact?: boolean; connectionError?: unknown; retryConnections: () => void; }) { - return
+ return
- @@ -63,7 +65,7 @@ export function AgentRosterFilters({ filters, options, onChange, activeCount, co )} - {count} {count === 1 ? "agent" : "agents"} + {!compact && {count} {count === 1 ? "agent" : "agents"}} {!!connectionError &&

Connection names couldn’t be loaded.

}
; } diff --git a/ui/src/components/artifacts/v1/ArtifactEmptyGraphic.tsx b/ui/src/components/artifacts/v1/ArtifactEmptyGraphic.tsx index b9ff9827e68..af86aa90307 100644 --- a/ui/src/components/artifacts/v1/ArtifactEmptyGraphic.tsx +++ b/ui/src/components/artifacts/v1/ArtifactEmptyGraphic.tsx @@ -1,4 +1,4 @@ -import { ArtifactFront } from "./ArtifactFront"; +import { IconSheet } from "./ArtifactSheet"; import { ArtifactStack } from "./ArtifactStack"; import type { ArtifactKind, ArtifactSubject } from "./kinds"; import brandPreview from "./assets/empty-brand.svg"; @@ -8,5 +8,5 @@ export function ArtifactEmptyGraphic({ kind = "file", stack = false }: { kind?: const subject: ArtifactSubject = stack ? { kind: "image", title: "", thumbnailUrl: brandPreview, aspect: 1.5 } : { kind, title: "", ...(kind === "document" ? { ext: "md" } : {}) }; - return stack ? : ; + return stack ? : ; } diff --git a/ui/src/components/artifacts/v1/ArtifactSheet.tsx b/ui/src/components/artifacts/v1/ArtifactSheet.tsx index 062a5762dbf..3ae7b9b80af 100644 --- a/ui/src/components/artifacts/v1/ArtifactSheet.tsx +++ b/ui/src/components/artifacts/v1/ArtifactSheet.tsx @@ -81,14 +81,14 @@ const LABEL: CSSProperties = { color: "var(--artifact-ink)", }; -export function IconSheet({ subject, play = false }: { subject: ArtifactSubject; play?: boolean }) { +export function IconSheet({ subject, play = false, showLabel = true }: { subject: ArtifactSubject; play?: boolean; showLabel?: boolean }) { return (
- + {showLabel && {labelOf(subject)} - + } {play ? ( :last-child { + grid-column: 1 / -1; + justify-self: end; + flex-wrap: wrap; + } + .agent-identity-preview-header { align-items: flex-start; flex-wrap: wrap; } + .agent-identity-preview-header > :first-child { flex: 1; } + .agent-identity-preview-header > :last-child { flex-shrink: 0; } + .agent-identity-file-heading { flex-direction: column; align-items: flex-start; } + .agent-identity-file-heading > * { max-width: 100%; } + .agent-identity-file-heading > span { white-space: nowrap; } + .agent-runner-upgrade { flex-direction: column; align-items: stretch; } + .agent-runner-upgrade > button { align-self: flex-end; } + .agent-config-panels { display: flex; flex-direction: column; } + .agent-trust-cards { grid-template-columns: minmax(0, 1fr); } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) > :first-child { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--collection-toolbar-gap); + } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) [data-slot="collection-toolbar-context"] { grid-column: 1; grid-row: 1; } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) [data-slot="collection-toolbar-search"] { grid-column: 2; grid-row: 1; } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) [data-slot="collection-toolbar-search"] :is(input, div) { min-width: 0; max-width: 100%; } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) > :first-child > :last-child { grid-column: 1 / -1; grid-row: 2; margin-inline-start: 0; flex-wrap: wrap; } + :is(.paperclip-task-list-toolbar, .mobile-task-toolbar) [data-slot="collection-toolbar-context"] button { + width: auto; + padding-inline: var(--collection-toolbar-padding); + } +} + +/* Compact hierarchy: leadership leads, report groups wrap to the viewport. */ +:root { + --org-leader-width: 280px; + --org-leader-height: 368px; + --org-manager-width: 232px; + --org-manager-height: 320px; + --org-member-width: 192px; + --org-member-height: 116px; + --org-member-character-width: 64px; + --org-member-detail-size: 11px; + --org-member-detail-leading: 14px; + --org-member-inset: calc(var(--spacing) * 3); + --org-gap: 32px; + --org-level-gap: 72px; + --org-padding: 48px; + --org-sparse-padding: 80px; + --org-fit-zoom-max: 1.25; + --org-sparse-fit-zoom-max: 1; + --org-leader-character-scale: 100%; + --org-leader-name-size: 24px; + --org-leader-name-leading: 29px; + --org-manager-name-size: 19px; + --org-manager-name-leading: 26px; + --org-leader-title-size: 13px; + --org-leader-title-leading: 19px; + --org-leader-inset: calc(var(--spacing) * 6); + --org-manager-inset: calc(var(--spacing) * 5); + --org-badge-slot-width: 72px; + --org-badge-slot-height: 16px; + --org-badge-rule: 1px solid color-mix(in oklab, var(--foreground) 5%, transparent); + --org-task-height: 32px; + --org-report-line: color-mix(in oklab, var(--foreground) 22%, transparent); + --org-task-line: color-mix(in oklab, var(--foreground) 35%, transparent); +} +.org-chart-agent-card { padding: calc(var(--spacing) * 4); } +.org-card-name { font-size: var(--text-xl); line-height: var(--text-xl--line-height); } +.org-card-title { font-size: var(--text-sm); line-height: var(--text-sm--line-height); } +.org-chart-agent-card[data-leader="true"] { --org-card-inset: var(--org-manager-inset); display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; padding: 0; border-radius: var(--radius-xl); background: var(--card); } +.org-chart-agent-card[data-tier="1"] { --org-card-inset: var(--org-leader-inset); } +.org-card-badge-slot { width: var(--org-badge-slot-width); height: var(--org-badge-slot-height); justify-self: center; margin-block: calc(var(--org-card-inset) / 1.5); border: var(--org-badge-rule); border-radius: var(--org-badge-slot-height); background: var(--background); } +.org-chart-agent-card[data-leader="true"] .org-card-heading { position: relative; padding-inline: var(--org-card-inset); } +.org-card-star { position: absolute; top: 0; right: var(--org-card-inset); } +.org-card-star:not([aria-pressed="true"]) { color: var(--muted-foreground); } +.org-chart-agent-card[data-leader="true"] .org-card-name { padding-inline-end: calc(var(--spacing) * 9); font-weight: var(--font-weight-medium); } +.org-card-status { font-size: var(--org-leader-title-size); line-height: var(--org-leader-title-leading); } +.org-chart-agent-card[data-tier="1"] .org-card-name { font-size: var(--org-leader-name-size); line-height: var(--org-leader-name-leading); } +.org-chart-agent-card[data-tier="2"] .org-card-name { font-size: var(--org-manager-name-size); line-height: var(--org-manager-name-leading); } +.org-chart-agent-card[data-leader="true"] .org-card-title { font-size: var(--org-leader-title-size); line-height: var(--org-leader-title-leading); } +.org-chart-agent-card[data-leader="true"] .org-card-model { font-size: var(--text-sm); line-height: var(--text-sm--line-height); } +.org-chart-agent-card[data-leader="true"] .org-card-character { padding: calc(var(--spacing) * 2) var(--org-card-inset); } +.org-card-character { min-width: 0; min-height: 0; } +.org-card-avatar[data-slot="agent-avatar"] { width: 100%; height: 100%; min-height: 0; } +.org-card-live-character { width: var(--org-leader-character-scale); height: var(--org-leader-character-scale); min-width: 0; min-height: 0; } +.org-card-live-character > [data-slot="agent-avatar"] { width: 100%; height: 100%; } +.org-card-model { font-size: var(--text-xs); line-height: var(--text-xs--line-height); } +.org-card-model-mark { display: inline-flex; flex-shrink: 0; margin-inline-end: var(--spacing); vertical-align: text-bottom; } +/* Leadership badges keep the model and secondary effort in a separated footer. */ +.org-chart-agent-card[data-leader="true"] .org-card-model { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--spacing); padding: calc(var(--org-card-inset) / 1.5) var(--org-card-inset); border-top: var(--org-badge-rule); text-align: center; } +.org-chart-agent-card[data-leader="true"] .org-card-model-label { display: inline-flex; align-items: center; min-width: 0; max-width: 100%; } +.org-chart-agent-card[data-leader="true"] .org-card-model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.org-chart-agent-card[data-leader="true"] .org-card-effort { flex-shrink: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Names span the card; the remaining details share space with a bottom-right portrait. */ +.org-chart-agent-card[data-leader="false"] { display: grid; grid-template-columns: minmax(0, 1fr) var(--org-member-character-width); grid-template-rows: auto minmax(0, 1fr) auto; column-gap: calc(var(--spacing) * 2); row-gap: calc(var(--spacing) / 2); padding: var(--org-member-inset); } +.org-chart-agent-card[data-leader="false"] .org-card-character { grid-column: 2; grid-row: 2 / 4; align-self: end; height: var(--org-member-character-width); max-height: 100%; translate: calc(var(--org-member-inset) / 2) calc(var(--org-member-inset) / 2); } +.org-chart-agent-card[data-leader="false"] .org-card-heading { display: contents; } +.org-chart-agent-card[data-leader="false"] .org-card-name { grid-column: 1 / -1; grid-row: 1; min-width: 0; font-size: var(--text-sm); line-height: var(--text-sm--line-height); } +.org-chart-agent-card[data-leader="false"] .org-card-title { grid-column: 1; grid-row: 2; align-self: start; min-width: 0; overflow-wrap: anywhere; } +.org-chart-agent-card[data-leader="false"] :is(.org-card-title, .org-card-model) { font-size: var(--org-member-detail-size); line-height: var(--org-member-detail-leading); } +.org-chart-agent-card[data-leader="false"] .org-card-model { grid-column: 1; grid-row: 3; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; text-align: left; } +.org-chart-agent-card[data-leader="false"] .org-card-model-mark, .org-chart-agent-card[data-leader="false"] .org-card-model-mark > :is(img, svg) { width: var(--text-xs); height: var(--text-xs); } +.org-reporting-edge { stroke: var(--org-report-line); stroke-width: 1.5; vector-effect: non-scaling-stroke; } +.org-task-edge { stroke: var(--org-task-line); stroke-width: 1; stroke-dasharray: 4 5; vector-effect: non-scaling-stroke; } +.org-task-label { background: var(--background); } +.org-task-legend { max-width: 32ch; } + +/* The organization is an edge-to-edge canvas beneath its floating toolbar. */ +:root { + --org-toolbar-clearance: 88px; + --org-task-band-height: 64px; + --org-task-label-width: 208px; + --org-task-legend-height: 20px; + --org-toolbar-layer: 30; + --org-mask-open: white; + --org-mask-card: black; +} +#main-content:has(.agents-surface) { scrollbar-gutter: stable; } +#main-content:has(.agents-org-surface) { padding: 0; overflow: hidden; } +/* Reserve camera-control space in both views so only opacity changes. */ +.agents-org-controls-reveal { opacity: 0; visibility: hidden; pointer-events: none; transition: opacity var(--motion-duration-exit) var(--motion-ease-in), visibility 0s var(--motion-duration-exit); } +.agents-org-controls-reveal[data-visible="true"] { opacity: 1; visibility: visible; pointer-events: auto; transition: opacity var(--motion-duration-enter) var(--motion-ease-out), visibility 0s; } +.org-chart-controls { flex-wrap: nowrap; } +.agents-org-controls-reveal .org-chart-controls { width: max-content; } +@starting-style { .agents-org-controls-reveal[data-visible="true"] { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .agents-org-controls-reveal, .agents-org-controls-reveal[data-visible="true"] { transition: none; } } +.agents-org-surface { position: relative; isolation: isolate; } +.agents-org-toolbar { position: absolute; inset: 0 0 auto; z-index: var(--org-toolbar-layer); padding: calc(var(--spacing) * 6); background: linear-gradient(var(--background), color-mix(in oklab, var(--background) 90%, transparent), transparent); } +.agents-org-content { position: absolute; inset: 0; } +.org-chart-shell { height: 100%; } +.org-chart-canvas { background: var(--background); } +.org-chart-cards { z-index: var(--z-1); } +.org-task-overlay { z-index: var(--z-2); } +.org-task-band { z-index: var(--z-20); pointer-events: none; } +.org-task-legend { height: var(--org-task-legend-height); max-width: none; padding-inline: var(--org-padding); text-align: center; pointer-events: auto; } +.org-task-strip { height: var(--org-task-height); overflow-x: auto; overflow-y: hidden; scrollbar-width: none; pointer-events: auto; } +.org-task-label { border: var(--task-row-rule-width) dashed var(--border); background: color-mix(in oklab, var(--background) 92%, transparent); } +.org-task-mask-open { fill: var(--org-mask-open); } +.org-task-mask-card { fill: var(--org-mask-card); } +@media (width < 48rem) { + .agents-org-toolbar { padding: calc(var(--spacing) * 4); } + .paperclip-app-shell:has(.agents-org-surface) { display: flex; flex-direction: column; height: 100dvh; min-height: 0; overflow: hidden; } + .paperclip-app-shell:has(.agents-org-surface) :is(.app-main-row, .app-content-row) { display: flex; flex: 1; min-height: 0; overflow: hidden; } + .paperclip-app-shell:has(.agents-org-surface) .app-content-column { flex: 1; min-height: 0; } + .paperclip-app-shell:has(.agents-org-surface) .app-breadcrumb { flex-shrink: 0; } + #main-content:has(.agents-org-surface) { min-height: 0; padding-bottom: var(--tc-composer-visible-nav-offset); } + .agents-org-toolbar [data-toolbar-search] { flex: 1; min-width: 0; } + .agents-org-toolbar .design-toolbar-actions { width: 100%; justify-content: flex-end; gap: var(--spacing); } + .agents-org-toolbar .org-chart-controls { gap: var(--spacing); } +} diff --git a/ui/src/lib/agent-character-slot.ts b/ui/src/lib/agent-character-slot.ts index ee8201e3f93..5536b41b183 100644 --- a/ui/src/lib/agent-character-slot.ts +++ b/ui/src/lib/agent-character-slot.ts @@ -1,9 +1,22 @@ -// The app gets one live hero. Other placements retain their static image. -let owner: symbol | null = null; -const listeners = new Set<() => void>(); -export const characterSlot = { - subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, - getSnapshot() { return owner; }, - acquire(id: symbol) { if (owner) return false; owner = id; for (const listener of listeners) listener(); return true; }, - release(id: symbol) { if (owner !== id) return; owner = null; for (const listener of listeners) listener(); }, -}; +/** Bound live WebGL contexts; excess placements retain their static image. */ +function createCharacterSlots(limit: number) { + let owners: ReadonlySet = new Set(); + const listeners = new Set<() => void>(); + const notify = () => { for (const listener of listeners) listener(); }; + return { + subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + getSnapshot() { return owners; }, + acquire(id: symbol) { + if (owners.has(id)) return true; + if (owners.size >= limit) return false; + owners = new Set([...owners, id]); notify(); return true; + }, + release(id: symbol) { + if (!owners.has(id)) return; + const next = new Set(owners); next.delete(id); owners = next; notify(); + }, + }; +} +// Other app surfaces retain the single-hero budget. Org leaders share a bounded pool. +export const characterSlot = createCharacterSlots(1); +export const orgChartCharacterSlots = createCharacterSlots(8); diff --git a/ui/src/pages/AgentDetail.instructions.test.tsx b/ui/src/pages/AgentDetail.instructions.test.tsx index 8bbe7adbe5a..0910e3feceb 100644 --- a/ui/src/pages/AgentDetail.instructions.test.tsx +++ b/ui/src/pages/AgentDetail.instructions.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, AgentInstructionsBundle, AgentInstructionsFileDetail, AgentInstructionsFileSummary } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AgentFileRunNotice, PromptsTab } from "./AgentDetail"; +import { TooltipProvider } from "../components/ui/tooltip"; import { queryKeys } from "../lib/queryKeys"; const mockAgentsApi = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const mockAgentsApi = vi.hoisted(() => ({ instructionsFile: vi.fn(), updateInstructionsBundle: vi.fn(), saveInstructionsFile: vi.fn(), + renameInstructionsFile: vi.fn(), deleteInstructionsFile: vi.fn(), instructionHistory: vi.fn(), instructionCandidates: vi.fn(), @@ -248,6 +250,9 @@ describe("PromptsTab instruction editor", () => { virtual: false, content: data.content, })); + mockAgentsApi.renameInstructionsFile.mockImplementation(async (_agentId, data) => ({ + ...makeSummary(data.renameTo, data.renameTo), content: data.content, contentHash: "b".repeat(64), + })); mockAgentsApi.deleteInstructionsFile.mockResolvedValue({}); }); @@ -278,7 +283,7 @@ describe("PromptsTab instruction editor", () => { root = createRoot(container); await act(async () => { root?.render( - + { onCancelActionChange={props.onCancelActionChange ?? vi.fn()} onSavingChange={props.onSavingChange ?? vi.fn()} /> - , + , ); }); await flushReact(); @@ -333,14 +338,16 @@ describe("PromptsTab instruction editor", () => { onDirtyChange, onCancelActionChange: next => { discard = next; }, }); await act(() => container.querySelector('[aria-label="Add agent file"]')!.click()); - const input = container.querySelector('[aria-label="New Markdown file name"]')!; + const input = container.querySelector('[aria-label="Markdown file name"]')!; + expect(document.activeElement).toBe(input); + expect(input.placeholder).toBe("Untitled"); + expect(container.textContent).toContain("Untitled.md"); for (const invalid of ["bad name", "file.json", "../escape", "notes.md", "bad!", "AGENTS"]) { await act(() => setNativeValue(input, invalid)); - expect(buttonByText(container, "Create").disabled).toBe(true); + expect(input.getAttribute("aria-invalid")).toBe("true"); } await act(() => setNativeValue(input, "valid-notes_2")); - expect(buttonByText(container, "Create").disabled).toBe(false); - await act(() => buttonByText(container, "Create").click()); + expect(input.getAttribute("aria-invalid")).not.toBe("true"); await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(true)); expect(container.textContent).toContain("valid-notes_2.md"); expect(container.querySelector('textarea')?.value).toBe(""); @@ -350,6 +357,68 @@ describe("PromptsTab instruction editor", () => { expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); }); + it("saves the live draft name and content together without creating Untitled.md", async () => { + await renderPromptsTab(makeBundle("AGENTS.md", []), {}); + await act(() => container.querySelector('[aria-label="Add agent file"]')!.click()); + await act(() => setNativeValue(container.querySelector('[aria-label="Markdown file name"]')!, "research")); + await act(() => setNativeValue(container.querySelector('[aria-label="Instruction file editor"]')!, "# Research notes")); + await waitFor(() => expect(saveAction).toEqual(expect.any(Function))); + await act(async () => { saveAction?.(); }); + await waitFor(() => expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalledWith("agent-1", expect.objectContaining({ path: "research.md", content: "# Research notes" }), "company-1")); + expect(mockAgentsApi.deleteInstructionsFile).not.toHaveBeenCalled(); + expect(mockAgentsApi.updateInstructionsBundle).not.toHaveBeenCalled(); + }); + + it("keeps the required root delete control disabled", async () => { + const summary = makeSummary("AGENTS.md", "AGENTS.md"); + await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original") }); + const button = container.querySelector('[aria-label="Delete AGENTS.md"]')!; + expect(button.disabled).toBe(true); + expect(button.closest('[aria-label="This file is required"]')).not.toBeNull(); + }); + + it("renames the root through one source-hash guarded server operation", async () => { + const summary = makeSummary("AGENTS.md", "AGENTS.md"); + await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original", { contentHash: "a".repeat(64) }) }); + await waitFor(() => expect(container.querySelector('[data-testid="instructions-raw-source"]')?.textContent).toBe("Original")); + await act(() => container.querySelector('[aria-label="Rename AGENTS.md"]')!.click()); + await act(() => setNativeValue(container.querySelector('[aria-label="Markdown file name"]')!, "ROOT")); + await waitFor(() => expect(saveAction).toEqual(expect.any(Function))); + await act(async () => { saveAction?.(); }); + await waitFor(() => expect(mockAgentsApi.renameInstructionsFile).toHaveBeenCalledWith("agent-1", { + path: "AGENTS.md", renameTo: "ROOT.md", content: "Original", baseHash: "a".repeat(64), + }, "company-1")); + expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); + expect(mockAgentsApi.updateInstructionsBundle).not.toHaveBeenCalled(); + expect(mockAgentsApi.deleteInstructionsFile).not.toHaveBeenCalled(); + }); + + it("persists the new configured name when renaming a missing root", async () => { + await renderPromptsTab(makeBundle("AGENTS.md", []), {}); + await act(() => container.querySelector('[aria-label="Rename AGENTS.md"]')!.click()); + await act(() => setNativeValue(container.querySelector('[aria-label="Markdown file name"]')!, "ROOT")); + await waitFor(() => expect(saveAction).toEqual(expect.any(Function))); + await act(async () => { saveAction?.(); }); + await waitFor(() => expect(mockAgentsApi.renameInstructionsFile).toHaveBeenCalledWith("agent-1", { + path: "AGENTS.md", renameTo: "ROOT.md", content: "", baseHash: null, + }, "company-1")); + expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); + }); + + it("retains the draft when the server rejects a stale rename", async () => { + const summary = makeSummary("AGENTS.md", "AGENTS.md"); + await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original", { contentHash: "a".repeat(64) }) }); + await waitFor(() => expect(container.querySelector('[aria-label="Rename AGENTS.md"]')).not.toBeNull()); + await act(() => container.querySelector('[aria-label="Rename AGENTS.md"]')!.click()); + await act(() => setNativeValue(container.querySelector('[aria-label="Markdown file name"]')!, "ROOT")); + mockAgentsApi.renameInstructionsFile.mockRejectedValueOnce(new Error("File changed")); + await waitFor(() => expect(saveAction).toEqual(expect.any(Function))); + await act(async () => { saveAction?.(); }); + await waitFor(() => expect(container.textContent).toContain("File changed")); + expect(mockAgentsApi.deleteInstructionsFile).not.toHaveBeenCalled(); + expect(mockAgentsApi.updateInstructionsBundle).not.toHaveBeenCalled(); + }); + it("shows inline copy feedback before Edit for five seconds without dirtying the file", async () => { const summary = makeSummary("AGENTS.md", "AGENTS.md"); await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original") }); @@ -662,15 +731,13 @@ describe("PromptsTab instruction editor", () => { container.querySelector('button[aria-label="Add agent file"]')!.click(); }); await flushReact(); - const input = container.querySelector('input[aria-label="New Markdown file name"]'); + const input = container.querySelector('input[aria-label="Markdown file name"]'); expect(input).not.toBeNull(); await act(async () => { setNativeValue(input!, "notes"); - buttonByText(container, "Create").click(); }); - await selectInstructionMode("Edit"); await waitFor(() => { expect(container.querySelector('textarea[placeholder="File contents"]')?.value).toBe(""); }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 76a25681c1d..cee501f065e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -34,6 +34,7 @@ import { issuesApi } from "../api/issues"; import { usePanel } from "../context/PanelContext"; import { useSidebar } from "../context/SidebarContext"; import { useCompany } from "../context/CompanyContext"; +import { useDialogActions } from "../context/DialogContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -80,6 +81,7 @@ import { AuditFeed } from "./audit/AuditFeed"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { + Paperclip, CheckCircle2, XCircle, Clock, @@ -95,6 +97,7 @@ import { ChevronRight, ChevronDown, ArrowLeft, + ArrowUpRight, HelpCircle, FolderOpen, AlertTriangle, @@ -387,22 +390,10 @@ export function AgentDetail() { const shouldLoadHeartbeats = needsOverviewData || needsRunData; const [configDirty, setConfigDirty] = useState(false); const [configSaving, setConfigSaving] = useState(false); - const [configJustSaved, setConfigJustSaved] = useState(false); - const [identitySaveVersion, setIdentitySaveVersion] = useState(0); - const onIdentitySaved = useCallback(() => setIdentitySaveVersion(value => value + 1), []); + const [configSaveVersion, setConfigSaveVersion] = useState(0); + const onConfigSaved = useCallback(() => setConfigSaveVersion(value => value + 1), []); const [pendingLeave, setPendingLeave] = useState(null); const leaveBypassRef = useRef(false); - const configWasSavingRef = useRef(false); - useEffect(() => { - if (configWasSavingRef.current && !configSaving && !configDirty) setConfigJustSaved(true); - configWasSavingRef.current = configSaving; - }, [configSaving, configDirty]); - useEffect(() => { - if (configDirty) setConfigJustSaved(false); - }, [configDirty]); - useEffect(() => { - setConfigJustSaved(false); - }, [activeView]); const saveConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null); const { isMobile } = useSidebar(); @@ -818,7 +809,7 @@ export function AgentDetail() { const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined; return ( -
+
{showLeftAgentNotice ? (

@@ -892,12 +883,11 @@ export function AgentDetail() {

saveHeaderField({ title: title || null })} + onSave={(name) => saveHeaderField({ name })} />
@@ -915,10 +905,11 @@ export function AgentDetail() { saveHeaderField({ name })} + value={agent.title ?? ""} + nullable + placeholder={roleLabels[agent.role] ?? agent.role} + className="agent-header-custom-title min-w-0 truncate text-sm leading-5 text-muted-foreground" + onSave={(title) => saveHeaderField({ title: title || null })} />
agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} /> @@ -928,7 +919,7 @@ export function AgentDetail() { companyId={resolvedCompanyId} assignLabel="New task" size="default" - showAssignIcon={false} + assignIcon={Paperclip} showChat showStatus={false} workActionsInMenu @@ -1099,7 +1090,7 @@ export function AgentDetail() { agent={agent} companyId={resolvedCompanyId ?? undefined} showSaveNotice={false} - onSaved={onIdentitySaved} + onSaved={onConfigSaved} onDirtyChange={setConfigDirty} onSaveActionChange={setSaveConfigAction} onCancelActionChange={setCancelConfigAction} @@ -1118,6 +1109,7 @@ export function AgentDetail() { onSaveActionChange={setSaveConfigAction} onCancelActionChange={setCancelConfigAction} onSavingChange={setConfigSaving} + onSaved={onConfigSaved} updatePermissions={updatePermissions} canConfigureProviderTrace={canUseProviderTrace} content="runtime" @@ -1136,6 +1128,7 @@ export function AgentDetail() { onSaveActionChange={setSaveConfigAction} onCancelActionChange={setCancelConfigAction} onSavingChange={setConfigSaving} + onSaved={onConfigSaved} updatePermissions={updatePermissions} content="secrets" /> @@ -1166,6 +1159,7 @@ export function AgentDetail() { onSaveActionChange={setSaveConfigAction} onCancelActionChange={setCancelConfigAction} onSavingChange={setConfigSaving} + onSaved={onConfigSaved} updatePermissions={updatePermissions} content="permissions" /> @@ -1208,23 +1202,10 @@ export function AgentDetail() { /> )} - {showConfigActionBar && activeView === "instructions" && cancelConfigActionRef.current?.()} onSave={() => { Promise.resolve(saveConfigActionRef.current?.()).catch(() => {}); }} />} - {showConfigActionBar && activeView !== "instructions" &&
-

- {configSaving ? null : configDirty ? ( - - ) : configJustSaved ? ( - - ) : null} - {configSaving ? "Saving changes…" : configDirty ? "Unsaved changes" : configJustSaved ? "Changes saved" : "No unsaved changes"} -

-
- - -
-
} +
); @@ -1440,6 +1421,12 @@ export function AgentOverview({ agentRouteId: string; }) { const agentRef = agentRouteRef(agent); + const { openNewIssue } = useDialogActions(); + const taskSuggestions = [ + "Research a topic and summarize the key findings", + "Create a step-by-step plan for a project", + "Review a document and suggest improvements", + ]; const hasHumanCreator = Boolean(humanAgentCreatorUserId(agent.createdByUserId)); const userDirectory = useQuery({ queryKey: queryKeys.access.companyUserDirectory(agent.companyId), @@ -1511,15 +1498,12 @@ export function AgentOverview({ className="min-h-(--agent-detail-tile-min-h) sm:col-span-2" aria-label="Skills" > - {skillNames.length > 0 ? ( -
- {skillNames.map((name) => ( - {name} - ))} -
- ) : ( -

No skills enabled.

- )} +
+ {skillNames.map((name) => {name})} + + Explore skills + +
@@ -1533,7 +1517,24 @@ export function AgentOverview({ } /> {assignedIssues.length === 0 ? ( -

No recent tasks.

+
+

Start with a suggested task

+
    + {taskSuggestions.map((title) => ( +
  • + +
  • + ))} +
+
) : (
@@ -1621,6 +1622,7 @@ export function ConfigurationTab({ onSaveActionChange, onCancelActionChange, onSavingChange, + onSaved, updatePermissions, hidePromptTemplate, hideInstructionsFile, @@ -1633,6 +1635,7 @@ export function ConfigurationTab({ onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; + onSaved?: () => void; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; hidePromptTemplate?: boolean; hideInstructionsFile?: boolean; @@ -1678,6 +1681,7 @@ export function ConfigurationTab({ setAwaitingRefreshAfterSave(true); }, onSuccess: (updated, { target }) => { + if (lastAgentRef.current.id === target.id) onSaved?.(); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(target.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(target.companyId) }); @@ -1889,13 +1893,17 @@ export function PromptsTab({ rootPath: string; entryFile: string; } | null>(null); - const [newFilePath, setNewFilePath] = useState(""); + const [fileNameDraft, setFileNameDraft] = useState(null); + const [editingFileName, setEditingFileName] = useState(false); + const [fileNameError, setFileNameError] = useState(null); + const fileNameInputRef = useRef(null); + const [fileSaveInProgress, setFileSaveInProgress] = useState(false); + const fileSaveLock = useRef(false); const [copyVisible, setCopyVisible] = useState(false); const [copyNotice, setCopyNotice] = useState<"copied" | "error" | null>(null); const copyTimer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(copyTimer.current), []); useEffect(() => { clearTimeout(copyTimer.current); setCopyNotice(null); }, [agent.id, selectedFile]); - const [showNewFileInput, setShowNewFileInput] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [filePanelWidth, setFilePanelWidth] = useState(260); @@ -1931,8 +1939,9 @@ export function PromptsTab({ setDraft(null); setReadOnlyCandidateRunId(null); setBundleDraft(null); - setNewFilePath(""); - setShowNewFileInput(false); + setFileNameDraft(null); + setEditingFileName(false); + setFileNameError(null); setPendingFiles([]); setExpandedDirs(new Set()); setAwaitingRefresh(false); @@ -1978,6 +1987,14 @@ export function PromptsTab({ [visibleFilePaths], ); const selectedOrEntryFile = selectedFile || currentEntryFile; + const displayedFilePath = fileNameDraft ?? selectedOrEntryFile; + const namingPrefix = displayedFilePath.replace(/\.md$/i, ""); + const renamedFilePath = fileNameDraft === null ? selectedOrEntryFile : markdownFileName(fileNameDraft.replace(/\.md$/i, "")); + const nameConflict = renamedFilePath !== selectedOrEntryFile && visibleFilePaths.some(path => path.toLowerCase() === renamedFilePath?.toLowerCase()); + const fileNameInvalid = !renamedFilePath || nameConflict; + useEffect(() => { + if (editingFileName) fileNameInputRef.current?.focus(); + }, [editingFileName]); const selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile); const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null; @@ -2220,9 +2237,9 @@ export function PromptsTab({ bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md") ), ); - const fileDirty = (draft !== null && draft !== currentContent) || pendingFiles.includes(selectedOrEntryFile); + const fileDirty = (fileNameDraft !== null && fileNameDraft !== selectedOrEntryFile) || (draft !== null && draft !== currentContent) || pendingFiles.includes(selectedOrEntryFile); const isDirty = bundleDirty || fileDirty || candidateRunId !== null; - const isSaving = updateBundle.isPending || saveFile.isPending || resolveCandidate.isPending || loadCandidate.isPending || refreshCandidateBase.isPending || deleteFile.isPending || awaitingRefresh; + const isSaving = fileSaveInProgress || updateBundle.isPending || saveFile.isPending || resolveCandidate.isPending || loadCandidate.isPending || refreshCandidateBase.isPending || deleteFile.isPending || awaitingRefresh; useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); @@ -2236,7 +2253,14 @@ export function PromptsTab({ useEffect(() => { onSaveActionChange(isDirty ? () => { + if (fileSaveLock.current) return; const save = async () => { + if (fileNameInvalid) { + setFileNameError(nameConflict ? "A file with this name already exists." : "Use letters, numbers, hyphens or underscores."); + setEditingFileName(true); + return; + } + setFileNameError(null); const shouldClearLegacy = Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive); if (bundleDirty && bundleDraft) { @@ -2249,6 +2273,30 @@ export function PromptsTab({ if (candidateRunId) { await resolveCandidate.mutateAsync({ runId: candidateRunId, content: displayValue, baseRevisionId: draftBaseRevisionRef.current ?? null }); + } else if (fileDirty && renamedFilePath && renamedFilePath !== selectedOrEntryFile) { + fileSaveLock.current = true; + setFileSaveInProgress(true); + const latest = await agentsApi.instructionsBundle(agent.id, companyId); + const sourceHash = draftBaseHashRef.current ?? selectedFileDetail?.contentHash; + if (selectedFileExists && !sourceHash) throw new Error("Reload this file before renaming; your draft is retained."); + const copied = selectedFileExists || selectedOrEntryFile === currentEntryFile + ? await agentsApi.renameInstructionsFile(agent.id, { + path: selectedOrEntryFile, renameTo: renamedFilePath, content: displayValue, baseHash: selectedFileExists ? sourceHash! : null, + }, companyId) + : await agentsApi.saveInstructionsFile(agent.id, { path: renamedFilePath, content: displayValue, baseHash: null }, companyId); + queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, renamedFilePath), copied); + queryClient.setQueryData(queryKeys.agents.instructionsBundle(agent.id), { + ...latest, + entryFile: selectedOrEntryFile === currentEntryFile ? renamedFilePath : latest.entryFile, + files: [...latest.files.filter(file => file.path !== selectedOrEntryFile), copied], + }); + setBundleDraft(null); + setPendingFiles([]); + setDraft(null); + setFileNameDraft(null); + setSelectedFile(renamedFilePath); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); } else if (fileDirty) { await saveFile.mutateAsync({ path: selectedOrEntryFile, @@ -2260,7 +2308,7 @@ export function PromptsTab({ } onSaved?.(); }; - void save().catch(() => undefined); + void save().catch((error) => setFileNameError(error instanceof Error ? error.message : "Could not save the file. Your unsaved edits are retained.")).finally(() => { fileSaveLock.current = false; setFileSaveInProgress(false); }); } : null); }, [ bundle, @@ -2278,6 +2326,15 @@ export function PromptsTab({ currentMode, saveFile, selectedOrEntryFile, + renamedFilePath, + fileNameInvalid, + nameConflict, + selectedFileExists, + currentContent, + selectedFileDetail?.contentHash, + agent.id, + companyId, + queryClient, updateBundle, ]); @@ -2289,6 +2346,9 @@ export function PromptsTab({ resolveCandidate.reset(); setDraft(null); setPendingFiles([]); + setFileNameDraft(null); + setEditingFileName(false); + setFileNameError(null); if (pendingFiles.includes(selectedOrEntryFile)) setSelectedFile(currentEntryFile); if (bundle) { setBundleDraft({ @@ -2381,13 +2441,29 @@ export function PromptsTab({

Files

- {!showNewFileInput && ( + {( @@ -2405,66 +2481,10 @@ export function PromptsTab({ )}
- {showNewFileInput && ( -
-
- setNewFilePath(event.target.value)} - placeholder="TOOLS" - className="min-w-0 border-0 bg-transparent font-mono text-sm shadow-none focus-visible:ring-0" - autoFocus - onKeyDown={(event) => { - if (event.key === "Escape") { - setShowNewFileInput(false); - setNewFilePath(""); - } - }} - /> - .md -
-

Use letters, numbers, hyphens or underscores.

-
- - -
-
- )} {}} showCheckboxes={false} wrapLabels={false} - fileRowClassName={() => "agent-instruction-file-row font-mono"} + fileRowClassName={(node) => cn("agent-instruction-file-row font-mono", pendingFiles.includes(node.path) && "agent-instruction-file-pending italic text-muted-foreground/60")} renderFileExtra={(node) => { const file = bundle?.files.find((entry) => entry.path === node.path); if (!file) return null; @@ -2523,7 +2546,7 @@ export function PromptsTab({ )}
-
+
{isMobile && ( + )} {currentMode === "external" ? "Managed externally" : "Managed by you"}

@@ -2581,6 +2627,16 @@ export function PromptsTab({ )} + {selectedOrEntryFile === currentEntryFile && ( + + + + + + + This file is required + + )} {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && ( }

} {(candidates.error || loadCandidate.error || resolveCandidate.error || refreshCandidateBase.error) &&

{(candidates.error ?? loadCandidate.error ?? resolveCandidate.error ?? refreshCandidateBase.error)?.message} Your preserved edits remain available.

} + {(fileNameError || (editingFileName && fileNameInvalid)) &&

{fileNameError ?? (nameConflict ? "A file with this name already exists." : "Use letters, numbers, hyphens or underscores.")}

} {(saveFile.error || fileError || updateBundle.error) &&

{(saveFile.error ?? fileError ?? updateBundle.error)?.message} Your unsaved edits are retained.

} {selectedFileDetail?.receipt?.materialization === "pending" &&

Revision saved. The instruction file still needs to be rebuilt from the saved revision.

} {selectedFileDetail?.revision && currentMode === "managed" && bundle?.persistence !== "agent_files" && { diff --git a/ui/src/pages/AgentOverview.test.tsx b/ui/src/pages/AgentOverview.test.tsx index b5938fb5669..11a0a924a3d 100644 --- a/ui/src/pages/AgentOverview.test.tsx +++ b/ui/src/pages/AgentOverview.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AgentDetail, AgentRuntimeState, HeartbeatRun, Issue } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { AgentOverview } from "./AgentDetail"; +import { DialogProvider } from "../context/DialogContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { queryKeys } from "../lib/queryKeys"; @@ -54,7 +55,7 @@ describe("AgentOverview", () => { } as unknown as AgentRuntimeState; const markup = renderToStaticMarkup( - + { agentRouteId="codexcoder" /> - , + , ); for (const label of ["Activity", "Runs", "Costs", "Budget used", "Profile", "Skills", "Recent tasks"]) { @@ -76,6 +77,8 @@ describe("AgentOverview", () => { expect(markup).toContain(`href="/agents/codexcoder/${page}"`); } expect(markup).toContain("2 enabled"); + expect(markup).toContain("Explore skills"); + expect(markup).toContain('href="/agents/codexcoder/skills"'); expect(markup).toContain("Design Guide"); expect(markup).toContain("Simplify agent information architecture"); expect(markup).toContain("PAP-42"); @@ -106,7 +109,7 @@ describe("AgentOverview", () => { access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] }, } as unknown as AgentDetail; return renderToStaticMarkup( - + { skillNames={[]} agentRouteId="sharedagent" /> - , + , ); } diff --git a/ui/src/pages/AgentRunnerUpgrade.test.tsx b/ui/src/pages/AgentRunnerUpgrade.test.tsx index 95301725ee6..fd0b6bbefb5 100644 --- a/ui/src/pages/AgentRunnerUpgrade.test.tsx +++ b/ui/src/pages/AgentRunnerUpgrade.test.tsx @@ -42,9 +42,10 @@ it("keeps an upgrade receipt on its original agent when navigation changes the c document.body.appendChild(container); const root = createRoot(container); const noop = () => {}; + const saved = vi.fn(); const render = (value: AgentDetail) => root.render( + onCancelActionChange={noop} onSavingChange={noop} onSaved={saved} updatePermissions={{ mutate: noop, isPending: false }} /> ); try { await act(async () => render(original)); @@ -54,6 +55,7 @@ it("keeps an upgrade receipt on its original agent when navigation changes the c await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); expect(mocks.update).toHaveBeenCalledWith(original.id, { runner: "paperclip" }, original.companyId); expect(mocks.navigate).not.toHaveBeenCalled(); + expect(saved).not.toHaveBeenCalled(); expect(invalidate).toHaveBeenCalledWith({ queryKey: ["agents", "detail", original.id] }); expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ["agents", "detail", "other-agent"] }); } finally { @@ -62,3 +64,29 @@ it("keeps an upgrade receipt on its original agent when navigation changes the c container.remove(); } }); + +it.each(["runtime", "secrets"] as const)("notifies the save tray after a successful %s save", async content => { + const original = agent("saved-agent"); + const updated = { ...original, adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }; + mocks.update.mockResolvedValue(updated); + const saved = vi.fn(), noop = () => {}; + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(() => root.render( + + )); + expect(saved).not.toHaveBeenCalled(); + await act(async () => container.querySelector("button")!.click()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(saved).toHaveBeenCalledOnce(); + } finally { + await act(() => root.unmount()); + client.clear(); + container.remove(); + } +}); diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index be526413f0d..c5f56248147 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import type { ReactNode } from "react"; +import { act as reactAct, type ReactNode } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; @@ -395,6 +395,58 @@ describe("Agents", () => { vi.clearAllMocks(); }); + it("shares harness filters across views while keeping dimmed chart cards navigable and stationary", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "alpha", name: "Alpha", adapterType: "codex_local" }), + makeAgent({ id: "beta", name: "Beta", adapterType: "claude_local" }), + ]); + mockAgentsApi.org.mockResolvedValue([ + { id: "alpha", name: "Alpha", role: "engineer", status: "active", reports: [ + { id: "beta", name: "Beta", role: "engineer", status: "active", reports: [] }, + ] }, + ]); + root = createRoot(container); + await reactAct(async () => renderAgentsView(root, queryClient)); + await flushReact(); + await flushReact(); + const filterButton = container.querySelector('[aria-label="Filter agents"]')!; + expect(filterButton.closest('[aria-label="Agents controls"]')).not.toBeNull(); + expect(container.querySelector("thead")?.textContent).toContain("2 agents"); + await reactAct(() => filterButton.click()); + await flushReact(); + const harnessOption = document.querySelector('select[aria-label="Harness"]')!; + await reactAct(() => { harnessOption.value = "codex_local"; harnessOption.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(container.querySelectorAll("tbody tr")).toHaveLength(1); + expect(container.querySelector("thead")?.textContent).toContain("1 agent"); + await reactAct(() => filterButton.click()); + await reactAct(() => container.querySelector('[aria-label="Org chart view"]')!.click()); + await flushReact(); + await flushReact(); + const cards = [...container.querySelectorAll("[data-org-card]")]; + expect(cards).toHaveLength(2); + const alpha = cards.find(card => card.textContent?.includes("Alpha"))!; + const beta = cards.find(card => card.textContent?.includes("Beta"))!; + expect(alpha.dataset.filterMatch).toBe("true"); + expect(beta.dataset.filterMatch).toBe("false"); + const position = beta.style.cssText; + const transform = container.querySelector('[data-testid="org-chart-card-layer"]')!.style.transform; + await reactAct(() => beta.click()); + expect(mockRouterState.navigate).toHaveBeenCalled(); + const input = container.querySelector('input[aria-label="Search agents"]')!; + await reactAct(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "no match"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(container.querySelectorAll('[data-org-card][data-filter-match="false"]')).toHaveLength(2); + expect(beta.style.cssText).toBe(position); + expect(container.querySelector('[data-testid="org-chart-card-layer"]')!.style.transform).toBe(transform); + await reactAct(() => container.querySelector('[aria-label="Clear search"]')!.click()); + await reactAct(() => filterButton.click()); + await flushReact(); + await reactAct(() => [...document.querySelectorAll('[aria-label="Agent filter choices"] button')].find(button => button.textContent === "Clear filters")!.click()); + expect(container.querySelectorAll('[data-org-card][data-filter-match="false"]')).toHaveLength(0); + }); + it("filters the rendered roster and clears search without changing its status route", async () => { mockAgentsApi.list.mockResolvedValue([ makeAgent({ id: "alpha", name: "Alpha", role: "engineer" }), @@ -451,7 +503,7 @@ describe("Agents", () => { expect(container.querySelector("tbody")?.textContent).not.toContain("Manager"); expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 hidden by filters"); expect(container.querySelector('[aria-label="Filter agents"]')?.textContent).toBe("4"); - expect(container.querySelector('[aria-label="Agent filters"]')?.textContent).toContain("1 agent"); + expect(container.querySelector(AgentList === Agents ? "thead" : '[aria-label="Agent filters"]')?.textContent).toContain("1 agent"); await act(() => { queryClient.setQueryData(queryKeys.agents.list("company-1"), agents.map(agent => agent.id === "alpha" ? { ...agent, adapterConfig: { model: "new-model" } } : agent)); }); await flushReact(); expect(document.querySelector('select[aria-label="Model"]')?.selectedOptions[0].textContent).toBe("Previously selected model"); @@ -468,10 +520,16 @@ describe("Agents", () => { await flushReact(); expect(container.textContent).toContain("Alpha"); expect(container.textContent).toContain("Manager"); - expect(container.textContent).not.toContain("Beta"); + if (AgentList === Agents) { + const beta = [...container.querySelectorAll("[data-org-card]")].find(card => card.textContent?.includes("Beta"))!; + expect(beta.dataset.filterMatch).toBe("false"); + } else { + expect(container.textContent).not.toContain("Beta"); + } await act(() => container.querySelector('[aria-label="Filter agents"]')!.click()); await select("Status", "paused"); - expect(container.textContent).toContain("No agents match"); + if (AgentList === Agents) expect(container.querySelectorAll('[data-org-card][data-filter-match="true"]')).toHaveLength(0); + else expect(container.textContent).toContain("No agents match"); const clear = [...document.querySelectorAll("button")].find(button => button.textContent === "Clear filters")!; await act(() => clear.click()); expect(container.textContent).toContain("Beta"); @@ -580,12 +638,24 @@ describe("Agents", () => { const orgViewport = container.querySelector('[data-testid="org-chart-viewport"]'); expect(orgViewport).not.toBeNull(); expect(orgViewport?.parentElement?.classList.contains("flex-1")).toBe(true); - expect(orgViewport?.parentElement?.classList.contains("md:min-h-0")).toBe(true); + expect(orgViewport?.parentElement?.classList.contains("min-h-0")).toBe(true); expect(orgViewport?.parentElement?.classList.contains("h-(--sz-calc-38)")).toBe(false); expect(orgViewport?.parentElement?.parentElement?.classList.contains("min-h-0")).toBe(true); expect(container.querySelector('[aria-label="Zoom in"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Fit chart to screen"]')).not.toBeNull(); + const controlsSlot = container.querySelector('[data-org-toolbar-controls]')!; + expect(controlsSlot.getAttribute("data-visible")).toBe("true"); + expect(controlsSlot.hasAttribute("inert")).toBe(false); + expect(controlsSlot.querySelector('[aria-label="Chart camera"]')).not.toBeNull(); + const cameraLayer = container.querySelector('[data-testid="org-chart-card-layer"]')!; + const initialCamera = cameraLayer.style.transform; + await act(async () => controlsSlot.querySelector('[aria-label="Zoom in"]')!.click()); + expect(cameraLayer.style.transform).not.toBe(initialCamera); + await act(async () => controlsSlot.querySelector('[aria-label="Zoom out"]')!.click()); + expect(controlsSlot.textContent).not.toContain("Tasks"); + expect(controlsSlot.nextElementSibling?.textContent).toContain("New Agent"); + expect(orgViewport?.querySelector('[aria-label="Org chart controls"]')).toBeNull(); await act(async () => { listToggle?.click(); @@ -593,6 +663,16 @@ describe("Agents", () => { await flushReact(); expect(container.querySelector('[data-testid="org-chart-viewport"]')).toBeNull(); expect(container.textContent).toContain("gpt-5.4"); + expect(container.querySelector('[data-org-toolbar-controls]')).toBe(controlsSlot); + expect(controlsSlot.getAttribute("data-visible")).toBe("false"); + expect(controlsSlot.getAttribute("aria-hidden")).toBe("true"); + expect(controlsSlot.hasAttribute("inert")).toBe(true); + await act(async () => orgToggle?.click()); + await flushReact(); + expect(container.querySelector('[data-org-toolbar-controls]')).toBe(controlsSlot); + expect(controlsSlot.getAttribute("data-visible")).toBe("true"); + expect(controlsSlot.getAttribute("aria-hidden")).toBe("false"); + expect(controlsSlot.hasAttribute("inert")).toBe(false); }); it("gives mobile agent names the full row width after the leading status indicator", async () => { @@ -1001,7 +1081,7 @@ describe("Agents", () => { expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 selected"); expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("Edit connection & model"); expect(mockRouterState.navigate).not.toHaveBeenCalled(); - expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "Agent", "Chat", "Status", "Harness", "Connection", "Default model"]); + expect(Array.from(container.querySelectorAll("th")).map(cell => cell.textContent)).toEqual(["", "1 agent", "Chat", "Status", "Harness", "Default model", "Connection"]); }); it.each([ diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index c397a3e6a01..068f0856802 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -4,7 +4,7 @@ import { AgentRosterFilters, useAgentRosterFilters } from "@/components/agents/A import { AgentAvatar } from "@/components/AgentAvatar"; import { AgentChatPin, useAgentChatPins } from "@/components/AgentChatPin"; import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled"; -import { useState, useEffect, useMemo, lazy, Suspense } from "react"; +import { useState, useEffect, useMemo, useRef, lazy, Suspense } from "react"; import { Link, useNavigate, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; @@ -27,6 +27,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { QueryErrorState, useQueryView } from "../components/QueryView"; import { OrgChart } from "./OrgChart"; import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; +import type { OrgChartActions } from "../components/OrgChartControls"; import { AgentsToolbar } from "../components/AgentsToolbar"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -193,6 +194,13 @@ export type AgentsView = "list" | "org"; export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) { const agentChatPins = useAgentChatPins(); const [query, setQuery] = useState(""); + const chartActionsRef = useRef(null); + const chartActions = useMemo(() => ({ + zoomIn: () => chartActionsRef.current?.zoomIn(), + zoomOut: () => chartActionsRef.current?.zoomOut(), + fit: () => chartActionsRef.current?.fit(), + }), []); + const [orgControlsContainer, setOrgControlsContainer] = useState(null); const agentChat = useAgentChatEnabled(); const { selectedCompanyId } = useCompany(); const { openNewAgent } = useDialogActions(); @@ -203,13 +211,14 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const pathSegment = location.pathname.split("/").pop() ?? "all"; const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "all"; - const [view, setView] = useState(() => streamlinedUiEnabled ? initialView : "org"); + const previewTasks = import.meta.env.DEV && new URLSearchParams(location.search).get("orgTasks") === "demo"; + const [view, setView] = useState(() => previewTasks ? "org" : streamlinedUiEnabled ? initialView : "org"); const forceListView = !streamlinedUiEnabled && isMobile; const effectiveView: AgentsView = forceListView ? "list" : view; useEffect(() => { - setView(streamlinedUiEnabled ? initialView : "org"); - }, [initialView, streamlinedUiEnabled]); + setView(previewTasks ? "org" : streamlinedUiEnabled ? initialView : "org"); + }, [initialView, streamlinedUiEnabled, previewTasks]); const { data: instanceSettings } = useQuery({ queryKey: queryKeys.instance.settings, @@ -327,6 +336,13 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = } }, [navigate, requestedTab]); + const eligibleAgents = useMemo(() => (agents ?? []).filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status)), [agents]); + const filtered = useMemo(() => filterAgents(eligibleAgents, tab, builtInAgentIds, query) + .filter(rosterFilters.matches), [eligibleAgents, tab, builtInAgentIds, query, rosterFilters.matches]); + // Keep hierarchy placement stable while search/filter only changes card emphasis. + const visibleOrg = useMemo(() => filterOrgTree(orgTree ?? [], "all", builtInAgentIds), [orgTree, builtInAgentIds]); + const matchingAgentIds = useMemo(() => new Set(filtered.map(agent => agent.id)), [filtered]); + if (!selectedCompanyId) { return ; } @@ -348,8 +364,6 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = ); } - const filtered = filterAgents(agents ?? [], tab, builtInAgentIds, query).filter(rosterFilters.matches); - const filteredOrg = filterOrgTree(orgTree ?? [], tab, builtInAgentIds, query.trim() || rosterFilters.activeCount ? new Set(filtered.map(agent => agent.id)) : undefined); const environmentDataLoading = environmentsEnabled && environments === undefined; const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1); const resolveRenderedEnvironment = (agentId: string) => ( @@ -432,15 +446,16 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = return (
- +
+ } /> +
- - -
+
{agents && agents.length === 0 && ( 0 && ( ( // Use Radix's standard hover delay, isolated from the app's instant provider. @@ -480,14 +495,8 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {/* Org chart view */} - {effectiveView === "org" && filteredOrg.length > 0 && ( - - )} - - {effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && ( -

- No agents match your search and filters. -

+ {effectiveView === "org" && visibleOrg.length > 0 && ( + )} {effectiveView === "org" && orgTree && orgTree.length === 0 && ( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 9819d41dbb5..cc6bb8e57cd 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -804,6 +804,7 @@ function InboxCollectionToolbar({ if (streamlined) { return ( ({ resourceMembershipsApi: { + listMine: (...args: unknown[]) => membershipListMock(...args), + updateAgent: (...args: unknown[]) => membershipUpdateMock(...args), +} })); const navigateMock = vi.fn(); const orgMock = vi.fn(); const listMock = vi.fn(); +const liveRunsMock = vi.fn().mockResolvedValue([]); +const summariesMock = vi.fn().mockResolvedValue([]); +vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: (...args: unknown[]) => liveRunsMock(...args) } })); +vi.mock("../api/issues", () => ({ issuesApi: { summaries: (...args: unknown[]) => summariesMock(...args) } })); +const readTransform = (layer: HTMLElement) => layer.style.transform.match(/-?[\d.]+/g)!.map(Number); vi.mock("@/lib/router", () => ({ - Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children}, useNavigate: () => navigateMock, + useLocation: () => ({ pathname: window.location.pathname, search: window.location.search }), })); vi.mock("../context/CompanyContext", () => ({ @@ -138,8 +151,14 @@ describe("OrgChart mobile gestures", () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + document.documentElement.style.setProperty("--org-card-width", "240px"); + document.documentElement.style.setProperty("--org-card-height", "336px"); + for (const [key, value] of Object.entries({ "leader-width": 280, "leader-height": 368, "manager-width": 232, "manager-height": 320, "member-width": 192, "member-height": 116, gap: 32, "level-gap": 72, padding: 48, "task-height": 32, "task-band-height": 64, "task-label-width": 208, "task-legend-height": 20, "toolbar-clearance": 88, "sparse-padding": 80, "fit-zoom-max": 1.25, "sparse-fit-zoom-max": 1 })) document.documentElement.style.setProperty(`--org-${key}`, `${value}px`); viewportWidth = 360; viewportHeight = 520; + window.history.replaceState({}, "", "/"); + liveRunsMock.mockResolvedValue([]); + summariesMock.mockResolvedValue([]); orgMock.mockResolvedValue(orgTree); listMock.mockResolvedValue(agents); @@ -191,16 +210,20 @@ describe("OrgChart mobile gestures", () => { } container.remove(); document.body.innerHTML = ""; + window.history.replaceState({}, "", "/"); + document.documentElement.style.removeProperty("--org-card-width"); + document.documentElement.style.removeProperty("--org-card-height"); + for (const key of ["leader-width", "leader-height", "manager-width", "manager-height", "member-width", "member-height", "gap", "level-gap", "padding", "task-height", "task-band-height", "task-label-width", "task-legend-height", "toolbar-clearance", "sparse-padding", "fit-zoom-max", "sparse-fit-zoom-max"]) document.documentElement.style.removeProperty(`--org-${key}`); vi.restoreAllMocks(); vi.clearAllMocks(); }); - async function renderOrgChart() { + async function renderOrgChart(enableTaskLayer = false, matchingAgentIds?: ReadonlySet) { root = createRoot(container); await act(async () => { root.render( - + , ); }); @@ -212,8 +235,33 @@ describe("OrgChart mobile gestures", () => { }; } + it("centers two agents at natural size on a large viewport, including after Fit", async () => { + viewportWidth = 1600; + viewportHeight = 1000; + const { layer } = await renderOrgChart(); + const assertCentered = () => { + const [panX, panY, zoom] = readTransform(layer); + expect(zoom).toBe(1); + const cards = Array.from(container.querySelectorAll("[data-org-card]")); + const left = Math.min(...cards.map(card => parseFloat(card.style.left))) * zoom + panX; + const right = Math.max(...cards.map(card => parseFloat(card.style.left) + parseFloat(card.style.width))) * zoom + panX; + const top = Math.min(...cards.map(card => parseFloat(card.style.top))) * zoom + panY; + const bottom = Math.max(...cards.map(card => parseFloat(card.style.top) + parseFloat(card.style.height))) * zoom + panY; + expect((left + right) / 2).toBeCloseTo(viewportWidth / 2); + expect((top + bottom) / 2).toBeCloseTo(viewportHeight / 2); + expect(top).toBeGreaterThan(200); + expect(left).toBeGreaterThan(500); + }; + assertCentered(); + await act(async () => container.querySelector('button[aria-label="Zoom in"]')!.click()); + expect(readTransform(layer)[2]).toBeGreaterThan(1); + await act(async () => container.querySelector('button[aria-label="Fit chart to screen"]')!.click()); + assertCentered(); + }); + it("pans the chart with one-finger touch drag", async () => { const { viewport, layer } = await renderOrgChart(); + const before = readTransform(layer); await act(async () => { viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }])); @@ -221,7 +269,10 @@ describe("OrgChart mobile gestures", () => { viewport.dispatchEvent(createTouchEvent("touchend", [])); }); - expect(layer.style.transform).toBe("translate(50px, 105px) scale(1)"); + const after = readTransform(layer); + expect(after[0]).toBeCloseTo(before[0] + 30); + expect(after[1]).toBeCloseTo(before[1] + 45); + expect(after[2]).toBeCloseTo(before[2]); }); it("suppresses card navigation after a touch pan", async () => { @@ -252,6 +303,7 @@ describe("OrgChart mobile gestures", () => { }); it("pinch-zooms toward the touch center", async () => { const { viewport, layer } = await renderOrgChart(); + const before = readTransform(layer); await act(async () => { viewport.dispatchEvent(createTouchEvent("touchstart", [ @@ -265,7 +317,10 @@ describe("OrgChart mobile gestures", () => { viewport.dispatchEvent(createTouchEvent("touchend", [])); }); - expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)"); + const after = readTransform(layer); + expect(after[2]).toBeCloseTo(before[2] * 1.5); + expect(after[0]).toBeCloseTo(150 - 1.5 * (150 - before[0])); + expect(after[1]).toBeCloseTo(100 - 1.5 * (100 - before[1])); }); it("does not produce a negative zoom while the viewport has no usable height", async () => { @@ -281,6 +336,120 @@ describe("OrgChart mobile gestures", () => { expect(layer.style.transform).toBe("translate(0px, 0px) scale(1)"); }); + it("dims reporting connections when either endpoint is filtered while preserving navigation", async () => { + await renderOrgChart(false, new Set(["agent-1"])); + const edge = () => container.querySelector(".org-reporting-edge")!; + expect(edge().getAttribute("data-filter-match")).toBe("false"); + for (const ids of [["agent-2"], ["agent-1", "agent-2"]]) { + await act(async () => root.render()); + expect(edge().getAttribute("data-filter-match")).toBe(ids.length === 2 ? "true" : "false"); + } + await act(async () => (container.querySelector('[aria-label="Open Engineer overview"]') as HTMLElement).click()); + expect(navigateMock).toHaveBeenCalledWith("/agents/engineer"); + }); + + it("uses live characters only for the two leadership tiers and keeps contributors static", async () => { + orgMock.mockResolvedValue([{ ...orgTree[0], reports: [{ ...orgTree[0].reports[0], reports: [{ id: "agent-3", name: "Contributor", role: "engineer", status: "idle", reports: [] }] }] }]); + await renderOrgChart(); + const cards = container.querySelectorAll("[data-org-card]"); + expect(cards).toHaveLength(3); + expect(cards[0].querySelector(".org-card-live-character")).not.toBeNull(); + expect(cards[1].querySelector(".org-card-live-character")).not.toBeNull(); + expect(cards[2].querySelector(".org-card-live-character")).toBeNull(); + expect(cards[2].querySelector("img")?.getAttribute("src")).toContain("view=portrait"); + expect(cards[0].querySelector(".org-card-status")?.textContent).toBe("Idle"); + }); + + it("stars a leader without opening its overview, including keyboard activation", async () => { + await renderOrgChart(); + const button = container.querySelector('[aria-label="Star CEO"]')!; + await act(async () => { + button.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + button.click(); + }); + await flushReact(); + expect(membershipUpdateMock).toHaveBeenCalledWith("company-1", "agent-1", { state: undefined, starred: true }); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("shows the custom title, configured model label, and effort on each portrait card", async () => { + listMock.mockResolvedValue([ + { ...agents[0], title: "Team lead", adapterType: "claude_local", adapterConfig: { model: "claude-opus-5-5", effort: "max" } }, + { ...agents[1], adapterConfig: { model: "custom-company-model", modelReasoningEffort: "xhigh" } }, + ]); + await renderOrgChart(); + const cards = container.querySelectorAll("[data-org-card]"); + expect(cards[0].textContent).toContain("Team lead"); + expect(cards[0].querySelector(".org-card-model")?.textContent).toBe("Opus 5.5 Max"); + expect(cards[0].textContent).toContain("Max"); + expect(cards[1].textContent).toContain("custom-company-model"); + expect(cards[1].textContent).toContain("Extra High"); + }); + + it("omits unknown model and effort labels and opens a focused card with Enter", async () => { + await renderOrgChart(); + const card = container.querySelector("[data-org-card]") as HTMLElement; + expect(card.querySelector(".org-card-model")).toBeNull(); + expect(card.textContent).not.toContain("Codex"); + expect(card.textContent).not.toContain("Harness default"); + expect(card.tabIndex).toBe(0); + await act(async () => { + card.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(navigateMock).toHaveBeenCalledWith("/agents/ceo"); + }); + + it("loads live task associations only when Tasks is enabled and deduplicates shared tasks", async () => { + liveRunsMock.mockResolvedValue([ + { id: "r1", status: "running", agentId: "agent-1", issueId: "task-1" }, + { id: "r2", status: "running", agentId: "agent-2", issueId: "task-1" }, + { id: "r3", status: "succeeded", agentId: "agent-2", issueId: "task-2" }, + ]); + summariesMock.mockResolvedValue([{ id: "task-1", title: "Review the launch", identifier: "PAP-1" }]); + const { layer } = await renderOrgChart(true); + await act(() => container.querySelector('[aria-label="Zoom in"]')!.click()); + const transform = layer.style.transform; + const cardStyles = [...container.querySelectorAll("[data-org-card]")].map(card => card.getAttribute("style")); + expect(liveRunsMock).not.toHaveBeenCalled(); + const toggle = [...container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Tasks")!; + await act(() => toggle.click()); await flushReact(); await flushReact(); + expect(container.querySelectorAll("[data-org-task]")).toHaveLength(1); + expect(container.querySelectorAll(".org-task-edge")).toHaveLength(2); + expect(container.querySelector("[data-org-task]")?.getAttribute("href")).toBe("/issues/PAP-1"); + expect(container.textContent).toContain("Review the launch"); + expect(layer.style.transform).toBe(transform); + expect([...container.querySelectorAll("[data-org-card]")].map(card => card.getAttribute("style"))).toEqual(cardStyles); + expect(layer.querySelector("[data-org-task]")).toBeNull(); + await act(() => toggle.click()); + expect(container.querySelectorAll("[data-org-task]")).toHaveLength(0); + expect(layer.style.transform).toBe(transform); + }); + + it("shows a task-layer failure without hiding the organization", async () => { + liveRunsMock.mockRejectedValue(new Error("Task service unavailable")); + await renderOrgChart(true); + const toggle = [...container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Tasks")!; + await act(() => toggle.click()); + await flushReact(); + await flushReact(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Task service unavailable"); + expect(container.querySelectorAll("[data-org-card]")).toHaveLength(2); + expect(container.querySelectorAll("[data-org-task]")).toHaveLength(0); + }); + + it("keeps task layering hidden on product surfaces even with the demo URL", async () => { + viewportWidth = 1200; + window.history.replaceState({}, "", "/?orgTasks=demo"); + await renderOrgChart(); + expect([...container.querySelectorAll("button")].some(button => button.textContent?.trim() === "Tasks")).toBe(false); + expect(container.textContent).not.toContain("simulated running tasks"); + expect(container.querySelectorAll("[data-org-task]")).toHaveLength(0); + expect(container.querySelectorAll("a[data-org-task]")).toHaveLength(0); + expect(container.querySelectorAll(".org-task-edge")).toHaveLength(0); + expect(liveRunsMock).not.toHaveBeenCalled(); + expect(summariesMock).not.toHaveBeenCalled(); + }); + it("shows both portability buttons on self-hosted instances", async () => { await renderOrgChart(); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index a271442849b..f30f3dab750 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -1,7 +1,22 @@ +import { AdapterMark } from "@/components/AdapterMark"; +import { models as claudeModels } from "@paperclipai/adapter-claude-local"; +import { models as codexModels } from "@paperclipai/adapter-codex-local"; +import { composerDefaultModel, EFFORT_LABELS, readComposerRunSettings } from "@/components/task-chat/composer-run-settings"; +import { OrgChartControls, type OrgChartActions } from "@/components/OrgChartControls"; +import { AgentCharacter } from "@/components/AgentCharacter"; +import { StarToggle } from "@/components/StarToggle"; +import { useResourceMemberships, useResourceMembershipMutation } from "@/hooks/useResourceMemberships"; import { AgentAvatar } from "@/components/AgentAvatar"; -import { useEffect, useRef, useState, useMemo, useCallback } from "react"; -import { Link, useNavigate } from "@/lib/router"; +import { createPortal } from "react-dom"; +import { useEffect, useRef, useState, useMemo, useCallback, useId, useImperativeHandle, type Ref } from "react"; +import { Link, useNavigate, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; +import { heartbeatsApi } from "../api/heartbeats"; +import { issuesApi } from "../api/issues"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; +import { layoutOrganization, routeReportingEdges, type LayoutNode } from "./org-chart/layout"; +import { collectChartTasks } from "./org-chart/tasks"; +import { createTaskPreview } from "./org-chart/task-preview"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; @@ -12,34 +27,17 @@ import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { QueryErrorState, useQueryView } from "../components/QueryView"; -import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react"; +import { Download, Network, Upload, Layers, Paperclip } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; -// Layout constants -const CARD_W = 200; -const CARD_H = 100; -const GAP_X = 32; -const GAP_Y = 80; -const PADDING = 60; -const MIN_ZOOM = 0.2; +const modelLabels = new Map([...claudeModels, ...codexModels].map(model => [model.id, model.label])); +const MIN_ZOOM = 0.05; const MAX_ZOOM = 2; const FIT_PADDING = 40; const TOUCH_MOVE_THRESHOLD = 6; -// ── Tree layout types ─────────────────────────────────────────────────── - -interface LayoutNode { - id: string; - name: string; - role: string; - status: string; - x: number; - y: number; - children: LayoutNode[]; -} - interface Point { x: number; y: number; @@ -57,62 +55,6 @@ interface TouchGesture { // ── Layout algorithm ──────────────────────────────────────────────────── -/** Compute the width each subtree needs. */ -function subtreeWidth(node: OrgNode): number { - if (node.reports.length === 0) return CARD_W; - const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); - const gaps = (node.reports.length - 1) * GAP_X; - return Math.max(CARD_W, childrenW + gaps); -} - -/** Recursively assign x,y positions. */ -function layoutTree(node: OrgNode, x: number, y: number): LayoutNode { - const totalW = subtreeWidth(node); - const layoutChildren: LayoutNode[] = []; - - if (node.reports.length > 0) { - const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0); - const gaps = (node.reports.length - 1) * GAP_X; - let cx = x + (totalW - childrenW - gaps) / 2; - - for (const child of node.reports) { - const cw = subtreeWidth(child); - layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y)); - cx += cw + GAP_X; - } - } - - return { - id: node.id, - name: node.name, - role: node.role, - status: node.status, - x: x + (totalW - CARD_W) / 2, - y, - children: layoutChildren, - }; -} - -/** Layout all root nodes side by side. */ -function layoutForest(roots: OrgNode[]): LayoutNode[] { - if (roots.length === 0) return []; - - const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0); - const gaps = (roots.length - 1) * GAP_X; - let x = PADDING; - const y = PADDING; - - const result: LayoutNode[] = []; - for (const root of roots) { - const w = subtreeWidth(root); - result.push(layoutTree(root, x, y)); - x += w + GAP_X; - } - - // Compute bounds and return - return result; -} - /** Flatten layout tree to list of nodes. */ function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { const result: LayoutNode[] = []; @@ -124,19 +66,6 @@ function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { return result; } -/** Collect all parent→child edges. */ -function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> { - const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = []; - function walk(n: LayoutNode) { - for (const c of n.children) { - edges.push({ parent: n, child: c }); - walk(c); - } - } - nodes.forEach(walk); - return edges; -} - function clampZoom(value: number): number { return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM); } @@ -144,13 +73,14 @@ function clampZoom(value: number): number { function fitChartToViewport( containerWidth: number, containerHeight: number, - bounds: { width: number; height: number }, + bounds: { width: number; height: number; maxFitZoom: number }, + topInset: number, ): { zoom: number; pan: Point } | null { - if (containerWidth <= FIT_PADDING || containerHeight <= FIT_PADDING) return null; + if (containerWidth <= FIT_PADDING || containerHeight - topInset <= FIT_PADDING || bounds.width <= 0 || bounds.height <= 0) return null; const scaleX = (containerWidth - FIT_PADDING) / bounds.width; - const scaleY = (containerHeight - FIT_PADDING) / bounds.height; - const zoom = clampZoom(Math.min(scaleX, scaleY, 1)); + const scaleY = (containerHeight - topInset - FIT_PADDING) / bounds.height; + const zoom = clampZoom(Math.min(scaleX, scaleY, bounds.maxFitZoom)); const chartWidth = bounds.width * zoom; const chartHeight = bounds.height * zoom; @@ -158,7 +88,7 @@ function fitChartToViewport( zoom, pan: { x: (containerWidth - chartWidth) / 2, - y: (containerHeight - chartHeight) / 2, + y: topInset + (containerHeight - topInset - chartHeight) / 2, }, }; } @@ -181,19 +111,7 @@ function touchCenter(a: React.Touch, b: React.Touch, container: HTMLDivElement): }; } -// ── Status dot colors (raw hex for SVG) ───────────────────────────────── - -import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry"; - -const statusDotColor: Record = { - running: "var(--hex-22d3ee)", - active: "var(--hex-4ade80)", - paused: "var(--hex-facc15)", - idle: "var(--hex-facc15)", - error: "var(--hex-f87171)", - terminated: "var(--hex-a3a3a3)", -}; -const defaultDotColor = "var(--hex-a3a3a3)"; +import { agentDisplayAdapterType } from "../adapters/adapter-display-registry"; // ── Main component ────────────────────────────────────────────────────── @@ -202,14 +120,24 @@ export interface OrgChartProps { orgTree?: OrgNode[]; /** Agent records paired with a pre-filtered embedded tree. */ agents?: Agent[]; + /** Nonmatching agents remain in place and navigable, with reduced opacity. */ + matchingAgentIds?: ReadonlySet; /** Hides page-level actions and breadcrumb ownership. */ embedded?: boolean; + /** Toolbar slot beside New Agent when embedded in the roster. */ + controlsContainer?: HTMLElement | null; + /** Let an external toolbar own camera controls through view transitions. */ + actionsRef?: Ref; + /** Task layering is hidden on product surfaces while its design is being explored. */ + enableTaskLayer?: boolean; } -export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, embedded = false }: OrgChartProps = {}) { +export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, matchingAgentIds, embedded = false, controlsContainer, actionsRef, enableTaskLayer = false }: OrgChartProps = {}) { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + const location = useLocation(); + const simulateTasks = import.meta.env.DEV && new URLSearchParams(location.search).get("orgTasks") === "demo"; // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the // button is hidden rather than dead-ending. Export stays available. Both // buttons also respect the operator-hidden settings registry. @@ -234,6 +162,10 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb const orgTree = providedOrgTree ?? queriedOrgTree; const agents = providedAgents ?? queriedAgents; + const memberships = useResourceMemberships(selectedCompanyId); + const membershipMutation = useResourceMembershipMutation(selectedCompanyId); + const starredAgents = useMemo(() => new Set(memberships.data?.starredAgentIds ?? []), [memberships.data?.starredAgentIds]); + const agentMap = useMemo(() => { const m = new Map(); for (const a of agents ?? []) m.set(a.id, a); @@ -244,24 +176,74 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb if (!embedded) setBreadcrumbs([{ label: "Org Chart" }]); }, [embedded, setBreadcrumbs]); - // Layout computation - const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]); + const [tasksVisible, setShowTasks] = useState(simulateTasks); + const showTasks = enableTaskLayer && tasksVisible; + const taskRunsQuery = useQuery({ + queryKey: [...queryKeys.liveRuns(selectedCompanyId!), "org-chart-tasks"], + queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!, { limit: 100 }), + enabled: Boolean(selectedCompanyId && showTasks && !simulateTasks), refetchInterval: showTasks && !simulateTasks ? 15000 : false, + }); + const taskIds = useMemo(() => [...new Set((taskRunsQuery.data ?? []).filter(run => run.status === "running" && run.issueId).map(run => run.issueId!))].sort(), [taskRunsQuery.data]); + const taskTitlesQuery = useQuery({ + queryKey: ["org-chart-task-titles", selectedCompanyId, taskIds], + queryFn: async () => { + const titles = []; + for (let start = 0; start < taskIds.length; start += ISSUE_SUMMARIES_MAX_IDS) { + titles.push(...await issuesApi.summaries(selectedCompanyId!, taskIds.slice(start, start + ISSUE_SUMMARIES_MAX_IDS))); + } + return titles; + }, + enabled: Boolean(selectedCompanyId && showTasks && taskIds.length && !simulateTasks), + }); + const taskRunsView = useQueryView(taskRunsQuery); + const taskTitlesView = useQueryView(taskTitlesQuery); + const tasks = useMemo(() => { + if (!showTasks) return []; + if (simulateTasks) return createTaskPreview(orgTree ?? []); + return collectChartTasks(taskRunsQuery.data ?? [], taskTitlesQuery.data ?? [], new Set(agentMap.keys())); + }, [showTasks, simulateTasks, orgTree, taskRunsQuery.data, taskTitlesQuery.data, agentMap]); + const [geometry] = useState(() => { + const css = getComputedStyle(document.documentElement); + const value = (name: string) => parseFloat(css.getPropertyValue(`--org-${name}`)) || 0; + return { leaderWidth: value("leader-width"), leaderHeight: value("leader-height"), managerWidth: value("manager-width"), managerHeight: value("manager-height"), memberWidth: value("member-width"), memberHeight: value("member-height"), gap: value("gap"), levelGap: value("level-gap"), padding: value("padding"), sparsePadding: value("sparse-padding"), fitZoomMax: value("fit-zoom-max"), sparseFitZoomMax: value("sparse-fit-zoom-max"), taskHeight: value("task-height"), taskBandHeight: value("task-band-height"), taskLabelWidth: value("task-label-width"), taskLegendHeight: value("task-legend-height"), toolbarClearance: value("toolbar-clearance") }; + }); + const containerRef = useRef(null); + const taskMaskId = useId(); + const [taskScroll, setTaskScroll] = useState(0); + const taskTouchX = useRef(null); + const [toolbarHeight, setToolbarHeight] = useState(embedded ? geometry.toolbarClearance : 0); + useEffect(() => { + const toolbar = controlsContainer?.closest(".agents-org-toolbar") as HTMLElement | null; + if (!toolbar) return; + const measure = () => setToolbarHeight(toolbar.offsetHeight); + measure(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(toolbar); + return () => observer?.disconnect(); + }, [controlsContainer]); + // When enabled, reserve a stable band so toggling the overlay never refits the tree. + const topInset = toolbarHeight + (enableTaskLayer ? geometry.taskBandHeight : 0); + const [viewport, setViewport] = useState({ width: 800, height: 600 }); + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const measure = () => setViewport(previous => { + const width = container.clientWidth, height = container.clientHeight; + return width === previous.width && height === previous.height ? previous : { width, height }; + }); + measure(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(container); window.addEventListener("resize", measure); + return () => { observer?.disconnect(); window.removeEventListener("resize", measure); }; + }, [orgTree]); + const arrangement = useMemo(() => layoutOrganization(orgTree ?? [], { width: viewport.width, height: Math.max(0, viewport.height - topInset) }, geometry), [orgTree, viewport, geometry, topInset]); + const layout = arrangement.roots; const allNodes = useMemo(() => flattenLayout(layout), [layout]); - const edges = useMemo(() => collectEdges(layout), [layout]); - - // Compute SVG bounds - const bounds = useMemo(() => { - if (allNodes.length === 0) return { width: 800, height: 600 }; - let maxX = 0, maxY = 0; - for (const n of allNodes) { - maxX = Math.max(maxX, n.x + CARD_W); - maxY = Math.max(maxY, n.y + CARD_H); - } - return { width: maxX + PADDING, height: maxY + PADDING }; - }, [allNodes]); + const nodesById = useMemo(() => new Map(allNodes.map(node => [node.id, node])), [allNodes]); + const edges = useMemo(() => routeReportingEdges(layout, geometry.gap), [layout, geometry.gap]); + const bounds = useMemo(() => ({ width: arrangement.width, height: arrangement.height, maxFitZoom: arrangement.maxFitZoom }), [arrangement]); // Pan & zoom state - const containerRef = useRef(null); const [pan, setPan] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [dragging, setDragging] = useState(false); @@ -290,24 +272,24 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb const hasInitialized = useRef(false); useEffect(() => { hasInitialized.current = false; - }, [orgTree]); + }, [orgTree, viewport, topInset]); useEffect(() => { if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; const container = containerRef.current; - const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds); + const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds, topInset); if (!fitted) return; hasInitialized.current = true; setZoom(fitted.zoom); setPan(fitted.pan); - }, [allNodes, bounds]); + }, [allNodes, bounds, topInset]); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; // Don't drag if clicking a card const target = e.target as HTMLElement; - if (target.closest("[data-org-card]")) return; + if (target.closest("[data-org-card], button, a")) return; setDragging(true); dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; }, [pan]); @@ -360,12 +342,13 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb containerRef.current.clientWidth, containerRef.current.clientHeight, bounds, + topInset, ); if (!fitted) return; setZoom(fitted.zoom); setPan(fitted.pan); - }, [bounds]); + }, [bounds, topInset]); const handleTouchStart = useCallback((e: React.TouchEvent) => { if (e.touches.length >= 2 && containerRef.current) { @@ -467,6 +450,35 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb }; }, [pan, zoom]); + const cameraActions = useMemo(() => ({ + zoomIn: () => zoomTowardPoint(zoom * 1.2, { x: viewport.width / 2, y: viewport.height / 2 }), + zoomOut: () => zoomTowardPoint(zoom * 0.8, { x: viewport.width / 2, y: viewport.height / 2 }), + fit: fitToScreen, + }), [zoomTowardPoint, zoom, viewport, fitToScreen]); + useImperativeHandle(actionsRef, () => cameraActions, [cameraActions]); + const controls = ( + + {enableTaskLayer && } + + ); + const taskWidth = Math.min(geometry.taskLabelWidth, Math.max(0, viewport.width - geometry.padding * 2)); + const taskGap = geometry.gap / 2; + const taskContentWidth = tasks.length * (taskWidth + taskGap) - taskGap; + const taskStartX = Math.max(geometry.padding, (viewport.width - taskContentWidth) / 2); + const taskLabelY = toolbarHeight + geometry.taskLegendHeight; + const taskLinks = useMemo(() => { + const links: Array<{ key: string; node: LayoutNode; x: number }> = []; + tasks.forEach((task, index) => { + const x = taskStartX + index * (taskWidth + taskGap) - taskScroll; + if (x + taskWidth < 0 || x > viewport.width) return; + task.agentIds.forEach((id, agentIndex) => { + const node = nodesById.get(id); + if (node) links.push({ key: `${task.id}-${id}`, node, x: x + taskWidth * (agentIndex + 1) / (task.agentIds.length + 1) }); + }); + }); + return links; + }, [tasks, taskStartX, taskWidth, taskGap, taskScroll, viewport.width, nodesById]); + if (!selectedCompanyId) { return ; } @@ -496,9 +508,10 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb return (
+ {actionsRef ? null : controlsContainer ? createPortal(controls, controlsContainer) : !embedded ?
{controls}
: null} {!embedded && (showImport || showExport) ? (
{showImport ? ( @@ -522,7 +535,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
- {/* Zoom controls */} -
- - - -
- {/* SVG layer for edges */} - {edges.map(({ parent, child }) => { - const x1 = parent.x + CARD_W / 2; - const y1 = parent.y + CARD_H; - const x2 = child.x + CARD_W / 2; - const y2 = child.y; - const midY = (y1 + y2) / 2; - + {edges.map(({ parent, child, path }) => { return ( ); })} @@ -614,7 +577,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb {/* Card layer */}
{allNodes.map((node) => { const agent = agentMap.get(node.id); - const dotColor = statusDotColor[node.status] ?? defaultDotColor; + const adapterType = agent ? agentDisplayAdapterType(agent) : undefined; + const model = composerDefaultModel(agent); + const modelLabel = (modelLabels.get(model) ?? model.split("/").pop() ?? model) + .replace(/^(?:Claude|Codex)\s+/i, ""); + const effort = agent ? readComposerRunSettings({ adapterConfig: agent.adapterConfig }, agent.adapterType).effort : null; + const status = (agent?.status ?? node.status).replace(/_/g, " "); + const statusLabel = status === "active" ? "Idle" : status.charAt(0).toUpperCase() + status.slice(1); + const starPending = membershipMutation.isPending && membershipMutation.variables?.resourceId === node.id; + const openAgent = () => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`); return ( { if (event.key === "Enter" && event.target === event.currentTarget) openAgent(); }} style={{ left: node.x, top: node.y, - width: CARD_W, - minHeight: CARD_H, + width: node.width, height: node.height, }} - onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} + onClick={openAgent} onClickCapture={(e) => { if (!suppressNextCardClick.current) return; suppressNextCardClick.current = false; @@ -643,41 +622,107 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb e.stopPropagation(); }} > -
- {/* Agent icon + status dot */} -
-
- -
- } +
+ {node.leader && ( + membershipMutation.mutate({ resourceType: "agent", resourceId: node.id, resourceName: node.name, starred })} /> + )} +
{node.name}
+
+ {agent?.title || roleLabel(node.role)}
- {/* Name + role + adapter type */} -
- - {node.name} - - - {agent?.title ?? roleLabel(node.role)} - - {agent && ( - - {getAdapterLabel(agentDisplayAdapterType(agent))} - - )} - {agent && agent.capabilities && ( - - {agent.capabilities} - - )} -
+ {node.leader &&
{statusLabel}
}
+
+ {node.leader ? ( + + ) : ( + + )} +
+ {adapterType && (modelLabel || effort) && ( +
+ {modelLabel && ( + + + + + {modelLabel} + + )} + {modelLabel && effort && " "} + {effort && ( + + {EFFORT_LABELS[effort] ?? effort} + + )} +
+ )} ); })}
+ {showTasks && <> + + + + + {allNodes.map(node => )} + + + + {taskLinks.map((link, index) => { + const lane = pan.x + (link.node.x - geometry.gap * (0.25 + (index + 1) / (taskLinks.length + 1) * 0.5)) * zoom; + const track = topInset + geometry.padding * zoom * (index + 1) / (taskLinks.length + 1); + const targetX = pan.x + link.node.x * zoom; + const targetY = pan.y + (link.node.y + link.node.height / 2) * zoom; + return ; + })} + + +
+
+ {simulateTasks ? `${tasks.length} simulated running tasks · preview only` + : taskRunsView.kind === "error" ? + : taskTitlesView.kind === "error" && taskIds.length ? + : taskRunsView.kind === "stale" ? "Task updates paused while reconnecting…" + : taskRunsQuery.isPending || taskRunsView.kind === "reconnecting" ? "Loading running tasks…" : tasks.length ? `${tasks.length} running ${tasks.length === 1 ? "task" : "tasks"}${(taskRunsQuery.data?.length ?? 0) >= 100 ? " · showing first 100 runs" : ""}` : "No running tasks"} +
+
setTaskScroll(event.currentTarget.scrollLeft)} onWheel={event => event.stopPropagation()} onMouseDown={event => event.stopPropagation()} + onTouchStart={event => { event.stopPropagation(); taskTouchX.current = event.touches[0]?.clientX ?? null; }} + onTouchMove={event => { + event.stopPropagation(); + const x = event.touches[0]?.clientX; + if (taskTouchX.current !== null && x !== undefined) event.currentTarget.scrollLeft += taskTouchX.current - x; + taskTouchX.current = x ?? null; + }} + onTouchEnd={event => { event.stopPropagation(); taskTouchX.current = null; }} + onTouchCancel={event => { event.stopPropagation(); taskTouchX.current = null; }}> +
+ {tasks.map((task, index) => { + const labelProps = { + "data-org-task": true, + title: `${simulateTasks ? "Simulated task" : task.identifier ?? "Task"} · ${task.title} · Working: ${task.agentIds.map(id => agentMap.get(id)?.name ?? "Agent").join(", ")}`, + className: "org-task-label absolute flex items-center gap-1.5 rounded px-2 text-xs text-muted-foreground no-underline hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", + style: { left: taskStartX + index * (taskWidth + taskGap), width: taskWidth, height: geometry.taskHeight }, + children: <>{task.title}, + }; + return simulateTasks ? : ; + })} +
+
+
+ }
); diff --git a/ui/src/pages/org-chart/layout.test.ts b/ui/src/pages/org-chart/layout.test.ts new file mode 100644 index 00000000000..8fb8507e0d0 --- /dev/null +++ b/ui/src/pages/org-chart/layout.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { layoutOrganization, routeReportingEdges, type LayoutNode, type ChartGeometry } from "./layout"; +import type { OrgNode } from "../../api/agents"; +const geometry: ChartGeometry = { leaderWidth: 280, leaderHeight: 368, managerWidth: 232, managerHeight: 320, memberWidth: 192, memberHeight: 116, gap: 32, levelGap: 72, padding: 48, taskHeight: 32, sparsePadding: 80, fitZoomMax: 1.25, sparseFitZoomMax: 1 }; +const node = (id: string, reports: OrgNode[] = []): OrgNode => ({ id, name: id, role: "general", status: "idle", reports }); +function flatten(roots: LayoutNode[]): LayoutNode[] { return roots.flatMap(root => [root, ...flatten(root.children)]); } +describe("compact organization layout", () => { + it("wraps broad teams, cascades leaders, and keeps every card separate", () => { + const tree = [node("ceo", [node("cto", Array.from({ length: 12 }, (_, i) => node(`eng-${i}`))), node("cmo", Array.from({ length: 4 }, (_, i) => node(`mkt-${i}`))), node("staff")])]; + for (const viewport of [{ width: 1500, height: 900 }, { width: 390, height: 750 }]) { + const result = layoutOrganization(tree, viewport, geometry); + const nodes = flatten(result.roots); + expect(nodes).toHaveLength(20); + expect(nodes[0].width).toBeGreaterThan(nodes[1].width); + expect(nodes[1].height).toBeGreaterThan(nodes[2].height); + expect(new Set(nodes.filter(n => n.id.startsWith("eng-")).map(n => n.y)).size).toBeGreaterThan(1); + for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i], b = nodes[j]; + expect(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y).toBe(true); + } + const scale = Math.min(viewport.width / result.width, viewport.height / result.height); + expect(result.height * scale).toBeGreaterThan(viewport.height * 0.65); + } + }); + it("keeps a mixed team's ICs beside its leaders", () => { + const tree = [node("ceo", [ + node("cto", Array.from({ length: 12 }, (_, i) => node(`eng-${i}`))), + node("cmo", Array.from({ length: 4 }, (_, i) => node(`mkt-${i}`))), + ...Array.from({ length: 4 }, (_, i) => node(`staff-${i}`)), + ])]; + const result = layoutOrganization(tree, { width: 1500, height: 900 }, geometry); + const nodes = flatten(result.roots); + const cto = nodes.find(n => n.id === "cto")!; + expect(nodes.find(n => n.id === "staff-0")!.y).toBe(cto.y); + for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i], b = nodes[j]; + const aBottom = a.y + a.height; + const bBottom = b.y + b.height; + expect(a.x + a.width <= b.x || b.x + b.width <= a.x || aBottom <= b.y || bBottom <= a.y).toBe(true); + } + }); + it("routes broad and stacked teams with separate orthogonal tracks outside cards", () => { + const tree = [node("ceo", [node("cmo", Array.from({ length: 4 }, (_, i) => node(`mkt-${i}`))), node("cto", Array.from({ length: 12 }, (_, i) => node(`eng-${i}`))), ...Array.from({ length: 4 }, (_, i) => node(`staff-${i}`))])]; + for (const viewport of [{ width: 1500, height: 900 }, { width: 390, height: 750 }]) { + const result = layoutOrganization(tree, viewport, geometry); + const nodes = flatten(result.roots); + const routes = routeReportingEdges(result.roots, geometry.gap); + expect(routes).toHaveLength(nodes.length - 1); + const segments = routes.flatMap(route => route.points.slice(1).map((end, i) => ({ start: route.points[i], end }))); + for (const segment of segments) { + const { start: a, end: b } = segment; + expect(a.x === b.x || a.y === b.y).toBe(true); + for (const card of nodes) { + const inside = a.x === b.x + ? a.x > card.x && a.x < card.x + card.width && Math.max(a.y, b.y) > card.y && Math.min(a.y, b.y) < card.y + card.height + : a.y > card.y && a.y < card.y + card.height && Math.max(a.x, b.x) > card.x && Math.min(a.x, b.x) < card.x + card.width; + expect(inside, `Line crosses ${card.id}`).toBe(false); + } + } + for (let i = 0; i < segments.length; i++) for (let j = i + 1; j < segments.length; j++) { + const a = segments[i], b = segments[j]; + const vertical = a.start.x === a.end.x && b.start.x === b.end.x; + const horizontal = a.start.y === a.end.y && b.start.y === b.end.y; + const overlap = vertical && a.start.x === b.start.x + ? Math.min(Math.max(a.start.y, a.end.y), Math.max(b.start.y, b.end.y)) > Math.max(Math.min(a.start.y, a.end.y), Math.min(b.start.y, b.end.y)) + : horizontal && a.start.y === b.start.y && Math.min(Math.max(a.start.x, a.end.x), Math.max(b.start.x, b.end.x)) > Math.max(Math.min(a.start.x, a.end.x), Math.min(b.start.x, b.end.x)); + expect(overlap, `Shared line segments ${i} and ${j}`).toBe(false); + } + } + }); + it("uses exactly three stable tiers, including deeper leads and root-level contributors", () => { + const tree = [node("ceo", [node("cto", [node("team-lead", [node("engineer")])]), node("staff")]), node("independent")]; + for (const viewport of [{ width: 1500, height: 900 }, { width: 2400, height: 1600 }, { width: 390, height: 750 }]) { + const nodes = new Map(flatten(layoutOrganization(tree, viewport, geometry).roots).map(n => [n.id, n])); + expect(nodes.get("ceo")).toMatchObject({ tier: 1, width: geometry.leaderWidth, height: geometry.leaderHeight }); + for (const id of ["cto", "team-lead"]) expect(nodes.get(id)).toMatchObject({ tier: 2, width: geometry.managerWidth, height: geometry.managerHeight }); + for (const id of ["engineer", "staff", "independent"]) expect(nodes.get(id)).toMatchObject({ tier: 3, width: geometry.memberWidth, height: geometry.memberHeight }); + for (const leader of [...nodes.values()].filter(n => n.leader)) expect(leader.height).toBeGreaterThan(leader.width); + } + }); + it("caps sparse organizations at natural size with generous, symmetric padding", () => { + for (const tree of [[node("solo")], [node("lead", [node("report")])], [node("first"), node("second")]]) { + const result = layoutOrganization(tree, { width: 2400, height: 1600 }, geometry); + const nodes = flatten(result.roots); + expect(result.maxFitZoom).toBe(1); + expect(Math.min(...nodes.map(n => n.x))).toBe(geometry.sparsePadding); + expect(Math.min(...nodes.map(n => n.y))).toBe(geometry.sparsePadding); + expect(result.width - Math.max(...nodes.map(n => n.x + n.width))).toBe(geometry.sparsePadding); + expect(result.height - Math.max(...nodes.map(n => n.y + n.height))).toBe(geometry.sparsePadding); + expect(nodes[0].width).toBe(nodes[0].leader ? geometry.leaderWidth : geometry.memberWidth); + } + }); + it("handles a large roster without revisiting source subtrees quadratically", () => { + let reads = 0; + const root = node("root", Array.from({ length: 500 }, (_, i) => node(`agent-${i}`))); + const reports = root.reports; + Object.defineProperty(root, "reports", { get: () => { reads++; if (reads > 30) throw new Error("Repeated subtree scan"); return reports; } }); + const result = layoutOrganization([root], { width: 1500, height: 900 }, geometry); + expect(flatten(result.roots)).toHaveLength(501); + expect(reads).toBeLessThanOrEqual(18); + }); +}); diff --git a/ui/src/pages/org-chart/layout.ts b/ui/src/pages/org-chart/layout.ts new file mode 100644 index 00000000000..41961647bfe --- /dev/null +++ b/ui/src/pages/org-chart/layout.ts @@ -0,0 +1,150 @@ +import type { OrgNode } from "../../api/agents"; + +export interface ChartGeometry { + leaderWidth: number; leaderHeight: number; + managerWidth: number; managerHeight: number; + memberWidth: number; memberHeight: number; + gap: number; levelGap: number; padding: number; taskHeight: number; + sparsePadding: number; fitZoomMax: number; sparseFitZoomMax: number; +} +export interface LayoutNode { + id: string; name: string; role: string; status: string; + x: number; y: number; width: number; height: number; + depth: number; tier: 1 | 2 | 3; leader: boolean; children: LayoutNode[]; + offsetX?: number; offsetY?: number; +} +interface Block { roots: LayoutNode[]; width: number; height: number } +function shift(node: LayoutNode, x: number, y: number) { + node.offsetX = (node.offsetX ?? 0) + x; + node.offsetY = (node.offsetY ?? 0) + y; +} +function resolvePositions(node: LayoutNode, x = 0, y = 0) { + x += node.offsetX ?? 0; y += node.offsetY ?? 0; + node.x += x; node.y += y; + node.children.forEach(child => resolvePositions(child, x, y)); + delete node.offsetX; delete node.offsetY; +} +function pack(blocks: Block[], columns: number, gap: number): Block { + const rows: Block[][] = []; + for (let i = 0; i < blocks.length; i += columns) rows.push(blocks.slice(i, i + columns)); + const width = Math.max(0, ...rows.map(row => row.reduce((sum, b) => sum + b.width, 0) + (row.length - 1) * gap)); + let y = 0; + const roots: LayoutNode[] = []; + for (const row of rows) { + // Keep partial rows aligned so connector lanes stay between columns. + let x = 0; + for (const block of row) { + block.roots.forEach(node => { shift(node, x, y); roots.push(node); }); + x += block.width + gap; + } + y += Math.max(...row.map(b => b.height)) + gap; + } + return { roots, width, height: Math.max(0, y - gap) }; +} + +/** Pack three fixed card tiers; keep source traversal and packing passes bounded. */ +export function layoutOrganization(roots: OrgNode[], viewport: { width: number; height: number }, g: ChartGeometry): Block & { maxFitZoom: number } { + let count = 0; + const snapshot = (node: OrgNode): OrgNode => { + count++; + const { reports, ...fields } = node; + return { ...fields, reports: reports.map(snapshot) }; + }; + const source = roots.map(snapshot); + // Small organizations should feel centered and roomy, not like oversized posters. + const density = Math.min(1, Math.max(0, (count - 4) / 8)); + const padding = g.sparsePadding + (g.padding - g.sparsePadding) * density; + const maxFitZoom = g.sparseFitZoomMax + (g.fitZoomMax - g.sparseFitZoomMax) * density; + if (!source.length) return { roots: [], width: 0, height: 0, maxFitZoom }; + const availableWidth = Math.max(1, viewport.width - g.padding); + const availableHeight = Math.max(1, viewport.height - g.padding); + const fitScale = (block: Block) => Math.min(availableWidth / block.width, availableHeight / block.height, maxFitZoom); + const arrange = (columns: number): Block => { + const build = (node: OrgNode, depth: number): Block => { + const reports = node.reports; + const leader = reports.length > 0; + const tier = !leader ? 3 : depth === 0 ? 1 : 2; + const width = tier === 1 ? g.leaderWidth : tier === 2 ? g.managerWidth : g.memberWidth; + const height = tier === 1 ? g.leaderHeight : tier === 2 ? g.managerHeight : g.memberHeight; + const childBlocks = reports.map(child => build(child, depth + 1)); + const teams = childBlocks.filter(block => block.roots[0].leader); + const members = childBlocks.filter(block => !block.roots[0].leader); + // A mixed leadership team keeps its individual contributors in a compact + // side column, instead of placing them below the deepest reporting branch. + const branches = teams.length && members.length + ? [...teams, pack(members, 1, g.gap)] + : childBlocks; + const children = pack(branches, columns, g.gap); + const blockWidth = Math.max(width, children.width); + children.roots.forEach(child => shift(child, (blockWidth - children.width) / 2, height + g.levelGap)); + return { + roots: [{ id: node.id, name: node.name, role: node.role, status: node.status, x: (blockWidth - width) / 2, y: 0, width, height, depth, tier, leader, children: children.roots }], + width: blockWidth, + height: height + (children.roots.length ? g.levelGap + children.height : 0), + }; + }; + const candidate = pack(source.map(root => build(root, 0)), columns, g.levelGap); + return { ...candidate, width: candidate.width + padding * 2, height: candidate.height + padding * 2 }; + }; + let best = arrange(1); + let bestScore = -Infinity; + for (let columns = 1; columns <= 6; columns++) { + const candidate = columns === 1 ? best : arrange(columns); + const scale = fitScale(candidate); + const coverage = candidate.width * candidate.height * scale * scale / Math.max(1, viewport.width * viewport.height); + const score = scale * (0.8 + coverage * 0.2); + if (score > bestScore) { + best = candidate; bestScore = score; + } + } + best.roots.forEach(root => { shift(root, padding, padding); resolvePositions(root); }); + return { ...best, maxFitZoom }; +} + +export interface ReportingRoute { + parent: LayoutNode; + child: LayoutNode; + points: Array<{ x: number; y: number }>; + path: string; +} + +/** Give every relationship a separate port, horizontal track, and column lane. */ +export function routeReportingEdges(roots: LayoutNode[], gap: number): ReportingRoute[] { + const routes: ReportingRoute[] = []; + const visit = (parent: LayoutNode) => { + const columns = new Map(); + for (const child of parent.children) { + const column = columns.get(child.x) ?? []; + column.push(child); + columns.set(child.x, column); + } + const lanes: Array<{ child: LayoutNode; x: number; port: number }> = []; + for (const column of columns.values()) { + column.sort((a, b) => a.y - b.y); + column.forEach((child, row) => lanes.push({ + child, x: child.x - gap * (row + 1) / (column.length + 1), port: 0, + })); + } + lanes.sort((a, b) => a.x - b.x); + lanes.forEach((lane, index) => { + lane.port = parent.x + gap / 2 + (parent.width - gap) * (index + 1) / (lanes.length + 1); + }); + const left = lanes.filter(lane => lane.x < lane.port); + const right = lanes.filter(lane => lane.x >= lane.port).reverse(); + const tracks = [...left, ...right]; + const bottom = parent.y + parent.height; + const firstRow = Math.min(...parent.children.map(child => child.y)); + tracks.forEach((lane, index) => { + const trackY = bottom + (firstRow - bottom) * (index + 1) / (tracks.length + 1); + const endY = lane.child.y + lane.child.height / 2; + const points = [ + { x: lane.port, y: bottom }, { x: lane.port, y: trackY }, + { x: lane.x, y: trackY }, { x: lane.x, y: endY }, { x: lane.child.x, y: endY }, + ]; + routes.push({ parent, child: lane.child, points, path: points.map((point, i) => `${i ? "L" : "M"} ${point.x} ${point.y}`).join(" ") }); + }); + parent.children.forEach(visit); + }; + roots.forEach(visit); + return routes; +} diff --git a/ui/src/pages/org-chart/task-preview.ts b/ui/src/pages/org-chart/task-preview.ts new file mode 100644 index 00000000000..e03900b6054 --- /dev/null +++ b/ui/src/pages/org-chart/task-preview.ts @@ -0,0 +1,19 @@ +import type { OrgNode } from "../../api/agents"; +import type { ChartTask } from "./tasks"; + +/** Development-only fixtures; these never create issues or start agent runs. */ +export function createTaskPreview(roots: OrgNode[]): ChartTask[] { + const nodes: OrgNode[] = []; + const visit = (node: OrgNode) => { nodes.push(node); node.reports.forEach(visit); }; + roots.forEach(visit); + if (!nodes.length) return []; + const engineering = nodes.find(node => node.role === "cto") ?? nodes.find(node => node.reports.length) ?? nodes[0]; + const marketing = nodes.find(node => node.role === "cmo") ?? nodes[0]; + const engineer = engineering.reports.find(node => node.role === "engineer") ?? engineering.reports[0] ?? engineering; + const reviewer = nodes.find(node => node.role === "qa") ?? nodes.at(-1)!; + return [ + { id: "preview-launch", title: "Plan product launch", identifier: null, ownerId: marketing.id, agentIds: [marketing.id] }, + { id: "preview-onboarding", title: "Build onboarding flow", identifier: null, ownerId: engineering.id, agentIds: [...new Set([engineering.id, engineer.id])] }, + { id: "preview-release", title: "Review release readiness", identifier: null, ownerId: reviewer.id, agentIds: [reviewer.id] }, + ]; +} diff --git a/ui/src/pages/org-chart/tasks.ts b/ui/src/pages/org-chart/tasks.ts new file mode 100644 index 00000000000..23574385742 --- /dev/null +++ b/ui/src/pages/org-chart/tasks.ts @@ -0,0 +1,17 @@ +import type { IssueSummary } from "@paperclipai/shared"; +import type { LiveRunForIssue } from "../../api/heartbeats"; +export interface ChartTask { id: string; title: string; identifier: string | null; agentIds: string[]; ownerId: string } +/** Only actual running executions create associations. No inference from reporting lines. */ +export function collectChartTasks(runs: LiveRunForIssue[], summaries: IssueSummary[], agentIds: ReadonlySet): ChartTask[] { + const names = new Map(summaries.map(task => [task.id, task])); + const associations = new Map>(); + for (const run of runs) { + if (run.status !== "running" || !run.issueId || !agentIds.has(run.agentId)) continue; + const ids = associations.get(run.issueId) ?? new Set(); + ids.add(run.agentId); associations.set(run.issueId, ids); + } + return [...associations].sort(([a], [b]) => a.localeCompare(b)).map(([id, agents]) => { + const task = names.get(id), ids = [...agents].sort(); + return { id, title: task?.title ?? "Running task", identifier: task?.identifier ?? null, agentIds: ids, ownerId: ids[0] }; + }); +} diff --git a/ui/storybook/fixtures/agent-avatar-url.test.ts b/ui/storybook/fixtures/agent-avatar-url.test.ts index f1aaebf8d0f..73fafbe4ebe 100644 --- a/ui/storybook/fixtures/agent-avatar-url.test.ts +++ b/ui/storybook/fixtures/agent-avatar-url.test.ts @@ -10,6 +10,7 @@ describe("packaged Storybook avatar URLs", () => { expect(new URL(agentAvatarUrl(appearance, 24, 2), "https://example.com/branches/personas/builds/123/iframe.html").href) .toBe("https://example.com/branches/personas/builds/123/agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png"); expect(agentAvatarUrl(appearance)).toBe("./agent-avatar-images/cap-v1/bubblegum-sky/rest-512-1.png"); + expect(agentAvatarUrl(appearance, 256, 2, "rest", false, "transparent", "portrait")).toBe("./agent-avatar-images/cap-v1/bubblegum-sky/rest-256-2-portrait-v1.png"); }); it("gives every finite preset a distinct filename, retaining logical size separately from density", () => { diff --git a/ui/storybook/fixtures/agent-avatar-url.ts b/ui/storybook/fixtures/agent-avatar-url.ts index 78013c31d93..2e3643e328f 100644 --- a/ui/storybook/fixtures/agent-avatar-url.ts +++ b/ui/storybook/fixtures/agent-avatar-url.ts @@ -6,7 +6,8 @@ export const agentAvatarUrl: typeof apiAvatarUrl = (...args) => { if (args[0].customAvatarAssetId) return url.pathname; const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, ""); const background = url.searchParams.get("background") === "paperclip-dark" ? "-paperclip-dark-v1" : ""; - const path = `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}${background}.png`; + const view = url.searchParams.get("view") === "portrait" ? "-portrait-v1" : ""; + const path = `./agent-avatar-images/${preset}-${url.searchParams.get("size")}-${url.searchParams.get("scale")}${background}${view}.png`; // The build-time asset packer needs relative file names. Browser consumers // need absolute URLs because CSS custom properties use the stylesheet base. return typeof document === "undefined" ? path : new URL(path, document.baseURI).href;