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 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 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 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. 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 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 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 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 header metadata position with the Skills count. Empty Direct reports sections
are hidden. The overview character tracks the page cursor, including paused are hidden. The overview character tracks the page cursor, including paused
agents, while respecting reduced-motion and custom-avatar fallbacks. Chat sits 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
beside the text-only New task action. 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.
the table, leaving the section heading on the page background.
Identity panels share 24px insets and gutters from the token layer. Advanced Identity panels share 24px insets and gutters from the token layer. Advanced
management contains the external-profile controls. New files accept a portable 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.
basename (letters, numbers, hyphens and underscores) with a fixed `.md` suffix.
Clicking file content enters editing without creating a dirty draft. The Identity 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 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 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. provider or warm-up shortcut; keyboard focus retains immediate accessible help.
Task-list titles use the remaining row width before truncating. Empty recovery, 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. 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. 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", () => { 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" }; const appearance = { ...appearanceForPalette("deep-tide"), customAvatarAssetId: "22222222-2222-4222-8222-222222222222" };
expect(resolveAgentAppearance(appearance)).toEqual(appearance); 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); 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", () => { 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, 48, 1)).toContain('id="agent-candle-light"');
expect(renderAgentSvg(appearance, 24, 2)).toBe(eyes); 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", () => { it("uses a separate URL for opaque Slack exports without changing ordinary portraits", () => {
const appearance = appearanceForPalette("cherry-pop"); 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"); 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 type CharacterPaletteId = AgentPaletteId | "muted-dream";
export const AGENT_AVATAR_SIZES = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512] as const; 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 AgentAvatarSize = typeof AGENT_AVATAR_SIZES[number];
export type AgentAvatarView = "front" | "portrait";
export type AgentAvatarBackground = "transparent" | "paperclip-dark"; export type AgentAvatarBackground = "transparent" | "paperclip-dark";
/** sRGB PNG equivalent of index.css .dark --background: oklch(0.205 0 0). */ /** sRGB PNG equivalent of index.css .dark --background: oklch(0.205 0 0). */
export const PAPERCLIP_DARK_AVATAR_BACKGROUND = "#171717"; export const PAPERCLIP_DARK_AVATAR_BACKGROUND = "#171717";
@@ -40,9 +41,9 @@ export function resolveAgentAppearance(appearance: unknown, id = "agent"): Agent
const parsed = agentAppearanceSchema.safeParse(appearance); const parsed = agentAppearanceSchema.safeParse(appearance);
return parsed.success ? parsed.data : legacyAgentAppearance(id); 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`; 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 { export function characterStateForAgent(status: string): CharacterState {
if (status === "running") return "working"; 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 { characterDefinition, characterStill } from "./definition.js";
import { CharacterRenderer } from "./renderer.js"; import { CharacterRenderer } from "./renderer.js";
import { snapshotSvg } from "./svg-snapshot.js"; import { snapshotSvg } from "./svg-snapshot.js";
/** Same geometry/face projection as the live character, without DOM or WebGL. */ /** 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 definition = characterDefinition(appearance, muted);
const renderer = new CharacterRenderer(null, { width: size * scale, height: size * scale, displaySize: size }); const renderer = new CharacterRenderer(null, { width: size * scale, height: size * scale, displaySize: size });
try { try {
renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false }, renderer.render({ ...definition.character, trueFront: view === "front", lockPosition: true, followCursor: false, followRotation: false },
characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } }); 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-"); return snapshotSvg(renderer.snapshotScene(), "agent-");
} finally { renderer.dispose(); } } finally { renderer.dispose(); }
} }
+1
View File
@@ -37,6 +37,7 @@ export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructio
export const upsertAgentInstructionsFileSchema = z.object({ export const upsertAgentInstructionsFileSchema = z.object({
path: z.string().min(1).max(512), path: z.string().min(1).max(512),
renameTo: z.string().min(1).max(512).optional(),
content: z.string().max(1024 * 1024), content: z.string().max(1024 * 1024),
baseRevisionId: z.string().uuid().nullable().optional(), baseRevisionId: z.string().uuid().nullable().optional(),
baseHash: z.string().regex(/^[a-f0-9]{64}$/).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", "modules/external-agents/muse.ts", "modules/external-agents/dot.ts",
"services/secret-proposals.ts", "services/connection-intents.ts", "services/dot-runner-broker.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/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) { function normalizedRelative(from, to) {
+8 -5
View File
@@ -18,10 +18,10 @@ export function storybookAgentAvatarAssets() {
})(); })();
server.middlewares.use((req, res, next) => { server.middlewares.use((req, res, next) => {
const pathname = new URL(req.url ?? "/", "http://storybook.local").pathname; 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(); if (!match) return next();
void (async () => { 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 { pool, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = await renderer();
const size = Number(sizeText); const size = Number(sizeText);
const scale = Number(scaleText); const scale = Number(scaleText);
@@ -33,7 +33,7 @@ export function storybookAgentAvatarAssets() {
} }
if (!images.has(pathname)) { if (!images.has(pathname)) {
const appearance = appearanceForPalette(muted ? AGENT_PALETTE_IDS[0] : palette); 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); images.delete(pathname);
throw error; throw error;
})); }));
@@ -60,6 +60,9 @@ export function storybookAgentAvatarAssets() {
(muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette => (muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette =>
CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size => CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size =>
[1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted })))))); [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. // 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" }))); 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); const pool = createAgentAvatarPool(2);
@@ -70,8 +73,8 @@ export function storybookAgentAvatarAssets() {
while (next < requests.length) { while (next < requests.length) {
const request = requests[next++]; const request = requests[next++];
const source = await pool.render(request); const source = await pool.render(request);
const { appearance, size, scale, pose, muted, background } = request; const { appearance, size, scale, pose, muted, background, view } = request;
const fileName = agentAvatarUrl(appearance, size, scale, pose, muted, background).slice(2); const fileName = agentAvatarUrl(appearance, size, scale, pose, muted, background, view).slice(2);
this.emitFile({ type: "asset", fileName, source }); this.emitFile({ type: "asset", fileName, source });
images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale }); 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); const png = await pool.render(request);
expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true }); expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true });
}, 20_000); }, 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 () => { it("preserves transparent background pixels in ordinary 512px avatars", async () => {
const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close()); const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close());
const png = await pool.render({ ...request, size: 512, scale: 1 }); const png = await pool.render({ ...request, size: 512, scale: 1 });
@@ -155,7 +173,7 @@ describe("on-demand agent avatars", () => {
await first.arrayBuffer(); await first.arrayBuffer();
const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } }); const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } });
expect(second.status).toBe(304); 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(); const invalid = await fetch(url + suffix); expect(invalid.status).toBe(400); await invalid.text();
} }
expect(render).toHaveBeenCalledTimes(1); expect(render).toHaveBeenCalledTimes(1);
@@ -6,7 +6,7 @@ import { Readable } from "node:stream";
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, afterEach, afterAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm"; 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 { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js"; import { agentFileStore, adoptAgentFiles, fileHash } from "../services/agent-file-store.js";
import { invalidateAgentFileProjection, persistDurableAgentFiles } from "../services/agent-file-durability.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: {} }); 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 () => { it("reads instruction compatibility from the committed head after deleting the entire home", async () => {
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original")); await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original"));
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, 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 })); vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies }));
const mockDownloadAgentFile = vi.hoisted(() => vi.fn()); const mockDownloadAgentFile = vi.hoisted(() => vi.fn());
const mockRenameAgentFile = vi.hoisted(() => vi.fn());
vi.mock("../services/agent-file-store.js", async (importOriginal) => { vi.mock("../services/agent-file-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/agent-file-store.js")>(); 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() })); 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 () => { it("commits entry bytes through the canonical service with the authenticated actor", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1") .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), pose: z.enum(CHARACTER_STATES),
size: z.string().regex(/^\d+$/).default("512").transform(Number).refine(n => (AGENT_AVATAR_SIZES as readonly number[]).includes(n)), 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"), scale: z.enum(["1", "2"]).default("1"),
view: z.enum(["front", "portrait"]).default("front"),
background: z.enum(["transparent", "paperclip-dark"]).default("transparent"), background: z.enum(["transparent", "paperclip-dark"]).default("transparent"),
}).strict(); }).strict();
@@ -30,14 +31,14 @@ export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatar
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => { router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
const file = String(req.params.file); 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 }); 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.setHeader("Cache-Control", "no-store");
res.status(400).json({ error: "Unsupported avatar version, palette, pose, size, or scale" }); return; 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 { try {
service ??= createAgentAvatarService(createStorageProviderFromConfig(loadConfig())); 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("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("ETag", etag); res.setHeader("ETag", etag);
res.setHeader("X-Content-Type-Options", "nosniff"); 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 id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return; 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; const entryFile = deriveBundleState(existing).entryFile;
if (instructionPath(req.body.path) === entryFile) { if (instructionPath(req.body.path) === entryFile) {
assertExternalInstructionsAdmin(req, existing); assertExternalInstructionsAdmin(req, existing);
+3 -2
View File
@@ -3238,7 +3238,7 @@ registry.registerPath({
path: "/api/agent-avatars/{version}/{palette}/{file}", path: "/api/agent-avatars/{version}/{palette}/{file}",
tags: ["agents"], tags: ["agents"],
summary: "Render or retrieve a public preset agent portrait", 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: { request: {
params: z.object({ params: z.object({
version: z.literal("cap-v1"), version: z.literal("cap-v1"),
@@ -3249,6 +3249,7 @@ registry.registerPath({
size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"), size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"),
scale: z.enum(["1", "2"]).optional().default("1"), scale: z.enum(["1", "2"]).optional().default("1"),
background: z.enum(["transparent", "paperclip-dark"]).optional().default("transparent"), background: z.enum(["transparent", "paperclip-dark"]).optional().default("transparent"),
view: z.enum(["front", "portrait"]).optional().default("front"),
}).strict(), }).strict(),
}, },
responses: { responses: {
@@ -3956,7 +3957,7 @@ registry.registerPath({
method: "put", method: "put",
path: "/api/agents/{id}/instructions-bundle/file", path: "/api/agents/{id}/instructions-bundle/file",
tags: ["agents"], tags: ["agents"],
summary: "Upsert agent instructions file", summary: "Save or rename a managed agent instructions file with a source hash guard",
request: { request: {
params: z.object({ id: z.string() }), params: z.object({ id: z.string() }),
body: jsonBody(upsertAgentInstructionsFileSchema), body: jsonBody(upsertAgentInstructionsFileSchema),
+1 -1
View File
@@ -6,7 +6,7 @@ import type { AgentAvatarRequest } from "./agent-avatars.js";
parentPort!.on("message", async (request: AgentAvatarRequest) => { parentPort!.on("message", async (request: AgentAvatarRequest) => {
try { 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)); const image = sharp(Buffer.from(svg));
if (request.background === "paperclip-dark") image.flatten({ background: PAPERCLIP_DARK_AVATAR_BACKGROUND }); if (request.background === "paperclip-dark") image.flatten({ background: PAPERCLIP_DARK_AVATAR_BACKGROUND });
const png = await image.png().toBuffer(); const png = await image.png().toBuffer();
+4 -3
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"; 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 type { StorageProvider } from "../storage/types.js";
import { createInviteRateLimiter } from "./invite-rate-limit.js"; import { createInviteRateLimiter } from "./invite-rate-limit.js";
import { createAgentAvatarPool } from "./agent-avatar-pool.js"; import { createAgentAvatarPool } from "./agent-avatar-pool.js";
@@ -11,10 +11,11 @@ export interface AgentAvatarRequest {
pose: CharacterState; pose: CharacterState;
muted: boolean; muted: boolean;
background?: AgentAvatarBackground; background?: AgentAvatarBackground;
view?: AgentAvatarView;
} }
export function avatarCacheKey(request: AgentAvatarRequest) { export function avatarCacheKey(request: AgentAvatarRequest) {
const { appearance, size, scale, pose, muted, background } = request; 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" : ""}.png`; 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 { export class AvatarAdmissionError extends Error {
constructor(public readonly retryAfterSeconds: number) { super("Too many cold avatar requests"); } 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 { currentPluginInteractionOrigin } from "./plugin-interaction-context.js";
import { extractAgentMentionIds } from "@paperclipai/shared"; import { extractAgentMentionIds } from "@paperclipai/shared";
import { assertAgentInteraction } from "./agent-interaction-policy.js"; 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) { export function agentFileStore(db: Db) {
db = fileStorageDb(db); db = fileStorageDb(db);
async function locked<T>(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean, 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 => { return db.transaction(async tx => {
if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null, if (write) await assertAgentInteraction(tx, { companyId, actorAgentId: actor.type === "agent" ? actor.agentId : null,
operation: "configure", targetAgentId: agentId, lockAgentIds: texts.flatMap(extractAgentMentionIds), lock: "update" }); 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 bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
const root = await adoptAgentFiles(tx, agent); const root = await adoptAgentFiles(tx, agent);
if (write) await invalidateAgentFileProjection(root); if (write) await invalidateAgentFileProjection(root);
const result = await fn(tx, agent, root, bound); let restore: (() => Promise<void>) | undefined;
if (write) await persistDurableAgentFiles(tx, agent, root); try {
return result; 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>) { 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) => read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) =>
locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))), 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) => 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) => { locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
const relative = agentFilePath(input.path); const relative = agentFilePath(input.path);
@@ -44,8 +44,8 @@
"packages/shared/src/adapter-auth-session.ts": "1bde62818a6e2a127ceb6e947882ded311798892", "packages/shared/src/adapter-auth-session.ts": "1bde62818a6e2a127ceb6e947882ded311798892",
"packages/shared/src/adapter-type.ts": "5af29dfc580ef18d53404b679cbe32a53587098e", "packages/shared/src/adapter-type.ts": "5af29dfc580ef18d53404b679cbe32a53587098e",
"packages/shared/src/adapter-types.test.ts": "49646cb8f85f86cb45d9dd0efda6b84ed0599341", "packages/shared/src/adapter-types.test.ts": "49646cb8f85f86cb45d9dd0efda6b84ed0599341",
"packages/shared/src/agent-appearance.test.ts": "95cb8b22a18bd68932d618bb97ddde24d0f8ee23", "packages/shared/src/agent-appearance.test.ts": "709173240aad4df58d5d6fb0f4d94bef36ac3911",
"packages/shared/src/agent-appearance.ts": "d45525d26e5899afe673aa4ff52ddc1b6cf67b11", "packages/shared/src/agent-appearance.ts": "e37f130eec7bb08efd4ed6d6a64812f2478c4320",
"packages/shared/src/agent-avatar-upload.ts": "7146d88adf363161494bddca0f1074c1aa4d7b52", "packages/shared/src/agent-avatar-upload.ts": "7146d88adf363161494bddca0f1074c1aa4d7b52",
"packages/shared/src/agent-commentary.test.ts": "d8eb09c29f30023705de111639054e35d98bc5bf", "packages/shared/src/agent-commentary.test.ts": "d8eb09c29f30023705de111639054e35d98bc5bf",
"packages/shared/src/agent-configuration-resources.ts": "a71afc1c1a4a617f2fbbf0fc5aed4f462e9aacf1", "packages/shared/src/agent-configuration-resources.ts": "a71afc1c1a4a617f2fbbf0fc5aed4f462e9aacf1",
@@ -201,7 +201,7 @@
"packages/shared/src/cliplab/particles.ts": "8ee5c63d1b8fc52367171b5fb6b63b3b3c554df5", "packages/shared/src/cliplab/particles.ts": "8ee5c63d1b8fc52367171b5fb6b63b3b3c554df5",
"packages/shared/src/cliplab/renderer.ts": "47a1154009e4071740204962615af43078b14862", "packages/shared/src/cliplab/renderer.ts": "47a1154009e4071740204962615af43078b14862",
"packages/shared/src/cliplab/runtime.ts": "a197f0f0eb28d7b06e1b956fa2feb1e396a35a8d", "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-canvas.ts": "2b1bb631749524487d05a61df435da7effb5d607",
"packages/shared/src/cliplab/svg-path.ts": "69ab5fc506a0ad9965837ddb41ecc245b2df1916", "packages/shared/src/cliplab/svg-path.ts": "69ab5fc506a0ad9965837ddb41ecc245b2df1916",
"packages/shared/src/cliplab/svg-snapshot.ts": "f1ef8e5d8e4d69db0d1cdcf80ba9b54a7ad3d0c7", "packages/shared/src/cliplab/svg-snapshot.ts": "f1ef8e5d8e4d69db0d1cdcf80ba9b54a7ad3d0c7",
@@ -439,7 +439,7 @@
"packages/shared/src/validators/adapter-skills.ts": "ae43f7b180f632d5f54dfa821b8dc5ef6c6aa53d", "packages/shared/src/validators/adapter-skills.ts": "ae43f7b180f632d5f54dfa821b8dc5ef6c6aa53d",
"packages/shared/src/validators/agent-commentary.ts": "8242c5b28a77131aefdc00c873ad84c6c6736e4b", "packages/shared/src/validators/agent-commentary.ts": "8242c5b28a77131aefdc00c873ad84c6c6736e4b",
"packages/shared/src/validators/agent.test.ts": "d38767b0c1fc1439871855ed55dca3c8c7b645c6", "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/app-definition.ts": "9fb660249eaf49a71adeab222cdb29a6db41ce28",
"packages/shared/src/validators/approval.test.ts": "051d29b27400fdd1e311466587b276264cf072e4", "packages/shared/src/validators/approval.test.ts": "051d29b27400fdd1e311466587b276264cf072e4",
"packages/shared/src/validators/approval.ts": "c1c074dc2f06d46adae0df8fa236d43c7aa01a25", "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 }, data: { path: string; content: string; baseRevisionId?: string | null; baseHash?: string | null; clearLegacyPromptTemplate?: boolean },
companyId?: string, companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data), ) => 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) => instructionCandidates: (id: string, companyId?: string) =>
api.get<AgentInstructionCandidate[]>(agentPath(id, companyId, "/instructions-bundle/candidates")), api.get<AgentInstructionCandidate[]>(agentPath(id, companyId, "/instructions-bundle/candidates")),
resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) => resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) =>
+5 -2
View File
@@ -163,6 +163,7 @@ export function AgentActionButtons({
size = "sm", size = "sm",
assignLabel = "Assign Task", assignLabel = "Assign Task",
showAssignIcon = true, showAssignIcon = true,
assignIcon: AssignIcon = Plus,
showChat = false, showChat = false,
runLabel = "Run now", runLabel = "Run now",
showStatus = true, showStatus = true,
@@ -187,6 +188,7 @@ export function AgentActionButtons({
size?: "sm" | "default"; size?: "sm" | "default";
assignLabel?: string; assignLabel?: string;
showAssignIcon?: boolean; showAssignIcon?: boolean;
assignIcon?: typeof Plus;
showChat?: boolean; showChat?: boolean;
runLabel?: string; runLabel?: string;
showStatus?: boolean; showStatus?: boolean;
@@ -524,9 +526,10 @@ export function AgentActionButtons({
)} )}
</PopoverContent> </PopoverContent>
</Popover> </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}`); }}> disabled={disabled} onClick={() => { if (!onBeforeNavigate || onBeforeNavigate()) navigate(`/chats/${canonicalAgentRef}`); }}>
<MessagesSquare className="size-4" /> <MessagesSquare className="size-4" />
Chat
</Button>} </Button>}
{needsSetup ? ( {needsSetup ? (
<Button size={size} disabled={disabled} onClick={finishSetup}> <Button size={size} disabled={disabled} onClick={finishSetup}>
@@ -540,7 +543,7 @@ export function AgentActionButtons({
disabled={assignAndRunDisabled} disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined} title={workActionsDisabled ? workActionsDisabledReason : undefined}
> >
{showAssignIcon && <Plus className="size-4" />} {showAssignIcon && <AssignIcon className="size-4" />}
{assignLabel} {assignLabel}
</Button> </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 container = document.createElement("div");
const root = createRoot(container); const root = createRoot(container);
try { 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")) { for (const image of container.querySelectorAll("img")) {
expect(image.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content"); expect(image.getAttribute("src")).toBe("/api/assets/22222222-2222-4222-8222-222222222222/content");
expect(image.hasAttribute("srcset")).toBe(false); expect(image.hasAttribute("srcset")).toBe(false);
+5 -4
View File
@@ -1,6 +1,6 @@
import { agentAvatarUrl } from "@/lib/agent-avatar-url"; import { agentAvatarUrl } from "@/lib/agent-avatar-url";
import { useState } from "react"; 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 { cn } from "@/lib/utils";
import { deriveInitials } from "./Identity"; import { deriveInitials } from "./Identity";
import { useAvatarInitials } from "./ui/avatar-initials"; import { useAvatarInitials } from "./ui/avatar-initials";
@@ -17,19 +17,20 @@ export interface AgentAvatarProps {
name?: string; name?: string;
label?: string; label?: string;
pose?: CharacterState; pose?: CharacterState;
view?: AgentAvatarView;
muted?: boolean; muted?: boolean;
className?: string; 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 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 [failedUrl, setFailedUrl] = useState<string | null>(null);
const initials = useAvatarInitials(deriveInitials(name ?? agent?.name ?? "Agent")); const initials = useAvatarInitials(deriveInitials(name ?? agent?.name ?? "Agent"));
return ( return (
<span data-slot="agent-avatar" className={cn("relative inline-flex shrink-0 items-center justify-center align-middle", avatarSizeClasses[size], className)} <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}> role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{failedUrl === src ? <span className="text-xs text-muted-foreground">{initials}</span> : {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)} />} decoding="async" loading="lazy" className="size-full object-contain" onError={() => setFailedUrl(src)} />}
</span> </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 { useEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react";
import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared"; import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared";
import { cn } from "@/lib/utils"; 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 { AgentAvatar, avatarSizeClasses, type AgentAvatarProps } from "./AgentAvatar";
import type { createCharacter } from "@paperclipai/shared/cliplab/runtime"; import type { createCharacter } from "@paperclipai/shared/cliplab/runtime";
type Player = ReturnType<typeof createCharacter>; 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"> { export interface AgentCharacterProps extends Omit<AgentAvatarProps, "pose"> {
state?: CharacterState; state?: CharacterState;
motion?: "auto" | "still"; motion?: "auto" | "still";
@@ -13,12 +20,16 @@ export interface AgentCharacterProps extends Omit<AgentAvatarProps, "pose"> {
trackingScope?: "region" | "page"; trackingScope?: "region" | "page";
followCursor?: boolean; followCursor?: boolean;
followRotation?: 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 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 root = useRef<HTMLSpanElement>(null), host = useRef<HTMLSpanElement>(null), player = useRef<Player | null>(null);
const slotId = useRef(Symbol("agent-character")); 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 [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"; const active = !identity.customAvatarAssetId && visible && !reduced && !failed && motion === "auto" && state !== "rest";
useEffect(() => { useEffect(() => {
@@ -30,15 +41,15 @@ export function AgentCharacter({ agent, appearance, size = 256, state = "idle",
return () => { observer.disconnect(); media.removeEventListener("change", change); }; return () => { observer.disconnect(); media.removeEventListener("change", change); };
}, []); }, []);
useEffect(() => { useEffect(() => {
if (active && owner === null) characterSlot.acquire(slotId.current); if (active && !ownsSlot) slots.acquire(slotId.current);
if (!active) characterSlot.release(slotId.current); if (!active) slots.release(slotId.current);
}, [active, owner]); }, [active, owners, ownsSlot, slots]);
useEffect(() => () => characterSlot.release(slotId.current), []); useEffect(() => () => slots.release(slotId.current), [slots]);
useEffect(() => { useEffect(() => {
if (!active || owner !== slotId.current) return; if (!active || !ownsSlot) return;
let disposed = false; let disposed = false;
setReady(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; if (disposed || !host.current) return;
player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), { player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), {
animation: library.animationId(state), trackingRegion: trackingRegion?.current ?? root.current ?? undefined, 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); setReady(true);
}).catch(() => { if (!disposed) setFailed(true); }); }).catch(() => { if (!disposed) setFailed(true); });
return () => { disposed = true; player.current?.destroy(); player.current = null; setReady(false); }; 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(() => { useEffect(() => {
if (!player.current) return; 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?.setDefinition(library.characterDefinition(identity, muted));
player.current?.setAnimation(library.animationId(state)); 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 { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { aiConnectionsApi } from "../api/ai-connections"; import { aiConnectionsApi } from "../api/ai-connections";
import * as ConnectionField from "./ai-connections/AiConnectionField";
import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields"; import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields";
import type { AdapterConfigFieldsProps } from "../adapters/types"; import type { AdapterConfigFieldsProps } from "../adapters/types";
import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui"; import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui";
@@ -1220,6 +1221,54 @@ describe("AgentConfigForm environment selector", () => {
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); 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 () => { it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => {
const dirty = vi.fn(); const dirty = vi.fn();
let save: (() => void) | null = null; 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"; type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
const displayedOriginals = new Map<string, unknown>();
/** Read effective value: overlay if dirty, else original */ /** Read effective value: overlay if dirty, else original */
function eff<T>(group: RecordOverlayGroup, field: string, original: T): T { function eff<T>(group: RecordOverlayGroup, field: string, original: T): T {
displayedOriginals.set(`${group}.${field}`, original);
const o = overlay[group]; const o = overlay[group];
if (field in o) return o[field] as T; if (field in o) return o[field] as T;
return original; return original;
@@ -551,10 +554,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
/** Mark field dirty in overlay */ /** Mark field dirty in overlay */
function mark(group: RecordOverlayGroup, field: string, value: unknown) { function mark(group: RecordOverlayGroup, field: string, value: unknown) {
setOverlay((prev) => ({ const key = `${group}.${field}`;
...prev, setOverlay((prev) => {
[group]: { ...prev[group], [field]: value }, 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() { function flushEnvironmentDraft() {
@@ -629,6 +638,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {}; const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {}; const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {}; const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
displayedOriginals.set("runtime.runtimeConfig", { ...runtimeConfig, aiConnection: runtimeConfig.aiConnection });
const adapterType = isCreate const adapterType = isCreate
? props.values.adapterType ? props.values.adapterType
@@ -1510,7 +1520,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}; };
}, [isCreate, overlay.heartbeat, runtimeConfig, val]); }, [isCreate, overlay.heartbeat, runtimeConfig, val]);
const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); 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 maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true);
const maxTurnContinuationMaxAttempts = clampInteger( const maxTurnContinuationMaxAttempts = clampInteger(
asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS),
@@ -1610,7 +1628,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<div className="space-y-6"> <div className="space-y-6">
{showRunnerUpgrade && !runnerUpgraded ? ( {showRunnerUpgrade && !runnerUpgraded ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4"> <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"> <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 font-medium">Upgrade to Paperclip Runner</p>
<p className="text-sm text-muted-foreground"> <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 { List, Network, Plus } from "lucide-react";
import { ToolbarSearch } from "./ToolbarSearch"; import { ToolbarSearch } from "./ToolbarSearch";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { OrgChartControls, type OrgChartActions } from "./OrgChartControls";
export interface AgentsToolbarProps { export interface AgentsToolbarProps {
query: string; query: string;
@@ -9,10 +11,13 @@ export interface AgentsToolbarProps {
onViewChange: (value: "list" | "org") => void; onViewChange: (value: "list" | "org") => void;
hideViewToggle?: boolean; hideViewToggle?: boolean;
onNewAgent: () => void; onNewAgent: () => void;
filters?: ReactNode;
orgControlsRef?: Ref<HTMLDivElement>;
orgActions?: OrgChartActions;
} }
/** Shared roster search, view controls and actions for the page and stories. */ /** 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"> 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"> <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…" /> <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 === "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> <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>} </div>}
{filters}
<div className="design-toolbar-actions"> <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> <Button size="sm" variant="outline" data-toolbar-primary onClick={onNewAgent}><Plus aria-hidden />New Agent</Button>
</div> </div>
</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 { ChatDetailSidebar } from "./chat/ChatDetailSidebar";
import { ChatSetupSidebar } from "./chat/ChatSetupNavigation"; import { ChatSetupSidebar } from "./chat/ChatSetupNavigation";
import { Store, ShieldQuestion } from "lucide-react"; import { Store, ShieldQuestion } from "lucide-react";
@@ -79,3 +79,14 @@ export function AppsSidebar() {
</aside> </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 <button
type="button" type="button"
data-slot="icon-button" data-slot="icon-button"
data-task-archive=""
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
+14
View File
@@ -88,6 +88,7 @@ vi.mock("./CompanySettingsSidebar", () => ({
vi.mock("./AppsSidebar", () => ({ vi.mock("./AppsSidebar", () => ({
AppsSidebar: () => <div>Apps sidebar</div>, AppsSidebar: () => <div>Apps sidebar</div>,
AppsMobileNav: () => <nav aria-label="Mobile connectors">Browse Review</nav>,
})); }));
vi.mock("./AgentContextualSidebar", () => ({ 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 () => { it("renders a mobile company settings selector on company settings routes", async () => {
currentPathname = "/PAP/company/settings/secrets"; currentPathname = "/PAP/company/settings/secrets";
mockSidebarState.isMobile = true; mockSidebarState.isMobile = true;
+2 -1
View File
@@ -13,7 +13,7 @@ import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar"; import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
import { CompanySettingsNav } from "./access/CompanySettingsNav"; import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { AppsSidebar } from "./AppsSidebar"; import { AppsSidebar, AppsMobileNav } from "./AppsSidebar";
import { AppDetailSidebar } from "./AppConnectionSidebar"; import { AppDetailSidebar } from "./AppConnectionSidebar";
import { AgentContextualSidebar } from "./AgentContextualSidebar"; import { AgentContextualSidebar } from "./AgentContextualSidebar";
import { RoutineContextualSidebar } from "./RoutineContextualSidebar"; import { RoutineContextualSidebar } from "./RoutineContextualSidebar";
@@ -752,6 +752,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
: "overflow-auto [scrollbar-gutter:stable]", : "overflow-auto [scrollbar-gutter:stable]",
)} )}
> >
{isMobile && isAppsRoute && (!companyPathSegments[1] || companyPathSegments[1] === "review") ? <AppsMobileNav /> : null}
{hasUnknownCompanyPrefix ? ( {hasUnknownCompanyPrefix ? (
<NotFoundPage <NotFoundPage
scope="invalid_company_prefix" 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") { if (layout === "cards") {
return ( return (
<div className="flex flex-col gap-(--agent-detail-module-gap)"> <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"> <div role="radiogroup" aria-label="Trust preset" className="contents">
{(["low_trust_review", "standard"] as const).map((value) => { {(["low_trust_review", "standard"] as const).map((value) => {
const selected = preset === 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 ( return (
<span <span
className={cn( className={cn(
"inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-xs leading-4", "inline-flex h-5 items-center gap-1 rounded-md px-1.5 text-xs leading-4",
mono && "font-mono", mono && "font-mono",
tone === "danger" ? "bg-destructive/10 text-destructive" : "bg-(--agent-detail-tile-chip) text-muted-foreground", tone === "danger" ? "bg-destructive/10 text-destructive" : "bg-(--agent-detail-tile-chip) text-muted-foreground",
className,
)} )}
> >
{children} {children}
@@ -131,7 +131,7 @@ describe("pending agent roster display", () => {
const row = render({ setupAdapterPending: true, const row = render({ setupAdapterPending: true,
setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } }); setupAdapterChoice: { adapterType: "paperclip_runner", runnerProvider: "antigravity", runner: "auto" } });
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([ 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"]'); const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
expect(checkbox).not.toBeNull(); expect(checkbox).not.toBeNull();
@@ -142,7 +142,7 @@ describe("pending agent roster display", () => {
it("preserves ordinary process display without a saved setup choice", () => { it("preserves ordinary process display without a saved setup choice", () => {
const row = render({ setupAdapterPending: true }); const row = render({ setupAdapterPending: true });
expect([...row.querySelectorAll("td")].slice(3).map(cell => cell.textContent)).toEqual([ 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"]'); const checkbox = row.querySelector<HTMLButtonElement>('[role="checkbox"]');
expect(checkbox).not.toBeNull(); 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"> <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> <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 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> </tr></thead>
<tbody>{visible.map(agent => { <tbody>{visible.map(agent => {
const metadata = rosterIndex.byAgentId.get(agent.id)!; const metadata = rosterIndex.byAgentId.get(agent.id)!;
@@ -98,8 +98,8 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
{renderChat && <td>{renderChat(agent)}</td>} {renderChat && <td>{renderChat(agent)}</td>}
<td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td> <td>{renderStatus ? renderStatus(agent) : <AgentStatusBadge status={agent.status} />}</td>
<td className="whitespace-nowrap text-xs">{metadata.harness.label}</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="text-xs">{metadata.connection.label}</td>
<td className="font-mono text-xs">{metadata.model.label}</td>
</tr>; </tr>;
})}</tbody> })}</tbody>
</table></div> </table></div>
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import type { Agent } from "@paperclipai/shared"; 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 accounts = useQuery({ queryKey: ["ai-connections", companyId], queryFn: () => aiConnectionsApi.list(companyId!), enabled: !!companyId });
const index = useMemo(() => buildAgentRosterIndex(agents, accounts.data), [agents, accounts.data]); const index = useMemo(() => buildAgentRosterIndex(agents, accounts.data), [agents, accounts.data]);
const activeCount = agentRosterFilterFields.filter(field => filters[field]).length; const activeCount = agentRosterFilterFields.filter(field => filters[field]).length;
const matches = useCallback((agent: Agent) => matchesAgentRosterFilters(index.byAgentId.get(agent.id), filters), [index, filters]);
return { return {
filters, options: index.options, activeCount, filters, options: index.options, activeCount,
onChange: (next: FilterState) => setSelection({ companyId, filters: next }), onChange: (next: FilterState) => setSelection({ companyId, filters: next }),
matches: (agent: Agent) => matchesAgentRosterFilters(index.byAgentId.get(agent.id), filters), matches,
connectionError: accounts.error, connectionError: accounts.error,
retryConnections: () => void accounts.refetch(), 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; filters: FilterState;
options: Record<AgentRosterFilterField, AgentRosterOption[]>; options: Record<AgentRosterFilterField, AgentRosterOption[]>;
onChange: (filters: FilterState) => void; onChange: (filters: FilterState) => void;
activeCount: number; activeCount: number;
count: number; count: number;
compact?: boolean;
connectionError?: unknown; connectionError?: unknown;
retryConnections: () => void; 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> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Filter agents" title={activeCount ? `Filters: ${activeCount}` : "Filter agents"} <Button variant={compact ? "outline" : "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")}> 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 /> <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>} {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> </Button>
@@ -63,7 +65,7 @@ export function AgentRosterFilters({ filters, options, onChange, activeCount, co
</label>)} </label>)}
</PopoverContent> </PopoverContent>
</Popover> </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>} {!!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>; </div>;
} }
@@ -1,4 +1,4 @@
import { ArtifactFront } from "./ArtifactFront"; import { IconSheet } from "./ArtifactSheet";
import { ArtifactStack } from "./ArtifactStack"; import { ArtifactStack } from "./ArtifactStack";
import type { ArtifactKind, ArtifactSubject } from "./kinds"; import type { ArtifactKind, ArtifactSubject } from "./kinds";
import brandPreview from "./assets/empty-brand.svg"; import brandPreview from "./assets/empty-brand.svg";
@@ -8,5 +8,5 @@ export function ArtifactEmptyGraphic({ kind = "file", stack = false }: { kind?:
const subject: ArtifactSubject = stack const subject: ArtifactSubject = stack
? { kind: "image", title: "", thumbnailUrl: brandPreview, aspect: 1.5 } ? { kind: "image", title: "", thumbnailUrl: brandPreview, aspect: 1.5 }
: { kind, title: "", ...(kind === "document" ? { ext: "md" } : {}) }; : { 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)", 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 ( return (
<div className="artifact-sheet relative shrink-0 overflow-clip" data-artifact-sheet="icon"> <div className="artifact-sheet relative shrink-0 overflow-clip" data-artifact-sheet="icon">
<Paper /> <Paper />
<Glyph subject={subject} /> <Glyph subject={subject} />
<span className="absolute whitespace-nowrap font-bold" style={LABEL}> {showLabel && <span className="absolute whitespace-nowrap font-bold" style={LABEL}>
{labelOf(subject)} {labelOf(subject)}
</span> </span>}
{play ? ( {play ? (
<span <span
aria-hidden 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]) { :is(.artifact-tile, .empty-state-artwork) :is([data-artifact-rotate][data-artifact-spread], [data-artifact-front]) {
transition: transform var(--artifact-hover-ease); 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); 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)); 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)); 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-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. */ /* Identity file chrome uses the same input surface and keeps filenames on one line. */
:root { :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; } .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. /** Bound live WebGL contexts; excess placements retain their static image. */
let owner: symbol | null = null; function createCharacterSlots(limit: number) {
const listeners = new Set<() => void>(); let owners: ReadonlySet<symbol> = new Set();
export const characterSlot = { const listeners = new Set<() => void>();
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, const notify = () => { for (const listener of listeners) listener(); };
getSnapshot() { return owner; }, return {
acquire(id: symbol) { if (owner) return false; owner = id; for (const listener of listeners) listener(); return true; }, subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
release(id: symbol) { if (owner !== id) return; owner = null; for (const listener of listeners) 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 type { Agent, AgentInstructionsBundle, AgentInstructionsFileDetail, AgentInstructionsFileSummary } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentFileRunNotice, PromptsTab } from "./AgentDetail"; import { AgentFileRunNotice, PromptsTab } from "./AgentDetail";
import { TooltipProvider } from "../components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
const mockAgentsApi = vi.hoisted(() => ({ const mockAgentsApi = vi.hoisted(() => ({
@@ -14,6 +15,7 @@ const mockAgentsApi = vi.hoisted(() => ({
instructionsFile: vi.fn(), instructionsFile: vi.fn(),
updateInstructionsBundle: vi.fn(), updateInstructionsBundle: vi.fn(),
saveInstructionsFile: vi.fn(), saveInstructionsFile: vi.fn(),
renameInstructionsFile: vi.fn(),
deleteInstructionsFile: vi.fn(), deleteInstructionsFile: vi.fn(),
instructionHistory: vi.fn(), instructionHistory: vi.fn(),
instructionCandidates: vi.fn(), instructionCandidates: vi.fn(),
@@ -248,6 +250,9 @@ describe("PromptsTab instruction editor", () => {
virtual: false, virtual: false,
content: data.content, content: data.content,
})); }));
mockAgentsApi.renameInstructionsFile.mockImplementation(async (_agentId, data) => ({
...makeSummary(data.renameTo, data.renameTo), content: data.content, contentHash: "b".repeat(64),
}));
mockAgentsApi.deleteInstructionsFile.mockResolvedValue({}); mockAgentsApi.deleteInstructionsFile.mockResolvedValue({});
}); });
@@ -278,7 +283,7 @@ describe("PromptsTab instruction editor", () => {
root = createRoot(container); root = createRoot(container);
await act(async () => { await act(async () => {
root?.render( root?.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}><TooltipProvider>
<PromptsTab <PromptsTab
agent={props.agent ?? makeAgent()} agent={props.agent ?? makeAgent()}
companyId={props.companyId ?? "company-1"} companyId={props.companyId ?? "company-1"}
@@ -287,7 +292,7 @@ describe("PromptsTab instruction editor", () => {
onCancelActionChange={props.onCancelActionChange ?? vi.fn()} onCancelActionChange={props.onCancelActionChange ?? vi.fn()}
onSavingChange={props.onSavingChange ?? vi.fn()} onSavingChange={props.onSavingChange ?? vi.fn()}
/> />
</QueryClientProvider>, </TooltipProvider></QueryClientProvider>,
); );
}); });
await flushReact(); await flushReact();
@@ -333,14 +338,16 @@ describe("PromptsTab instruction editor", () => {
onDirtyChange, onCancelActionChange: next => { discard = next; }, onDirtyChange, onCancelActionChange: next => { discard = next; },
}); });
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Add agent file"]')!.click()); 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"]) { for (const invalid of ["bad name", "file.json", "../escape", "notes.md", "bad!", "AGENTS"]) {
await act(() => setNativeValue(input, invalid)); 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")); await act(() => setNativeValue(input, "valid-notes_2"));
expect(buttonByText(container, "Create").disabled).toBe(false); expect(input.getAttribute("aria-invalid")).not.toBe("true");
await act(() => buttonByText(container, "Create").click());
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(true)); await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(true));
expect(container.textContent).toContain("valid-notes_2.md"); expect(container.textContent).toContain("valid-notes_2.md");
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe(""); expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe("");
@@ -350,6 +357,68 @@ describe("PromptsTab instruction editor", () => {
expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled(); 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 () => { it("shows inline copy feedback before Edit for five seconds without dirtying the file", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md"); const summary = makeSummary("AGENTS.md", "AGENTS.md");
await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original") }); 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(); container.querySelector<HTMLButtonElement>('button[aria-label="Add agent file"]')!.click();
}); });
await flushReact(); 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(); expect(input).not.toBeNull();
await act(async () => { await act(async () => {
setNativeValue(input!, "notes"); setNativeValue(input!, "notes");
buttonByText(container, "Create").click();
}); });
await selectInstructionMode("Edit");
await waitFor(() => { await waitFor(() => {
expect(container.querySelector<HTMLTextAreaElement>('textarea[placeholder="File contents"]')?.value).toBe(""); 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 { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useToastActions } from "../context/ToastContext"; import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -80,6 +81,7 @@ import { AuditFeed } from "./audit/AuditFeed";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import {
Paperclip,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Clock, Clock,
@@ -95,6 +97,7 @@ import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
ArrowLeft, ArrowLeft,
ArrowUpRight,
HelpCircle, HelpCircle,
FolderOpen, FolderOpen,
AlertTriangle, AlertTriangle,
@@ -387,22 +390,10 @@ export function AgentDetail() {
const shouldLoadHeartbeats = needsOverviewData || needsRunData; const shouldLoadHeartbeats = needsOverviewData || needsRunData;
const [configDirty, setConfigDirty] = useState(false); const [configDirty, setConfigDirty] = useState(false);
const [configSaving, setConfigSaving] = useState(false); const [configSaving, setConfigSaving] = useState(false);
const [configJustSaved, setConfigJustSaved] = useState(false); const [configSaveVersion, setConfigSaveVersion] = useState(0);
const [identitySaveVersion, setIdentitySaveVersion] = useState(0); const onConfigSaved = useCallback(() => setConfigSaveVersion(value => value + 1), []);
const onIdentitySaved = useCallback(() => setIdentitySaveVersion(value => value + 1), []);
const [pendingLeave, setPendingLeave] = useState<HTMLAnchorElement | null>(null); const [pendingLeave, setPendingLeave] = useState<HTMLAnchorElement | null>(null);
const leaveBypassRef = useRef(false); 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 saveConfigActionRef = useRef<(() => void) | null>(null);
const cancelConfigActionRef = useRef<(() => void) | null>(null); const cancelConfigActionRef = useRef<(() => void) | null>(null);
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
@@ -818,7 +809,7 @@ export function AgentDetail() {
const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined; const agentJoinLeavePending = agentMembershipPending && membershipMutation.variables?.starred === undefined;
return ( 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 ? ( {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"> <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"> <p className="min-w-0 flex-1">
@@ -892,12 +883,11 @@ export function AgentDetail() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<InlineEditor <InlineEditor
as="h1" as="h1"
value={agent.title ?? ""} value={agent.name}
fitContent fitContent
nullable placeholder="Agent name"
placeholder={roleLabels[agent.role] ?? agent.role}
className="min-w-0 truncate text-2xl font-semibold leading-8 text-foreground" 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} /> <PrimaryAgentCrownButton agent={agent} />
</div> </div>
@@ -915,10 +905,11 @@ export function AgentDetail() {
<span aria-hidden="true">•</span> <span aria-hidden="true">•</span>
<InlineEditor <InlineEditor
fitContent fitContent
value={agent.name} value={agent.title ?? ""}
placeholder="Agent name" nullable
className="min-w-0 truncate text-sm leading-5 text-muted-foreground" placeholder={roleLabels[agent.role] ?? agent.role}
onSave={(name) => saveHeaderField({ name })} className="agent-header-custom-title min-w-0 truncate text-sm leading-5 text-muted-foreground"
onSave={(title) => saveHeaderField({ title: title || null })}
/> />
</div> </div>
<AgentLifecycleStatus agent={currentAgent} refreshError={lifecycle.isError} onRetry={() => agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} /> <AgentLifecycleStatus agent={currentAgent} refreshError={lifecycle.isError} onRetry={() => agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} />
@@ -928,7 +919,7 @@ export function AgentDetail() {
companyId={resolvedCompanyId} companyId={resolvedCompanyId}
assignLabel="New task" assignLabel="New task"
size="default" size="default"
showAssignIcon={false} assignIcon={Paperclip}
showChat showChat
showStatus={false} showStatus={false}
workActionsInMenu workActionsInMenu
@@ -1099,7 +1090,7 @@ export function AgentDetail() {
agent={agent} agent={agent}
companyId={resolvedCompanyId ?? undefined} companyId={resolvedCompanyId ?? undefined}
showSaveNotice={false} showSaveNotice={false}
onSaved={onIdentitySaved} onSaved={onConfigSaved}
onDirtyChange={setConfigDirty} onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction} onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction} onCancelActionChange={setCancelConfigAction}
@@ -1118,6 +1109,7 @@ export function AgentDetail() {
onSaveActionChange={setSaveConfigAction} onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction} onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving} onSavingChange={setConfigSaving}
onSaved={onConfigSaved}
updatePermissions={updatePermissions} updatePermissions={updatePermissions}
canConfigureProviderTrace={canUseProviderTrace} canConfigureProviderTrace={canUseProviderTrace}
content="runtime" content="runtime"
@@ -1136,6 +1128,7 @@ export function AgentDetail() {
onSaveActionChange={setSaveConfigAction} onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction} onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving} onSavingChange={setConfigSaving}
onSaved={onConfigSaved}
updatePermissions={updatePermissions} updatePermissions={updatePermissions}
content="secrets" content="secrets"
/> />
@@ -1166,6 +1159,7 @@ export function AgentDetail() {
onSaveActionChange={setSaveConfigAction} onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction} onCancelActionChange={setCancelConfigAction}
onSavingChange={setConfigSaving} onSavingChange={setConfigSaving}
onSaved={onConfigSaved}
updatePermissions={updatePermissions} updatePermissions={updatePermissions}
content="permissions" 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?.()} onDiscard={() => cancelConfigActionRef.current?.()}
onSave={() => { Promise.resolve(saveConfigActionRef.current?.()).catch(() => {}); }} />} 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> </div>
); );
@@ -1440,6 +1421,12 @@ export function AgentOverview({
agentRouteId: string; agentRouteId: string;
}) { }) {
const agentRef = agentRouteRef(agent); 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 hasHumanCreator = Boolean(humanAgentCreatorUserId(agent.createdByUserId));
const userDirectory = useQuery({ const userDirectory = useQuery({
queryKey: queryKeys.access.companyUserDirectory(agent.companyId), 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" className="min-h-(--agent-detail-tile-min-h) sm:col-span-2"
aria-label="Skills" 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)">
<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>)}
{skillNames.map((name) => ( <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">
<AgentChip key={name}>{name}</AgentChip> Explore skills <ArrowUpRight className="size-3.5" aria-hidden />
))} </Link>
</div> </div>
) : (
<p className="px-(--agent-detail-module-inset) pt-1 pb-(--agent-detail-module-inset) text-sm text-muted-foreground">No skills enabled.</p>
)}
</AgentPanel> </AgentPanel>
</div> </div>
@@ -1533,7 +1517,24 @@ export function AgentOverview({
} }
/> />
{assignedIssues.length === 0 ? ( {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)"> <div className="overflow-hidden rounded-lg border border-border bg-(--agent-detail-tile)">
<TaskSummaryList issues={assignedIssues.slice(0, 5)} /> <TaskSummaryList issues={assignedIssues.slice(0, 5)} />
@@ -1621,6 +1622,7 @@ export function ConfigurationTab({
onSaveActionChange, onSaveActionChange,
onCancelActionChange, onCancelActionChange,
onSavingChange, onSavingChange,
onSaved,
updatePermissions, updatePermissions,
hidePromptTemplate, hidePromptTemplate,
hideInstructionsFile, hideInstructionsFile,
@@ -1633,6 +1635,7 @@ export function ConfigurationTab({
onSaveActionChange: (save: (() => void) | null) => void; onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void; onSavingChange: (saving: boolean) => void;
onSaved?: () => void;
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean }; updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
hidePromptTemplate?: boolean; hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean; hideInstructionsFile?: boolean;
@@ -1678,6 +1681,7 @@ export function ConfigurationTab({
setAwaitingRefreshAfterSave(true); setAwaitingRefreshAfterSave(true);
}, },
onSuccess: (updated, { target }) => { onSuccess: (updated, { target }) => {
if (lastAgentRef.current.id === target.id) onSaved?.();
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(target.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(target.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(target.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(target.companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(target.companyId) });
@@ -1889,13 +1893,17 @@ export function PromptsTab({
rootPath: string; rootPath: string;
entryFile: string; entryFile: string;
} | null>(null); } | 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 [copyVisible, setCopyVisible] = useState(false);
const [copyNotice, setCopyNotice] = useState<"copied" | "error" | null>(null); const [copyNotice, setCopyNotice] = useState<"copied" | "error" | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(copyTimer.current), []); useEffect(() => () => clearTimeout(copyTimer.current), []);
useEffect(() => { clearTimeout(copyTimer.current); setCopyNotice(null); }, [agent.id, selectedFile]); useEffect(() => { clearTimeout(copyTimer.current); setCopyNotice(null); }, [agent.id, selectedFile]);
const [showNewFileInput, setShowNewFileInput] = useState(false);
const [pendingFiles, setPendingFiles] = useState<string[]>([]); const [pendingFiles, setPendingFiles] = useState<string[]>([]);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()); const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [filePanelWidth, setFilePanelWidth] = useState(260); const [filePanelWidth, setFilePanelWidth] = useState(260);
@@ -1931,8 +1939,9 @@ export function PromptsTab({
setDraft(null); setDraft(null);
setReadOnlyCandidateRunId(null); setReadOnlyCandidateRunId(null);
setBundleDraft(null); setBundleDraft(null);
setNewFilePath(""); setFileNameDraft(null);
setShowNewFileInput(false); setEditingFileName(false);
setFileNameError(null);
setPendingFiles([]); setPendingFiles([]);
setExpandedDirs(new Set()); setExpandedDirs(new Set());
setAwaitingRefresh(false); setAwaitingRefresh(false);
@@ -1978,6 +1987,14 @@ export function PromptsTab({
[visibleFilePaths], [visibleFilePaths],
); );
const selectedOrEntryFile = selectedFile || currentEntryFile; 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 selectedFileExists = bundleMatchesDraft && fileOptions.includes(selectedOrEntryFile);
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null; const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
@@ -2220,9 +2237,9 @@ export function PromptsTab({
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md") 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 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(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
@@ -2236,7 +2253,14 @@ export function PromptsTab({
useEffect(() => { useEffect(() => {
onSaveActionChange(isDirty ? () => { onSaveActionChange(isDirty ? () => {
if (fileSaveLock.current) return;
const save = async () => { 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 = const shouldClearLegacy =
Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive); Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
if (bundleDirty && bundleDraft) { if (bundleDirty && bundleDraft) {
@@ -2249,6 +2273,30 @@ export function PromptsTab({
if (candidateRunId) { if (candidateRunId) {
await resolveCandidate.mutateAsync({ runId: candidateRunId, content: displayValue, await resolveCandidate.mutateAsync({ runId: candidateRunId, content: displayValue,
baseRevisionId: draftBaseRevisionRef.current ?? null }); 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) { } else if (fileDirty) {
await saveFile.mutateAsync({ await saveFile.mutateAsync({
path: selectedOrEntryFile, path: selectedOrEntryFile,
@@ -2260,7 +2308,7 @@ export function PromptsTab({
} }
onSaved?.(); 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); } : null);
}, [ }, [
bundle, bundle,
@@ -2278,6 +2326,15 @@ export function PromptsTab({
currentMode, currentMode,
saveFile, saveFile,
selectedOrEntryFile, selectedOrEntryFile,
renamedFilePath,
fileNameInvalid,
nameConflict,
selectedFileExists,
currentContent,
selectedFileDetail?.contentHash,
agent.id,
companyId,
queryClient,
updateBundle, updateBundle,
]); ]);
@@ -2289,6 +2346,9 @@ export function PromptsTab({
resolveCandidate.reset(); resolveCandidate.reset();
setDraft(null); setDraft(null);
setPendingFiles([]); setPendingFiles([]);
setFileNameDraft(null);
setEditingFileName(false);
setFileNameError(null);
if (pendingFiles.includes(selectedOrEntryFile)) setSelectedFile(currentEntryFile); if (pendingFiles.includes(selectedOrEntryFile)) setSelectedFile(currentEntryFile);
if (bundle) { if (bundle) {
setBundleDraft({ setBundleDraft({
@@ -2381,13 +2441,29 @@ export function PromptsTab({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Files</h4> <h4 className="text-sm font-semibold text-foreground">Files</h4>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{!showNewFileInput && ( {(
<Button <Button
type="button" type="button"
size="icon-xs" size="icon-xs"
variant="ghost" variant="ghost"
aria-label="Add agent file" 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" /> <Plus className="size-4" />
</Button> </Button>
@@ -2405,66 +2481,10 @@ export function PromptsTab({
)} )}
</div> </div>
</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 <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} selectedFile={selectedOrEntryFile}
expandedDirs={expandedDirs} expandedDirs={expandedDirs}
checkedFiles={new Set()} checkedFiles={new Set()}
@@ -2479,6 +2499,9 @@ export function PromptsTab({
if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return; if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return;
setDraft(null); setDraft(null);
setPendingFiles([]); setPendingFiles([]);
setFileNameDraft(null);
setEditingFileName(false);
setFileNameError(null);
setSelectedFile(filePath); setSelectedFile(filePath);
setInstructionMode("raw"); setInstructionMode("raw");
if (!fileOptions.includes(filePath)) setDraft(""); if (!fileOptions.includes(filePath)) setDraft("");
@@ -2487,7 +2510,7 @@ export function PromptsTab({
onToggleCheck={() => {}} onToggleCheck={() => {}}
showCheckboxes={false} showCheckboxes={false}
wrapLabels={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) => { renderFileExtra={(node) => {
const file = bundle?.files.find((entry) => entry.path === node.path); const file = bundle?.files.find((entry) => entry.path === node.path);
if (!file) return null; 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 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"> <div className="flex items-center gap-2 min-w-0">
{isMobile && ( {isMobile && (
<Button <Button
@@ -2537,8 +2560,31 @@ export function PromptsTab({
</Button> </Button>
)} )}
<div className="flex min-w-0 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2"> <div className="agent-identity-file-heading 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> {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> <AgentChip>{currentMode === "external" ? "Managed externally" : "Managed by you"}</AgentChip>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -2581,6 +2627,16 @@ export function PromptsTab({
<Copy className="size-4" /> <Copy className="size-4" />
</Button> </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 && ( {selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button <Button
type="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>} {(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>} </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>} {(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>} {(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?.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 {selectedFileDetail?.revision && currentMode === "managed" && bundle?.persistence !== "agent_files" && <InstructionHistory
@@ -2678,7 +2735,7 @@ export function PromptsTab({
) : instructionMode === "edit" ? ( ) : instructionMode === "edit" ? (
<textarea <textarea
aria-label="Instruction file editor" aria-label="Instruction file editor"
autoFocus autoFocus={!editingFileName}
value={displayValue} value={displayValue}
spellCheck={false} spellCheck={false}
onChange={(event) => { 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 type { AgentDetail, AgentRuntimeState, HeartbeatRun, Issue } from "@paperclipai/shared";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AgentOverview } from "./AgentDetail"; import { AgentOverview } from "./AgentDetail";
import { DialogProvider } from "../context/DialogContext";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -54,7 +55,7 @@ describe("AgentOverview", () => {
} as unknown as AgentRuntimeState; } as unknown as AgentRuntimeState;
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}><DialogProvider>
<TooltipProvider> <TooltipProvider>
<AgentOverview <AgentOverview
agent={agent} agent={agent}
@@ -66,7 +67,7 @@ describe("AgentOverview", () => {
agentRouteId="codexcoder" agentRouteId="codexcoder"
/> />
</TooltipProvider> </TooltipProvider>
</QueryClientProvider>, </DialogProvider></QueryClientProvider>,
); );
for (const label of ["Activity", "Runs", "Costs", "Budget used", "Profile", "Skills", "Recent tasks"]) { 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(`href="/agents/codexcoder/${page}"`);
} }
expect(markup).toContain("2 enabled"); 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("Design Guide");
expect(markup).toContain("Simplify agent information architecture"); expect(markup).toContain("Simplify agent information architecture");
expect(markup).toContain("PAP-42"); expect(markup).toContain("PAP-42");
@@ -106,7 +109,7 @@ describe("AgentOverview", () => {
access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] }, access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] },
} as unknown as AgentDetail; } as unknown as AgentDetail;
return renderToStaticMarkup( return renderToStaticMarkup(
<QueryClientProvider client={client}> <QueryClientProvider client={client}><DialogProvider>
<AgentOverview <AgentOverview
agent={agent} agent={agent}
runs={[] as HeartbeatRun[]} runs={[] as HeartbeatRun[]}
@@ -115,7 +118,7 @@ describe("AgentOverview", () => {
skillNames={[]} skillNames={[]}
agentRouteId="sharedagent" 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); document.body.appendChild(container);
const root = createRoot(container); const root = createRoot(container);
const noop = () => {}; const noop = () => {};
const saved = vi.fn();
const render = (value: AgentDetail) => root.render(<QueryClientProvider client={client}> const render = (value: AgentDetail) => root.render(<QueryClientProvider client={client}>
<ConfigurationTab agent={value} companyId={value.companyId} onDirtyChange={noop} onSaveActionChange={noop} <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>); </QueryClientProvider>);
try { try {
await act(async () => render(original)); 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)); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(mocks.update).toHaveBeenCalledWith(original.id, { runner: "paperclip" }, original.companyId); expect(mocks.update).toHaveBeenCalledWith(original.id, { runner: "paperclip" }, original.companyId);
expect(mocks.navigate).not.toHaveBeenCalled(); expect(mocks.navigate).not.toHaveBeenCalled();
expect(saved).not.toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["agents", "detail", original.id] }); expect(invalidate).toHaveBeenCalledWith({ queryKey: ["agents", "detail", original.id] });
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ["agents", "detail", "other-agent"] }); expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ["agents", "detail", "other-agent"] });
} finally { } finally {
@@ -62,3 +64,29 @@ it("keeps an upgrade receipt on its original agent when navigation changes the c
container.remove(); 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 // @vitest-environment jsdom
import type { ReactNode } from "react"; import { act as reactAct, type ReactNode } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query";
@@ -395,6 +395,58 @@ describe("Agents", () => {
vi.clearAllMocks(); 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 () => { it("filters the rendered roster and clears search without changing its status route", async () => {
mockAgentsApi.list.mockResolvedValue([ mockAgentsApi.list.mockResolvedValue([
makeAgent({ id: "alpha", name: "Alpha", role: "engineer" }), makeAgent({ id: "alpha", name: "Alpha", role: "engineer" }),
@@ -451,7 +503,7 @@ describe("Agents", () => {
expect(container.querySelector("tbody")?.textContent).not.toContain("Manager"); expect(container.querySelector("tbody")?.textContent).not.toContain("Manager");
expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("1 hidden by filters"); 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="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 act(() => { queryClient.setQueryData(queryKeys.agents.list("company-1"), agents.map(agent => agent.id === "alpha" ? { ...agent, adapterConfig: { model: "new-model" } } : agent)); });
await flushReact(); await flushReact();
expect(document.querySelector<HTMLSelectElement>('select[aria-label="Model"]')?.selectedOptions[0].textContent).toBe("Previously selected model"); expect(document.querySelector<HTMLSelectElement>('select[aria-label="Model"]')?.selectedOptions[0].textContent).toBe("Previously selected model");
@@ -468,10 +520,16 @@ describe("Agents", () => {
await flushReact(); await flushReact();
expect(container.textContent).toContain("Alpha"); expect(container.textContent).toContain("Alpha");
expect(container.textContent).toContain("Manager"); 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 act(() => container.querySelector<HTMLButtonElement>('[aria-label="Filter agents"]')!.click());
await select("Status", "paused"); 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")!; const clear = [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Clear filters")!;
await act(() => clear.click()); await act(() => clear.click());
expect(container.textContent).toContain("Beta"); expect(container.textContent).toContain("Beta");
@@ -580,12 +638,24 @@ describe("Agents", () => {
const orgViewport = container.querySelector('[data-testid="org-chart-viewport"]'); const orgViewport = container.querySelector('[data-testid="org-chart-viewport"]');
expect(orgViewport).not.toBeNull(); expect(orgViewport).not.toBeNull();
expect(orgViewport?.parentElement?.classList.contains("flex-1")).toBe(true); 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?.classList.contains("h-(--sz-calc-38)")).toBe(false);
expect(orgViewport?.parentElement?.parentElement?.classList.contains("min-h-0")).toBe(true); 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 in"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Zoom out"]')).not.toBeNull();
expect(container.querySelector('[aria-label="Fit chart to screen"]')).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 () => { await act(async () => {
listToggle?.click(); listToggle?.click();
@@ -593,6 +663,16 @@ describe("Agents", () => {
await flushReact(); await flushReact();
expect(container.querySelector('[data-testid="org-chart-viewport"]')).toBeNull(); expect(container.querySelector('[data-testid="org-chart-viewport"]')).toBeNull();
expect(container.textContent).toContain("gpt-5.4"); 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 () => { 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("1 selected");
expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("Edit connection & model"); expect(document.querySelector('[aria-label="Bulk actions"]')?.textContent).toContain("Edit connection & model");
expect(mockRouterState.navigate).not.toHaveBeenCalled(); 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([ it.each([
+31 -22
View File
@@ -4,7 +4,7 @@ import { AgentRosterFilters, useAgentRosterFilters } from "@/components/agents/A
import { AgentAvatar } from "@/components/AgentAvatar"; import { AgentAvatar } from "@/components/AgentAvatar";
import { AgentChatPin, useAgentChatPins } from "@/components/AgentChatPin"; import { AgentChatPin, useAgentChatPins } from "@/components/AgentChatPin";
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled"; 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 { Link, useNavigate, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents"; import { agentsApi, type OrgNode } from "../api/agents";
@@ -27,6 +27,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { QueryErrorState, useQueryView } from "../components/QueryView"; import { QueryErrorState, useQueryView } from "../components/QueryView";
import { OrgChart } from "./OrgChart"; import { OrgChart } from "./OrgChart";
import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils"; import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import type { OrgChartActions } from "../components/OrgChartControls";
import { AgentsToolbar } from "../components/AgentsToolbar"; import { AgentsToolbar } from "../components/AgentsToolbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -193,6 +194,13 @@ export type AgentsView = "list" | "org";
export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) { export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) {
const agentChatPins = useAgentChatPins(); const agentChatPins = useAgentChatPins();
const [query, setQuery] = useState(""); 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 agentChat = useAgentChatEnabled();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewAgent } = useDialogActions(); const { openNewAgent } = useDialogActions();
@@ -203,13 +211,14 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const pathSegment = location.pathname.split("/").pop() ?? "all"; const pathSegment = location.pathname.split("/").pop() ?? "all";
const requestedTab: FilterTab = isFilterTab(pathSegment) ? pathSegment : "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 forceListView = !streamlinedUiEnabled && isMobile;
const effectiveView: AgentsView = forceListView ? "list" : view; const effectiveView: AgentsView = forceListView ? "list" : view;
useEffect(() => { useEffect(() => {
setView(streamlinedUiEnabled ? initialView : "org"); setView(previewTasks ? "org" : streamlinedUiEnabled ? initialView : "org");
}, [initialView, streamlinedUiEnabled]); }, [initialView, streamlinedUiEnabled, previewTasks]);
const { data: instanceSettings } = useQuery({ const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings, queryKey: queryKeys.instance.settings,
@@ -327,6 +336,13 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
} }
}, [navigate, requestedTab]); }, [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) { if (!selectedCompanyId) {
return <EmptyState icon={Bot} message="Select an organization to view agents." />; 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 environmentDataLoading = environmentsEnabled && environments === undefined;
const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1); const showEnvironmentColumn = environmentsEnabled && (environments === undefined || environments.length > 1);
const resolveRenderedEnvironment = (agentId: string) => ( const resolveRenderedEnvironment = (agentId: string) => (
@@ -432,15 +446,16 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
return ( return (
<div className={cn( <div className={cn(
"flex flex-col gap-4 @container", "agents-surface flex flex-col gap-4 @container",
effectiveView === "org" && "h-full min-h-0", effectiveView === "org" && "agents-org-surface h-full min-h-0",
)}> )}>
<AgentsToolbar query={query} onQueryChange={setQuery} view={effectiveView} onViewChange={setView} <div className={effectiveView === "org" ? "agents-org-toolbar" : undefined}>
hideViewToggle={forceListView} onNewAgent={openNewAgent} /> <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" && "agents-org-content flex flex-1 flex-col")}>
<div className={cn("min-h-0 space-y-4", effectiveView === "org" && "flex flex-1 flex-col")}>
{agents && agents.length === 0 && ( {agents && agents.length === 0 && (
<EmptyState <EmptyState
@@ -454,7 +469,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
{/* List view */} {/* List view */}
{effectiveView === "list" && agents && agents.length > 0 && ( {effectiveView === "list" && agents && agents.length > 0 && (
<AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId} <AgentBulkRoster key={selectedCompanyId} companyId={selectedCompanyId}
agents={agents} agents={eligibleAgents}
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus} visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus}
renderChat={agentChat.enabled ? agent => ( renderChat={agentChat.enabled ? agent => (
// Use Radix's standard hover delay, isolated from the app's instant provider. // 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 */} {/* Org chart view */}
{effectiveView === "org" && filteredOrg.length > 0 && ( {effectiveView === "org" && visibleOrg.length > 0 && (
<OrgChart embedded orgTree={filteredOrg} agents={agents ?? []} /> <OrgChart embedded actionsRef={chartActionsRef} controlsContainer={orgControlsContainer} orgTree={visibleOrg} agents={agents ?? []} matchingAgentIds={query.trim() || rosterFilters.activeCount ? matchingAgentIds : undefined} />
)}
{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" && orgTree && orgTree.length === 0 && ( {effectiveView === "org" && orgTree && orgTree.length === 0 && (
+1
View File
@@ -804,6 +804,7 @@ function InboxCollectionToolbar({
if (streamlined) { if (streamlined) {
return ( return (
<CollectionToolbar <CollectionToolbar
className={ariaLabel === "Tasks controls" ? "mobile-task-toolbar" : undefined}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
context={context} context={context}
search={search} 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { OrgChart } from "./OrgChart"; 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 navigateMock = vi.fn();
const orgMock = vi.fn(); const orgMock = vi.fn();
const listMock = 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", () => ({ 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, useNavigate: () => navigateMock,
useLocation: () => ({ pathname: window.location.pathname, search: window.location.search }),
})); }));
vi.mock("../context/CompanyContext", () => ({ vi.mock("../context/CompanyContext", () => ({
@@ -138,8 +151,14 @@ describe("OrgChart mobile gestures", () => {
queryClient = new QueryClient({ queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }, 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; viewportWidth = 360;
viewportHeight = 520; viewportHeight = 520;
window.history.replaceState({}, "", "/");
liveRunsMock.mockResolvedValue([]);
summariesMock.mockResolvedValue([]);
orgMock.mockResolvedValue(orgTree); orgMock.mockResolvedValue(orgTree);
listMock.mockResolvedValue(agents); listMock.mockResolvedValue(agents);
@@ -191,16 +210,20 @@ describe("OrgChart mobile gestures", () => {
} }
container.remove(); container.remove();
document.body.innerHTML = ""; 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.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
async function renderOrgChart() { async function renderOrgChart(enableTaskLayer = false, matchingAgentIds?: ReadonlySet<string>) {
root = createRoot(container); root = createRoot(container);
await act(async () => { await act(async () => {
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<OrgChart /> <ToastProvider><OrgChart enableTaskLayer={enableTaskLayer} matchingAgentIds={matchingAgentIds} /></ToastProvider>
</QueryClientProvider>, </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 () => { it("pans the chart with one-finger touch drag", async () => {
const { viewport, layer } = await renderOrgChart(); const { viewport, layer } = await renderOrgChart();
const before = readTransform(layer);
await act(async () => { await act(async () => {
viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }])); viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }]));
@@ -221,7 +269,10 @@ describe("OrgChart mobile gestures", () => {
viewport.dispatchEvent(createTouchEvent("touchend", [])); 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 () => { 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 () => { it("pinch-zooms toward the touch center", async () => {
const { viewport, layer } = await renderOrgChart(); const { viewport, layer } = await renderOrgChart();
const before = readTransform(layer);
await act(async () => { await act(async () => {
viewport.dispatchEvent(createTouchEvent("touchstart", [ viewport.dispatchEvent(createTouchEvent("touchstart", [
@@ -265,7 +317,10 @@ describe("OrgChart mobile gestures", () => {
viewport.dispatchEvent(createTouchEvent("touchend", [])); 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 () => { 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)"); 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 () => { it("shows both portability buttons on self-hosted instances", async () => {
await renderOrgChart(); 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 { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { createPortal } from "react-dom";
import { Link, useNavigate } from "@/lib/router"; 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 { 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 { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -12,34 +27,17 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { QueryErrorState, useQueryView } from "../components/QueryView"; 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 { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { useHiddenSettings } from "@/hooks/useHiddenSettings";
// Layout constants const modelLabels = new Map([...claudeModels, ...codexModels].map(model => [model.id, model.label]));
const CARD_W = 200; const MIN_ZOOM = 0.05;
const CARD_H = 100;
const GAP_X = 32;
const GAP_Y = 80;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 2; const MAX_ZOOM = 2;
const FIT_PADDING = 40; const FIT_PADDING = 40;
const TOUCH_MOVE_THRESHOLD = 6; 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 { interface Point {
x: number; x: number;
y: number; y: number;
@@ -57,62 +55,6 @@ interface TouchGesture {
// ── Layout algorithm ──────────────────────────────────────────────────── // ── 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. */ /** Flatten layout tree to list of nodes. */
function flattenLayout(nodes: LayoutNode[]): LayoutNode[] { function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
const result: LayoutNode[] = []; const result: LayoutNode[] = [];
@@ -124,19 +66,6 @@ function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
return result; 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 { function clampZoom(value: number): number {
return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM); return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM);
} }
@@ -144,13 +73,14 @@ function clampZoom(value: number): number {
function fitChartToViewport( function fitChartToViewport(
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
bounds: { width: number; height: number }, bounds: { width: number; height: number; maxFitZoom: number },
topInset: number,
): { zoom: number; pan: Point } | null { ): { 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 scaleX = (containerWidth - FIT_PADDING) / bounds.width;
const scaleY = (containerHeight - FIT_PADDING) / bounds.height; const scaleY = (containerHeight - topInset - FIT_PADDING) / bounds.height;
const zoom = clampZoom(Math.min(scaleX, scaleY, 1)); const zoom = clampZoom(Math.min(scaleX, scaleY, bounds.maxFitZoom));
const chartWidth = bounds.width * zoom; const chartWidth = bounds.width * zoom;
const chartHeight = bounds.height * zoom; const chartHeight = bounds.height * zoom;
@@ -158,7 +88,7 @@ function fitChartToViewport(
zoom, zoom,
pan: { pan: {
x: (containerWidth - chartWidth) / 2, 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 } from "../adapters/adapter-display-registry";
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)";
// ── Main component ────────────────────────────────────────────────────── // ── Main component ──────────────────────────────────────────────────────
@@ -202,14 +120,24 @@ export interface OrgChartProps {
orgTree?: OrgNode[]; orgTree?: OrgNode[];
/** Agent records paired with a pre-filtered embedded tree. */ /** Agent records paired with a pre-filtered embedded tree. */
agents?: Agent[]; agents?: Agent[];
/** Nonmatching agents remain in place and navigable, with reduced opacity. */
matchingAgentIds?: ReadonlySet<string>;
/** Hides page-level actions and breadcrumb ownership. */ /** Hides page-level actions and breadcrumb ownership. */
embedded?: boolean; 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 { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate(); 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 // 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 // button is hidden rather than dead-ending. Export stays available. Both
// buttons also respect the operator-hidden settings registry. // 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 orgTree = providedOrgTree ?? queriedOrgTree;
const agents = providedAgents ?? queriedAgents; 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 agentMap = useMemo(() => {
const m = new Map<string, Agent>(); const m = new Map<string, Agent>();
for (const a of agents ?? []) m.set(a.id, a); 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" }]); if (!embedded) setBreadcrumbs([{ label: "Org Chart" }]);
}, [embedded, setBreadcrumbs]); }, [embedded, setBreadcrumbs]);
// Layout computation const [tasksVisible, setShowTasks] = useState(simulateTasks);
const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]); 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 allNodes = useMemo(() => flattenLayout(layout), [layout]);
const edges = useMemo(() => collectEdges(layout), [layout]); const nodesById = useMemo(() => new Map(allNodes.map(node => [node.id, node])), [allNodes]);
const edges = useMemo(() => routeReportingEdges(layout, geometry.gap), [layout, geometry.gap]);
// Compute SVG bounds const bounds = useMemo(() => ({ width: arrangement.width, height: arrangement.height, maxFitZoom: arrangement.maxFitZoom }), [arrangement]);
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]);
// Pan & zoom state // Pan & zoom state
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 }); const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -290,24 +272,24 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
useEffect(() => { useEffect(() => {
hasInitialized.current = false; hasInitialized.current = false;
}, [orgTree]); }, [orgTree, viewport, topInset]);
useEffect(() => { useEffect(() => {
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return; if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
const container = containerRef.current; const container = containerRef.current;
const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds); const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds, topInset);
if (!fitted) return; if (!fitted) return;
hasInitialized.current = true; hasInitialized.current = true;
setZoom(fitted.zoom); setZoom(fitted.zoom);
setPan(fitted.pan); setPan(fitted.pan);
}, [allNodes, bounds]); }, [allNodes, bounds, topInset]);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; if (e.button !== 0) return;
// Don't drag if clicking a card // Don't drag if clicking a card
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest("[data-org-card]")) return; if (target.closest("[data-org-card], button, a")) return;
setDragging(true); setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]); }, [pan]);
@@ -360,12 +342,13 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
containerRef.current.clientWidth, containerRef.current.clientWidth,
containerRef.current.clientHeight, containerRef.current.clientHeight,
bounds, bounds,
topInset,
); );
if (!fitted) return; if (!fitted) return;
setZoom(fitted.zoom); setZoom(fitted.zoom);
setPan(fitted.pan); setPan(fitted.pan);
}, [bounds]); }, [bounds, topInset]);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => { const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length >= 2 && containerRef.current) { if (e.touches.length >= 2 && containerRef.current) {
@@ -467,6 +450,35 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
}; };
}, [pan, zoom]); }, [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) { if (!selectedCompanyId) {
return <EmptyState icon={Network} message="Select an organization to view the org chart." />; 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 ( return (
<div <div
className={embedded 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"} : "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) ? ( {!embedded && (showImport || showExport) ? (
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2"> <div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport ? ( {showImport ? (
@@ -522,7 +535,7 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
<div <div
ref={containerRef} ref={containerRef}
data-testid="org-chart-viewport" 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={{ style={{
cursor: dragging ? "grabbing" : "grab", cursor: dragging ? "grabbing" : "grab",
touchAction: "none", touchAction: "none",
@@ -538,50 +551,6 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchCancel={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 layer for edges */}
<svg <svg
className="absolute inset-0 pointer-events-none" 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})`}> <g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{edges.map(({ parent, child }) => { {edges.map(({ parent, child, path }) => {
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;
return ( return (
<path <path
key={`${parent.id}-${child.id}`} key={`${parent.id}-${child.id}`}
d={`M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`} d={path}
fill="none" fill="none"
stroke="var(--border)" className="org-reporting-edge"
strokeWidth={1.5} 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 */} {/* Card layer */}
<div <div
data-testid="org-chart-card-layer" data-testid="org-chart-card-layer"
className="absolute inset-0" className="org-chart-cards absolute inset-0"
style={{ style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0", transformOrigin: "0 0",
@@ -622,20 +585,36 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
> >
{allNodes.map((node) => { {allNodes.map((node) => {
const agent = agentMap.get(node.id); 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 ( return (
<Card <Card
key={node.id} key={node.id}
data-org-card 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={{ style={{
left: node.x, left: node.x,
top: node.y, top: node.y,
width: CARD_W, width: node.width, height: node.height,
minHeight: CARD_H,
}} }}
onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} onClick={openAgent}
onClickCapture={(e) => { onClickCapture={(e) => {
if (!suppressNextCardClick.current) return; if (!suppressNextCardClick.current) return;
suppressNextCardClick.current = false; suppressNextCardClick.current = false;
@@ -643,41 +622,107 @@ export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, emb
e.stopPropagation(); e.stopPropagation();
}} }}
> >
<div className="flex items-center px-4 py-3 gap-3"> {node.leader && <div className="org-card-badge-slot" aria-hidden />}
{/* Agent icon + status dot */} <div className="org-card-heading min-w-0 text-left">
<div className="relative shrink-0"> {node.leader && (
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center"> <StarToggle
<AgentAvatar agent={agent} size={16} className="h-4.5 w-4.5 text-foreground/70"/> size="button"
</div> className="org-card-star"
<span starred={starredAgents.has(node.id)}
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card" resourceName={node.name}
style={{ backgroundColor: dotColor }} 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> </div>
{/* Name + role + adapter type */} {node.leader && <div className="org-card-status truncate text-muted-foreground">{statusLabel}</div>}
<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>
</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> </Card>
); );
})} })}
</div> </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>
</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) 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"); .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)).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", () => { 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; if (args[0].customAvatarAssetId) return url.pathname;
const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, ""); const preset = url.pathname.slice("/api/agent-avatars/".length).replace(/\.png$/, "");
const background = url.searchParams.get("background") === "paperclip-dark" ? "-paperclip-dark-v1" : ""; 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 // The build-time asset packer needs relative file names. Browser consumers
// need absolute URLs because CSS custom properties use the stylesheet base. // need absolute URLs because CSS custom properties use the stylesheet base.
return typeof document === "undefined" ? path : new URL(path, document.baseURI).href; return typeof document === "undefined" ? path : new URL(path, document.baseURI).href;