fix(ui): refine agent profiles, identity editing, and list actions (#19535)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent pages expose identity files, reporting relationships, and
runtime settings.
> - Several controls had inconsistent spacing, weak navigation, or
misleading save feedback.
> - Empty task-row detail slots also reduced the title width before
truncation.
> - This pull request fixes those behaviors and aligns the reviewed UI
details.
> - The result is clearer agent editing and more readable task and agent
lists.

## Linked Issues or Issue Description

**What happened?**
Agent headers and file controls had inconsistent layout. The Identity
footer appeared without edits and did not provide clear save
confirmation. File names could wrap or omit the Markdown suffix. Task
titles truncated while empty detail slots occupied the row.

**Expected behavior**
Keep headers and actions aligned. Reveal the Identity footer only for
changes or save feedback. Use valid Markdown file names and actual file
dates. Let titles use available space.

**Steps to reproduce**
1. Open an agent overview, then its Identity tab.
2. Edit, undo, save, copy, and add an instruction file.
3. Resize the file panel and inspect file names and date labels.
4. Hover an agent row and inspect the Chat action and tooltip.
5. Open a task list with no recovery or external details and inspect
title width.

Related: #18716 is the prior UI bundle. #16934 and #14483 concern role
editing; this change does not add a role picker.

## What Changed

- Refine the character header, inline title and name editing, crown
position, runtime shortcut, and Chat/New task actions.
- Align profile metadata and manager selection; hide empty direct
reports.
- Refine Identity panels, Markdown filename validation, file-row hover,
truncation, Paper Mono typography, and copy feedback.
- Add a viewport-bound save footer with dirty, saving, and saved states.
Keep undo/discard separate from successful saves.
- Return recorded creation or modification dates for instruction files.
Reuse the active file transaction for metadata reads to avoid exhausting
the small connection pool.
- Reveal the agent-row Chat icon and label on hover, with a standard
delayed tooltip.
- Collapse empty task-row details so titles can use the available width.

## Verification

- Browser checks covered the local overview and Identity flows, narrow
file panels, successful file creation/update, undo/discard, header
navigation, row hover, tooltip delayed-open state, and full task titles.
- Focused instruction, save-footer, date, font-asset, and task-row tests
passed during development. After review, 55 file/instruction tests
passed, including cold restoration with recorded and unknown dates;
server typecheck and 18 immediate-save response tests also passed.
- Full `pnpm -r typecheck` and `pnpm build` passed after rebase onto
current master. The complete `pnpm test:run` attempt hit a 15-second
timeout in the existing chat Retry HTTP integration test; that case
passed in isolation (1 test passed). A second full attempt hit timeouts
in two other chat integration cases (GitHub webhook draining and
delivery retry budget); both passed in isolation without increasing
timeouts. All 55 remote checks passed on f31405a730, including the full
test matrix, build, typecheck, and final verification gate.
- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `git diff
--check` passed on the submitted revision.

## Risks

- File dates are optional. Managed files require recorded dates because
restored projections have synthetic filesystem timestamps. External
files can use modification time; unknown dates are omitted.
- Save-footer timing depends on animation completion; reduced-motion and
failed-save paths have tests.
- Global Paper Mono typography can change text widths. Browser checks
covered the edited surfaces.
- No schema migration or new execution permissions are introduced.

## Model Used

OpenAI Codex, GPT-6, with repository tools, code execution, and browser
verification. The exact serving model ID and context-window size are not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes:` / `Closes:`
/ `Refs:` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run the relevant tests locally and they pass; full-run
timeout limitations and successful isolated reruns are documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
TonioandPaperclip authored and GitHub committed 2026-11-06 11:14:56 -08:00
1 parent 705c3e955d
commit c650f36007
31 files changed
+671 -156

No files matched your search

+38 -2
View File
@@ -239,8 +239,8 @@ semantic tokens.
Review **Design proposals → Foundations → Typography → Type specification**
(`design-foundations-typography--type-specification`). The proposal and the
opt-in design sandbox use Inter Variable for interface text and bundled Paper
Mono for all monospace text, including IDs, code and run diagnostics.
opt-in design sandbox use Inter Variable for interface text. All product
monospace text uses bundled Paper Mono, including IDs, code and run diagnostics.
**Conversation styles** (`design-foundations-typography--conversation-styles`)
shows the same composition as the sandbox task detail page by mounting
@@ -1052,3 +1052,39 @@ a visible Reload page action. Keep the character inline and embed the marked
`offline-recovery` section from `ui/src/index.css` at build time so the page needs
no network requests or cached assets. Preserve the supplied character palette
through the offline tokens.
### Agent overview and Identity editing
Agent headers lead with the 24px custom title and crown on one line, followed by “harness • name.” The harness logo and label link to the agent’s Harness / Runtime subtab with hover and keyboard-focus feedback. The logo has a 4px gap before its label; Chat and New task share the default 40px button height. Header spacing is reduced by 4px at each vertical edge.
The character renders in a 102px square (20% smaller than 128px, rounded to a whole pixel) with an 8px gap before the title, without CSS transform scaling. Reporting relationships live in Profile; its creation date shares the
header metadata position with the Skills count. Empty Direct reports sections
are hidden. The overview character tracks the page cursor, including paused
agents, while respecting reduced-motion and custom-avatar fallbacks. Chat sits
beside the text-only New task action. Recent tasks applies its raised surface to
the table, leaving the section heading on the page background.
Identity panels share 24px insets and gutters from the token layer. Advanced
management contains the external-profile controls. New files accept a portable
basename (letters, numbers, hyphens and underscores) with a fixed `.md` suffix.
Clicking file content enters editing without creating a dirty draft. The Identity
save footer slides from the viewport bottom only for unsaved changes or a save
in progress; undoing or discarding changes hides it. Successful saves swap the
orange-dot unsaved label for a rising green-dot saved label, hold it for two
seconds after arrival, then dismiss the footer. Copy feedback appears beside
the edit action for five seconds and then fades. File rows use monospaced,
single-line truncated names, 4px row spacing, a filled ROOT chip, and borderless
size labels. Hover uses the shared menu-option radius and muted highlight fill
with foreground text. The preview title uses 18px Paper Mono at medium (500) weight. Creation dates come from
recorded new-file saves; existing files without creation metadata show their
last modification date. Disclosure panels animate height and opacity and respect
reduced motion.
Agent roster rows reveal a muted MessagesSquare icon and “Chat” action on row
hover, using the task-list inline action treatment. Keyboard focus also reveals
the action; its reserved cell prevents layout movement. Its tooltip waits for
Radix’s standard 700ms hover delay on each entry, without the instant-tooltip
provider or warm-up shortcut; keyboard focus retains immediate accessible help.
Task-list titles use the remaining row width before truncating. Empty recovery,
external-status, and metadata slots do not reserve space; populated details and
trailing actions retain their existing layout.
+2 -2
View File
@@ -97,10 +97,10 @@ which render inside the agent, filtered to it. Skills, Tools, Channels, API Keys
and Revisions are not in the nav but still open by URL. Edit the name and title
in place in the header (Enter or blur saves, Escape cancels); while a tab has
unsaved changes, the header refuses the edit and asks to save or discard first.
Assign task is the header's one button (Finish setup while setup is pending);
New task is the header action beside Chat (Finish setup while setup is pending);
Run now, Pause/Resume, Clear error, **Agent history** (a dialog listing
revisions with a confirmed Restore), Duplicate, Copy ID, Reset sessions and
Terminate are in the actions menu. On Identity, **Advanced settings** →
Terminate are in the actions menu. On Identity, **Advanced management** →
**Load profile** switches to an external root only when the root directory and
entry file already exist; otherwise the server refuses and the panel shows why.
+3
View File
@@ -38,6 +38,9 @@ export type AgentInstructionsBundleMode = "managed" | "external";
export interface AgentInstructionsFileSummary {
path: string;
/** Creation is exposed only when recorded; historical files may only have a modification date. */
createdAt?: string;
updatedAt?: string;
contentHash?: string;
binary?: boolean;
size: number;
@@ -111,6 +111,31 @@ describe("durable agent file authority", () => {
} })).toEqual({ entryFile: "missing.md", contentHash: null });
});
it("keeps recorded file dates through restore without treating projection times as edits", async () => {
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Historical instructions"));
await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root,
instructionsEntryFile: "AGENTS.md" } }).where(eq(agents.id, agentId));
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
await store().read(companyId, agentId, "AGENTS.md", actor());
await store().write({ companyId, agentId, path: "NOTES.md", bytes: Buffer.from("Recorded creation"), baseHash: null }, actor());
const instructions = agentInstructionsService(db);
const created = await instructions.readFile(agent, "NOTES.md");
expect(Number.isFinite(Date.parse(created.createdAt!))).toBe(true);
await replaceFilesystem();
vi.stubEnv("PAPERCLIP_DURABLE_AGENT_FILES", "false");
const restored = await instructions.readFile(agent, "AGENTS.md");
expect(restored.content).toBe("Historical instructions");
expect(restored.createdAt).toBeUndefined();
expect(restored.updatedAt).toBeUndefined();
const summary = (await instructions.getBundle(agent)).files.find(file => file.path === "AGENTS.md");
expect(summary).toBeDefined();
expect(summary?.createdAt).toBeUndefined();
expect(summary?.updatedAt).toBeUndefined();
const recorded = await instructions.readFile(agent, "NOTES.md");
expect(recorded.createdAt).toBe(created.createdAt);
expect(recorded.updatedAt).toBeUndefined();
});
it("imports legacy bytes, modes and empty entries, then restores without any old filesystem", async () => {
const binary = Buffer.alloc(2 * 1024 * 1024, 0x81);
await materializeInstructionBytes(root, "AGENTS.md", Buffer.from("Original"));
@@ -7,6 +7,7 @@ import { and, eq, sql } from "drizzle-orm";
import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns,
agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentFileStore } from "../services/agent-file-store.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js";
@@ -64,6 +65,31 @@ describe("canonical instruction revisions", () => {
async function save(content: string, baseRevisionId: string | null) {
return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
}
it("exposes recorded creation dates and update dates for root and supporting files", async () => {
const files = agentFileStore(db);
const instructions = agentInstructionsService(db);
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
const supporting = "NOTES.md";
const created = await files.write({ ...target(), path: supporting, bytes: Buffer.from("first"), baseHash: null }, actor);
const first = await instructions.readFile(agent!, supporting);
expect(Number.isFinite(Date.parse(first.createdAt!))).toBe(true);
expect(first.updatedAt).toBeUndefined();
await files.write({ ...target(), path: supporting, bytes: Buffer.from("second"), baseHash: created.contentHash }, actor);
// The file-storage pool has two connections. Concurrent locked reads must
// reuse their transactions for metadata rather than wait on a third lease.
const [updated] = await Promise.all([
instructions.readFile(agent!, supporting), instructions.getBundle(agent!),
instructions.readFile(agent!, supporting), instructions.getBundle(agent!),
]);
expect(updated.createdAt).toBeUndefined();
expect(Date.parse(updated.updatedAt!)).toBeGreaterThanOrEqual(Date.parse(first.createdAt!));
// The root file uses the revision-compatible API, with the same recorded dates.
await fs.unlink(path.join(root, entryFile));
const rootCreated = await save("new root", null);
expect(await instructions.getFileDates(agent!, entryFile, rootCreated.revision.contentHash)).toHaveProperty("createdAt");
const rootUpdated = await save("updated root", rootCreated.revision.id);
expect(await instructions.getFileDates(agent!, entryFile, rootUpdated.revision.contentHash)).toHaveProperty("updatedAt");
});
it("reads peer instructions with read-only agent and responsible-user grants but denies saves", async () => {
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId));
await db.insert(principalPermissionGrants).values([
@@ -17,6 +17,7 @@ const mockBuiltInAgentService = vi.hoisted(() => ({
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
getFileDates: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
@@ -206,6 +207,7 @@ describe("agent instructions bundle routes", () => {
...makeAgent(),
adapterConfig: patch.adapterConfig ?? {},
}));
mockAgentInstructionsService.getFileDates.mockResolvedValue({ updatedAt: "2026-11-05T12:00:00.000Z" });
mockAgentInstructionsService.getBundle.mockResolvedValue({
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
@@ -597,6 +599,7 @@ describe("agent instructions bundle routes", () => {
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?path=AGENTS.md"));
expect(res.status).toBe(200);
expect(res.body.content).toBe("committed");
expect(res.body.updatedAt).toBe("2026-11-05T12:00:00.000Z");
expect(res.body.revision.id).toBe("33333333-3333-4333-8333-333333333333");
expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
});
@@ -59,6 +59,33 @@ describe("agent instructions service", () => {
if (entry === "../invalid") expect(bundle.warnings.length).toBeGreaterThan(0);
});
it("records creation on new unversioned supporting files, then modification on updates", async () => {
const root = await makeTempDir("external-new-file-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "instructions");
const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root });
const svc = agentInstructionsService();
const first = await svc.writeFile(agent, "NOTES.md", "first");
expect(Number.isFinite(Date.parse(first.file.createdAt!))).toBe(true);
expect(first.file.updatedAt).toBeUndefined();
const second = await svc.writeFile(agent, "NOTES.md", "second");
expect(second.file.createdAt).toBeUndefined();
expect(Number.isFinite(Date.parse(second.file.updatedAt!))).toBe(true);
});
it("returns actual modification dates without inventing creation dates", async () => {
const root = await makeTempDir("external-file-dates-"); cleanupDirs.add(root);
const filename = path.join(root, "AGENTS.md");
await fs.writeFile(filename, "instructions");
const updated = new Date("2026-10-01T12:00:00.000Z");
await fs.utimes(filename, updated, updated);
const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root });
const svc = agentInstructionsService();
const file = await svc.readFile(agent, "AGENTS.md");
expect(file.updatedAt).toBe(updated.toISOString());
expect(file.createdAt).toBeUndefined();
expect((await svc.getBundle(agent)).files[0]?.updatedAt).toBe(updated.toISOString());
});
it("lists external bundles containing large binary assets", async () => {
const root = await makeTempDir("external-large-assets-"); cleanupDirs.add(root);
await fs.writeFile(path.join(root, "AGENTS.md"), "external instructions");
+8 -6
View File
@@ -801,10 +801,10 @@ export function agentRoutes(
const agentFiles = agentFileStore(db);
const instructionRevisions = agentInstructionRevisionService(db);
const instructionWorkingCopies = agentInstructionWorkingCopyService(db);
function instructionFileDetail(snapshot: import("@paperclipai/shared").AgentInstructionSnapshot,
async function instructionFileDetail(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>, snapshot: import("@paperclipai/shared").AgentInstructionSnapshot,
receipt?: import("@paperclipai/shared").AgentInstructionCommitReceipt) {
const path = snapshot.revision.entryFile;
return { path, content: snapshot.content, contentHash: snapshot.revision.contentHash, size: snapshot.revision.byteLength, revision: snapshot.revision, receipt,
return { path, ...await instructions.getFileDates(agent, path, snapshot.revision.contentHash), content: snapshot.content, contentHash: snapshot.revision.contentHash, size: snapshot.revision.byteLength, revision: snapshot.revision, receipt,
language: path.toLowerCase().endsWith(".md") ? "markdown" : "text", markdown: path.toLowerCase().endsWith(".md"),
isEntryFile: true, editable: true, deprecated: false, virtual: false };
}
@@ -5250,7 +5250,7 @@ export function agentRoutes(
}
if (agentInstructionsBundleMode(existing) !== "external" && instructionPath(relativePath) === deriveBundleState(existing).entryFile) {
const snapshot = await instructionRevisions.readCurrent({ companyId: existing.companyId, agentId: existing.id }, req.actor);
if (snapshot) { res.json(instructionFileDetail(snapshot)); return; }
if (snapshot) { res.json(await instructionFileDetail(existing, snapshot)); return; }
}
res.json(await instructions.readFile(existing, relativePath));
});
@@ -5286,7 +5286,7 @@ export function agentRoutes(
await svc.update(existing.id, { adapterConfig });
}
}
res.json(instructionFileDetail(receipt, receipt));
res.json(await instructionFileDetail(existing, receipt, receipt));
return;
}
await assertCanManageInstructionsPath(req, existing);
@@ -5331,6 +5331,8 @@ export function agentRoutes(
entityId: existing.id,
details: {
path: result.file.path,
contentHash: result.file.contentHash,
created: Boolean(result.file.createdAt),
size: result.file.size,
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true,
},
@@ -5354,7 +5356,7 @@ export function agentRoutes(
if (!isUuidLike(runId)) throw unprocessable("Invalid instruction candidate run id");
const receipt = await instructionWorkingCopies.resolve({ companyId: existing.companyId, agentId: existing.id,
runId, baseRevisionId: req.body.baseRevisionId, content: req.body.content }, req.actor);
res.json(instructionFileDetail(receipt, receipt));
res.json(await instructionFileDetail(existing, receipt, receipt));
});
router.get("/agents/:id/instructions-bundle/history", async (req, res) => {
@@ -5396,7 +5398,7 @@ export function agentRoutes(
runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
contentHash: receipt.revision.contentHash }).catch(() => undefined);
}
res.json(instructionFileDetail(receipt, receipt));
res.json(await instructionFileDetail(existing, receipt, receipt));
});
router.delete("/agents/:id/instructions-bundle/file", async (req, res) => {
+1 -1
View File
@@ -249,7 +249,7 @@ export function agentFileStore(db: Db) {
}
if (input.bytes === null) await fs.unlink(await assertInstructionPathSafe(root, relative));
else await materializeInstructionBytes(root, relative, input.bytes);
await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
await audit(tx, agent, bound, { path: relative, contentHash: incomingHash, created: previous === null && input.bytes !== null });
return { contentHash: incomingHash, changed: true };
}),
apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot; checkpoint?: AgentFileManifest }, actor: AuthorizationActor) =>
@@ -255,7 +255,7 @@ export function agentInstructionRevisionService(db: Db) {
actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId,
responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId,
details: { path: input.entryFile, contentHash: row.contentHash, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
details: { path: input.entryFile, contentHash: row.contentHash, created: current === null, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
const bindEntry = async () => {
if (
!state.agent.adapterConfig.instructionsRootPath ||
+37 -10
View File
@@ -2,8 +2,8 @@ import { adoptAgentFiles, inspectAgentFile, fileHash, agentFilePath, MAX_AGENT_F
import { invalidateAgentFileProjection, persistDurableAgentFiles, restoreDurableAgentFiles } from "./agent-file-durability.js";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { agentFileRoots, agentInstructionHeads, agents, fileStorageDb, type Db } from "@paperclipai/db";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { activityLog, agentFileRoots, agentInstructionHeads, agents, fileStorageDb, type Db } from "@paperclipai/db";
import { instructionPath, assertInstructionPathSafe, instructionBytes, readInstructionBytes } from "./agent-instruction-files.js";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
@@ -41,6 +41,9 @@ type AgentLike = {
type AgentInstructionsFileSummary = {
path: string;
/** Creation is exposed only when recorded; historical files may only have a modification date. */
createdAt?: string;
updatedAt?: string;
contentHash?: string;
binary?: boolean;
size: number;
@@ -218,12 +221,12 @@ async function listFilesRecursive(
return output.sort((left, right) => left.localeCompare(right));
}
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string, includeFilesystemDate: boolean): Promise<AgentInstructionsFileSummary> {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
const stat = await fs.stat(absolutePath);
// External bundles may contain large assets; listing must not read them.
const file = stat.size > MAX_AGENT_FILE_BYTES ? null : await inspectAgentFile(rootPath, relativePath);
return summarizeFile(relativePath, entryFile, file?.size ?? stat.size, file?.bytes ?? null, file?.hash);
return { ...summarizeFile(relativePath, entryFile, file?.size ?? stat.size, file?.bytes ?? null, file?.hash), ...(includeFilesystemDate ? { updatedAt: stat.mtime.toISOString() } : {}) };
}
function summarizeFile(relativePath: string, entryFile: string, size: number, bytes: Buffer | null, hash?: string): AgentInstructionsFileSummary {
@@ -517,6 +520,25 @@ export function syncInstructionsBundleConfigFromFilePath(
export function agentInstructionsService(db?: Db) {
if (db) db = fileStorageDb(db);
async function getFileDates(agent: AgentLike, relativePath: string, contentHash?: string, connection?: Pick<Db, "select">): Promise<{ createdAt?: string; updatedAt?: string }> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (!state.rootPath) return {};
const filename = await assertInstructionPathSafe(state.rootPath, relativePath);
if (connection && contentHash) {
// One bounded lookup for the open file, never one query per tree row.
// Match the content hash so an unrecorded external edit cannot inherit an old save date.
const [saved] = await connection.select({ date: activityLog.createdAt, details: activityLog.details }).from(activityLog)
.where(and(eq(activityLog.companyId, agent.companyId), eq(activityLog.entityType, "agent"),
eq(activityLog.entityId, agent.id), inArray(activityLog.action, ["agent.files_updated", "agent.instructions_file_updated"]),
eq(sql<string>`${activityLog.details}->>'path'`, relativePath),
eq(sql<string>`${activityLog.details}->>'contentHash'`, contentHash)))
.orderBy(desc(activityLog.createdAt)).limit(1);
if (saved) return saved.details?.created === true ? { createdAt: saved.date.toISOString() } : { updatedAt: saved.date.toISOString() };
}
if (db && state.mode !== "external") return {};
const stat = await statIfExists(filename);
return stat?.isFile() ? { updatedAt: stat.mtime.toISOString() } : {};
}
async function getManagedEntryFingerprint(agent: AgentLike): Promise<{ entryFile: string; contentHash: string | null } | null> {
if (!db || agentInstructionsBundleMode(agent) !== "managed") return null;
const state = deriveBundleState(agent);
@@ -555,12 +577,12 @@ export function agentInstructionsService(db?: Db) {
const summaries: AgentInstructionsFileSummary[] = [];
// Bound open descriptors and text buffers even for a large personal folder.
for (let index = 0; index < files.length; index += 8) {
summaries.push(...await Promise.all(files.slice(index, index + 8).map(relativePath => readFileSummary(state.rootPath!, relativePath, state.entryFile))));
summaries.push(...await Promise.all(files.slice(index, index + 8).map(relativePath => readFileSummary(state.rootPath!, relativePath, state.entryFile, !db || state.mode === "external"))));
}
return toBundle(agent, state, summaries);
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
async function readFile(agent: AgentLike, relativePath: string, connection?: Pick<Db, "select">): Promise<AgentInstructionsFileDetail> {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
const content = asString(state.config[PROMPT_KEY]);
@@ -582,7 +604,7 @@ export function agentInstructionsService(db?: Db) {
const file = await inspectAgentFile(state.rootPath, relativePath);
if (file === null) throw notFound("Instructions file not found");
const summary = summarizeFile(relativePath, state.entryFile, file.size, file.bytes, file.hash);
return { ...summary, content: summary.binary ? "" : file.bytes!.toString("utf8") };
return { ...summary, ...await getFileDates(agent, relativePath, file.hash, connection), content: summary.binary ? "" : file.bytes!.toString("utf8") };
}
async function ensureWritableBundle(
@@ -722,12 +744,16 @@ export function agentInstructionsService(db?: Db) {
await assertInstructionPathSafe(prepared.state.rootPath!, relativePath);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
const existed = Boolean(await statIfExists(absolutePath));
await fs.writeFile(absolutePath, content, "utf8");
const writtenAt = new Date().toISOString();
const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, relativePath),
]);
if (existed) file.updatedAt = writtenAt;
else { file.createdAt = writtenAt; delete file.updatedAt; }
return { bundle, file, adapterConfig: prepared.adapterConfig };
}
@@ -882,7 +908,7 @@ export function agentInstructionsService(db?: Db) {
// Public managed reads and configuration changes share the same row lock as
// editor/runtime writes. Internal helpers operate on the already-loaded tree.
async function withProjection<T>(agent: AgentLike, write: boolean, operation: (current: AgentLike) => Promise<T>, managedDestination = false) {
async function withProjection<T>(agent: AgentLike, write: boolean, operation: (current: AgentLike, connection?: Pick<Db, "select">) => Promise<T>, managedDestination = false) {
if (!db) return operation(agent);
return db.transaction(async tx => {
const [current] = await tx.select().from(agents).where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId))).for("update");
@@ -891,7 +917,7 @@ export function agentInstructionsService(db?: Db) {
const root = managed ? await adoptAgentFiles(tx, current) : managedDestination ? resolveManagedInstructionsRoot(current) : null;
if (!managed && root) await restoreDurableAgentFiles(tx, current, root);
if (write && root) await invalidateAgentFileProjection(root);
const result = await operation(current);
const result = await operation(current, tx);
if (write && root) await persistDurableAgentFiles(tx, current, root);
return result;
});
@@ -899,9 +925,10 @@ export function agentInstructionsService(db?: Db) {
return {
getManagedEntryFingerprint,
getFileDates: (agent: AgentLike, relativePath: string, hash?: string) => getFileDates(agent, relativePath, hash, db),
getBundleLocation: (agent: AgentLike) => withProjection(agent, false, getBundleLocation),
getBundle: (agent: AgentLike) => withProjection(agent, false, getBundle),
readFile: (agent: AgentLike, relativePath: string) => withProjection(agent, false, current => readFile(current, relativePath)),
readFile: (agent: AgentLike, relativePath: string) => withProjection(agent, false, (current, connection) => readFile(current, relativePath, connection)),
updateBundle: (agent: AgentLike, input: Parameters<typeof updateBundle>[1]) => withProjection(agent, true, current => updateBundle(current, input), input.mode === "managed"),
writeFile,
deleteFile,
+10 -1
View File
@@ -6,6 +6,7 @@ import {
Play,
Plus,
MoreHorizontal,
MessagesSquare,
Loader2,
Copy,
RotateCcw,
@@ -161,6 +162,8 @@ export function AgentActionButtons({
companyId,
size = "sm",
assignLabel = "Assign Task",
showAssignIcon = true,
showChat = false,
runLabel = "Run now",
showStatus = true,
showRun = true,
@@ -183,6 +186,8 @@ export function AgentActionButtons({
companyId?: string | null;
size?: "sm" | "default";
assignLabel?: string;
showAssignIcon?: boolean;
showChat?: boolean;
runLabel?: string;
showStatus?: boolean;
showRun?: boolean;
@@ -522,6 +527,10 @@ export function AgentActionButtons({
)}
</PopoverContent>
</Popover>
{showChat && <Button variant="outline" size="icon" aria-label={`Chat with ${agent.name}`} title="Chat with this agent"
disabled={disabled} onClick={() => { if (!onBeforeNavigate || onBeforeNavigate()) navigate(`/chats/${canonicalAgentRef}`); }}>
<MessagesSquare className="size-4" />
</Button>}
{needsSetup ? (
<Button size={size} disabled={disabled} onClick={finishSetup}>
<Play className="size-4" />
@@ -534,7 +543,7 @@ export function AgentActionButtons({
disabled={assignAndRunDisabled}
title={workActionsDisabled ? workActionsDisabledReason : undefined}
>
<Plus className="size-4" />
{showAssignIcon && <Plus className="size-4" />}
{assignLabel}
</Button>
)}
+8 -5
View File
@@ -46,7 +46,7 @@ function measureChar(container: HTMLElement): { w: number; h: number } {
const span = document.createElement("span");
span.textContent = "M";
span.style.cssText =
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
"position:absolute;visibility:hidden;white-space:pre;font-size:inherit;font-family:inherit;line-height:1;";
container.appendChild(span);
const rect = span.getBoundingClientRect();
container.removeChild(span);
@@ -302,14 +302,16 @@ export function AsciiArtAnimation() {
}
}
const observer = new ResizeObserver(() => {
const measureGrid = () => {
const size = measureChar(preEl);
charW = size.w;
charH = size.h;
rebuildGrid();
syncLoop();
});
};
const observer = new ResizeObserver(measureGrid);
observer.observe(preEl);
document.fonts?.addEventListener("loadingdone", measureGrid);
const onVisibilityChange = () => {
isVisible = document.visibilityState !== "hidden";
@@ -332,6 +334,7 @@ export function AsciiArtAnimation() {
loopActive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
observer.disconnect();
document.fonts?.removeEventListener("loadingdone", measureGrid);
document.removeEventListener("visibilitychange", onVisibilityChange);
motionMedia.removeEventListener("change", onMotionChange);
};
@@ -340,8 +343,8 @@ export function AsciiArtAnimation() {
return (
<pre
ref={preRef}
className="w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
style={{ fontSize: "var(--text-micro)", fontFamily: "monospace" }}
className="font-mono w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
style={{ fontSize: "var(--text-micro)" }}
aria-hidden="true"
/>
);
+1 -1
View File
@@ -381,7 +381,7 @@ export function IssueRow({
>
{issue.title}{titleSuffix}
</span>}
{stableLayout ? (
{stableLayout && recoveryIndicator ? (
<span data-slot="task-row-recovery">
<span className="hidden sm:inline">{recoveryIndicator}</span>
{recoveryIndicator ? (
+16 -9
View File
@@ -6,9 +6,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { User } from "lucide-react";
import { FlyoutAppearanceProvider, FlyoutHeader, FlyoutOptions } from "./ui/flyout";
import { Check, User } from "lucide-react";
import { cn } from "../lib/utils";
import { roleLabels } from "./agent-config-primitives";
export function ReportsToPicker({
agents,
@@ -41,7 +41,7 @@ export function ReportsToPicker({
const unknownManager = Boolean(value && !current);
return (
<Popover open={open} onOpenChange={setOpen}>
<FlyoutAppearanceProvider><Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{trigger ?? <button
type="button"
@@ -79,9 +79,12 @@ export function ReportsToPicker({
)}
</button>}
</PopoverTrigger>
<PopoverContent className={trigger ? "w-64 max-h-80 overflow-y-auto p-1" : "w-48 p-1"} align="start" aria-label="Choose manager">
<PopoverContent className="w-64" align="start" aria-label="Choose manager">
<FlyoutHeader>Reports to</FlyoutHeader>
<FlyoutOptions className="max-h-80 overflow-y-auto">
<button
type="button"
aria-pressed={value === null}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
value === null && "bg-accent",
@@ -91,7 +94,9 @@ export function ReportsToPicker({
setOpen(false);
}}
>
No manager
<User className="size-5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 text-left">No manager</span>
{value === null && <Check className="size-4 shrink-0" aria-hidden />}
</button>
{terminatedManager && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
@@ -110,6 +115,7 @@ export function ReportsToPicker({
<button
type="button"
key={a.id}
aria-pressed={a.id === value}
disabled={disabledAgentIds.includes(a.id)}
title={disabledAgentIds.includes(a.id) ? "Would create a reporting loop" : undefined}
className={cn(
@@ -121,12 +127,13 @@ export function ReportsToPicker({
setOpen(false);
}}
>
<AgentAvatar agent={a} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
<span className="min-w-0 truncate">{a.name}</span>
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
<AgentAvatar agent={a} size={24} className="size-6 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-left">{a.name}</span>
{a.id === value && <Check className="size-4 shrink-0" aria-hidden />}
</button>
))}
</FlyoutOptions>
</PopoverContent>
</Popover>
</Popover></FlyoutAppearanceProvider>
);
}
+1 -1
View File
@@ -121,7 +121,7 @@ export function AgentBulkRoster({ companyId, agents, visibleAgents, renderAgent,
</tr></thead>
<tbody>{visible.map(agent => {
const pendingSetup = pendingAgentSetupDisplayAdapter(agent);
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="border-t border-border data-[state=selected]:bg-accent/30 hover:bg-accent/50 focus-within:bg-accent/50 [&_td]:px-3 [&_td]:align-middle">
return <tr key={agent.id} data-state={selected.includes(agent.id) ? "selected" : undefined} className="group/agent-row border-t border-border data-[state=selected]:bg-accent/30 hover:bg-accent/50 focus-within:bg-accent/50 [&_td]:px-3 [&_td]:align-middle">
<td title={bulkAgentDisabledReason(agent)}><Checkbox aria-label={`Select ${agent.name}`} checked={selectedAgents.some(a => a.id === agent.id)} disabled={!!bulkAgentDisabledReason(agent)} onCheckedChange={value => changeSelection(value ? [...new Set([...selected, agent.id])] : selected.filter(id => id !== agent.id))} /></td>
<td className="min-w-64 p-0">{renderAgent(agent)}</td>
{renderChat && <td>{renderChat(agent)}</td>}
+76 -5
View File
@@ -203,10 +203,9 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
/* Keep this font runtime-resolved: the proposal/sandbox theme substitutes
Paper Mono for every shared font-mono consumer, including portaled content. */
/* Use bundled Paper Mono for all monospace surfaces, including portals. */
@theme {
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-mono: var(--font-paper-mono);
}
:root {
@@ -618,7 +617,6 @@
--agent-detail-module-gap: 12px;
--agent-detail-band-gap: 48px;
--agent-detail-module-inset: 20px;
--agent-detail-portrait-zoom: 1.21875;
--agent-detail-tile-min-h: 122px;
--agent-detail-control-h: 40px;
--agent-detail-logo-rows-h: 60px;
@@ -3400,7 +3398,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
> :nth-child(n + 3) {
@apply sm:col-span-2;
}
.agent-settings-overview section:not([data-agent-tile]),
.agent-settings-permissions .rounded-lg:not([data-agent-tile]),
.agent-settings-api-keys .rounded-lg,
.agent-settings-revisions .rounded-md {
@@ -3851,6 +3848,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
white-space: nowrap;
}
.task-row-stable [data-slot="task-row-content"] { align-self: center; }
/* Optional details must not reserve title space when they render no content. */
.task-row-stable :is([data-slot="task-row-external"], [data-slot="task-row-metadata"]):empty { display: none; }
@media (min-width: 640px) {
.task-row-stable [data-slot="task-row-recovery"] { width: var(--task-row-recovery-width); }
}
@@ -5324,3 +5323,75 @@ html:not(.dark) .coordinator-sidebar {
@media (prefers-reduced-motion: reduce) {
.agent-selection-bar { transition: none; transform: translate(-50%, 0); }
}
/* Agent Identity: equal panel gutters and a viewport-bound dirty footer. */
:root {
--agent-identity-panel-gap: calc(var(--spacing) * 6);
--agent-identity-resizer-width: calc(var(--spacing) * 2);
--agent-identity-column-gap: calc((var(--agent-identity-panel-gap) - var(--agent-identity-resizer-width)) / 2);
--agent-save-bar-motion: var(--motion-duration-base);
--agent-save-bar-clearance: calc(var(--spacing) * 24);
}
.agent-instruction-panels { row-gap: var(--agent-identity-panel-gap); column-gap: var(--agent-identity-column-gap); }
.agent-config-save-bar {
position: fixed;
bottom: 0;
z-index: 40;
padding: calc(var(--spacing) * 4) calc(var(--spacing) * 6);
padding-bottom: max(calc(var(--spacing) * 4), env(safe-area-inset-bottom));
background: var(--background);
border-top: var(--sz-1px) solid var(--border);
box-shadow: var(--shadow-flyout);
transform: translateY(100%);
visibility: hidden;
transition: transform var(--agent-save-bar-motion) var(--motion-ease-standard), visibility var(--agent-save-bar-motion);
}
.agent-config-save-bar[data-visible="true"] { transform: translateY(0); visibility: visible; transition-timing-function: var(--motion-ease-out-expo); }
.agent-file-copy-notice { transition: opacity var(--motion-duration-fast); }
@media (prefers-reduced-motion: reduce) {
.agent-config-save-bar, .agent-file-copy-notice { transition: none; }
}
.agent-settings-instructions { padding-bottom: var(--agent-save-bar-clearance); }
/* Identity file chrome uses the same input surface and keeps filenames on one line. */
:root {
--agent-identity-preview-title-size: 18px;
--agent-identity-preview-title-weight: 500;
--agent-save-status-motion: var(--motion-duration-base);
--agent-save-status-offset: calc(var(--spacing) * 2);
--agent-save-dot-unsaved: var(--color-amber-500);
}
.agent-markdown-name-field { background: var(--background-alt); }
html:has(.design-proposal-theme) .agent-markdown-name-field [data-slot="input"],
.agent-markdown-name-field [data-slot="input"] { background: transparent; }
.agent-instruction-file-row { margin-block: var(--spacing); border-radius: var(--flyout-option-radius); background: transparent; }
.agent-instruction-file-row:hover { background: var(--muted-highlight); color: var(--foreground); }
.agent-instruction-file-row > span:has(> .truncate) { overflow: hidden; }
[data-slot="collapsible-content"][data-agent-disclosure] { overflow: hidden; }
[data-slot="collapsible-content"][data-agent-disclosure][data-state="open"] {
animation: decision-disclosure-open var(--motion-duration-swap) var(--motion-ease-out);
}
[data-slot="collapsible-content"][data-agent-disclosure][data-state="closed"] {
animation: decision-disclosure-close var(--motion-duration-swap) var(--motion-ease-out);
}
.agent-save-status { display: grid; overflow: hidden; }
.agent-save-status > span { grid-area: 1 / 1; }
.agent-save-status-out { animation: agent-save-status-out var(--agent-save-status-motion) var(--motion-ease-standard) both; }
.agent-save-status-in { animation: agent-save-status-in var(--agent-save-status-motion) var(--motion-ease-out-expo) both; }
@keyframes agent-save-status-out { to { transform: translateY(var(--agent-save-status-offset)); opacity: 0; } }
@keyframes agent-save-status-in { from { transform: translateY(var(--agent-save-status-offset)); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
[data-slot="collapsible-content"][data-agent-disclosure], .agent-save-status-out, .agent-save-status-in { animation: none; }
}
.agent-identity-preview-title { font-weight: var(--agent-identity-preview-title-weight); }
/* Tighten the header’s surrounding vertical space by one spacing step per edge. */
.agent-character-header {
margin-block-start: calc(var(--spacing) * -1);
margin-block-end: calc(var(--spacing) * 7);
}
:root { --agent-header-character-size: 102px; }
.agent-header-character { width: var(--agent-header-character-size); height: var(--agent-header-character-size); }
+2 -2
View File
@@ -7,10 +7,10 @@ const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..
const fontDir = path.join(uiRoot, "public", "fonts");
const cssPath = path.join(uiRoot, "src", "index.css");
const fontFiles = ["InterVariable.woff2", "InterVariable-Italic.woff2"];
const fontFiles = ["InterVariable.woff2", "InterVariable-Italic.woff2", "PaperMono-Variable.woff2"];
describe("bundled UI font assets", () => {
it("ships the Inter variable font files used by the CSS", () => {
it("ships the variable font files used by the CSS", () => {
const css = readFileSync(cssPath, "utf8");
for (const fileName of fontFiles) {
+72 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { act as reactAct, type ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -303,6 +303,74 @@ describe("PromptsTab instruction editor", () => {
await flushReact();
}
it("opens preview text for editing without dirtying it, and clears dirty state after undo or discard", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
const onDirtyChange = vi.fn();
let discard: (() => void) | null = null;
await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original") }, {
onDirtyChange, onCancelActionChange: next => { discard = next; },
});
await waitFor(() => expect(container.querySelector('[data-testid="instructions-raw-source"]')).not.toBeNull());
await act(() => container.querySelector<HTMLElement>('[data-testid="instructions-raw-source"]')!.click());
expect(container.querySelector('[aria-label="Stop editing AGENTS.md"]')?.getAttribute("aria-pressed")).toBe("true");
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
const editor = container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Instruction file editor"]')!;
await act(() => setNativeValue(editor, "Changed"));
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(true));
await act(() => setNativeValue(editor, "Original"));
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
await act(() => setNativeValue(editor, "Changed again"));
await waitFor(() => expect(discard).toEqual(expect.any(Function)));
await act(() => discard?.());
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
expect(editor.value).toBe("Original");
});
it("only creates Markdown basenames and discards an unsaved empty file", async () => {
const onDirtyChange = vi.fn();
let discard: (() => void) | null = null;
await renderPromptsTab(makeBundle("AGENTS.md", []), {}, {
onDirtyChange, onCancelActionChange: next => { discard = next; },
});
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Add agent file"]')!.click());
const input = container.querySelector<HTMLInputElement>('[aria-label="New Markdown file name"]')!;
for (const invalid of ["bad name", "file.json", "../escape", "notes.md", "bad!", "AGENTS"]) {
await act(() => setNativeValue(input, invalid));
expect(buttonByText(container, "Create").disabled).toBe(true);
}
await act(() => setNativeValue(input, "valid-notes_2"));
expect(buttonByText(container, "Create").disabled).toBe(false);
await act(() => buttonByText(container, "Create").click());
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(true));
expect(container.textContent).toContain("valid-notes_2.md");
expect(container.querySelector<HTMLTextAreaElement>('textarea')?.value).toBe("");
await act(() => discard?.());
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
expect(container.textContent).not.toContain("valid-notes_2.md");
expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled();
});
it("shows inline copy feedback before Edit for five seconds without dirtying the file", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "Original") });
await waitFor(() => expect(container.querySelector('[aria-label="Copy instructions file as markdown"]')).not.toBeNull());
vi.useFakeTimers();
try {
await reactAct(async () => { container.querySelector<HTMLButtonElement>('[aria-label="Copy instructions file as markdown"]')!.click(); });
await reactAct(() => {});
const notice = container.querySelector('.agent-file-copy-notice')!;
expect(notice.textContent).toBe("File copied");
expect(notice.classList.contains("opacity-0")).toBe(false);
expect(notice.nextElementSibling?.getAttribute("aria-label")).toBe("Edit AGENTS.md");
expect(copyTextToClipboardMock).toHaveBeenCalledWith("Original");
await reactAct(() => { vi.advanceTimersByTime(4999); });
expect(notice.classList.contains("opacity-0")).toBe(false);
await reactAct(() => { vi.advanceTimersByTime(1); });
expect(notice.classList.contains("opacity-0")).toBe(true);
expect(saveAction).toBeNull();
} finally { vi.useRealTimers(); }
});
it("shows extensionless Markdown files as raw text and saves the edited draft", async () => {
const summary = makeSummary("AGENTS", "AGENTS", {
language: "markdown",
@@ -559,7 +627,7 @@ describe("PromptsTab instruction editor", () => {
return {};
});
await act(async () => buttonByText(container, "Advanced settings").click());
await act(async () => buttonByText(container, "Advanced management").click());
const panel = container.querySelector<HTMLElement>('[aria-label="External management"]')!;
const [rootInput, entryInput] = Array.from(panel.querySelectorAll("input"));
await act(async () => setNativeValue(rootInput, "/srv/prompts/atlas"));
@@ -594,11 +662,11 @@ describe("PromptsTab instruction editor", () => {
container.querySelector<HTMLButtonElement>('button[aria-label="Add agent file"]')!.click();
});
await flushReact();
const input = container.querySelector<HTMLInputElement>('input[placeholder="TOOLS.md"]');
const input = container.querySelector<HTMLInputElement>('input[aria-label="New Markdown file name"]');
expect(input).not.toBeNull();
await act(async () => {
setNativeValue(input!, "notes.md");
setNativeValue(input!, "notes");
buttonByText(container, "Create").click();
});
+122 -89
View File
@@ -137,6 +137,9 @@ import { InstructionsBundleSettings } from "./agent-detail/InstructionsBundleSet
import { AgentAllowedConnectors } from "./agent-detail/AgentAllowedConnectors";
import { AgentAuditTab } from "./AgentAuditTab";
import { AgentDirectReports } from "./agent-detail/AgentDirectReports";
import { instructionFileDateLabel } from "./agent-detail/instruction-file-date";
import { AgentConfigSaveBar } from "./agent-detail/AgentConfigSaveBar";
import { markdownFileName } from "./agent-detail/markdown-file-name";
import { AgentReportingManager } from "./agent-detail/AgentReportingManager";
import { AgentChip, AgentMetricTile, AgentPanel, AgentSectionHeading } from "../components/agent-detail/AgentPanels";
import { AgentHistoryDialog } from "./agent-detail/AgentHistoryDialog";
@@ -385,6 +388,8 @@ export function AgentDetail() {
const [configDirty, setConfigDirty] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configJustSaved, setConfigJustSaved] = useState(false);
const [identitySaveVersion, setIdentitySaveVersion] = useState(0);
const onIdentitySaved = useCallback(() => setIdentitySaveVersion(value => value + 1), []);
const [pendingLeave, setPendingLeave] = useState<HTMLAnchorElement | null>(null);
const leaveBypassRef = useRef(false);
const configWasSavingRef = useRef(false);
@@ -879,55 +884,52 @@ export function AgentDetail() {
</div>
) : null}
{/* Header */}
<header className="flex items-center gap-4">
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0" style={{ zoom: "var(--agent-detail-portrait-zoom)" }}>
<AgentCharacter agent={agent} state={characterStateForAgent(currentAgent.status)} size={96} trackingScope="page" />
<header className="agent-character-header flex items-center gap-2">
<div role="img" aria-label={`${agent.name} avatar`} className="shrink-0">
<AgentCharacter agent={agent} state={characterStateForAgent(currentAgent.status) === "rest" ? "idle" : characterStateForAgent(currentAgent.status)} size={128} className="agent-header-character" trackingScope="page" followCursor />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<InlineEditor
as="h1"
fitContent
value={agent.name}
placeholder="Agent name"
className="min-w-0 truncate text-3xl font-semibold leading-9 text-foreground"
onSave={(name) => saveHeaderField({ name })}
/>
<PrimaryAgentCrownButton agent={agent} />
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs leading-4 text-muted-foreground">
{agent.adapterType === "claude_local" || agent.adapterType === "codex_local"
? <img src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`} className="-ml-0.5 size-3.5" alt="" />
: null}
<span>{getAdapterDisplay(agentDisplayAdapterType(agent)).label}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-4 text-muted-foreground">
<InlineEditor
value={agent.title ?? ""}
fitContent
nullable
placeholder={roleLabels[agent.role] ?? agent.role}
className="whitespace-nowrap text-xs leading-4 text-muted-foreground"
className="min-w-0 truncate text-2xl font-semibold leading-8 text-foreground"
onSave={(title) => saveHeaderField({ title: title || null })}
/>
<span aria-hidden>·</span>
<span>
Reports to{" "}
{reportsToAgent ? (
<Link className="text-muted-foreground hover:text-foreground" to={agentDetailHref(agentRouteRef(reportsToAgent))}>
{reportsToAgent.name}
</Link>
) : (
"Board"
)}
</span>
<PrimaryAgentCrownButton agent={agent} />
</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-sm leading-5 text-muted-foreground">
<Link
to={agentDetailHref(canonicalAgentRef, "runtime")}
aria-label={`Open ${getAdapterDisplay(agentDisplayAdapterType(agent)).label} harness settings`}
className="flex shrink-0 items-center gap-1 rounded-sm no-underline transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
{agent.adapterType === "claude_local" || agent.adapterType === "codex_local"
? <img src={`/brands/${agent.adapterType === "claude_local" ? "claude" : "codex"}-color.svg`} className="-ml-0.5 size-4" alt="" />
: null}
<span>{getAdapterDisplay(agentDisplayAdapterType(agent)).label}</span>
</Link>
<span aria-hidden="true">•</span>
<InlineEditor
fitContent
value={agent.name}
placeholder="Agent name"
className="min-w-0 truncate text-sm leading-5 text-muted-foreground"
onSave={(name) => saveHeaderField({ name })}
/>
</div>
<AgentLifecycleStatus agent={currentAgent} refreshError={lifecycle.isError} onRetry={() => agentAction.mutate("retryLifecycle")} retryPending={agentAction.isPending} />
</div>
<AgentActionButtons
agent={currentAgent}
companyId={resolvedCompanyId}
assignLabel="Assign task"
assignLabel="New task"
size="default"
showAssignIcon={false}
showChat
showStatus={false}
workActionsInMenu
menuItems={[{ label: "Agent history", icon: HistoryIcon, onSelect: () => setHistoryOpen(true) }]}
@@ -1097,6 +1099,7 @@ export function AgentDetail() {
agent={agent}
companyId={resolvedCompanyId ?? undefined}
showSaveNotice={false}
onSaved={onIdentitySaved}
onDirtyChange={setConfigDirty}
onSaveActionChange={setSaveConfigAction}
onCancelActionChange={setCancelConfigAction}
@@ -1205,7 +1208,10 @@ export function AgentDetail() {
/>
)}
{showConfigActionBar && <footer className="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
{showConfigActionBar && activeView === "instructions" && <AgentConfigSaveBar key={agent.id} dirty={configDirty} saving={configSaving} savedVersion={identitySaveVersion}
onDiscard={() => cancelConfigActionRef.current?.()}
onSave={() => { Promise.resolve(saveConfigActionRef.current?.()).catch(() => {}); }} />}
{showConfigActionBar && activeView !== "instructions" && <footer className="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-border bg-background py-4">
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
{configSaving ? null : configDirty ? (
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--agent-detail-dot-unsaved)" />
@@ -1486,27 +1492,17 @@ export function AgentOverview({
description={budgetPercent === null ? "No monthly budget set" : `of ${formatCents(budget)} this month`}
to={agentDetailHref(agentRef, "budgets")}
/>
<AgentPanel title="Profile" className="min-h-(--agent-detail-tile-min-h) sm:col-span-2" aria-label="Profile">
<AgentPanel title="Profile" className="min-h-(--agent-detail-tile-min-h) sm:col-span-2" aria-label="Profile"
meta={<span className={creatorFailed ? "text-destructive" : undefined}>
{creatorFailed ? "Could not load creator" : creatorLabel ? `Created by ${creatorLabel} · ${formatDate(agent.createdAt)}` : `Created ${formatDate(agent.createdAt)}`}
</span>}>
<div className="flex flex-col pb-3">
<div className="flex items-center gap-2 px-(--agent-detail-module-inset) py-1.5 text-sm">
<span className="text-muted-foreground">{creatorLabel || creatorFailed ? "Created by" : "Created"}</span>
{creatorFailed ? (
<span className="text-destructive">Could not load creator</span>
) : (
<span className="min-w-0 truncate text-foreground">
{creatorLabel ? `${creatorLabel} · ${formatDate(agent.createdAt)}` : formatDate(agent.createdAt)}
</span>
)}
</div>
<AgentReportingManager key={agent.id} agent={agent} />
<div className="flex flex-col gap-1.5 px-(--agent-detail-module-inset) py-1.5 text-sm">
{directReports.length > 0 && <div className="flex flex-col gap-1.5 px-(--agent-detail-module-inset) py-1.5 text-sm">
<span className="text-muted-foreground">Direct reports:</span>
{directReports.length === 0 ? (
<span className="text-foreground">None</span>
) : (
<AgentDirectReports reports={directReports} />
)}
</div>
<AgentDirectReports reports={directReports} />
</div>}
</div>
</AgentPanel>
<AgentPanel
@@ -1539,7 +1535,7 @@ export function AgentOverview({
{assignedIssues.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent tasks.</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<div className="overflow-hidden rounded-lg border border-border bg-(--agent-detail-tile)">
<TaskSummaryList issues={assignedIssues.slice(0, 5)} />
</div>
)}
@@ -1865,8 +1861,10 @@ export function PromptsTab({
onCancelActionChange,
onSavingChange,
showSaveNotice = true,
onSaved,
}: {
showSaveNotice?: boolean;
onSaved?: () => void;
agent: Agent;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
@@ -1893,6 +1891,11 @@ export function PromptsTab({
entryFile: string;
} | null>(null);
const [newFilePath, setNewFilePath] = useState("");
const [copyVisible, setCopyVisible] = useState(false);
const [copyNotice, setCopyNotice] = useState<"copied" | "error" | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => clearTimeout(copyTimer.current), []);
useEffect(() => { clearTimeout(copyTimer.current); setCopyNotice(null); }, [agent.id, selectedFile]);
const [showNewFileInput, setShowNewFileInput] = useState(false);
const [pendingFiles, setPendingFiles] = useState<string[]>([]);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
@@ -2080,6 +2083,7 @@ export function PromptsTab({
path: file.path, size: file.size, language: file.language, markdown: file.markdown,
isEntryFile: file.isEntryFile, editable: file.editable, deprecated: file.deprecated,
virtual: file.virtual, binary: file.binary, contentHash: file.contentHash,
createdAt: file.createdAt, updatedAt: file.updatedAt,
}],
} : previous);
setPendingFiles((prev) => prev.filter((f) => f !== variables.path));
@@ -2120,6 +2124,7 @@ export function PromptsTab({
return;
}
const availablePaths = bundle.files.map((file) => file.path);
if (pendingFiles.includes(selectedFile)) return;
if (availablePaths.length === 0) {
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
return;
@@ -2216,7 +2221,7 @@ export function PromptsTab({
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
),
);
const fileDirty = draft !== null && draft !== currentContent;
const fileDirty = (draft !== null && draft !== currentContent) || pendingFiles.includes(selectedOrEntryFile);
const isDirty = bundleDirty || fileDirty || candidateRunId !== null;
const isSaving = updateBundle.isPending || saveFile.isPending || resolveCandidate.isPending || loadCandidate.isPending || refreshCandidateBase.isPending || deleteFile.isPending || awaitingRefresh;
@@ -2254,6 +2259,7 @@ export function PromptsTab({
clearLegacyPromptTemplate: shouldClearLegacy,
});
}
onSaved?.();
};
void save().catch(() => undefined);
} : null);
@@ -2267,6 +2273,7 @@ export function PromptsTab({
fileDirty,
isDirty,
onSaveActionChange,
onSaved,
selectedFileDetail?.revision?.id,
currentEntryFile,
currentMode,
@@ -2282,6 +2289,8 @@ export function PromptsTab({
setCandidateRunId(null);
resolveCandidate.reset();
setDraft(null);
setPendingFiles([]);
if (pendingFiles.includes(selectedOrEntryFile)) setSelectedFile(currentEntryFile);
if (bundle) {
setBundleDraft({
mode: persistedMode,
@@ -2290,7 +2299,7 @@ export function PromptsTab({
});
}
} : null);
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath, resolveCandidate]);
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath, resolveCandidate, pendingFiles, selectedOrEntryFile, currentEntryFile, setSelectedFile]);
const handleSeparatorDrag = useCallback((event: React.MouseEvent) => {
event.preventDefault();
@@ -2358,19 +2367,19 @@ export function PromptsTab({
<div
ref={containerRef}
className="grid min-w-0 gap-3"
className="grid min-w-0 agent-instruction-panels"
style={
instructionsSideBySide
? { gridTemplateColumns: `${filePanelWidth}px 0.5rem minmax(0, 1fr)` }
? { gridTemplateColumns: `${filePanelWidth}px var(--agent-identity-resizer-width) minmax(0, 1fr)` }
: undefined
}
>
<div data-agent-tile="" className={cn(
"min-w-0 w-full space-y-3 self-start rounded-lg bg-(--agent-detail-tile) px-3 pt-(--agent-detail-module-inset) pb-3",
"min-w-0 w-full space-y-3 self-start rounded-lg bg-(--agent-detail-tile) p-(--agent-identity-panel-gap)",
isMobile && showFilePanel && "block",
isMobile && !showFilePanel && "hidden",
)}>
<div className="flex items-center justify-between px-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Files</h4>
<div className="flex items-center gap-1">
{!showNewFileInput && (
@@ -2399,11 +2408,15 @@ export function PromptsTab({
</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.md"
className="font-mono text-sm"
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") {
@@ -2412,19 +2425,24 @@ export function PromptsTab({
}
}}
/>
<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={!newFilePath.trim() || newFilePath.includes("..")}
disabled={!markdownFileName(newFilePath) || visibleFilePaths.includes(markdownFileName(newFilePath) ?? "")}
onClick={() => {
const candidate = newFilePath.trim();
if (!candidate || candidate.includes("..")) return;
setPendingFiles((prev) => prev.includes(candidate) ? prev : [...prev, candidate]);
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);
}}
@@ -2458,6 +2476,10 @@ export function PromptsTab({
return next;
})}
onSelectFile={(filePath) => {
if (filePath === selectedOrEntryFile) return;
if (isDirty && !window.confirm(DISCARD_AGENT_CONFIG_CHANGES_MESSAGE)) return;
setDraft(null);
setPendingFiles([]);
setSelectedFile(filePath);
setInstructionMode("raw");
if (!fileOptions.includes(filePath)) setDraft("");
@@ -2465,7 +2487,8 @@ export function PromptsTab({
}}
onToggleCheck={() => {}}
showCheckboxes={false}
wrapLabels
wrapLabels={false}
fileRowClassName={() => "agent-instruction-file-row font-mono"}
renderFileExtra={(node) => {
const file = bundle?.files.find((entry) => entry.path === node.path);
if (!file) return null;
@@ -2484,8 +2507,8 @@ export function PromptsTab({
);
}
return (
<span className="ml-3 shrink-0 rounded border border-border text-muted-foreground px-1.5 py-0.5 text-(length:--text-nano) uppercase tracking-wide">
{file.isEntryFile ? "entry" : `${file.size}b`}
<span className={cn("ml-3 shrink-0 rounded text-muted-foreground px-1.5 py-0.5 font-sans text-(length:--text-nano) uppercase tracking-wide", file.isEntryFile && "bg-(--agent-detail-tile-chip)")}>
{file.isEntryFile ? "ROOT" : `${file.size}b`}
</span>
);
}}
@@ -2501,7 +2524,7 @@ export function PromptsTab({
)}
<div data-agent-tile="" className={cn("min-w-0 w-full overflow-hidden rounded-lg bg-(--agent-detail-tile)", isMobile && showFilePanel && "hidden")}>
<div className="flex items-center justify-between gap-3 px-(--agent-detail-module-inset) pt-(--agent-detail-module-inset) pb-5">
<div className="flex items-center justify-between gap-3 px-(--agent-identity-panel-gap) pt-(--agent-identity-panel-gap) pb-5">
<div className="flex items-center gap-2 min-w-0">
{isMobile && (
<Button
@@ -2516,19 +2539,23 @@ export function PromptsTab({
)}
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
<h4 className="truncate font-mono text-sm font-normal text-foreground">{selectedOrEntryFile}</h4>
<h4 className="agent-identity-preview-title min-w-0 truncate font-mono text-(length:--agent-identity-preview-title-size) text-foreground">{selectedOrEntryFile}</h4>
<AgentChip>{currentMode === "external" ? "Managed externally" : "Managed by you"}</AgentChip>
</div>
<p className="text-xs text-muted-foreground">
{selectedFileExists
? selectedFileSummary?.deprecated
? "Deprecated virtual file"
: `${selectedFileDetail?.language ?? "text"} file`
: instructionFileDateLabel(selectedFileDetail ?? selectedFileSummary)
: "New file in this bundle"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span role="status" aria-hidden={!copyVisible} className={cn("agent-file-copy-notice flex items-center gap-1.5 whitespace-nowrap text-xs text-muted-foreground", !copyVisible && "opacity-0", copyNotice === "error" && "text-destructive")}>
{copyNotice === "copied" && <CheckCircle2 className="size-3.5" aria-hidden />}
{copyNotice === "copied" ? "File copied" : copyNotice === "error" ? "Copy failed" : null}
</span>
<Button
type="button"
variant="ghost"
@@ -2543,15 +2570,17 @@ export function PromptsTab({
<Pencil className="size-4" />
</Button>
{!fileLoading && (
<CopyText
text={displayValue}
ariaLabel="Copy instructions file as markdown"
title="Copy as markdown"
copiedLabel="Copied"
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Button type="button" variant="ghost" size="icon-xs" className="size-8"
aria-label="Copy instructions file as markdown" title="Copy as markdown"
onClick={async () => {
clearTimeout(copyTimer.current);
setCopyVisible(true);
try { await copyTextToClipboard(displayValue); setCopyNotice("copied"); }
catch { setCopyNotice("error"); }
copyTimer.current = setTimeout(() => setCopyVisible(false), 5000);
}}>
<Copy className="size-4" />
</CopyText>
</Button>
)}
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button
@@ -2579,8 +2608,8 @@ export function PromptsTab({
</div>
</div>
<div aria-hidden className="mx-(--agent-detail-module-inset) h-px bg-border" />
<div className="space-y-3 px-(--agent-detail-module-inset) pt-4 empty:hidden">
<div aria-hidden className="h-px" />
<div className="space-y-3 px-(--agent-identity-panel-gap) pt-4 empty:hidden">
{currentMode === "managed" && preservedCandidates.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Preserved instruction edits</p>
@@ -2639,17 +2668,18 @@ export function PromptsTab({
/>}
</div>
{selectedFileDetail?.binary ? (
<div className="space-y-3 px-(--agent-detail-module-inset) py-5">
<div className="space-y-3 px-(--agent-identity-panel-gap) py-5">
<p className="text-sm text-muted-foreground">This file is preserved with the agent directory. Download it to view its contents.</p>
<a className="text-sm text-primary underline" href={agentsApi.downloadInstructionsFile(agent.id, selectedOrEntryFile, companyId)} download>Download {selectedOrEntryFile}</a>
</div>
) : selectedFileExists && fileLoading && !selectedFileDetail ? (
<div className="px-(--agent-detail-module-inset) py-5">
<div className="px-(--agent-identity-panel-gap) py-5">
<PromptEditorSkeleton />
</div>
) : instructionMode === "edit" ? (
<textarea
aria-label="Instruction file editor"
autoFocus
value={displayValue}
spellCheck={false}
onChange={(event) => {
@@ -2657,18 +2687,21 @@ export function PromptsTab({
if (draftBaseHashRef.current === undefined) draftBaseHashRef.current = selectedFileDetail?.contentHash ?? null;
setDraft(event.target.value);
}}
className="block min-h-(--sz-420px) w-full field-sizing-content resize-none bg-transparent px-6 py-5 font-mono text-xs leading-5 whitespace-pre-wrap text-foreground outline-none"
className="block min-h-(--sz-420px) w-full field-sizing-content resize-none bg-transparent p-(--agent-identity-panel-gap) font-mono text-xs leading-5 whitespace-pre-wrap text-foreground outline-none"
placeholder="File contents"
/>
) : displayValue.trim() ? (
<pre
) : (
<div role="button" tabIndex={0} aria-label={`Edit ${selectedOrEntryFile} contents`}
className="cursor-text outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setInstructionMode("edit")}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setInstructionMode("edit"); } }}>
{displayValue.trim() ? <pre
data-testid="instructions-raw-source"
className="block min-h-(--sz-420px) w-full overflow-x-auto px-6 py-5 font-mono text-xs leading-5 whitespace-pre-wrap break-words text-muted-foreground"
className="block min-h-(--sz-420px) w-full overflow-x-auto p-(--agent-identity-panel-gap) font-mono text-xs leading-5 whitespace-pre-wrap break-words text-muted-foreground"
>
{displayValue}
</pre>
) : (
<p className="min-h-(--sz-420px) px-6 py-5 text-sm text-muted-foreground">This instruction file is empty.</p>
</pre> : <p className="min-h-(--sz-420px) p-(--agent-identity-panel-gap) text-sm text-muted-foreground">This instruction file is empty.</p>}
</div>
)}
</div>
</div>
-1
View File
@@ -154,7 +154,6 @@ describe("AgentOverview", () => {
it("shows an error when the company user directory cannot be loaded", () => {
const markup = renderOverview("user-alice", failedDirectoryClient());
expect(markup).toContain("Created by");
expect(markup).toContain("Could not load creator");
});
+18 -8
View File
@@ -28,6 +28,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
import { AgentsToolbar } from "../components/AgentsToolbar";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { AlertTriangle, Bot, MessagesSquare } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent, type Environment, type EnvironmentCapabilities } from "@paperclipai/shared";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
@@ -448,14 +449,23 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
agents={agents.filter(agent => !HIDDEN_AGENT_STATUSES.has(agent.status))}
visibleAgents={filtered} renderAgent={renderAgentRow} renderStatus={renderAgentStatus}
renderChat={agentChat.enabled ? agent => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`Chat with ${agent.name}`} onClick={() => navigate(`/chats/${agentRouteRef(agent)}`)}>
<MessagesSquare className="size-4 text-foreground" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent>chat with this agent</TooltipContent>
</Tooltip>
// Use Radix's standard hover delay, isolated from the app's instant provider.
<TooltipPrimitive.Provider skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/agent-row:opacity-100 focus-visible:opacity-100"
aria-label={`Chat with ${agent.name}`}
onClick={() => navigate(`/chats/${agentRouteRef(agent)}`)}
>
<MessagesSquare className="size-3.5" aria-hidden />
Chat
</button>
</TooltipTrigger>
<TooltipContent>chat with this agent</TooltipContent>
</Tooltip>
</TooltipPrimitive.Provider>
) : undefined} />
)}
+1 -1
View File
@@ -564,7 +564,7 @@ function EnvironmentCustomImageBrowserTerminal({
cursorStyle: "bar",
cursorWidth: 2,
customGlyphs: true,
fontFamily: CUSTOM_IMAGE_TERMINAL_FONT_FAMILY,
fontFamily: `${getComputedStyle(element).getPropertyValue("--font-mono").trim()}, ${CUSTOM_IMAGE_TERMINAL_FONT_FAMILY}`,
fontSize: 12,
letterSpacing: 0,
lineHeight: 1.35,
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentConfigSaveBar } from "./AgentConfigSaveBar";
// React chooses the animation event name at import time; jsdom has no AnimationEvent constructor.
vi.hoisted(() => { Object.defineProperty(window, "AnimationEvent", { configurable: true, value: Event }); });
let root: Root;
let host: HTMLDivElement;
const save = vi.fn(), discard = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(() => root.unmount()); host.remove(); vi.useRealTimers(); vi.unstubAllGlobals(); });
async function render(dirty = false, saving = false, savedVersion = 0) {
await act(() => root.render(<AgentConfigSaveBar dirty={dirty} saving={saving} savedVersion={savedVersion} onSave={save} onDiscard={discard} />));
}
const visible = () => host.querySelector("footer")?.getAttribute("data-visible") === "true";
const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms));
describe("Identity save footer", () => {
it("stays hidden on entry and hides on undo/discard without claiming a save", async () => {
await render(); expect(visible()).toBe(false);
await render(true); expect(visible()).toBe(true);
await render(); expect(visible()).toBe(false);
expect(host.textContent).not.toContain("Saved changes");
});
it("holds for two seconds after the saved label finishes entering", async () => {
await render(true);
await render(true, true);
await render(false, false, 1);
expect(visible()).toBe(true);
expect(host.textContent).toContain("Saved changes");
await advance(3000); expect(visible()).toBe(true); // animation has not ended
await act(() => host.querySelector(".agent-save-status-in")!.dispatchEvent(new Event("animationend", { bubbles: true })));
await advance(1999); expect(visible()).toBe(true);
await advance(1); expect(visible()).toBe(false);
expect(host.querySelector(".agent-save-status-in")?.textContent).toContain("Saved changes");
});
it("keeps a new dirty draft visible when the prior success timer expires", async () => {
await render(true); await render(false, false, 1);
await act(() => host.querySelector(".agent-save-status-in")!.dispatchEvent(new Event("animationend", { bubbles: true })));
await advance(1000); await render(true, false, 1); await advance(3000);
expect(visible()).toBe(true);
expect(host.textContent).not.toContain("Saved changes");
});
it("holds successful saves with reduced motion without waiting for an animation event", async () => {
vi.stubGlobal("matchMedia", () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
await render(true); await render(false, false, 1);
await advance(1999); expect(visible()).toBe(true);
await advance(1); expect(visible()).toBe(false);
});
it("does not show success after a failed save", async () => {
await render(true); await render(true, true); await render(true);
expect(visible()).toBe(true);
expect(host.textContent).not.toContain("Saved changes");
});
});
@@ -0,0 +1,78 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
/** Tracks the content viewport so sidebar resizing never covers navigation. */
export function AgentConfigSaveBar({ dirty, saving, savedVersion = 0, onDiscard, onSave }: {
dirty: boolean;
saving: boolean;
savedVersion?: number;
onDiscard: () => void;
onSave: () => void;
}) {
const anchor = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState<{ left: number; width: number } | null>(null);
const lastSave = useRef(savedVersion);
const [pendingSuccess, setPendingSuccess] = useState(false);
const [success, setSuccess] = useState<"idle" | "entering" | "holding" | "exiting">("idle");
const showSuccess = success !== "idle" && !dirty && !saving;
const visible = dirty || saving || (showSuccess && success !== "exiting");
useEffect(() => {
if (savedVersion === lastSave.current) return;
lastSave.current = savedVersion;
setPendingSuccess(true);
}, [savedVersion]);
useEffect(() => {
if (pendingSuccess && !dirty && !saving) {
setPendingSuccess(false);
setSuccess("entering");
} else if (dirty || saving) setSuccess("idle");
}, [dirty, saving, pendingSuccess]);
useEffect(() => {
if (success !== "entering") return;
const media = window.matchMedia?.("(prefers-reduced-motion: reduce)");
const settle = () => { if (media?.matches) setSuccess("holding"); };
settle();
media?.addEventListener("change", settle);
return () => media?.removeEventListener("change", settle);
}, [success]);
useEffect(() => {
if (success !== "holding") return;
// Start the two-second reading interval only after the label arrives.
const timer = setTimeout(() => setSuccess("exiting"), 2000);
return () => clearTimeout(timer);
}, [success]);
useLayoutEffect(() => {
const main = anchor.current?.closest("main") ?? anchor.current?.parentElement;
if (!main) return;
const measure = () => {
const rect = main.getBoundingClientRect();
setBounds({ left: rect.left, width: rect.width });
};
measure();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(main);
window.addEventListener("resize", measure);
return () => { observer?.disconnect(); window.removeEventListener("resize", measure); };
}, []);
return <div ref={anchor}>
<footer aria-label="Unsaved agent changes" aria-hidden={!visible} inert={!visible}
data-visible={visible} style={bounds ?? undefined}
className="agent-config-save-bar flex flex-wrap items-center justify-between gap-3">
<p role="status" className="agent-save-status text-xs text-muted-foreground">
<span aria-hidden={showSuccess} className={showSuccess ? "agent-save-status-out flex items-center gap-2" : "flex items-center gap-2"}>
<span aria-hidden className="size-2 shrink-0 rounded-full bg-(--agent-save-dot-unsaved)" />
{saving ? "Saving changes…" : "Unsaved changes"}
</span>
{showSuccess && <span key={savedVersion} className="agent-save-status-in flex items-center gap-2"
onAnimationEnd={(event) => { if (event.target === event.currentTarget) setSuccess("holding"); }}>
<span aria-hidden className="size-2 shrink-0 rounded-full bg-(--agent-detail-dot-saved)" />
Saved changes
</span>}
</p>
<div className="flex gap-2">
<Button variant="ghost" disabled={!dirty || saving} onClick={onDiscard}>Discard</Button>
<Button disabled={!dirty || saving} onClick={onSave}>{saving ? "Saving…" : "Save changes"}</Button>
</div>
</footer>
</div>;
}
@@ -53,7 +53,7 @@ export function AgentReportingManager({ agent }: { agent: Agent }) {
disabled={disabled}
disabledAgentIds={invalidManagerIds(agent.id, agents)}
trigger={<button type="button" disabled={disabled} aria-label={manager ? "Change manager" : "Choose manager"}
className={`${manager ? "agent-identity-chip inline-flex size-7 shrink-0 items-center justify-center rounded-md" : agentIdentityChipClass} disabled:cursor-not-allowed disabled:opacity-50`}>
className={`${manager ? "agent-identity-chip inline-flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-accent hover:text-foreground transition-colors" : agentIdentityChipClass} disabled:cursor-not-allowed disabled:opacity-50`}>
{manager ? <Pencil className="size-3.5" aria-hidden /> : <><UserRound className="size-5 text-muted-foreground" aria-hidden /><span>{agent.reportsTo ? "Manager unavailable" : "No manager"}</span></>}
</button>}
/>
@@ -67,7 +67,7 @@ describe("InstructionsBundleSettings", () => {
</QueryClientProvider>,
),
);
await act(() => button(container, "Advanced settings").click());
await act(() => button(container, "Advanced management").click());
const [rootInput, entryInput] = Array.from(container.querySelectorAll("input"));
await act(() => setNativeValue(rootInput, rootPath));
await act(() => setNativeValue(entryInput, entryFile));
@@ -101,14 +101,14 @@ export function InstructionsBundleSettings({
<Collapsible defaultOpen={mode === "external"} className="flex flex-col gap-(--agent-detail-module-gap)">
<CollapsibleTrigger className="group flex w-fit items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:text-foreground">
<Settings2 className="size-4" aria-hidden />
Advanced settings
Advanced management
<ChevronDown className="size-4 transition-transform group-data-[state=open]:rotate-180" aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>
<AgentPanel className="gap-4 p-(--agent-detail-module-inset)" aria-label="External management">
<CollapsibleContent data-agent-disclosure>
<AgentPanel className="gap-4 p-(--agent-identity-panel-gap)" aria-label="External management">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">External management</p>
<p className="mt-0.5 text-xs text-muted-foreground">Point this agent at a directory on disk. Paperclip checks that the entry file exists before it switches over.</p>
<p className="mt-0.5 text-xs text-muted-foreground">Point this agent at a directory on disk. Paperclip checks that the root file exists before it switches over.</p>
</div>
<div className="grid gap-(--agent-detail-module-gap) sm:grid-cols-2">
<label className="flex min-w-0 flex-col gap-1.5">
@@ -123,7 +123,7 @@ export function InstructionsBundleSettings({
/>
</label>
<label className="flex min-w-0 flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Entry file</span>
<span className="text-xs text-muted-foreground">Root file</span>
<Input
value={entry}
onChange={(event) => edit(setEntry)(event.target.value)}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { instructionFileDateLabel } from "./instruction-file-date";
describe("Identity file dates", () => {
it("uses recorded creation until a modification date exists", () => {
const createdAt = "2026-11-01T12:00:00.000Z";
expect(instructionFileDateLabel({ createdAt })).toMatch(/^Date created /);
expect(instructionFileDateLabel({ createdAt, updatedAt: "2026-11-02T12:00:00.000Z" })).toMatch(/^Last updated /);
});
it("does not fabricate dates for old or invalid metadata", () => {
expect(instructionFileDateLabel(null)).toBe("Date unavailable");
expect(instructionFileDateLabel({ updatedAt: "invalid" })).toBe("Date unavailable");
});
});
@@ -0,0 +1,9 @@
import type { AgentInstructionsFileSummary } from "@paperclipai/shared";
import { formatDate } from "@/lib/utils";
/** Missing historical metadata must never become an invented creation date. */
export function instructionFileDateLabel(file?: Pick<AgentInstructionsFileSummary, "createdAt" | "updatedAt"> | null) {
if (file?.updatedAt && Number.isFinite(Date.parse(file.updatedAt))) return `Last updated ${formatDate(file.updatedAt)}`;
if (file?.createdAt && Number.isFinite(Date.parse(file.createdAt))) return `Date created ${formatDate(file.createdAt)}`;
return "Date unavailable";
}
@@ -0,0 +1,4 @@
/** New identity files use a portable basename and a fixed Markdown extension. */
export function markdownFileName(prefix: string): string | null {
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(prefix) ? `${prefix}.md` : null;
}