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 <noreply@paperclip.ing>
This commit is contained in:
TonioandPaperclip authored and GitHub committed 2026-11-12 01:23:59 +00:00
1 parent 45170b0cac
commit 9dfdeaad86
56 files changed
+1864 -489

No files matched your search

+36 -8
View File
@@ -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.
+11
View File
@@ -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.
+10 -1
View File
@@ -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");
+3 -2
View File
@@ -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";
+6 -4
View File
@@ -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(); }
}
+1
View File
@@ -37,6 +37,7 @@ export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructio
export const upsertAgentInstructionsFileSchema = z.object({
path: z.string().min(1).max(512),
renameTo: z.string().min(1).max(512).optional(),
content: z.string().max(1024 * 1024),
baseRevisionId: z.string().uuid().nullable().optional(),
baseHash: z.string().regex(/^[a-f0-9]{64}$/).nullable().optional(),
+1
View File
@@ -14,6 +14,7 @@ const configurationTransactionCallers = new Set([
"modules/external-agents/muse.ts", "modules/external-agents/dot.ts",
"services/secret-proposals.ts", "services/connection-intents.ts", "services/dot-runner-broker.ts",
"services/public-mcp/oauth.ts", "services/agent-profile-avatar.ts", "services/agent-instruction-revisions.ts", "services/company-skills.ts",
"services/agent-file-store.ts",
]);
function normalizedRelative(from, to) {
+8 -5
View File
@@ -18,10 +18,10 @@ export function storybookAgentAvatarAssets() {
})();
server.middlewares.use((req, res, next) => {
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 });
}
+19 -1
View File
@@ -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);
@@ -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<void>(resolve => { enter = resolve; });
const released = new Promise<void>(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,
@@ -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<typeof import("../services/agent-file-store.js")>();
return { ...actual, agentFileStore: (...args: Parameters<typeof actual.agentFileStore>) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile }) };
return { ...actual, agentFileStore: (...args: Parameters<typeof actual.agentFileStore>) => ({ ...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")
+4 -3
View File
@@ -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<typeof createAgentAvatar
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
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");
+11
View File
@@ -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);
+3 -2
View File
@@ -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),
+1 -1
View File
@@ -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();
+4 -3
View File
@@ -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"); }
+51 -4
View File
@@ -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<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise<T>, texts: string[] = []) {
fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor, onRollback: (restore: () => Promise<void>) => void) => Promise<T>, 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<void>) | 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<string, unknown>) {
@@ -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);
@@ -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",
+2
View File
@@ -193,6 +193,8 @@ export const agentsApi = {
data: { path: string; content: string; baseRevisionId?: string | null; baseHash?: string | null; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
renameInstructionsFile: (id: string, data: { path: string; renameTo: string; content: string; baseHash: string | null }, companyId?: string) =>
api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
instructionCandidates: (id: string, companyId?: string) =>
api.get<AgentInstructionCandidate[]>(agentPath(id, companyId, "/instructions-bundle/candidates")),
resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) =>
+5 -2
View File
@@ -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({
)}
</PopoverContent>
</Popover>
{showChat && <Button variant="outline" size="icon" aria-label={`Chat with ${agent.name}`} title="Chat with this agent"
{showChat && <Button variant="outline" size={size} aria-label={`Chat with ${agent.name}`} title="Chat with this agent"
disabled={disabled} onClick={() => { if (!onBeforeNavigate || onBeforeNavigate()) navigate(`/chats/${canonicalAgentRef}`); }}>
<MessagesSquare className="size-4" />
Chat
</Button>}
{needsSetup ? (
<Button size={size} disabled={disabled} onClick={finishSetup}>
@@ -540,7 +543,7 @@ export function AgentActionButtons({
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
>
{showAssignIcon && <Plus className="size-4" />}
{showAssignIcon && <AssignIcon className="size-4" />}
{assignLabel}
</Button>
)}
+1 -1
View File
@@ -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(<><AgentAvatar appearance={appearance} /><AgentCharacter appearance={appearance} /></>));
await act(async () => root.render(<><AgentAvatar appearance={appearance} view="portrait" /><AgentCharacter appearance={appearance} /></>));
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);
+5 -4
View File
@@ -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<string | null>(null);
const initials = useAvatarInitials(deriveInitials(name ?? agent?.name ?? "Agent"));
return (
<span data-slot="agent-avatar" className={cn("relative inline-flex shrink-0 items-center justify-center align-middle", avatarSizeClasses[size], className)}
role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{failedUrl === src ? <span className="text-xs text-muted-foreground">{initials}</span> :
<img src={src} srcSet={identity.customAvatarAssetId ? undefined : `${agentAvatarUrl(identity, size, 2, pose, muted)} 2x`} alt="" width={size} height={size}
<img src={src} srcSet={identity.customAvatarAssetId ? undefined : `${agentAvatarUrl(identity, size, 2, pose, muted, "transparent", view)} 2x`} alt="" width={size} height={size}
decoding="async" loading="lazy" className="size-full object-contain" onError={() => setFailedUrl(src)} />}
</span>
);
+54
View File
@@ -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<typeof createRoot>;
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) => <AgentCharacter key={index} animationGroup="org-chart" trackingScope="page" followCursor />)}</>));
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(<><AgentCharacter /><AgentCharacter /><AgentCharacter animationGroup="org-chart" appearance={{ schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue", customAvatarAssetId: "22222222-2222-4222-8222-222222222222" }} /></>));
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();
});
+22 -11
View File
@@ -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<typeof createCharacter>;
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<AgentAvatarProps, "pose"> {
state?: CharacterState;
motion?: "auto" | "still";
@@ -13,12 +20,16 @@ export interface AgentCharacterProps extends Omit<AgentAvatarProps, "pose"> {
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<HTMLSpanElement>(null), host = useRef<HTMLSpanElement>(null), player = useRef<Player | null>(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));
});
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 }) => <>
<button onClick={() => onChange({ ...binding, method: "subscription" })}>Other account</button>
<button onClick={() => onChange({ ...binding })}>Original account</button>
</>);
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;
+24 -6
View File
@@ -542,8 +542,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
const displayedOriginals = new Map<string, unknown>();
/** Read effective value: overlay if dirty, else original */
function eff<T>(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<string, unknown>) : {};
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
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) {
<div className="space-y-6">
{showRunnerUpgrade && !runnerUpgraded ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="agent-runner-upgrade flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Upgrade to Paperclip Runner</p>
<p className="text-sm text-muted-foreground">
+13 -1
View File
@@ -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<HTMLDivElement>;
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 <div data-canonical-toolbar="agents" role="toolbar" aria-label="Agents controls" className="flex flex-wrap items-center gap-3">
<div className="design-toolbar-controls flex w-full flex-wrap items-center gap-4">
<ToolbarSearch query={query} onQueryChange={onQueryChange} label="Search agents" placeholder="Search agents…" />
@@ -20,7 +25,14 @@ export function AgentsToolbar({ query, onQueryChange, view, onViewChange, hideVi
<Button type="button" size="icon-sm" variant={view === "list" ? "secondary" : "ghost"} className="design-toolbar-icon" onClick={() => onViewChange("list")} title="List view" aria-label="List view" aria-pressed={view === "list"}><List aria-hidden /></Button>
<Button type="button" size="icon-sm" variant={view === "org" ? "secondary" : "ghost"} className="design-toolbar-icon" onClick={() => onViewChange("org")} title="Org chart view" aria-label="Org chart view" aria-pressed={view === "org"}><Network aria-hidden /></Button>
</div>}
{filters}
<div className="design-toolbar-actions">
{orgActions && (
<div ref={orgControlsRef} data-org-toolbar-controls className="agents-org-controls-reveal"
data-visible={view === "org"} aria-hidden={view !== "org"} inert={view !== "org"}>
<div className="min-w-0 overflow-hidden"><OrgChartControls {...orgActions} /></div>
</div>
)}
<Button size="sm" variant="outline" data-toolbar-primary onClick={onNewAgent}><Plus aria-hidden />New Agent</Button>
</div>
</div>
+12 -1
View File
@@ -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() {
</aside>
);
}
/** 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 <nav aria-label="Connectors" className="mb-4 flex rounded-lg border border-border bg-background p-1">
<NavLink to="/apps" end className={segmentClass}>Browse</NavLink>
<NavLink to="/apps/review" className={segmentClass}>Review{reviewCount > 0 && <span className="rounded-full bg-muted px-1.5 text-xs" aria-label={`${reviewCount} waiting for your OK`}>{reviewCount}</span>}</NavLink>
</nav>;
}
+1
View File
@@ -108,6 +108,7 @@ export function InboxArchiveButton({
<button
type="button"
data-slot="icon-button"
data-task-archive=""
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
+14
View File
@@ -88,6 +88,7 @@ vi.mock("./CompanySettingsSidebar", () => ({
vi.mock("./AppsSidebar", () => ({
AppsSidebar: () => <div>Apps sidebar</div>,
AppsMobileNav: () => <nav aria-label="Mobile connectors">Browse Review</nav>,
}));
vi.mock("./AgentContextualSidebar", () => ({
@@ -638,6 +639,19 @@ describe("Layout", () => {
});
});
it.each(["/PAP/apps", "/PAP/apps/review"])("shows top connector segments without a side rail on mobile: %s", async (path) => {
currentPathname = path;
mockSidebarState.isMobile = true;
mockSidebarState.sidebarOpen = false;
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => renderLayoutView(root, queryClient));
await flushReact();
expect(container.querySelector('#main-content [aria-label="Mobile connectors"]')).not.toBeNull();
expect(container.querySelector('.app-content-row [data-secondary-sidebar]')).toBeNull();
await act(async () => root.unmount());
});
it("renders a mobile company settings selector on company settings routes", async () => {
currentPathname = "/PAP/company/settings/secrets";
mockSidebarState.isMobile = true;
+2 -1
View File
@@ -13,7 +13,7 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "
import { Sidebar } from "./Sidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { AppsSidebar } from "./AppsSidebar";
import { AppsSidebar, AppsMobileNav } from "./AppsSidebar";
import { AppDetailSidebar } from "./AppConnectionSidebar";
import { AgentContextualSidebar } from "./AgentContextualSidebar";
import { RoutineContextualSidebar } from "./RoutineContextualSidebar";
@@ -752,6 +752,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
: "overflow-auto [scrollbar-gutter:stable]",
)}
>
{isMobile && isAppsRoute && (!companyPathSegments[1] || companyPathSegments[1] === "review") ? <AppsMobileNav /> : null}
{hasUnknownCompanyPrefix ? (
<NotFoundPage
scope="invalid_company_prefix"
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { Maximize2, Minus, Plus } from "lucide-react";
import { Button } from "./ui/button";
export interface OrgChartActions {
zoomIn: () => void;
zoomOut: () => void;
fit: () => void;
}
/** Shared camera controls can stay mounted while the chart view fades away. */
export function OrgChartControls({ zoomIn, zoomOut, fit, children }: OrgChartActions & { children?: ReactNode }) {
return (
<div className="org-chart-controls flex items-center gap-2" role="group" aria-label="Org chart controls">
<div className="design-view-toggle" role="group" aria-label="Chart camera">
<Button variant="ghost" size="icon-sm" className="design-toolbar-icon" aria-label="Zoom out" title="Zoom out" onClick={zoomOut}><Minus className="size-4" /></Button>
<Button variant="ghost" size="icon-sm" className="design-toolbar-icon" aria-label="Zoom in" title="Zoom in" onClick={zoomIn}><Plus className="size-4" /></Button>
<Button variant="ghost" size="icon-sm" className="design-toolbar-icon" aria-label="Fit chart to screen" title="Fit chart to screen" onClick={fit}><Maximize2 className="size-4" /></Button>
</div>
{children}
</div>
);
}
+1 -1
View File
@@ -242,7 +242,7 @@ export function TrustPresetSection({
if (layout === "cards") {
return (
<div className="flex flex-col gap-(--agent-detail-module-gap)">
<div className="grid gap-(--agent-detail-module-gap) sm:grid-cols-3">
<div className="agent-trust-cards grid gap-(--agent-detail-module-gap) sm:grid-cols-3">
<div role="radiogroup" aria-label="Trust preset" className="contents">
{(["low_trust_review", "standard"] as const).map((value) => {
const selected = preset === value;
@@ -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 (
<span
className={cn(
"inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-xs leading-4",
mono && "font-mono",
tone === "danger" ? "bg-destructive/10 text-destructive" : "bg-(--agent-detail-tile-chip) text-muted-foreground",
className,
)}
>
{children}
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="checkbox"]');
expect(checkbox).not.toBeNull();
+2 -2
View File
@@ -88,7 +88,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
<div className="overflow-x-auto"><table aria-label="Agent roster" className="agent-roster w-full text-left text-sm">
<thead className="text-xs text-muted-foreground [&_th]:px-3 [&_th]:py-3 [&_th]:font-normal"><tr>
<th className="w-10"><Checkbox aria-label="Select all visible editable agents" checked={all ? true : some ? "indeterminate" : false} disabled={!selectable.length} onCheckedChange={toggleAll} /></th>
<th scope="col">Agent</th>{renderChat && <th scope="col"><span className="sr-only">Chat</span></th>}<th scope="col">Status</th><th scope="col">Harness</th><th scope="col">Connection</th><th scope="col">Default model</th>
<th scope="col">{visible.length} {visible.length === 1 ? "agent" : "agents"}</th>{renderChat && <th scope="col"><span className="sr-only">Chat</span></th>}<th scope="col">Status</th><th scope="col">Harness</th><th scope="col">Default model</th><th scope="col">Connection</th>
</tr></thead>
<tbody>{visible.map(agent => {
const metadata = rosterIndex.byAgentId.get(agent.id)!;
@@ -98,8 +98,8 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
{renderChat && <td>{renderChat(agent)}</td>}
<td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td>
<td className="whitespace-nowrap text-xs">{metadata.harness.label}</td>
<td className="text-xs">{metadata.model.label}</td>
<td className="text-xs">{metadata.connection.label}</td>
<td className="font-mono text-xs">{metadata.model.label}</td>
</tr>;
})}</tbody>
</table></div>
@@ -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<AgentRosterFilterField, AgentRosterOption[]>;
onChange: (filters: FilterState) => void;
activeCount: number;
count: number;
compact?: boolean;
connectionError?: unknown;
retryConnections: () => void;
}) {
return <div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className="flex flex-wrap items-center justify-between gap-3">
return <div data-canonical-toolbar="agent-filters" role="toolbar" aria-label="Agent filters" className={cn("flex flex-wrap items-center gap-3", !compact && "justify-between")}>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={activeCount ? `Filters: ${activeCount}` : "Filter agents"}
className={cn("relative shrink-0", activeCount > 0 && "text-blue-600 dark:text-blue-400")}>
<Button variant={compact ? "outline" : "ghost"} size="icon" aria-label="Filter agents" title={activeCount ? `Filters: ${activeCount}` : "Filter agents"}
className={cn("relative shrink-0", compact && "design-toolbar-icon", activeCount > 0 && "text-blue-600 dark:text-blue-400")}>
<Filter className="size-3.5" aria-hidden />
{activeCount > 0 && <span aria-hidden className="absolute -right-1 -top-1 flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-(length:--text-nano) font-bold text-white">{activeCount}</span>}
</Button>
@@ -63,7 +65,7 @@ export function AgentRosterFilters({ filters, options, onChange, activeCount, co
</label>)}
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground">{count} {count === 1 ? "agent" : "agents"}</span>
{!compact && <span className="text-xs text-muted-foreground">{count} {count === 1 ? "agent" : "agents"}</span>}
{!!connectionError && <p role="alert" className="basis-full text-xs text-destructive">Connection names couldn’t be loaded. <Button variant="link" size="sm" onClick={retryConnections}>Retry</Button></p>}
</div>;
}
@@ -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 ? <ArtifactStack front={subject} /> : <ArtifactFront subject={subject} />;
return stack ? <ArtifactStack front={subject} /> : <IconSheet subject={subject} showLabel={false} />;
}
@@ -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 (
<div className="artifact-sheet relative shrink-0 overflow-clip" data-artifact-sheet="icon">
<Paper />
<Glyph subject={subject} />
<span className="absolute whitespace-nowrap font-bold" style={LABEL}>
{showLabel && <span className="absolute whitespace-nowrap font-bold" style={LABEL}>
{labelOf(subject)}
</span>
</span>}
{play ? (
<span
aria-hidden
+202 -4
View File
@@ -5089,13 +5089,13 @@ html:has(.design-proposal-theme) .design-navigation-group-label {
:is(.artifact-tile, .empty-state-artwork) :is([data-artifact-rotate][data-artifact-spread], [data-artifact-front]) {
transition: transform var(--artifact-hover-ease);
}
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-front] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible)) [data-artifact-front] {
transform: translateY(-6px);
}
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-spread="left"] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible)) [data-artifact-spread="left"] {
transform: rotate(calc(var(--artifact-base, 0deg) + 5deg));
}
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible), .empty-state-artwork:has(.empty-state-graphic:hover)) [data-artifact-spread="right"] {
:is(.artifact-tile:hover, .artifact-tile:focus-visible, .empty-state-artwork:has(button:hover, button:focus-visible)) [data-artifact-spread="right"] {
transform: rotate(calc(var(--artifact-base, 0deg) - 5deg));
}
}
@@ -5458,7 +5458,7 @@ span.paperclip-patch-line { display: block; }
.agent-config-save-bar, .agent-file-copy-notice { transition: none; }
}
.agent-settings-instructions { padding-bottom: var(--agent-save-bar-clearance); }
.agent-settings-with-save-bar { padding-bottom: calc(var(--agent-save-bar-clearance) + var(--spacing) * 4); }
/* Identity file chrome uses the same input surface and keeps filenames on one line. */
:root {
@@ -5521,3 +5521,201 @@ html:has(.design-proposal-theme) .agent-markdown-name-field [data-slot="input"],
}
}
.agent-contact-pin-row[data-has-unread="true"] .agent-pin-text { mask-image: none; }
/* Only the active row reserves space for its contextual Archive action. */
.task-row-refreshed [data-task-archive] {
max-width: 0;
padding-inline: 0;
margin-inline-start: calc(-1 * var(--task-row-gap));
overflow: hidden;
white-space: nowrap;
opacity: 0;
transition: max-width var(--motion-duration-base) var(--motion-ease-out-expo), padding var(--motion-duration-base) var(--motion-ease-out-expo), margin var(--motion-duration-base) var(--motion-ease-out-expo), opacity var(--motion-duration-fast) ease-out;
}
.task-row-refreshed:is(:hover, :focus-within) [data-task-archive] {
max-width: 8rem;
padding-inline: calc(2 * var(--spacing));
margin-inline-start: 0;
opacity: 1;
}
@media (hover: none) {
.task-row-refreshed [data-task-archive] { max-width: 8rem; padding-inline: calc(2 * var(--spacing)); margin-inline-start: 0; opacity: 1; }
}
.agent-instruction-file-row[aria-selected="true"] { background: var(--muted-highlight); color: var(--foreground); }
.agent-header-custom-title:is(textarea, input) { color: var(--foreground); }
.agent-identity-file-name-input { field-sizing: content; width: auto; max-width: 100%; min-width: 1ch; }
.agent-instruction-file-pending[aria-selected="true"] { color: var(--muted-foreground); }
/* Portrait org-chart geometry is also read by the hierarchy layout. */
:root {
--org-card-width: 240px;
--org-card-height: 336px;
}
.org-chart-agent-card {
width: var(--org-card-width);
height: var(--org-card-height);
background: var(--secondary);
}
:root { --org-card-filtered-opacity: 0.12; }
.org-chart-agent-card { transition: opacity var(--motion-duration-fast), border-color var(--motion-duration-fast), box-shadow var(--motion-duration-fast); }
.org-chart-agent-card[data-filter-match="false"], .org-reporting-edge[data-filter-match="false"] { opacity: var(--org-card-filtered-opacity); }
.org-reporting-edge { transition: opacity var(--motion-duration-fast); }
@media (prefers-reduced-motion: reduce) { .org-chart-agent-card, .org-reporting-edge { transition: none; } }
/* Mobile-only collection and agent layouts; desktop composition is unchanged. */
@media (width < 48rem) {
.agent-character-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
row-gap: calc(var(--spacing) * 4);
}
.agent-character-header > :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); }
}
+22 -9
View File
@@ -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<symbol> = 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);
+76 -9
View File
@@ -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(
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}><TooltipProvider>
<PromptsTab
agent={props.agent ?? makeAgent()}
companyId={props.companyId ?? "company-1"}
@@ -287,7 +292,7 @@ describe("PromptsTab instruction editor", () => {
onCancelActionChange={props.onCancelActionChange ?? vi.fn()}
onSavingChange={props.onSavingChange ?? vi.fn()}
/>
</QueryClientProvider>,
</TooltipProvider></QueryClientProvider>,
);
});
await flushReact();
@@ -333,14 +338,16 @@ describe("PromptsTab instruction editor", () => {
onDirtyChange, onCancelActionChange: next => { discard = next; },
});
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Add agent file"]')!.click());
const input = container.querySelector<HTMLInputElement>('[aria-label="New Markdown file name"]')!;
const input = container.querySelector<HTMLInputElement>('[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<HTMLTextAreaElement>('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<HTMLButtonElement>('[aria-label="Add agent file"]')!.click());
await act(() => setNativeValue(container.querySelector<HTMLInputElement>('[aria-label="Markdown file name"]')!, "research"));
await act(() => setNativeValue(container.querySelector<HTMLTextAreaElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[aria-label="Rename AGENTS.md"]')!.click());
await act(() => setNativeValue(container.querySelector<HTMLInputElement>('[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<HTMLButtonElement>('[aria-label="Rename AGENTS.md"]')!.click());
await act(() => setNativeValue(container.querySelector<HTMLInputElement>('[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<HTMLButtonElement>('[aria-label="Rename AGENTS.md"]')!.click());
await act(() => setNativeValue(container.querySelector<HTMLInputElement>('[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<HTMLButtonElement>('button[aria-label="Add agent file"]')!.click();
});
await flushReact();
const input = container.querySelector<HTMLInputElement>('input[aria-label="New Markdown file name"]');
const input = container.querySelector<HTMLInputElement>('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<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe("");
});
+180 -123
View File
@@ -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<HTMLAnchorElement | null>(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 (
<div className={cn("agent-settings-content mx-auto max-w-5xl space-y-8", `agent-settings-${activeView}`)}>
<div className={cn("agent-settings-content mx-auto max-w-5xl space-y-8", `agent-settings-${activeView}`, showConfigActionBar && "agent-settings-with-save-bar")}>
{showLeftAgentNotice ? (
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-900 dark:text-yellow-100">
<p className="min-w-0 flex-1">
@@ -892,12 +883,11 @@ export function AgentDetail() {
<div className="flex min-w-0 items-center gap-2">
<InlineEditor
as="h1"
value={agent.title ?? ""}
value={agent.name}
fitContent
nullable
placeholder={roleLabels[agent.role] ?? agent.role}
placeholder="Agent name"
className="min-w-0 truncate text-2xl font-semibold leading-8 text-foreground"
onSave={(title) => saveHeaderField({ title: title || null })}
onSave={(name) => saveHeaderField({ name })}
/>
<PrimaryAgentCrownButton agent={agent} />
</div>
@@ -915,10 +905,11 @@ export function AgentDetail() {
<span aria-hidden="true">•</span>
<InlineEditor
fitContent
value={agent.name}
placeholder="Agent name"
className="min-w-0 truncate text-sm leading-5 text-muted-foreground"
onSave={(name) => 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 })}
/>
</div>
<AgentLifecycleStatus agent={currentAgent} refreshError={lifecycle.isError} onRetry={() => 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" && <AgentConfigSaveBar key={agent.id} dirty={configDirty} saving={configSaving} savedVersion={identitySaveVersion}
{showConfigActionBar && <AgentConfigSaveBar key={`${agent.id}-${activeView}`} dirty={configDirty} saving={configSaving} savedVersion={configSaveVersion}
onDiscard={() => cancelConfigActionRef.current?.()}
onSave={() => { Promise.resolve(saveConfigActionRef.current?.()).catch(() => {}); }} />}
{showConfigActionBar && activeView !== "instructions" && <footer className="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
{configSaving ? null : configDirty ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--agent-detail-dot-unsaved)" />
) : configJustSaved ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--agent-detail-dot-saved)" />
) : null}
{configSaving ? "Saving changes…" : configDirty ? "Unsaved changes" : configJustSaved ? "Changes saved" : "No unsaved changes"}
</p>
<div className="flex gap-2">
<Button variant="ghost" disabled={!configDirty || configSaving} onClick={() => cancelConfigActionRef.current?.()}>Discard</Button>
<Button disabled={!configDirty || configSaving} onClick={() => {Promise.resolve(saveConfigActionRef.current?.()).catch(() => {});}}>{configSaving ? "Saving…" : "Save changes"}</Button>
</div>
</footer>}
</div>
);
@@ -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 ? (
<div className="flex flex-wrap gap-1.5 px-(--agent-detail-module-inset) pt-1 pb-(--agent-detail-module-inset)">
{skillNames.map((name) => (
<AgentChip key={name}>{name}</AgentChip>
))}
</div>
) : (
<p className="px-(--agent-detail-module-inset) pt-1 pb-(--agent-detail-module-inset) text-sm text-muted-foreground">No skills enabled.</p>
)}
<div className="flex flex-wrap gap-1.5 px-(--agent-detail-module-inset) pt-1 pb-(--agent-detail-module-inset)">
{skillNames.map((name) => <AgentChip key={name} className="h-7 px-2">{name}</AgentChip>)}
<Link to={agentDetailHref(agentRef, "skills")} className="inline-flex h-7 items-center gap-1.5 rounded-md border border-dashed border-border px-2 py-1 text-sm text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground">
Explore skills <ArrowUpRight className="size-3.5" aria-hidden />
</Link>
</div>
</AgentPanel>
</div>
@@ -1533,7 +1517,24 @@ export function AgentOverview({
}
/>
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent tasks.</p>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">Start with a suggested task</p>
<ul className="overflow-hidden rounded-lg border border-border bg-(--agent-detail-tile) divide-y divide-dashed divide-border" aria-label="Suggested tasks">
{taskSuggestions.map((title) => (
<li key={title}>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-4 text-left text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => openNewIssue({ title, assigneeAgentId: agent.id })}
>
<Paperclip className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{title}</span>
<ChevronRight className="size-4 shrink-0" aria-hidden />
</button>
</li>
))}
</ul>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border bg-(--agent-detail-tile)">
<TaskSummaryList issues={assignedIssues.slice(0, 5)} />
@@ -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<string | null>(null);
const [editingFileName, setEditingFileName] = useState(false);
const [fileNameError, setFileNameError] = useState<string | null>(null);
const fileNameInputRef = useRef<HTMLInputElement>(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<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(copyTimer.current), []);
useEffect(() => { clearTimeout(copyTimer.current); setCopyNotice(null); }, [agent.id, selectedFile]);
const [showNewFileInput, setShowNewFileInput] = useState(false);
const [pendingFiles, setPendingFiles] = useState<string[]>([]);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(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<AgentInstructionsBundle>(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({
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Files</h4>
<div className="flex items-center gap-1">
{!showNewFileInput && (
{(
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label="Add agent file"
onClick={() => setShowNewFileInput(true)}
disabled={isSaving}
onClick={() => {
if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return;
let candidate = "Untitled.md";
let suffix = 2;
while (fileOptions.some(path => path.toLowerCase() === candidate.toLowerCase())) candidate = `Untitled-${suffix++}.md`;
setPendingFiles([candidate]);
setSelectedFile(candidate);
draftBaseRevisionRef.current = null;
draftBaseHashRef.current = null;
setDraft("");
setFileNameDraft(null);
setFileNameError(null);
setEditingFileName(true);
setInstructionMode("edit");
setShowFilePanel(false);
}}
>
<Plus className="size-4" />
</Button>
@@ -2405,66 +2481,10 @@ export function PromptsTab({
)}
</div>
</div>
{showNewFileInput && (
<div className="space-y-2">
<div className="agent-markdown-name-field flex items-center rounded-md border border-input focus-within:ring-2 focus-within:ring-ring">
<Input
aria-label="New Markdown file name"
aria-describedby="markdown-file-name-hint"
aria-invalid={Boolean(newFilePath && !markdownFileName(newFilePath))}
value={newFilePath}
onChange={(event) => 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("");
}
}}
/>
<span className="pr-3 font-mono text-sm text-muted-foreground/50" aria-hidden>.md</span>
</div>
<p id="markdown-file-name-hint" className="text-xs text-muted-foreground">Use letters, numbers, hyphens or underscores.</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="default"
className="flex-1"
disabled={!markdownFileName(newFilePath) || visibleFilePaths.includes(markdownFileName(newFilePath) ?? "")}
onClick={() => {
const candidate = markdownFileName(newFilePath);
if (!candidate || visibleFilePaths.includes(candidate)) return;
if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return;
setPendingFiles([candidate]);
setSelectedFile(candidate);
setDraft("");
setInstructionMode("edit");
setNewFilePath("");
setShowNewFileInput(false);
}}
>
Create
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
setShowNewFileInput(false);
setNewFilePath("");
}}
>
Cancel
</Button>
</div>
</div>
)}
<FileTree
nodes={fileTree}
nodes={fileTree.map(function labelDraft(node): typeof node {
return { ...node, name: node.path === selectedOrEntryFile ? displayedFilePath.split("/").pop()! : node.name, children: node.children.map(labelDraft) };
})}
selectedFile={selectedOrEntryFile}
expandedDirs={expandedDirs}
checkedFiles={new Set()}
@@ -2479,6 +2499,9 @@ export function PromptsTab({
if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return;
setDraft(null);
setPendingFiles([]);
setFileNameDraft(null);
setEditingFileName(false);
setFileNameError(null);
setSelectedFile(filePath);
setInstructionMode("raw");
if (!fileOptions.includes(filePath)) setDraft("");
@@ -2487,7 +2510,7 @@ export function PromptsTab({
onToggleCheck={() => {}}
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({
)}
<div data-agent-tile="" className={cn("min-w-0 w-full overflow-hidden rounded-lg bg-(--agent-detail-tile)", isMobile && showFilePanel && "hidden")}>
<div className="flex items-center justify-between gap-3 px-(--agent-identity-panel-gap) pt-(--agent-identity-panel-gap) pb-5">
<div className="agent-identity-preview-header flex items-center justify-between gap-3 px-(--agent-identity-panel-gap) pt-(--agent-identity-panel-gap) pb-5">
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<Button
@@ -2537,8 +2560,31 @@ export function PromptsTab({
</Button>
)}
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
<h4 className="agent-identity-preview-title min-w-0 truncate font-mono text-(length:--agent-identity-preview-title-size) text-foreground">{selectedOrEntryFile}</h4>
<div className="agent-identity-file-heading flex min-w-0 items-center gap-2">
{editingFileName ? (
<div className="agent-identity-preview-title flex min-w-0 items-baseline font-mono text-(length:--agent-identity-preview-title-size)">
<input ref={fileNameInputRef} aria-label="Markdown file name" autoFocus
value={fileNameDraft === null && pendingFiles.includes(selectedOrEntryFile) ? "" : namingPrefix}
placeholder={selectedOrEntryFile.replace(/\.md$/i, "")}
aria-invalid={fileNameInvalid || undefined}
disabled={isSaving}
className="agent-identity-file-name-input min-w-0 bg-transparent text-foreground outline-none placeholder:italic placeholder:text-muted-foreground/50"
onChange={(event) => { setFileNameDraft(`${event.target.value}.md`); setFileNameError(null); }}
onBlur={() => { if (!fileNameInvalid) setEditingFileName(false); }}
onKeyDown={(event) => {
if (event.key === "Enter") { event.preventDefault(); if (!fileNameInvalid) setEditingFileName(false); }
if (event.key === "Escape") { setFileNameDraft(null); setEditingFileName(false); setFileNameError(null); }
}}
/>
<span className="text-muted-foreground/50">.md</span>
</div>
) : (
<button type="button" disabled={isSaving || Boolean(candidateRunId) || selectedFileSummary?.deprecated || selectedFileDetail?.binary || !selectedOrEntryFile.endsWith(".md")}
aria-label={`Rename ${displayedFilePath}`} onClick={() => setEditingFileName(true)}
className={cn("agent-identity-preview-title min-w-0 truncate rounded font-mono text-(length:--agent-identity-preview-title-size) text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", pendingFiles.includes(selectedOrEntryFile) ? "italic text-muted-foreground/60" : "text-foreground")}>
{displayedFilePath}
</button>
)}
<AgentChip>{currentMode === "external" ? "Managed externally" : "Managed by you"}</AgentChip>
</div>
<p className="text-xs text-muted-foreground">
@@ -2581,6 +2627,16 @@ export function PromptsTab({
<Copy className="size-4" />
</Button>
)}
{selectedOrEntryFile === currentEntryFile && (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} aria-label="This file is required" className="inline-flex">
<Button type="button" variant="ghost" size="icon-xs" className="size-8" aria-label={`Delete ${selectedOrEntryFile}`} disabled><Trash2 className="size-4" /></Button>
</span>
</TooltipTrigger>
<TooltipContent>This file is required</TooltipContent>
</Tooltip>
)}
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button
type="button"
@@ -2652,6 +2708,7 @@ export function PromptsTab({
{(resolveCandidate.error || draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null)) && <Button type="button" variant="outline" size="sm" disabled={isSaving} onClick={() => refreshCandidateBase.mutate()}>Refresh current revision</Button>}
</div>}
{(candidates.error || loadCandidate.error || resolveCandidate.error || refreshCandidateBase.error) && <p role="alert" className="text-sm text-destructive">{(candidates.error ?? loadCandidate.error ?? resolveCandidate.error ?? refreshCandidateBase.error)?.message} Your preserved edits remain available.</p>}
{(fileNameError || (editingFileName && fileNameInvalid)) && <p role="alert" className="text-sm text-destructive">{fileNameError ?? (nameConflict ? "A file with this name already exists." : "Use letters, numbers, hyphens or underscores.")}</p>}
{(saveFile.error || fileError || updateBundle.error) && <p role="alert" className="text-sm text-destructive">{(saveFile.error ?? fileError ?? updateBundle.error)?.message} Your unsaved edits are retained.</p>}
{selectedFileDetail?.receipt?.materialization === "pending" && <p role="status" className="text-sm text-muted-foreground">Revision saved. The instruction file still needs to be rebuilt from the saved revision.</p>}
{selectedFileDetail?.revision && currentMode === "managed" && bundle?.persistence !== "agent_files" && <InstructionHistory
@@ -2678,7 +2735,7 @@ export function PromptsTab({
) : instructionMode === "edit" ? (
<textarea
aria-label="Instruction file editor"
autoFocus
autoFocus={!editingFileName}
value={displayValue}
spellCheck={false}
onChange={(event) => {
+7 -4
View File
@@ -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(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}><DialogProvider>
<TooltipProvider>
<AgentOverview
agent={agent}
@@ -66,7 +67,7 @@ describe("AgentOverview", () => {
agentRouteId="codexcoder"
/>
</TooltipProvider>
</QueryClientProvider>,
</DialogProvider></QueryClientProvider>,
);
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(
<QueryClientProvider client={client}>
<QueryClientProvider client={client}><DialogProvider>
<AgentOverview
agent={agent}
runs={[] as HeartbeatRun[]}
@@ -115,7 +118,7 @@ describe("AgentOverview", () => {
skillNames={[]}
agentRouteId="sharedagent"
/>
</QueryClientProvider>,
</DialogProvider></QueryClientProvider>,
);
}
+29 -1
View File
@@ -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(<QueryClientProvider client={client}>
<ConfigurationTab agent={value} companyId={value.companyId} onDirtyChange={noop} onSaveActionChange={noop}
onCancelActionChange={noop} onSavingChange={noop} updatePermissions={{ mutate: noop, isPending: false }} />
onCancelActionChange={noop} onSavingChange={noop} onSaved={saved} updatePermissions={{ mutate: noop, isPending: false }} />
</QueryClientProvider>);
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(<QueryClientProvider client={client}>
<ConfigurationTab agent={original} companyId={original.companyId} content={content}
onDirtyChange={noop} onSaveActionChange={noop} onCancelActionChange={noop} onSavingChange={noop}
onSaved={saved} updatePermissions={{ mutate: noop, isPending: false }} />
</QueryClientProvider>));
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();
}
});
+86 -6
View File
@@ -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<HTMLButtonElement>('[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<HTMLSelectElement>('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<HTMLButtonElement>('[aria-label="Org chart view"]')!.click());
await flushReact();
await flushReact();
const cards = [...container.querySelectorAll<HTMLElement>("[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<HTMLElement>('[data-testid="org-chart-card-layer"]')!.style.transform;
await reactAct(() => beta.click());
expect(mockRouterState.navigate).toHaveBeenCalled();
const input = container.querySelector<HTMLInputElement>('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<HTMLElement>('[data-testid="org-chart-card-layer"]')!.style.transform).toBe(transform);
await reactAct(() => container.querySelector<HTMLButtonElement>('[aria-label="Clear search"]')!.click());
await reactAct(() => filterButton.click());
await flushReact();
await reactAct(() => [...document.querySelectorAll<HTMLButtonElement>('[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<HTMLSelectElement>('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<HTMLElement>("[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<HTMLButtonElement>('[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<HTMLButtonElement>("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<HTMLElement>('[data-testid="org-chart-card-layer"]')!;
const initialCamera = cameraLayer.style.transform;
await act(async () => controlsSlot.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]')!.click());
expect(cameraLayer.style.transform).not.toBe(initialCamera);
await act(async () => controlsSlot.querySelector<HTMLButtonElement>('[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([
+31 -22
View File
@@ -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<OrgChartActions | null>(null);
const chartActions = useMemo<OrgChartActions>(() => ({
zoomIn: () => chartActionsRef.current?.zoomIn(),
zoomOut: () => chartActionsRef.current?.zoomOut(),
fit: () => chartActionsRef.current?.fit(),
}), []);
const [orgControlsContainer, setOrgControlsContainer] = useState<HTMLDivElement | null>(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<AgentsView>(() => streamlinedUiEnabled ? initialView : "org");
const previewTasks = import.meta.env.DEV && new URLSearchParams(location.search).get("orgTasks") === "demo";
const [view, setView] = useState<AgentsView>(() => 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 <EmptyState icon={Bot} message="Select an organization to view agents." />;
}
@@ -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 (
<div className={cn(
"flex flex-col gap-4 @container",
effectiveView === "org" && "h-full min-h-0",
"agents-surface flex flex-col gap-4 @container",
effectiveView === "org" && "agents-org-surface h-full min-h-0",
)}>
<AgentsToolbar query={query} onQueryChange={setQuery} view={effectiveView} onViewChange={setView}
hideViewToggle={forceListView} onNewAgent={openNewAgent} />
<div className={effectiveView === "org" ? "agents-org-toolbar" : undefined}>
<AgentsToolbar orgActions={visibleOrg.length ? chartActions : undefined} orgControlsRef={setOrgControlsContainer} query={query} onQueryChange={setQuery} view={effectiveView} onViewChange={setView}
hideViewToggle={forceListView} onNewAgent={openNewAgent}
filters={<AgentRosterFilters {...rosterFilters} count={filtered.length} compact />} />
</div>
<AgentRosterFilters {...rosterFilters} count={filtered.length} />
<div className={cn("min-h-0 space-y-4", effectiveView === "org" && "flex flex-1 flex-col")}>
<div className={cn("min-h-0 space-y-4", effectiveView === "org" && "agents-org-content flex flex-1 flex-col")}>
{agents && agents.length === 0 && (
<EmptyState
@@ -454,7 +469,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{/* List view */}
{effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents}
agents={eligibleAgents}
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus}
renderChat={agentChat.enabled ? agent => (
// 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 && (
<OrgChart embedded orgTree={filteredOrg} agents={agents ?? []} />
)}
{effectiveView === "org" && orgTree && orgTree.length > 0 && filteredOrg.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No agents match your search and filters.
</p>
{effectiveView === "org" && visibleOrg.length > 0 && (
<OrgChart embedded actionsRef={chartActionsRef} controlsContainer={orgControlsContainer} orgTree={visibleOrg} agents={agents ?? []} matchingAgentIds={query.trim() || rosterFilters.activeCount ? matchingAgentIds : undefined} />
)}
{effectiveView === "org" && orgTree && orgTree.length === 0 && (
+1
View File
@@ -804,6 +804,7 @@ function InboxCollectionToolbar({
if (streamlined) {
return (
<CollectionToolbar
className={ariaLabel === "Tasks controls" ? "mobile-task-toolbar" : undefined}
ariaLabel={ariaLabel}
context={context}
search={search}
+174 -5
View File
@@ -6,14 +6,27 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { OrgChart } from "./OrgChart";
import { ToastProvider } from "../context/ToastContext";
const membershipListMock = vi.fn().mockResolvedValue({ starredAgentIds: [] });
const membershipUpdateMock = vi.fn().mockResolvedValue({ resourceId: "agent-1", state: "joined", starredAt: new Date() });
vi.mock("../api/resourceMemberships", () => ({ 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 }) => <a href={to}>{children}</a>,
Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a>,
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<string>) {
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OrgChart />
<ToastProvider><OrgChart enableTaskLayer={enableTaskLayer} matchingAgentIds={matchingAgentIds} /></ToastProvider>
</QueryClientProvider>,
);
});
@@ -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<HTMLElement>("[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<HTMLButtonElement>('button[aria-label="Zoom in"]')!.click());
expect(readTransform(layer)[2]).toBeGreaterThan(1);
await act(async () => container.querySelector<HTMLButtonElement>('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(<QueryClientProvider client={queryClient}><ToastProvider><OrgChart matchingAgentIds={new Set(ids)} /></ToastProvider></QueryClientProvider>));
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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();
+265 -220
View File
@@ -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<string, string> = {
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<string>;
/** 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<OrgChartActions>;
/** 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<string, Agent>();
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<HTMLDivElement>(null);
const taskMaskId = useId();
const [taskScroll, setTaskScroll] = useState(0);
const taskTouchX = useRef<number | null>(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<HTMLDivElement>(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<HTMLDivElement>) => {
if (e.touches.length >= 2 && containerRef.current) {
@@ -467,6 +450,35 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
};
}, [pan, zoom]);
const cameraActions = useMemo<OrgChartActions>(() => ({
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 = (
<OrgChartControls {...cameraActions}>
{enableTaskLayer && <Button variant={showTasks ? "secondary" : "outline"} size="sm" aria-pressed={showTasks} onClick={() => setShowTasks(value => !value)}><Layers className="size-4" />Tasks</Button>}
</OrgChartControls>
);
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 <EmptyState icon={Network} message="Select an organization to view the org chart." />;
}
@@ -496,9 +508,10 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
return (
<div
className={embedded
? "flex min-h-(--sz-420px) flex-1 flex-col md:min-h-0"
? "org-chart-shell flex min-h-0 flex-1 flex-col"
: "flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0"}
>
{actionsRef ? null : controlsContainer ? createPortal(controls, controlsContainer) : !embedded ? <div className="mb-2 flex justify-end">{controls}</div> : null}
{!embedded && (showImport || showExport) ? (
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport ? (
@@ -522,7 +535,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
<div
ref={containerRef}
data-testid="org-chart-viewport"
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
className="org-chart-canvas w-full flex-1 min-h-0 overflow-hidden relative"
style={{
cursor: dragging ? "grabbing" : "grab",
touchAction: "none",
@@ -538,50 +551,6 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{/* Zoom controls */}
<div className="absolute top-3 right-3 z-10 flex flex-col gap-1.5">
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 1.2, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom in"
aria-label="Zoom in"
>
<Plus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 0.8, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom out"
aria-label="Zoom out"
>
<Minus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-(length:--text-nano) transition-colors hover:bg-accent sm:size-7"
onClick={fitToScreen}
title="Fit to screen"
aria-label="Fit chart to screen"
>
<Maximize2 className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
</div>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
@@ -591,20 +560,14 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
}}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{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 (
<path
key={`${parent.id}-${child.id}`}
d={`M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`}
d={path}
fill="none"
stroke="var(--border)"
strokeWidth={1.5}
className="org-reporting-edge"
data-filter-match={matchingAgentIds ? matchingAgentIds.has(parent.id) && matchingAgentIds.has(child.id) : undefined}
/>
);
})}
@@ -614,7 +577,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
{/* Card layer */}
<div
data-testid="org-chart-card-layer"
className="absolute inset-0"
className="org-chart-cards absolute inset-0"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0",
@@ -622,20 +585,36 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
>
{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 (
<Card
key={node.id}
data-org-card
className="block absolute py-0 hover:shadow-md hover:border-foreground/20 transition-(--tp-box-shadow-border-color) duration-150 cursor-pointer select-none"
data-leader={node.leader}
data-tier={node.tier}
data-depth={node.depth}
data-filter-match={matchingAgentIds ? matchingAgentIds.has(node.id) : undefined}
interactive
role="link"
tabIndex={0}
aria-label={`Open ${node.name} overview`}
className="org-chart-agent-card absolute gap-0 select-none"
onKeyDown={(event) => { 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();
}}
>
<div className="flex items-center px-4 py-3 gap-3">
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentAvatar agent={agent} size={16} className="h-4.5 w-4.5 text-foreground/70"/>
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"
style={{ backgroundColor: dotColor }}
{node.leader && <div className="org-card-badge-slot" aria-hidden />}
<div className="org-card-heading min-w-0 text-left">
{node.leader && (
<StarToggle
size="button"
className="org-card-star"
starred={starredAgents.has(node.id)}
resourceName={node.name}
pending={starPending}
error={
membershipMutation.isError && membershipMutation.variables?.resourceId === node.id // query-error-ok: mutation feedback; cached cards remain visible
}
onToggle={(starred) => membershipMutation.mutate({ resourceType: "agent", resourceId: node.id, resourceName: node.name, starred })}
/>
)}
<div className="org-card-name truncate font-medium text-foreground" title={node.name}>{node.name}</div>
<div className="org-card-title line-clamp-2 text-muted-foreground" title={agent?.title || roleLabel(node.role)}>
{agent?.title || roleLabel(node.role)}
</div>
{/* Name + role + adapter type */}
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="text-sm font-semibold text-foreground leading-tight">
{node.name}
</span>
<span className="text-(length:--text-micro) text-muted-foreground leading-tight mt-0.5">
{agent?.title ?? roleLabel(node.role)}
</span>
{agent && (
<span className="text-(length:--text-nano) text-muted-foreground/60 font-mono leading-tight mt-1">
{getAdapterLabel(agentDisplayAdapterType(agent))}
</span>
)}
{agent && agent.capabilities && (
<span className="text-(length:--text-nano) text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
{agent.capabilities}
</span>
)}
</div>
{node.leader && <div className="org-card-status truncate text-muted-foreground">{statusLabel}</div>}
</div>
<div className="org-card-character flex min-h-0 items-center justify-center">
{node.leader ? (
<AgentCharacter agent={agent ?? { id: node.id, name: node.name }} size={256} state="idle" trackingScope="page" followCursor animationGroup="org-chart" className="org-card-live-character" />
) : (
<AgentAvatar agent={agent ?? { id: node.id, name: node.name }} size={128} pose="rest" view="portrait" className="org-card-avatar" />
)}
</div>
{adapterType && (modelLabel || effort) && (
<div className="org-card-model min-w-0">
{modelLabel && (
<span className="org-card-model-label" title={model}>
<span className="org-card-model-mark">
<AdapterMark type={adapterType} className="size-4 shrink-0" />
</span>
<span className="org-card-model-name">{modelLabel}</span>
</span>
)}
{modelLabel && effort && " "}
{effort && (
<span className="org-card-effort text-muted-foreground" title={effort}>
{EFFORT_LABELS[effort] ?? effort}
</span>
)}
</div>
)}
</Card>
);
})}
</div>
{showTasks && <>
<svg className="org-task-overlay absolute inset-0 size-full pointer-events-none" aria-hidden>
<defs>
<mask id={taskMaskId}>
<rect width="100%" height="100%" className="org-task-mask-open" />
{allNodes.map(node => <rect key={node.id} className="org-task-mask-card" x={pan.x + node.x * zoom} y={pan.y + node.y * zoom} width={node.width * zoom} height={node.height * zoom} />)}
</mask>
</defs>
<g mask={`url(#${taskMaskId})`}>
{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 <path key={link.key} className="org-task-edge" d={`M ${link.x} ${taskLabelY + geometry.taskHeight} V ${track} H ${lane} V ${targetY} H ${targetX}`} fill="none" />;
})}
</g>
</svg>
<div className="org-task-band absolute inset-x-0" style={{ top: toolbarHeight }}>
<div className="org-task-legend text-xs text-muted-foreground" role="status">
{simulateTasks ? `${tasks.length} simulated running tasks · preview only`
: taskRunsView.kind === "error" ? <QueryErrorState size="inline" error={taskRunsView.error} action="load running tasks" onRetry={taskRunsView.retry} />
: taskTitlesView.kind === "error" && taskIds.length ? <QueryErrorState size="inline" error={taskTitlesView.error} action="load task titles" onRetry={taskTitlesView.retry} />
: 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"}
</div>
<div className="org-task-strip" onScroll={event => 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; }}>
<div className="relative h-full" style={{ width: Math.max(viewport.width, taskContentWidth + geometry.padding * 2) }}>
{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: <><Paperclip className="size-3 shrink-0" /><span className="truncate">{task.title}</span></>,
};
return simulateTasks ? <span key={task.id} {...labelProps} /> : <Link key={task.id} to={`/issues/${task.identifier ?? task.id}`} {...labelProps} />;
})}
</div>
</div>
</div>
</>}
</div>
</div>
);
+102
View File
@@ -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);
});
});
+150
View File
@@ -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<number, LayoutNode[]>();
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;
}
+19
View File
@@ -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] },
];
}
+17
View File
@@ -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<string>): ChartTask[] {
const names = new Map(summaries.map(task => [task.id, task]));
const associations = new Map<string, Set<string>>();
for (const run of runs) {
if (run.status !== "running" || !run.issueId || !agentIds.has(run.agentId)) continue;
const ids = associations.get(run.issueId) ?? new Set<string>();
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] };
});
}
@@ -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", () => {
+2 -1
View File
@@ -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;