diff --git a/DESIGN.md b/DESIGN.md index 7b860708c8e..a5fd4335b0f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/maps/feature-map/agents.md b/maps/feature-map/agents.md index 78739494253..9e22cafb1d7 100644 --- a/maps/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -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. diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 06a6fe90c20..ec6079f87cb 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -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; diff --git a/server/src/__tests__/agent-file-durability.test.ts b/server/src/__tests__/agent-file-durability.test.ts index 7c79a267ebe..eb23f65b520 100644 --- a/server/src/__tests__/agent-file-durability.test.ts +++ b/server/src/__tests__/agent-file-durability.test.ts @@ -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")); diff --git a/server/src/__tests__/agent-instruction-revisions.test.ts b/server/src/__tests__/agent-instruction-revisions.test.ts index 3688dab1f7b..ae58868d34f 100644 --- a/server/src/__tests__/agent-instruction-revisions.test.ts +++ b/server/src/__tests__/agent-instruction-revisions.test.ts @@ -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([ diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 843ffe9a2b4..794e724fd82 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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(); }); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index dc75d22c976..e72ce8621bf 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -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"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d0625789a9a..ad0d748e57a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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>>, 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) => { diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts index 1cd4a63b1e6..e6589547760 100644 --- a/server/src/services/agent-file-store.ts +++ b/server/src/services/agent-file-store.ts @@ -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) => diff --git a/server/src/services/agent-instruction-revisions.ts b/server/src/services/agent-instruction-revisions.ts index cd60264463e..a9d5707a8f4 100644 --- a/server/src/services/agent-instruction-revisions.ts +++ b/server/src/services/agent-instruction-revisions.ts @@ -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 || diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 1e06d02c9fb..7403f547d3a 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -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 { +async function readFileSummary(rootPath: string, relativePath: string, entryFile: string, includeFilesystemDate: boolean): Promise { 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): 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`${activityLog.details}->>'path'`, relativePath), + eq(sql`${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 { + async function readFile(agent: AgentLike, relativePath: string, connection?: Pick): Promise { 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(agent: AgentLike, write: boolean, operation: (current: AgentLike) => Promise, managedDestination = false) { + async function withProjection(agent: AgentLike, write: boolean, operation: (current: AgentLike, connection?: Pick) => Promise, 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[1]) => withProjection(agent, true, current => updateBundle(current, input), input.mode === "managed"), writeFile, deleteFile, diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index d7aca4b8952..a610530c3c5 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -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({ )} + {showChat && } {needsSetup ? ( )} diff --git a/ui/src/components/AsciiArtAnimation.tsx b/ui/src/components/AsciiArtAnimation.tsx index aab5dc43356..fa203c981ca 100644 --- a/ui/src/components/AsciiArtAnimation.tsx +++ b/ui/src/components/AsciiArtAnimation.tsx @@ -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 (