From f7d82fbfb61adc2b0b6e3f41ebd56b97c1924fa4 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Wed, 2 Sep 2026 22:19:36 -0600 Subject: [PATCH 1/6] Surfaces v2: slotted schema, opinionated action rendering, design contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking pre-release schema change (v1 docs render a recreate notice): - Document slots: header (status+progress strip), footer.actions; section actions footer; per-item/row actions[] (1 inline ghost, 2+ kebab menu). - Standalone actions block removed — placement is renderer-owned. - Action policy: split button with overflow, destructive de-emphasized (ghost-destructive, never gains area), compact h-7/h-6 buttons. - Tone rescale (healthy dim, neutral plain text), typographic ramp, 3-primary-column table budget, 2-col tables render as key/value, timestamp + enum-label normalization, form submit full-width primary, muted required markers, attestation checkbox grouping, text tone callouts. - Tool descriptions carry a thin design contract; surfaces skill rewritten; seed gallery + tests + e2e re-authored under v2; migration 0044 adds header/footer columns. Co-Authored-By: Claude Fable 5 --- .../db/migrations/0044_agent-surfaces-v2.sql | 6 + apps/server/src/db/seed/surfaces.ts | 287 +++++++++++------- apps/server/src/shared/mcp/surface-tools.ts | 37 ++- apps/server/src/surfaces/service.ts | 99 ++++-- apps/server/src/surfaces/types.ts | 124 +++++--- apps/server/test/surfaces.test.ts | 260 ++++++++++++---- .../blocks/action-ref-button.test.tsx | 11 +- .../blocks/action-ref-button.tsx | 35 ++- .../blocks/actions-block.test.tsx | 282 ----------------- .../agent-surfaces/blocks/actions-block.tsx | 146 --------- .../agent-surfaces/blocks/block-header.tsx | 16 +- .../app/agent-surfaces/blocks/form-block.tsx | 62 +++- .../app/agent-surfaces/blocks/form-fields.tsx | 7 +- .../app/agent-surfaces/blocks/item-action.tsx | 105 ------- .../agent-surfaces/blocks/item-actions.tsx | 181 +++++++++++ .../agent-surfaces/blocks/list-block.test.tsx | 55 ++-- .../app/agent-surfaces/blocks/list-block.tsx | 125 ++++---- .../agent-surfaces/blocks/progress-block.tsx | 30 +- .../agent-surfaces/blocks/section-block.tsx | 80 +++-- .../agent-surfaces/blocks/slot-actions.tsx | 239 +++++++++++++++ .../agent-surfaces/blocks/status-block.tsx | 13 +- .../blocks/table-block.test.tsx | 83 +++-- .../app/agent-surfaces/blocks/table-block.tsx | 249 ++++++++------- .../app/agent-surfaces/blocks/text-block.tsx | 46 ++- .../components/app/agent-surfaces/format.ts | 62 ++++ .../app/agent-surfaces/surface-panel.test.tsx | 114 +++++-- .../app/agent-surfaces/surface-panel.tsx | 109 +++++-- .../src/components/app/agent-surfaces/tone.ts | 22 +- .../components/app/agent-surfaces/types.ts | 8 +- .../app/docs-sections/agent-surfaces.tsx | 37 ++- apps/web/src/components/ui/dropdown-menu.tsx | 13 + e2e/agent-surfaces.spec.ts | 77 +++-- packages/shared/src/index.ts | 8 +- packages/shared/src/surface-types.ts | 76 +++-- plugins/dispatch/skills/surfaces/SKILL.md | 226 +++++++------- 35 files changed, 2063 insertions(+), 1267 deletions(-) create mode 100644 apps/server/src/db/migrations/0044_agent-surfaces-v2.sql delete mode 100644 apps/web/src/components/app/agent-surfaces/blocks/actions-block.test.tsx delete mode 100644 apps/web/src/components/app/agent-surfaces/blocks/actions-block.tsx delete mode 100644 apps/web/src/components/app/agent-surfaces/blocks/item-action.tsx create mode 100644 apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx create mode 100644 apps/web/src/components/app/agent-surfaces/blocks/slot-actions.tsx create mode 100644 apps/web/src/components/app/agent-surfaces/format.ts diff --git a/apps/server/src/db/migrations/0044_agent-surfaces-v2.sql b/apps/server/src/db/migrations/0044_agent-surfaces-v2.sql new file mode 100644 index 000000000..f7e02a045 --- /dev/null +++ b/apps/server/src/db/migrations/0044_agent-surfaces-v2.sql @@ -0,0 +1,6 @@ +-- Surface schema v2: slotted documents. The header (status + progress strip) +-- and footer (document-level actions) live beside the block list. Existing +-- rows keep schema_version 1 and render as a legacy notice in the sidebar; +-- new writes stamp schema_version 2. +ALTER TABLE agent_surfaces ADD COLUMN IF NOT EXISTS header JSONB; +ALTER TABLE agent_surfaces ADD COLUMN IF NOT EXISTS footer JSONB; diff --git a/apps/server/src/db/seed/surfaces.ts b/apps/server/src/db/seed/surfaces.ts index f9c8ba4e9..740618167 100644 --- a/apps/server/src/db/seed/surfaces.ts +++ b/apps/server/src/db/seed/surfaces.ts @@ -1,18 +1,21 @@ import type { PoolClient } from "pg"; +import { SURFACE_SCHEMA_VERSION } from "@dispatch/shared"; const owner = "seed-agent-running-feature"; // These documents are intentionally narrow-pane examples rather than a page -// builder. Together they exercise every V1 leaf and form control in realistic -// agent-to-user decision flows. +// builder. Together they exercise every v2 leaf, slot, and form control in +// realistic agent-to-user decision flows: header (status + progress), footer +// actions, section actions, per-item action menus, toned text callouts, and +// key/value tables. export const surfaceExamples = [ { id: "tab_seed_release_choice", title: "Release decision", icon: "flag", sortOrder: 0, - blocks: [ - { + header: { + status: { id: "context", type: "status", title: "Deployment status", @@ -21,6 +24,8 @@ export const surfaceExamples = [ detail: "Both paths passed CI. **Canary** reduces blast radius.", timestamp: "2026-08-27T15:30:00.000Z", }, + }, + blocks: [ { id: "comparison", type: "table", @@ -48,46 +53,44 @@ export const surfaceExamples = [ }, ], }, - { - id: "choices", - type: "actions", - title: "Choose a path", - layout: "stack", - actions: [ - { - id: "canary", - label: "Use canary", - intent: "choose_release_canary", - style: "primary", - icon: "flag", - }, - { - id: "direct", - label: "Release directly", - intent: "choose_release_direct", - style: "destructive", - confirm: { - title: "Release directly?", - description: "This skips the observation window.", - }, - }, - ], - }, ], + footer: { + actions: [ + { + id: "canary", + label: "Use canary", + intent: "choose_release_canary", + style: "primary", + icon: "flag", + }, + { + id: "direct", + label: "Release directly", + intent: "choose_release_direct", + style: "destructive", + confirm: { + title: "Release directly?", + description: "This skips the observation window.", + }, + }, + ], + }, }, { id: "tab_seed_feedback", title: "Design feedback", icon: "form", sortOrder: 1, - blocks: [ - { + header: { + status: { id: "status", type: "status", status: "Ready for review", tone: "success", detail: "Focus on hierarchy, density, and the interaction flow.", }, + }, + blocks: [ { id: "feedback", type: "form", @@ -128,7 +131,6 @@ export const surfaceExamples = [ id: "submit", label: "Send feedback", intent: "submit_design_feedback", - style: "primary", icon: "message", }, resetLabel: "Clear draft", @@ -141,8 +143,8 @@ export const surfaceExamples = [ title: "Release work summary", icon: "checklist", sortOrder: 2, - blocks: [ - { + header: { + progress: { id: "readiness", type: "progress", title: "Release readiness", @@ -151,6 +153,8 @@ export const surfaceExamples = [ label: "5 of 8 complete", detail: "Two items need a decision; one is waiting on CI.", }, + }, + blocks: [ { id: "release_details", type: "section", @@ -181,11 +185,18 @@ export const surfaceExamples = [ detail: "Queue it once the release owner approves.", url: "https://example.com/runbooks/migration", group: "Next steps", - action: { - id: "queue_migration", - label: "Queue migration", - intent: "queue_release_migration", - }, + actions: [ + { + id: "queue_migration", + label: "Queue", + intent: "queue_release_migration", + }, + { + id: "hold_migration", + label: "Hold", + intent: "hold_release_migration", + }, + ], }, { id: "a11y", @@ -206,24 +217,19 @@ export const surfaceExamples = [ collapse: { after: 2, label: "Show all release work" }, showItemCount: true, }, + ], + actions: [ { - id: "queued_action", - type: "actions", - title: "Unblock work", - actions: [ - { - id: "queue_migration", - label: "Queue migration", - intent: "queue_release_migration", - style: "primary", - icon: "clock", - }, - { - id: "hold", - label: "Keep on hold", - intent: "hold_release_work", - }, - ], + id: "queue_all", + label: "Queue migration", + intent: "queue_release_migration", + style: "primary", + icon: "clock", + }, + { + id: "hold", + label: "Keep on hold", + intent: "hold_release_work", }, ], }, @@ -234,6 +240,17 @@ export const surfaceExamples = [ title: "Incident handoff", icon: "message", sortOrder: 3, + header: { + status: { + id: "severity", + type: "status", + status: "Monitoring", + tone: "warning", + detail: + "No new errors for 12 minutes; continue watching the primary region.", + timestamp: "2026-08-27T15:42:00.000Z", + }, + }, blocks: [ { id: "summary", @@ -242,13 +259,11 @@ export const surfaceExamples = [ text: "The checkout error rate is back within baseline after rolling back the **pricing worker**. [Open the runbook](https://example.com/runbook) before taking the handoff.", }, { - id: "severity", - type: "status", - status: "Monitoring", + id: "risk_note", + type: "text", + title: "Watch out", tone: "warning", - detail: - "No new errors for 12 minutes; continue watching the primary region.", - timestamp: "2026-08-27T15:42:00.000Z", + text: "The pricing worker rollback leaves surge pricing disabled — re-enable it before the 18:00 UTC peak.", }, { id: "timeline", @@ -277,28 +292,45 @@ export const surfaceExamples = [ ], }, { - id: "handoff", - type: "actions", - layout: "auto", - actions: [ + id: "impact", + type: "table", + title: "Impact", + columns: [ + { id: "metric", label: "Metric" }, + { id: "value", label: "Value" }, + ], + rows: [ { - id: "ack", - label: "Acknowledge handoff", - intent: "acknowledge_incident_handoff", - style: "primary", - icon: "checklist", + id: "duration", + cells: { metric: "Duration", value: "34 minutes" }, }, + { id: "failed", cells: { metric: "Failed checkouts", value: 1204 } }, { - id: "page", - label: "Page incident lead", - intent: "page_incident_lead", - style: "destructive", - icon: "flag", - confirm: { title: "Page the incident lead?" }, + id: "regions", + cells: { metric: "Regions affected", value: "us-east-1" }, }, ], }, ], + footer: { + actions: [ + { + id: "ack", + label: "Acknowledge handoff", + intent: "acknowledge_incident_handoff", + style: "primary", + icon: "checklist", + }, + { + id: "page", + label: "Page incident lead", + intent: "page_incident_lead", + style: "destructive", + icon: "flag", + confirm: { title: "Page the incident lead?" }, + }, + ], + }, }, { id: "tab_seed_service_health", @@ -356,11 +388,13 @@ export const surfaceExamples = [ trace: "https://example.com/traces/api", build: "api@4.18.0", }, - action: { - id: "inspect_api", - label: "Inspect API", - intent: "inspect_api_health", - }, + actions: [ + { + id: "inspect_api", + label: "Inspect", + intent: "inspect_api_health", + }, + ], }, { id: "search", @@ -372,11 +406,18 @@ export const surfaceExamples = [ trace: "https://example.com/traces/search", build: "search@4.18.0", }, - action: { - id: "retry_search", - label: "Retry search check", - intent: "retry_search_health_check", - }, + actions: [ + { + id: "retry_search", + label: "Retry", + intent: "retry_search_health_check", + }, + { + id: "mute_search", + label: "Mute alerts", + intent: "mute_search_alerts", + }, + ], }, { id: "worker", @@ -486,7 +527,6 @@ export const surfaceExamples = [ id: "start", label: "Start research", intent: "submit_research_request", - style: "primary", icon: "sparkles", }, resetLabel: "Reset request", @@ -509,7 +549,7 @@ export const surfaceExamples = [ { id: "seed", text: "Expanded **surface examples** for sidebar coverage.", - detail: "Includes actions, forms, and compact data leaves.", + detail: "Includes slots, forms, and compact data leaves.", }, { id: "badge", @@ -535,57 +575,68 @@ export const surfaceExamples = [ title: "Access request", icon: "clock", sortOrder: 7, - blocks: [ - { + header: { + status: { id: "review_state", type: "status", status: "Awaiting approval", tone: "danger", detail: "The requested production role is not yet assigned.", }, + }, + blocks: [ { - id: "approve_actions", - type: "actions", - title: "Approval controls", - description: "A disabled action explains why it cannot currently run.", - layout: "stack", - actions: [ - { - id: "approve", - label: "Approve temporary access", - intent: "approve_temporary_access", - style: "primary", - confirm: { - title: "Approve temporary access?", - description: "Access expires automatically after one hour.", - }, - }, - { - id: "revoke", - label: "Revoke access", - intent: "revoke_access", - style: "destructive", - disabled: true, - disabledReason: "No active access grant exists.", - }, - ], + id: "grant_terms", + type: "text", + title: "Grant terms", + tone: "info", + text: "Approval issues a temporary production role that expires automatically after one hour.", }, ], + footer: { + actions: [ + { + id: "approve", + label: "Approve temporary access", + intent: "approve_temporary_access", + style: "primary", + confirm: { + title: "Approve temporary access?", + description: "Access expires automatically after one hour.", + }, + }, + { + id: "revoke", + label: "Revoke access", + intent: "revoke_access", + style: "destructive", + disabled: true, + disabledReason: "No active access grant exists.", + }, + ], + }, }, ] as const; export async function seedSurfaces(client: PoolClient): Promise { for (const surface of surfaceExamples) { + const withSlots = surface as { + header?: unknown; + footer?: unknown; + }; await client.query( - `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, blocks) - VALUES ($1,$2,$3,$4,$5,$6)`, + `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, schema_version, header, blocks, footer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [ surface.id, owner, surface.title, surface.icon, surface.sortOrder, + SURFACE_SCHEMA_VERSION, + withSlots.header ? JSON.stringify(withSlots.header) : null, JSON.stringify(surface.blocks), + withSlots.footer ? JSON.stringify(withSlots.footer) : null, ] ); } diff --git a/apps/server/src/shared/mcp/surface-tools.ts b/apps/server/src/shared/mcp/surface-tools.ts index 2e84236ca..858b2d85c 100644 --- a/apps/server/src/shared/mcp/surface-tools.ts +++ b/apps/server/src/shared/mcp/surface-tools.ts @@ -4,6 +4,8 @@ import type { SurfaceService } from "../../surfaces/service.js"; import { MAX_SURFACE_TOP_LEVEL_BLOCKS, surfaceBlockSchema, + surfaceFooterSchema, + surfaceHeaderSchema, surfaceIconSchema, } from "../../surfaces/types.js"; import { jsonText } from "./response.js"; @@ -36,23 +38,42 @@ export function registerSurfaceTools( }); }; + const designContract = + "Design contract — the renderer owns styling; supply meaning, not layout. " + + "Put the surface's headline state in header (status + progress) and its verbs in footer.actions — they render as a compact split button with an overflow menu. " + + "One primary action per surface; leave others default. Use destructive only for irreversible verbs and always with confirm — it renders in the overflow menu on purpose. " + + "Sections group related blocks and take their own actions footer for group-scoped verbs. List items and table rows take actions (one renders inline, more become a per-item menu); repeating one label across every item is fine. " + + "Tables: at most 3 primary columns; mark the rest secondary (they collapse behind a per-row disclosure); 2-column tables render as a key/value list. " + + 'Color means state: tone neutral for categories (environment, repo, owner), danger/warning for exceptions; write human labels ("Rolled back"), never enum tokens. ' + + "text takes a tone — use it for the one sentence that changes a decision. Collapse finished work, never the thesis. " + + "Shapes: dashboard = header+table+footer · worklist = header+list(group, actions) · approval = text(tone warning)+form · board = section-per-column+check list · report = text+2-col table+list."; + register( "dispatch_surface_create", - "Create a one-column custom sidebar tab with up to 100 top-level blocks and 100 additional nested blocks. Blocks support text, list, table, status, progress, actions, form, and titled section groups. A section may nest up to four levels, hold 20 direct children, and use collapse: { initiallyCollapsed? }; its title remains visible. Keep block, item, and field IDs stable; list/table action IDs are scoped to their item or row.", + 'Create a custom sidebar tab (fixed 400px column): optional header { status?, progress? }, blocks (text, list, table, status, progress, form, section; up to 100 top-level and 100 nested), optional footer { actions }. Sections nest four levels, hold 20 direct children, take collapse: { initiallyCollapsed? } and an optional actions footer. Keep block, item, action, and field IDs stable; item/row action IDs are scoped to their item, and footer actions use the reserved block id "footer" in interactions. ' + + designContract, { title: z.string().min(1).max(32), icon: surfaceIconSchema.optional(), + header: surfaceHeaderSchema.optional(), blocks: z.array(surfaceBlockSchema).max(MAX_SURFACE_TOP_LEVEL_BLOCKS), + footer: surfaceFooterSchema.optional(), }, - async ({ title, icon, blocks }) => { - const s = await service.create(context.agentId, { title, icon, blocks }); + async ({ title, icon, header, blocks, footer }) => { + const s = await service.create(context.agentId, { + title, + icon, + header, + blocks, + footer, + }); return { tabId: s.id, revision: s.revision, sortOrder: s.sortOrder }; } ); register( "dispatch_surface_update", - "Replace all or part of an owned surface document using expectedRevision. Whole-document blocks replacement only; no JSON Patch. Section groups use a required title, nested blocks, and optional collapse: { initiallyCollapsed? }.", + "Replace all or part of an owned surface document using expectedRevision. Whole-document blocks replacement only; no JSON Patch. header and footer accept null to clear the slot. Same design contract as dispatch_surface_create.", { tabId: z.string().min(1), expectedRevision: z.number().int().positive(), @@ -61,10 +82,18 @@ export function registerSurfaceTools( .union([surfaceIconSchema, z.null()]) .optional() .describe("Set null to clear the icon."), + header: z + .union([surfaceHeaderSchema, z.null()]) + .optional() + .describe("Set null to clear the header slot."), blocks: z .array(surfaceBlockSchema) .max(MAX_SURFACE_TOP_LEVEL_BLOCKS) .optional(), + footer: z + .union([surfaceFooterSchema, z.null()]) + .optional() + .describe("Set null to clear the footer slot."), lifecycle: z.enum(["active", "frozen"]).optional(), }, async ({ tabId, expectedRevision, ...patch }) => { diff --git a/apps/server/src/surfaces/service.ts b/apps/server/src/surfaces/service.ts index cd4cdd720..76b7c7219 100644 --- a/apps/server/src/surfaces/service.ts +++ b/apps/server/src/surfaces/service.ts @@ -2,10 +2,16 @@ import { randomUUID } from "node:crypto"; import type { Pool, PoolClient } from "pg"; import type { Surface, + SurfaceFooter, + SurfaceHeader, SurfaceInteractionRecord as SurfaceInteraction, SurfaceInteractionResponse, SurfaceInteractionSummary, } from "@dispatch/shared"; +import { + SURFACE_FOOTER_BLOCK_ID, + SURFACE_SCHEMA_VERSION, +} from "@dispatch/shared"; import { interactionRequestSchema, @@ -43,7 +49,9 @@ type SurfaceRow = { revision: number; lifecycle: SurfaceLifecycle; sort_order: number; + header: SurfaceHeader | null; blocks: SurfaceBlock[]; + footer: SurfaceFooter | null; created_at: Date; updated_at: Date; }; @@ -76,7 +84,7 @@ function toSurface( latestInteractions: SurfaceInteractionSummary[] = [] ): Surface { return { - schemaVersion: 1, + schemaVersion: row.schema_version, id: row.id, ownerAgentId: row.agent_id, title: row.title, @@ -84,7 +92,9 @@ function toSurface( revision: row.revision, lifecycle: row.lifecycle, sortOrder: row.sort_order, + ...(row.header ? { header: row.header } : {}), blocks: row.blocks, + ...(row.footer ? { footer: row.footer } : {}), createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), unresolvedInteractionCount: unresolved, @@ -251,14 +261,17 @@ export class SurfaceService { ); const id = surfaceId(); const row = await client.query( - `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, blocks) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, + `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, schema_version, header, blocks, footer) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [ id, agentId, parsed.data.title, parsed.data.icon ?? null, order.rows[0].next, + SURFACE_SCHEMA_VERSION, + parsed.data.header ? JSON.stringify(parsed.data.header) : null, JSON.stringify(parsed.data.blocks), + parsed.data.footer ? JSON.stringify(parsed.data.footer) : null, ] ); await client.query("COMMIT"); @@ -280,19 +293,37 @@ export class SurfaceService { patch: { title?: unknown; icon?: unknown; + header?: unknown; blocks?: unknown; + footer?: unknown; lifecycle?: unknown; } ): Promise { const current = await this.getOwned(id, agentId); if (current.lifecycle === "frozen") throw new SurfaceError("Frozen surfaces cannot be updated.", 409); + if ( + current.schemaVersion !== SURFACE_SCHEMA_VERSION && + patch.blocks === undefined + ) + throw new SurfaceError( + `This surface was authored under schema v${current.schemaVersion}; supply a complete v${SURFACE_SCHEMA_VERSION} blocks array (plus optional header/footer) to upgrade it, or delete and recreate the tab.`, + 409 + ); const nextIcon = patch.icon === null ? undefined : (patch.icon ?? current.icon); + // Like icon, header/footer use null-to-clear semantics; omitting keeps + // the stored slot. + const nextHeader = + patch.header === null ? undefined : (patch.header ?? current.header); + const nextFooter = + patch.footer === null ? undefined : (patch.footer ?? current.footer); const document = { title: patch.title ?? current.title, blocks: patch.blocks ?? current.blocks, ...(nextIcon ? { icon: nextIcon } : {}), + ...(nextHeader ? { header: nextHeader } : {}), + ...(nextFooter ? { footer: nextFooter } : {}), }; const parsed = surfaceDocumentSchema.safeParse(document); if (!parsed.success) @@ -304,7 +335,7 @@ export class SurfaceService { if (lifecycle !== "active" && lifecycle !== "frozen") throw new SurfaceError("lifecycle must be active or frozen."); const result = await this.pool.query( - `UPDATE agent_surfaces SET title=$4, icon=$5, blocks=$6, lifecycle=$7, revision=revision+1, updated_at=NOW() + `UPDATE agent_surfaces SET title=$4, icon=$5, header=$6, blocks=$7, footer=$8, lifecycle=$9, schema_version=$10, revision=revision+1, updated_at=NOW() WHERE id=$1 AND agent_id=$2 AND revision=$3 AND deleted_at IS NULL RETURNING *`, [ id, @@ -312,8 +343,11 @@ export class SurfaceService { expectedRevision, parsed.data.title, parsed.data.icon ?? null, + parsed.data.header ? JSON.stringify(parsed.data.header) : null, JSON.stringify(parsed.data.blocks), + parsed.data.footer ? JSON.stringify(parsed.data.footer) : null, lifecycle, + SURFACE_SCHEMA_VERSION, ] ); if (!result.rows[0]) @@ -437,6 +471,11 @@ export class SurfaceService { "This surface is frozen and no longer accepts interactions.", 409 ); + if (surface.schemaVersion !== SURFACE_SCHEMA_VERSION) + throw new SurfaceError( + "This surface uses an older schema and no longer accepts interactions; the agent must recreate it.", + 409 + ); if (surface.revision !== parsed.data.baseRevision) throw new SurfaceError( "Surface revision conflict; reload before submitting.", @@ -641,6 +680,20 @@ export class SurfaceService { } } +function assertActionEnabled(action: { + disabled?: boolean; + disabledReason?: string; + id: string; +}): void { + if (action.disabled) + throw new SurfaceError( + typeof action.disabledReason === "string" + ? action.disabledReason + : "This action is disabled.", + 409 + ); +} + function validateAndCapture( surface: Surface, request: InteractionRequest @@ -650,6 +703,24 @@ function validateAndCapture( snapshot: Record; onceFormBlockId: string | null; } { + // Document footer actions are addressed with the reserved block id. + if (request.blockId === SURFACE_FOOTER_BLOCK_ID) { + if (request.kind !== "action") + throw new SurfaceError("Form submissions must reference a form block."); + if (request.itemId) + throw new SurfaceError("This action does not accept an itemId."); + const action = surface.footer?.actions.find( + (candidate) => candidate.id === request.actionId + ); + if (!action) throw new SurfaceError("Referenced action does not exist."); + assertActionEnabled(action); + return { + intent: action.intent, + payload: { blockId: SURFACE_FOOTER_BLOCK_ID, actionId: action.id }, + snapshot: { footer: surface.footer, action }, + onceFormBlockId: null, + }; + } const findBlock = (blocks: SurfaceBlock[]): SurfaceBlock | undefined => { for (const candidate of blocks) { if (candidate.id === request.blockId) return candidate; @@ -671,7 +742,7 @@ function validateAndCapture( throw new SurfaceError("Item actions must include an itemId."); if ( request.kind === "action" && - (block.type === "actions" || block.type === "form") && + (block.type === "section" || block.type === "form") && itemId ) throw new SurfaceError("This action does not accept an itemId."); @@ -682,25 +753,19 @@ function validateAndCapture( ? block.rows.find((candidate) => candidate.id === itemId) : undefined; const action = - block.type === "actions" - ? block.actions.find((a) => a.id === request.actionId) - : item?.action?.id === request.actionId - ? item.action + block.type === "section" + ? (block.actions ?? []).find((a) => a.id === request.actionId) + : item + ? (item.actions ?? []).find((a) => a.id === request.actionId) : block.type === "form" && block.submit.id === request.actionId ? block.submit : undefined; if (!action) throw new SurfaceError("Referenced action does not exist."); - if ("disabled" in action && action.disabled) - throw new SurfaceError( - "disabledReason" in action && typeof action.disabledReason === "string" - ? action.disabledReason - : "This action is disabled.", - 409 - ); + assertActionEnabled(action); if (request.kind === "action") { - if (block.type !== "actions" && !item) + if (block.type !== "section" && !item) throw new SurfaceError( - "Action interactions must reference an actions block or an item action." + "Action interactions must reference a section's actions, the document footer, or an item action." ); return { intent: action.intent, diff --git a/apps/server/src/surfaces/types.ts b/apps/server/src/surfaces/types.ts index 90ca6a736..a5de74ba9 100644 --- a/apps/server/src/surfaces/types.ts +++ b/apps/server/src/surfaces/types.ts @@ -7,6 +7,7 @@ import type { SurfaceInteractionStatus, SurfaceLifecycle, } from "@dispatch/shared"; +import { SURFACE_FOOTER_BLOCK_ID } from "@dispatch/shared"; export const SURFACE_ICONS = [ "layout", @@ -79,11 +80,18 @@ const actionSchema = z disabledReason: z.string().max(240).optional(), }) .strict(); +// A form submit is by definition its form's primary action, so it carries no +// style knob — the renderer always gives it primary weight. +const submitActionSchema = actionSchema.omit({ style: true }); +/** Slot actions: 1 renders inline / as the split-button main; more collapse + * into the overflow menu. Small caps keep the slot a decision, not a menu. */ +const slotActionsSchema = z.array(actionSchema).min(1).max(4); const itemActionSchema = actionSchema.pick({ id: true, label: true, intent: true, }); +const itemActionsSchema = z.array(itemActionSchema).min(1).max(4); const collapseSchema = z .object({ after: z.number().int().min(1).max(99), @@ -154,12 +162,36 @@ const fieldSchema = z.discriminatedUnion("type", [ .strict(), ]); +const statusBlockSchema = z + .object({ + ...base, + type: z.literal("status"), + status: z.string().trim().min(1).max(40), + tone: toneSchema.optional(), + detail: constrainedMarkdown(1000).optional(), + timestamp: z.iso.datetime().optional(), + }) + .strict(); +const progressBlockSchema = z + .object({ + ...base, + type: z.literal("progress"), + value: z.number().min(0), + max: z.number().positive(), + label: z.string().max(120).optional(), + detail: constrainedMarkdown(1000).optional(), + tone: z.enum(["neutral", "info", "success", "warning"]).optional(), + }) + .strict(); + const leafBlockSchema = z.discriminatedUnion("type", [ z .object({ ...base, type: z.literal("text"), text: constrainedMarkdown(8000), + // Renders the block as a callout; reserve for decision-changing prose. + tone: toneSchema.optional(), }) .strict(), z @@ -185,7 +217,7 @@ const leafBlockSchema = z.discriminatedUnion("type", [ }) .optional(), group: z.string().trim().min(1).max(80).optional(), - action: itemActionSchema.optional(), + actions: itemActionsSchema.optional(), }) .strict() ) @@ -216,7 +248,7 @@ const leafBlockSchema = z.discriminatedUnion("type", [ message: "badgeVariants supports at most 50 entries", }) .optional(), - align: z.enum(["left", "center", "right"]).optional(), + align: z.enum(["left", "right"]).optional(), // "secondary" always renders behind a per-row disclosure (the // rail is a fixed width, not a breakpoint) — reserve it for // verbose diagnostics, never a decision-critical value. @@ -232,48 +264,21 @@ const leafBlockSchema = z.discriminatedUnion("type", [ .object({ id: idSchema, cells: z.record(z.string(), tableCellScalarSchema), - action: itemActionSchema.optional(), + actions: itemActionsSchema.optional(), }) .strict() ) .max(100), }) .strict(), - z - .object({ - ...base, - type: z.literal("status"), - status: z.string().trim().min(1).max(40), - tone: toneSchema.optional(), - detail: constrainedMarkdown(1000).optional(), - timestamp: z.iso.datetime().optional(), - }) - .strict(), - z - .object({ - ...base, - type: z.literal("progress"), - value: z.number().min(0), - max: z.number().positive(), - label: z.string().max(120).optional(), - detail: constrainedMarkdown(1000).optional(), - tone: z.enum(["neutral", "info", "success", "warning"]).optional(), - }) - .strict(), - z - .object({ - ...base, - type: z.literal("actions"), - layout: z.enum(["auto", "stack"]).optional(), - actions: z.array(actionSchema).min(1).max(6), - }) - .strict(), + statusBlockSchema, + progressBlockSchema, z .object({ ...base, type: z.literal("form"), fields: z.array(fieldSchema).min(1).max(20), - submit: actionSchema, + submit: submitActionSchema, resetLabel: z.string().min(1).max(48).optional(), submitMode: z.enum(["once", "repeatable"]).optional(), }) @@ -283,6 +288,7 @@ const leafBlockSchema = z.discriminatedUnion("type", [ const MAX_SECTION_DEPTH = 4; const MAX_SECTION_CHILDREN = 20; const MAX_NESTED_BLOCKS = 100; +const MAX_PRIMARY_TABLE_COLUMNS = 3; export const MAX_SURFACE_TOP_LEVEL_BLOCKS = 100; const sectionCollapseSchema = z .object({ initiallyCollapsed: z.boolean().optional() }) @@ -300,6 +306,8 @@ function blockSchemaAtDepth(depth: number): z.ZodType { // A section heading is always visible, including when its body is collapsed. title: titleSchema, blocks: z.array(childSchema).min(1).max(MAX_SECTION_CHILDREN), + // The section's footer slot: verbs that act on this group. + actions: slotActionsSchema.optional(), collapse: sectionCollapseSchema.optional(), }) .strict(), @@ -313,11 +321,23 @@ function blockSchemaAtDepth(depth: number): z.ZodType { */ export const surfaceBlockSchema = blockSchemaAtDepth(0); +export const surfaceHeaderSchema = z + .object({ + status: statusBlockSchema.optional(), + progress: progressBlockSchema.optional(), + }) + .strict(); +export const surfaceFooterSchema = z + .object({ actions: slotActionsSchema }) + .strict(); + export const surfaceDocumentSchema = z .object({ title: z.string().trim().min(1).max(32), icon: surfaceIconSchema.optional(), + header: surfaceHeaderSchema.optional(), blocks: z.array(surfaceBlockSchema).max(MAX_SURFACE_TOP_LEVEL_BLOCKS), + footer: surfaceFooterSchema.optional(), }) .strict() .superRefine((doc, ctx) => { @@ -336,7 +356,22 @@ export const surfaceDocumentSchema = z code: "custom", message: "Surface supports at most 100 nested blocks", }); + if (doc.header?.status) blocks.push(doc.header.status); + if (doc.header?.progress) blocks.push(doc.header.progress); + if (doc.footer) { + const footerActionIds = doc.footer.actions.map((action) => action.id); + if (new Set(footerActionIds).size !== footerActionIds.length) + ctx.addIssue({ + code: "custom", + message: "Duplicate action id in footer", + }); + } for (const block of blocks) { + if (block.id === SURFACE_FOOTER_BLOCK_ID || block.id === "header") + ctx.addIssue({ + code: "custom", + message: `Block id "${block.id}" is reserved for the document slot`, + }); if (blockIds.has(block.id)) ctx.addIssue({ code: "custom", @@ -356,8 +391,8 @@ export const surfaceDocumentSchema = z ...block.columns.map((x) => x.id), ...block.rows.map((x) => x.id), ] - : block.type === "actions" - ? block.actions.map((x) => x.id) + : block.type === "section" + ? (block.actions ?? []).map((x) => x.id) : block.type === "form" ? [...block.fields.map((x) => x.id), block.submit.id] : []; @@ -366,7 +401,26 @@ export const surfaceDocumentSchema = z code: "custom", message: `Duplicate child id in block ${block.id}`, }); + if (block.type === "list" || block.type === "table") { + const entries = block.type === "list" ? block.items : block.rows; + for (const entry of entries) { + const actionIds = (entry.actions ?? []).map((action) => action.id); + if (new Set(actionIds).size !== actionIds.length) + ctx.addIssue({ + code: "custom", + message: `Duplicate action id in item ${entry.id} of block ${block.id}`, + }); + } + } if (block.type === "table") { + const primaryCount = block.columns.filter( + (column) => column.priority !== "secondary" + ).length; + if (primaryCount > MAX_PRIMARY_TABLE_COLUMNS) + ctx.addIssue({ + code: "custom", + message: `Table ${block.id} allows at most ${MAX_PRIMARY_TABLE_COLUMNS} primary columns at the 400px rail; mark the rest priority: "secondary" (they collapse behind a per-row disclosure)`, + }); for (const column of block.columns) if (column.badgeVariants && column.format !== "badge") ctx.addIssue({ diff --git a/apps/server/test/surfaces.test.ts b/apps/server/test/surfaces.test.ts index cba7cc071..6474239af 100644 --- a/apps/server/test/surfaces.test.ts +++ b/apps/server/test/surfaces.test.ts @@ -25,26 +25,23 @@ async function authed(method: "GET" | "POST", url: string, payload?: unknown) { const actionDocument = { title: "Release choice", icon: "flag", - blocks: [ - { - id: "choices", - type: "actions", - actions: [ - { - id: "canary", - label: "Use canary", - intent: "choose_canary", - style: "primary", - }, - { - id: "disabled", - label: "Unavailable", - intent: "disabled", - disabled: true, - }, - ], - }, - ], + blocks: [{ id: "context", type: "text", text: "Choose the rollout shape." }], + footer: { + actions: [ + { + id: "canary", + label: "Use canary", + intent: "choose_canary", + style: "primary", + }, + { + id: "disabled", + label: "Unavailable", + intent: "disabled", + disabled: true, + }, + ], + }, }; beforeEach(async () => { @@ -86,7 +83,7 @@ describe("surface API", () => { first.id, ]); expect(body.surfaces[1]).toMatchObject({ - schemaVersion: 1, + schemaVersion: 2, title: "Release choice", revision: 1, unresolvedInteractionCount: 0, @@ -114,7 +111,7 @@ describe("surface API", () => { const request = { idempotencyKey: "click-1", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 1, }; @@ -130,7 +127,7 @@ describe("surface API", () => { interaction: { status: "queued", intent: "choose_canary", - payload: { blockId: "choices", actionId: "canary" }, + payload: { blockId: "footer", actionId: "canary" }, }, }); @@ -163,7 +160,7 @@ describe("surface API", () => { expect.objectContaining({ id: first.json().interaction.id, tabRevision: 1, - blockId: "choices", + blockId: "footer", actionId: "canary", kind: "action", status: "queued", @@ -226,11 +223,13 @@ describe("surface authoring and inbox", () => { checked: false, group: "Before rollout", url: "https://example.com/runbook", - action: { - id: "open-runbook", - label: "Open runbook", - intent: "open_release_runbook", - }, + actions: [ + { + id: "open-runbook", + label: "Open runbook", + intent: "open_release_runbook", + }, + ], }, { id: "verify", text: "Verify health" }, { id: "announce", text: "Announce release" }, @@ -278,12 +277,12 @@ describe("surface authoring and inbox", () => { { id: "first", text: "First task", - action: { id: "run", label: "Run", intent: "run_first" }, + actions: [{ id: "run", label: "Run", intent: "run_first" }], }, { id: "second", text: "Second task", - action: { id: "run", label: "Run", intent: "run_second" }, + actions: [{ id: "run", label: "Run", intent: "run_second" }], }, ], }, @@ -296,11 +295,13 @@ describe("surface authoring and inbox", () => { { id: "api", cells: { name: "API" }, - action: { - id: "restart", - label: "Restart", - intent: "restart_api", - }, + actions: [ + { + id: "restart", + label: "Restart", + intent: "restart_api", + }, + ], }, ], }, @@ -389,14 +390,9 @@ describe("surface authoring and inbox", () => { description: "The current deployment steps.", collapse: { initiallyCollapsed: true }, blocks: [ - { - id: "deploy", - type: "actions" as const, - actions: [ - { id: "start", label: "Start", intent: "start_deploy" }, - ], - }, + { id: "deploy", type: "text" as const, text: "Deploy steps." }, ], + actions: [{ id: "start", label: "Start", intent: "start_deploy" }], }, ], }; @@ -480,15 +476,15 @@ describe("surface authoring and inbox", () => { const result = await service.submitInteraction(agentId, surface.id, { idempotencyKey: "nested-start", kind: "action", - blockId: "deploy", + blockId: "rollout", actionId: "start", baseRevision: 1, }); expect(result.interaction).toMatchObject({ intent: "start_deploy", - payload: { blockId: "deploy", actionId: "start" }, + payload: { blockId: "rollout", actionId: "start" }, definitionSnapshot: { - block: { id: "deploy", type: "actions" }, + block: { id: "rollout", type: "section" }, action: { id: "start", intent: "start_deploy" }, }, }); @@ -662,7 +658,7 @@ describe("surface authoring and inbox", () => { await service.submitInteraction(agentId, surface.id, { idempotencyKey: "pending", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 2, }); @@ -782,7 +778,7 @@ describe("surface authoring and inbox", () => { service.submitInteraction(agentId, surface.id, { idempotencyKey: key, kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 1, }); @@ -832,7 +828,7 @@ describe("surface authoring and inbox", () => { const result = await notifying.submitInteraction(agentId, surface.id, { idempotencyKey: "durable", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 1, }); @@ -870,14 +866,14 @@ describe("surface authoring and inbox", () => { await notifying.submitInteraction(agentId, surface.id, { idempotencyKey: "one", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 1, }); await notifying.submitInteraction(agentId, surface.id, { idempotencyKey: "two", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 1, }); @@ -903,10 +899,170 @@ describe("surface authoring and inbox", () => { notifying.submitInteraction(agentId, surface.id, { idempotencyKey: "three", kind: "action", - blockId: "choices", + blockId: "footer", actionId: "canary", baseRevision: 2, }) ).rejects.toMatchObject({ statusCode: 409 }); }); }); + +describe("surface schema v2", () => { + it("rejects block ids reserved for document slots", () => { + const parsed = surfaceDocumentSchema.safeParse({ + title: "Reserved", + blocks: [{ id: "footer", type: "text", text: "Nope" }], + }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.message).toMatch(/reserved/); + }); + + it("enforces the 3-primary-column table budget", () => { + const table = (priorities: ("primary" | "secondary" | undefined)[]) => ({ + title: "Wide", + blocks: [ + { + id: "wide", + type: "table", + columns: priorities.map((priority, index) => ({ + id: `c${index}`, + label: `C${index}`, + ...(priority ? { priority } : {}), + })), + rows: [], + }, + ], + }); + expect( + surfaceDocumentSchema.safeParse( + table([undefined, undefined, undefined, undefined]) + ).success + ).toBe(false); + expect( + surfaceDocumentSchema.safeParse( + table([undefined, undefined, undefined, "secondary"]) + ).success + ).toBe(true); + }); + + it("rejects the retired center alignment", () => { + const parsed = surfaceDocumentSchema.safeParse({ + title: "Centered", + blocks: [ + { + id: "t", + type: "table", + columns: [{ id: "a", label: "A", align: "center" }], + rows: [], + }, + ], + }); + expect(parsed.success).toBe(false); + }); + + it("rejects a style knob on a form submit", () => { + const parsed = surfaceDocumentSchema.safeParse({ + title: "Form", + blocks: [ + { + id: "f", + type: "form", + fields: [{ id: "note", type: "text", label: "Note" }], + submit: { + id: "send", + label: "Send", + intent: "send", + style: "primary", + }, + }, + ], + }); + expect(parsed.success).toBe(false); + }); + + it("validates header and footer slots with duplicate-id checks", () => { + const document = { + title: "Slots", + header: { + status: { id: "hs", type: "status", status: "OK" }, + progress: { id: "hp", type: "progress", value: 1, max: 2 }, + }, + blocks: [{ id: "body", type: "text", text: "Body" }], + footer: { + actions: [ + { id: "go", label: "Go", intent: "go" }, + { id: "stop", label: "Stop", intent: "stop" }, + ], + }, + }; + expect(surfaceDocumentSchema.safeParse(document).success).toBe(true); + expect( + surfaceDocumentSchema.safeParse({ + ...document, + footer: { + actions: [ + { id: "go", label: "Go", intent: "go" }, + { id: "go", label: "Again", intent: "again" }, + ], + }, + }).success + ).toBe(false); + expect( + surfaceDocumentSchema.safeParse({ + ...document, + header: { + status: { id: "body", type: "status", status: "OK" }, + }, + }).success + ).toBe(false); + }); + + it("stores slots, requires full upgrades for v1 rows, and gates their interactions", async () => { + const created = await service.create(agentId, { + title: "Slots", + header: { status: { id: "hs", type: "status", status: "OK" } }, + blocks: [{ id: "body", type: "text", text: "Body" }], + footer: { actions: [{ id: "go", label: "Go", intent: "go" }] }, + }); + expect(created.schemaVersion).toBe(2); + const fetched = await service.get(created.id); + expect(fetched?.header?.status?.status).toBe("OK"); + expect(fetched?.footer?.actions[0]?.id).toBe("go"); + + // A stored v1 row (legacy shape, schema_version 1) must not crash reads, + // must refuse partial updates, and must refuse interactions. + await ctx.pool.query( + `INSERT INTO agent_surfaces (id, agent_id, title, sort_order, schema_version, blocks) + VALUES ('tab_legacy_v1', $1, 'Legacy', 99, 1, $2)`, + [ + agentId, + JSON.stringify([ + { + id: "old-actions", + type: "actions", + actions: [{ id: "go", label: "Go", intent: "go" }], + }, + ]), + ] + ); + const legacy = await service.get("tab_legacy_v1"); + expect(legacy?.schemaVersion).toBe(1); + await expect( + service.update(agentId, "tab_legacy_v1", 1, { title: "Renamed" }) + ).rejects.toMatchObject({ statusCode: 409 }); + await expect( + service.submitInteraction(agentId, "tab_legacy_v1", { + idempotencyKey: "legacy-click", + kind: "action", + blockId: "old-actions", + actionId: "go", + baseRevision: 1, + }) + ).rejects.toMatchObject({ statusCode: 409 }); + // A full v2 blocks replacement upgrades the row in place. + const upgraded = await service.update(agentId, "tab_legacy_v1", 1, { + blocks: [{ id: "body", type: "text", text: "Upgraded" }], + }); + expect(upgraded.schemaVersion).toBe(2); + }); +}); diff --git a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.test.tsx b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.test.tsx index 647b95c23..9137864d7 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.test.tsx @@ -12,11 +12,13 @@ afterEach(() => { const BASE: ActionRef = { id: "go", label: "Go", intent: "go" }; describe("actionButtonVariant", () => { - it("maps each ActionRef style to its Button variant, including destructive", () => { + it("maps each ActionRef style to its Button variant, de-emphasizing destructive", () => { expect(actionButtonVariant(undefined)).toBe("default"); expect(actionButtonVariant("default")).toBe("default"); expect(actionButtonVariant("primary")).toBe("primary"); - expect(actionButtonVariant("destructive")).toBe("destructive"); + // An irreversible verb should be findable, not the loudest object on the + // surface — destructive renders as the ghost danger variant. + expect(actionButtonVariant("destructive")).toBe("ghost-destructive"); }); }); @@ -45,7 +47,7 @@ describe("ActionRefButton", () => { expect(button.querySelector("svg.animate-spin")).not.toBeNull(); }); - it("applies the destructive style class for a destructive action", () => { + it("applies the quiet ghost-destructive treatment for a destructive action", () => { render( { /> ); const button = screen.getByRole("button", { name: "Go" }); - expect(button.className).toContain("bg-destructive"); + expect(button.className).toContain("text-status-blocked"); + expect(button.className).not.toContain("bg-destructive"); }); it("wires disabledReasonId as the accessible description", () => { diff --git a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx index c4b1789e4..dc6ed7112 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx @@ -5,23 +5,25 @@ import { SurfaceIconGlyph } from "@/components/app/agent-surfaces/surface-icon"; import type { ActionRef } from "@/components/app/agent-surfaces/types"; import { cn } from "@/lib/utils"; -/** Canonical `ActionRef.style` -> Button `variant`, shared so an action - * button and a form submit button render the same way for the same style - * (including "destructive"). `ActionRef["style"]` values pass straight - * through as Button variant names; the return type annotation is what keeps - * this coupled to Button's variant union at compile time — if Button ever - * drops one of these variants, this stops compiling. */ +/** Canonical `ActionRef.style` -> Button `variant`. The mapping is where the + * emphasis policy lives: `primary` is the loudest thing on a surface, + * `default` is quiet chrome, and `destructive` maps to the *ghost* danger + * variant — an irreversible verb should be findable, not the brightest object + * on screen. The return type annotation keeps this coupled to Button's + * variant union at compile time. */ export function actionButtonVariant( style: ActionRef["style"] ): NonNullable { + if (style === "destructive") return "ghost-destructive"; return style ?? "default"; } /** - * Renders one `ActionRef` as a button: icon (or a submitting spinner in its - * place), style variant, and label. Used for both actions-block's action - * buttons and form-block's submit button so an agent-authored action looks - * and behaves the same wherever it appears. + * Renders one `ActionRef` as a compact button: icon (or a submitting spinner + * in its place), emphasis variant, and label. Used by the slot-actions + * split button and the form submit so an agent-authored action looks and + * behaves the same wherever it appears. Buttons on a surface are quieter + * than content — h-7 visual box, with the 44px coarse-pointer hit area kept. * * `disabled` and `authoredDisabled` are deliberately distinct props. `disabled` * covers transient/system reasons (already submitting, already queued or @@ -42,8 +44,12 @@ export function ActionRefButton({ onClick, disabledReasonId, ariaLabel, + variantOverride, }: { - action: ActionRef; + action: Pick< + ActionRef, + "id" | "label" | "icon" | "style" | "disabledReason" + > & { style?: ActionRef["style"] }; type?: "button" | "submit"; className?: string; busy: boolean; @@ -52,17 +58,20 @@ export function ActionRefButton({ onClick?: () => void; disabledReasonId?: string; ariaLabel?: string; + /** Renderer policy hook: a form submit is always primary, a split-button + * main segment keeps its own emphasis. */ + variantOverride?: NonNullable; }): JSX.Element { const blocked = disabled || authoredDisabled; return ( +
+ +
) : null} diff --git a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx index ecf346d95..d83ee1e47 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx @@ -36,7 +36,12 @@ function FieldShell({ > {field.label} {field.required ? ( - * + // Muted, not danger-colored: a required marker is not an error + // state, and five red glyphs down a form read as five failures. + ) : null} {field.description ? ( diff --git a/apps/web/src/components/app/agent-surfaces/blocks/item-action.tsx b/apps/web/src/components/app/agent-surfaces/blocks/item-action.tsx deleted file mode 100644 index aa59ec8ae..000000000 --- a/apps/web/src/components/app/agent-surfaces/blocks/item-action.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - makeIdempotencyKey, - useSubmitSurfaceInteraction, -} from "@/hooks/use-agent-surfaces"; -import type { SurfaceItemAction } from "@/components/app/agent-surfaces/types"; -import { ActionRefButton } from "@/components/app/agent-surfaces/blocks/action-ref-button"; -import { ActionFeedback } from "@/components/app/agent-surfaces/blocks/interaction-status-caption"; -import { - IDLE_INTERACTION_STATE, - useKeyedInteractionState, -} from "@/components/app/agent-surfaces/local-interaction-state"; -import { - findInteraction, - resolveInteractionPresentation, - type SurfaceInteractionIndex, -} from "@/components/app/agent-surfaces/interaction-presentation"; -import { cn } from "@/lib/utils"; - -/** A compact action attached to one authored list item or table row. */ -export function ItemAction({ - action, - itemId, - blockId, - agentId, - surfaceId, - surfaceRevision, - interactions, - onRequestRefresh, - readOnly, - idPrefix, - ariaLabel, - buttonClassName, -}: { - action: SurfaceItemAction; - itemId: string; - blockId: string; - agentId: string; - surfaceId: string; - surfaceRevision: number; - interactions: SurfaceInteractionIndex; - onRequestRefresh: () => Promise; - readOnly: boolean; - idPrefix: string; - ariaLabel?: string; - buttonClassName?: string; -}): JSX.Element { - const mutation = useSubmitSurfaceInteraction(agentId, surfaceId); - const { states, submit, clear } = useKeyedInteractionState( - surfaceRevision, - mutation.mutate - ); - const key = `${itemId}:${action.id}`; - const presentation = resolveInteractionPresentation({ - local: states[key] ?? IDLE_INTERACTION_STATE, - durable: findInteraction(interactions, blockId, action.id, itemId), - surfaceRevision, - mode: "action", - readOnly, - }); - const disabledReasonId = `${idPrefix}-${blockId}-${itemId}-${action.id}-disabled-reason`; - - const run = () => { - submit( - key, - { - idempotencyKey: makeIdempotencyKey(), - kind: "action", - blockId, - itemId, - actionId: action.id, - baseRevision: surfaceRevision, - }, - "Couldn't send this action" - ); - }; - - return ( -
- { - if (readOnly) return; - run(); - }} - /> - { - void onRequestRefresh().then(() => clear(key)); - }} - /> -
- ); -} diff --git a/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx new file mode 100644 index 000000000..33b9bdf69 --- /dev/null +++ b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx @@ -0,0 +1,181 @@ +import { Loader2, MoreHorizontal } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + makeIdempotencyKey, + useSubmitSurfaceInteraction, +} from "@/hooks/use-agent-surfaces"; +import type { SurfaceItemAction } from "@/components/app/agent-surfaces/types"; +import { ActionFeedback } from "@/components/app/agent-surfaces/blocks/interaction-status-caption"; +import { + IDLE_INTERACTION_STATE, + useKeyedInteractionState, +} from "@/components/app/agent-surfaces/local-interaction-state"; +import { + findInteraction, + resolveInteractionPresentation, + type SurfaceInteractionIndex, +} from "@/components/app/agent-surfaces/interaction-presentation"; +import { cn } from "@/lib/utils"; + +/** + * Actions attached to one authored list item or table row. One action renders + * as a compact ghost affordance on the item's title row — it adds no item + * height and brightens on hover; repeating the same verb down a list reads as + * a column, not a stack of buttons. Two or more collapse into a per-item + * overflow (⋯) menu. + */ +export function ItemActions({ + actions, + itemId, + blockId, + agentId, + surfaceId, + surfaceRevision, + interactions, + onRequestRefresh, + readOnly, + idPrefix, + itemLabel, +}: { + actions: SurfaceItemAction[]; + itemId: string; + blockId: string; + agentId: string; + surfaceId: string; + surfaceRevision: number; + interactions: SurfaceInteractionIndex; + onRequestRefresh: () => Promise; + readOnly: boolean; + idPrefix: string; + /** Accessible context: " for ". */ + itemLabel?: string; +}): JSX.Element | null { + const mutation = useSubmitSurfaceInteraction(agentId, surfaceId); + const { states, submit, clear } = useKeyedInteractionState( + surfaceRevision, + mutation.mutate + ); + if (actions.length === 0) return null; + + const presentationOf = (action: SurfaceItemAction) => + resolveInteractionPresentation({ + local: states[`${itemId}:${action.id}`] ?? IDLE_INTERACTION_STATE, + durable: findInteraction(interactions, blockId, action.id, itemId), + surfaceRevision, + mode: "action", + readOnly, + }); + + const run = (action: SurfaceItemAction) => { + if (readOnly || presentationOf(action).locked) return; + submit( + `${itemId}:${action.id}`, + { + idempotencyKey: makeIdempotencyKey(), + kind: "action", + blockId, + itemId, + actionId: action.id, + baseRevision: surfaceRevision, + }, + "Couldn't send this action" + ); + }; + + const feedback = actions.map((action) => { + const presentation = presentationOf(action); + if (!presentation.caption) return null; + return ( + { + void onRequestRefresh().then(() => clear(`${itemId}:${action.id}`)); + }} + /> + ); + }); + + if (actions.length === 1) { + const action = actions[0]; + const presentation = presentationOf(action); + return ( +
+ + {feedback} +
+ ); + } + + return ( +
+ + + + + + {actions.map((action) => { + const presentation = presentationOf(action); + return ( + run(action)} + > + {presentation.busy ? ( + + ) : null} + {action.label} + + ); + })} + + + {feedback} +
+ ); +} diff --git a/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx b/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx index 764388ce4..fd5818447 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx @@ -95,14 +95,11 @@ describe("ListBlockView v2", () => { "text-status-waiting" ); expect( - screen.getByRole("link", { name: "Open Review" }).getAttribute("href") + screen.getByRole("link", { name: /Review/ }).getAttribute("href") ).toBe("https://example.com"); - expect( - screen.getByRole("link", { name: "Open Review" }).className - ).toContain("[@media(pointer:coarse)]:h-11"); }); - it("wraps long status and action affordances below the item content", () => { + it("renders a single item action as a compact affordance on the title row", () => { renderList({ id: "work", type: "list", @@ -110,26 +107,42 @@ describe("ListBlockView v2", () => { { id: "one", text: "Review the release", - status: - "Waiting for a particularly detailed cross-functional approval", + status: "Waiting for approval", tone: "warning", - action: { - id: "request", - label: "Request approval from the release coordination team", - intent: "request_approval", - }, + actions: [ + { id: "request", label: "Request", intent: "request_approval" }, + ], }, ], }); - const affordances = screen.getByTestId("list-item-affordances"); - expect(affordances.className).toContain("flex-wrap"); - expect(screen.getByText(/particularly detailed/).className).toContain( - "max-w-full" - ); + const button = screen.getByRole("button", { + name: "Request for Review the release", + }); + expect(button.className).toContain("h-6"); + expect(button.getAttribute("data-action-id")).toBe("request"); + }); + + it("collapses multiple item actions into a per-item menu", () => { + renderList({ + id: "work", + type: "list", + items: [ + { + id: "one", + text: "One", + actions: [ + { id: "start", label: "Start", intent: "start" }, + { id: "reassign", label: "Reassign", intent: "reassign" }, + ], + }, + ], + }); + + expect(screen.queryByRole("button", { name: /Start/ })).toBeNull(); expect( - screen.getByRole("button", { name: /Request approval/ }).className - ).toContain("whitespace-normal"); + screen.getByRole("button", { name: "Actions for One" }) + ).toBeTruthy(); }); it("collapses then expands the structured long-list tail", () => { @@ -160,11 +173,11 @@ describe("ListBlockView v2", () => { { id: "one", text: "One", - action: { id: "approve", label: "Approve", intent: "approve" }, + actions: [{ id: "approve", label: "Approve", intent: "approve" }], }, ], }); - fireEvent.click(screen.getByRole("button", { name: "Approve" })); + fireEvent.click(screen.getByRole("button", { name: "Approve for One" })); expect(mutate).toHaveBeenCalledWith( expect.objectContaining({ kind: "action", diff --git a/apps/web/src/components/app/agent-surfaces/blocks/list-block.tsx b/apps/web/src/components/app/agent-surfaces/blocks/list-block.tsx index 5017dd76d..e4df1de0a 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/list-block.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/list-block.tsx @@ -1,5 +1,5 @@ import { Fragment, useId, useState } from "react"; -import { ArrowUpRight, Check, ChevronDown, Minus } from "lucide-react"; +import { ArrowUpRight, Check, ChevronDown, Circle } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Markdown } from "@/components/ui/markdown"; @@ -9,8 +9,9 @@ import type { SurfaceListItem, } from "@/components/app/agent-surfaces/types"; import { BlockHeader } from "@/components/app/agent-surfaces/blocks/block-header"; -import { ItemAction } from "@/components/app/agent-surfaces/blocks/item-action"; +import { ItemActions } from "@/components/app/agent-surfaces/blocks/item-actions"; import type { SurfaceInteractionIndex } from "@/components/app/agent-surfaces/interaction-presentation"; +import { humanizeLabel } from "@/components/app/agent-surfaces/format"; import { isAllowedSurfaceUrl } from "@/components/app/agent-surfaces/surface-url"; import { TONE_CLASSES } from "@/components/app/agent-surfaces/tone"; @@ -26,38 +27,19 @@ function ListItemRow({ index: number; block: ListBlock; interactionProps: Omit< - React.ComponentProps, - "action" | "itemId" | "blockId" + React.ComponentProps, + "actions" | "itemId" | "blockId" | "itemLabel" >; }): JSX.Element { const statusTone = item.tone ?? "neutral"; - const content = ( - <> -
- - {item.text} - - {item.url && isAllowedSurfaceUrl(item.url) ? ( - - - ) : null} -
- {item.detail ? ( - {item.detail} - ) : null} - + const linked = item.url && isAllowedSurfaceUrl(item.url); + const text = ( + + {item.text} + ); return ( @@ -70,18 +52,18 @@ function ListItemRow({ aria-label="Completed" className={cn( "mt-0.5 h-3.5 w-3.5 shrink-0 stroke-[2.5]", - TONE_CLASSES[statusTone].text + TONE_CLASSES[item.tone ?? "success"].text )} /> ) : ( - ) ) : ( @@ -90,30 +72,51 @@ function ListItemRow({ )}
- {content} - {item.status || item.action ? ( -
- {item.status ? ( - + + {item.actions?.length ? ( + + ) : null} +
+ {item.detail ? ( + {item.detail} + ) : null} + {item.status ? ( +
+ + {humanizeLabel(item.status)} +
) : null}
@@ -165,7 +168,7 @@ export function ListBlockView({ {showGroup ? (
  • {group}
  • diff --git a/apps/web/src/components/app/agent-surfaces/blocks/progress-block.tsx b/apps/web/src/components/app/agent-surfaces/blocks/progress-block.tsx index 69cb9c1b3..9b9d671c8 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/progress-block.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/progress-block.tsx @@ -11,28 +11,38 @@ export function ProgressBlockView({ }: { block: ProgressBlock; }): JSX.Element { - // Unspecified tone defaults to success — an agent posting a progress bar - // with no explicit tone is reporting normal, on-track progress. - const tone = block.tone ?? "success"; + // Unspecified tone is a neutral fill: a saturated bar reads as interactive + // (it lands in the same hue as primary buttons), so color is reserved for + // explicitly authored warning states. + const tone = block.tone ?? "neutral"; const max = block.max > 0 ? block.max : 1; const clamped = Math.min(Math.max(block.value, 0), max); const percent = Math.round((clamped / max) * 100); + // The label line carries the number; the bar carries the shape. When title + // and label are both present the label wins the line next to the percent. + const line = block.label ?? block.title; return (
    - - {block.label ? ( -
    - {block.label} -
    - ) : null} + +
    + + {line} + + + {percent}% + +
    Promise; + readOnly: boolean; + idPrefix: string; }): JSX.Element { const contentId = useId(); const collapsible = block.collapse !== undefined; const [expanded, setExpanded] = useState(!block.collapse?.initiallyCollapsed); + const label = ( + + {block.title} + + ); + return (
    - {collapsible ? ( -
    +
    + {collapsible ? (

    - {block.description ? ( - - {block.description} - - ) : null} -
    - ) : ( - - )} + ) : ( +

    {label}

    + )} + {block.description ? ( + + {block.description} + + ) : null} +
    ); diff --git a/apps/web/src/components/app/agent-surfaces/blocks/slot-actions.tsx b/apps/web/src/components/app/agent-surfaces/blocks/slot-actions.tsx new file mode 100644 index 000000000..e4757bb67 --- /dev/null +++ b/apps/web/src/components/app/agent-surfaces/blocks/slot-actions.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { ChevronDown, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { + makeIdempotencyKey, + useSubmitSurfaceInteraction, +} from "@/hooks/use-agent-surfaces"; +import type { ActionRef } from "@/components/app/agent-surfaces/types"; +import { + ActionRefButton, + actionButtonVariant, +} from "@/components/app/agent-surfaces/blocks/action-ref-button"; +import { ActionConfirmDialog } from "@/components/app/agent-surfaces/blocks/action-confirm-dialog"; +import { ActionFeedback } from "@/components/app/agent-surfaces/blocks/interaction-status-caption"; +import { + IDLE_INTERACTION_STATE, + useKeyedInteractionState, +} from "@/components/app/agent-surfaces/local-interaction-state"; +import { + findInteraction, + resolveInteractionPresentation, + type InteractionPresentation, + type SurfaceInteractionIndex, +} from "@/components/app/agent-surfaces/interaction-presentation"; + +/** + * Renders a slot's actions (document footer or section footer) under the + * renderer-owned emphasis policy: + * + * - one action → one compact button; + * - two actions, neither destructive → two compact buttons; + * - otherwise → a split button: the main verb plus a chevron menu holding + * the rest, destructive verbs last. A destructive action never renders as + * a standalone loud button while quieter verbs exist — an irreversible + * verb should be findable, not the brightest object on screen. + * + * The main verb is the slot's `primary` action when one exists, else its + * first non-destructive action, else (all-destructive slot) the first action. + */ +export function SlotActions({ + blockId, + actions, + agentId, + surfaceId, + surfaceRevision, + interactions, + onRequestRefresh, + readOnly, + idPrefix, +}: { + /** Interaction address: a section id, or the reserved "footer". */ + blockId: string; + actions: ActionRef[]; + agentId: string; + surfaceId: string; + surfaceRevision: number; + interactions: SurfaceInteractionIndex; + onRequestRefresh: () => Promise; + readOnly: boolean; + idPrefix: string; +}): JSX.Element { + const [confirmAction, setConfirmAction] = useState(null); + const mutation = useSubmitSurfaceInteraction(agentId, surfaceId); + const { states, submit, clear } = useKeyedInteractionState( + surfaceRevision, + mutation.mutate + ); + + const presentationOf = (action: ActionRef): InteractionPresentation => + resolveInteractionPresentation({ + local: states[action.id] ?? IDLE_INTERACTION_STATE, + durable: findInteraction(interactions, blockId, action.id), + surfaceRevision, + mode: "action", + readOnly, + }); + + const runAction = (action: ActionRef) => { + submit( + action.id, + { + idempotencyKey: makeIdempotencyKey(), + kind: "action", + blockId, + actionId: action.id, + baseRevision: surfaceRevision, + }, + "Couldn't send this action" + ); + }; + + const handleClick = (action: ActionRef) => { + if (action.disabled || readOnly) return; + if (presentationOf(action).locked) return; + if (action.confirm) { + setConfirmAction(action); + return; + } + runAction(action); + }; + + const main = + actions.find((action) => action.style === "primary") ?? + actions.find((action) => action.style !== "destructive") ?? + actions[0]; + const rest = actions.filter((action) => action.id !== main.id); + const menuNeeded = + rest.length > 1 || rest.some((action) => action.style === "destructive"); + const menuActions = menuNeeded + ? [ + ...rest.filter((action) => action.style !== "destructive"), + ...rest.filter((action) => action.style === "destructive"), + ] + : []; + const hasDestructiveSeparator = + menuActions.some((action) => action.style === "destructive") && + menuActions.some((action) => action.style !== "destructive"); + const mainPresentation = presentationOf(main); + const anyMenuBusy = menuActions.some((action) => presentationOf(action).busy); + + return ( +
    +
    + {menuNeeded ? ( +
    + handleClick(main)} + /> + + + + + + {menuActions.map((action, index) => { + const presentation = presentationOf(action); + const destructive = action.style === "destructive"; + return ( +
    + {destructive && + hasDestructiveSeparator && + menuActions[index - 1]?.style !== "destructive" ? ( + + ) : null} + handleClick(action)} + > + {presentation.busy ? ( + + ) : null} + {action.label} + +
    + ); + })} +
    +
    +
    + ) : ( + [main, ...rest].map((action) => { + const presentation = presentationOf(action); + return ( + handleClick(action)} + /> + ); + }) + )} +
    + {actions.map((action) => { + const presentation = presentationOf(action); + if (!presentation.caption && !action.disabled) return null; + return ( +
    + { + void onRequestRefresh().then(() => clear(action.id)); + }} + /> +
    + ); + })} + setConfirmAction(null)} + onConfirm={(action) => { + setConfirmAction(null); + runAction(action); + }} + /> +
    + ); +} diff --git a/apps/web/src/components/app/agent-surfaces/blocks/status-block.tsx b/apps/web/src/components/app/agent-surfaces/blocks/status-block.tsx index 1135b68a8..931b383aa 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/status-block.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/status-block.tsx @@ -2,17 +2,21 @@ import { Markdown } from "@/components/ui/markdown"; import { cn } from "@/lib/utils"; import type { StatusBlock } from "@/components/app/agent-surfaces/types"; import { BlockHeader } from "@/components/app/agent-surfaces/blocks/block-header"; +import { formatSurfaceTime } from "@/components/app/agent-surfaces/format"; import { TONE_CLASSES } from "@/components/app/agent-surfaces/tone"; /** A plain readout, not a bordered box — status is informational, and a * rounded/bordered container reads too much like a button or input field. A - * tone dot plus text keeps the non-interactive signal clear. */ + * tone dot plus text keeps the non-interactive signal clear. The timestamp + * sits inline after the status label (relative, absolute on hover) so the + * least important datum on the row never pulls the eye across empty space. */ export function StatusBlockView({ block, }: { block: StatusBlock; }): JSX.Element { const tone = block.tone ?? "neutral"; + const time = block.timestamp ? formatSurfaceTime(block.timestamp) : null; return (
    @@ -25,18 +29,19 @@ export function StatusBlockView({ )} />
    -
    +
    {block.status} - {block.timestamp ? ( + {time ? ( ) : null}
    diff --git a/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx b/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx index 6f2cd7c3a..3cd659845 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx @@ -89,27 +89,21 @@ describe("TableBlockView URL cells", () => { name: "Show details for https://example.com", }); expect(disclosure.className).toContain("h-6"); - expect(disclosure.className).toContain("w-full"); - expect(disclosure.className).toContain("h-8"); expect(disclosure.className).toContain("[@media(pointer:coarse)]:min-h-11"); - expect(disclosure.textContent).toContain("Show"); const detailsId = disclosure.getAttribute("aria-controls"); expect(detailsId).toBeTruthy(); const detailsRow = document.getElementById(detailsId!); expect(detailsRow?.hidden).toBe(true); - expect(detailsRow?.className).toContain("hidden"); fireEvent.click(disclosure); expect(screen.getByText("More information")).not.toBeNull(); expect(detailsRow?.hidden).toBe(false); - expect(detailsRow?.className).toContain("md:table-row"); fireEvent.click( screen.getByRole("button", { name: "Hide details for https://example.com", }) ); expect(detailsRow?.hidden).toBe(true); - expect(detailsRow?.className).toContain("hidden"); }); it("preserves authored calendar days when formatting date-only values", () => { @@ -171,7 +165,7 @@ describe("TableBlockView URL cells", () => { ).toBeNull(); }); - it("keeps row actions inline at a safe width and submits with the row id", () => { + it("keeps row actions compact and submits with the row id", () => { const block: TableBlock = { id: "deployments", type: "table", @@ -185,35 +179,23 @@ describe("TableBlockView URL cells", () => { { id: "one", cells: { name: "One", detail: "First detail" }, - action: { id: "approve", label: "Approve", intent: "approve" }, + actions: [{ id: "approve", label: "Approve", intent: "approve" }], }, { id: "two", cells: { name: "Two", detail: "Second detail" } }, ], }; - const { container } = renderTable(block); + renderTable(block); expect(screen.getByText("2")).toBeTruthy(); - expect(screen.getByRole("columnheader", { name: "Action" })).toBeTruthy(); const rows = document.querySelectorAll("tbody tr[data-row-id]"); expect(rows[0].querySelectorAll("td").length).toBe( rows[1].querySelectorAll("td").length ); - const actionCell = rows[0].querySelector("td:last-child"); - expect(actionCell?.className).toContain("md:min-w-32"); - expect(actionCell?.querySelector("button")?.className).toContain( - "md:whitespace-nowrap" - ); - expect(actionCell?.querySelector("button")?.className).toContain("min-h-8"); - expect(rows[0].className).toContain("grid"); - expect(rows[0].className).toContain("md:table-row"); - expect(container.querySelector("table")?.className).toContain("md:table"); - expect(actionCell?.className).toContain("order-3"); - const disclosureCell = rows[0].querySelector("td:first-child"); - expect(disclosureCell?.className).toContain("order-2"); - expect(disclosureCell?.textContent).toContain("Show"); - expect(rows[1].querySelector("td:last-child")?.className).toContain( - "hidden" - ); - fireEvent.click(screen.getByRole("button", { name: "Approve for One" })); + const actionButton = screen.getByRole("button", { + name: "Approve for One", + }); + // Ghost row affordance: no extra row height, quiet by default. + expect(actionButton.className).toContain("h-6"); + fireEvent.click(actionButton); expect(mutate).toHaveBeenCalledWith( expect.objectContaining({ itemId: "one", actionId: "approve" }), expect.any(Object) @@ -229,12 +211,12 @@ describe("TableBlockView URL cells", () => { { id: "one", cells: { name: "Canary" }, - action: { id: "retry", label: "Retry", intent: "retry" }, + actions: [{ id: "retry", label: "Retry", intent: "retry" }], }, { id: "two", cells: { name: "Production" }, - action: { id: "retry", label: "Retry", intent: "retry" }, + actions: [{ id: "retry", label: "Retry", intent: "retry" }], }, ], }); @@ -246,4 +228,47 @@ describe("TableBlockView URL cells", () => { screen.getByRole("button", { name: "Retry for Production" }) ).toBeTruthy(); }); + + it("renders a 2-column action-free table as a key/value list", () => { + renderTable({ + id: "impact", + type: "table", + columns: [ + { id: "metric", label: "Metric" }, + { id: "value", label: "Value" }, + ], + rows: [ + { id: "duration", cells: { metric: "Duration", value: "47 minutes" } }, + { + id: "revenue", + cells: { metric: "Revenue impact", value: "$18,600" }, + }, + ], + }); + + // No table chrome: the boilerplate header row disappears entirely. + expect(document.querySelector("table")).toBeNull(); + expect(screen.queryByText("Metric")).toBeNull(); + expect(screen.getByText("Duration")).toBeTruthy(); + expect(screen.getByText("$18,600")).toBeTruthy(); + }); + + it("demotes columns past the 3-primary budget behind the row disclosure", () => { + renderTable({ + id: "wide", + type: "table", + columns: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + { id: "d", label: "D" }, + ], + rows: [{ id: "one", cells: { a: "1", b: "2", c: "3", d: "4" } }], + }); + + expect(screen.queryByRole("columnheader", { name: "D" })).toBeNull(); + expect( + screen.getByRole("button", { name: /Show details for/ }) + ).toBeTruthy(); + }); }); diff --git a/apps/web/src/components/app/agent-surfaces/blocks/table-block.tsx b/apps/web/src/components/app/agent-surfaces/blocks/table-block.tsx index b3cb82e22..837e20a89 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/table-block.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/table-block.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react"; +import { Fragment, useId, useState } from "react"; import { ChevronRight } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -10,11 +10,20 @@ import type { TableColumn, } from "@/components/app/agent-surfaces/types"; import { BlockHeader } from "@/components/app/agent-surfaces/blocks/block-header"; -import { ItemAction } from "@/components/app/agent-surfaces/blocks/item-action"; +import { ItemActions } from "@/components/app/agent-surfaces/blocks/item-actions"; import type { SurfaceInteractionIndex } from "@/components/app/agent-surfaces/interaction-presentation"; +import { + formatSurfaceTime, + humanizeLabel, +} from "@/components/app/agent-surfaces/format"; import { isAllowedSurfaceUrl } from "@/components/app/agent-surfaces/surface-url"; import { TONE_CLASSES } from "@/components/app/agent-surfaces/tone"; +/** The rail is a fixed 400px, so the practical budget is 3 visible columns; + * the schema enforces it for v2 documents and this constant is the renderer's + * defensive fallback for anything that slips through. */ +const MAX_PRIMARY_COLUMNS = 3; + function formatCell(value: Scalar, format: TableColumn["format"]): string { if (value === null || value === undefined) return "—"; if (format === "date" && typeof value === "string") { @@ -26,7 +35,8 @@ function formatCell(value: Scalar, format: TableColumn["format"]): string { if (parsed.toISOString().slice(0, 10) !== value) return value; return parsed.toLocaleDateString(undefined, { timeZone: "UTC" }); } - return parsed.toLocaleDateString(); + // Datetime instants get the renderer-owned compact treatment. + return formatSurfaceTime(value).text; } return String(value); } @@ -45,9 +55,12 @@ function Cell({ return ( - {text} + {humanizeLabel(text)} ); } @@ -77,10 +90,50 @@ function Cell({ return {text}; } -/** One row: primary columns stay visible and reflow into a labeled card when - * the containing rail is narrow. Secondary columns remain behind a disclosure. - * Agents should reserve `secondary` for verbose diagnostics; decision-critical - * values belong in `primary`. */ +function cellAlignment(column: TableColumn): string | undefined { + // Numbers right-align automatically so magnitudes line up; an explicit + // align always wins. + if (column.align === "right") return "text-right"; + if (column.align === "left") return "text-left"; + if (column.format === "number") return "text-right"; + return undefined; +} + +/** A 2-column, action-free table is a key/value list wearing table chrome — + * render it as the stat list it is: dim keys left, values emphasized right, + * no header row, no rules. */ +function KeyValueView({ + block, + keyColumn, + valueColumn, +}: { + block: TableBlock; + keyColumn: TableColumn; + valueColumn: TableColumn; +}): JSX.Element { + return ( +
    + {block.rows.map((row) => ( +
    +
    + +
    +
    + +
    +
    + ))} +
    + ); +} + function TableRowView({ row, primaryColumns, @@ -94,8 +147,8 @@ function TableRowView({ secondaryColumns: TableColumn[]; block: TableBlock; interactionProps: Omit< - React.ComponentProps, - "action" | "itemId" | "blockId" + React.ComponentProps, + "actions" | "itemId" | "blockId" | "itemLabel" >; hasActionColumn: boolean; }): JSX.Element { @@ -107,22 +160,16 @@ function TableRowView({ ); return ( - <> + {secondaryColumns.length > 0 ? ( - + ) : null} - {primaryColumns.map((column) => ( + {primaryColumns.map((column, index) => ( 0 && secondaryColumns.length === 0 && "pl-0", + cellAlignment(column) )} > - - {column.label} - - - - + ))} {hasActionColumn ? ( - - {row.action ? ( - + {row.actions?.length ? ( + ) : null} @@ -187,20 +219,17 @@ function TableRowView({ -
    +
    {secondaryColumns.map((column) => (
    {column.label}: @@ -217,7 +246,7 @@ function TableRowView({ ) : null} - + ); } @@ -234,17 +263,30 @@ export function TableBlockView({ readOnly: boolean; idPrefix: string; }): JSX.Element { - const primaryColumns = block.columns.filter( + const authoredPrimary = block.columns.filter( (c) => c.priority !== "secondary" ); - const secondaryColumns = block.columns.filter( - (c) => c.priority === "secondary" - ); + // Defensive demotion past the budget; column 1 (row identity) never demotes. + const primaryColumns = authoredPrimary.slice(0, MAX_PRIMARY_COLUMNS); + const secondaryColumns = [ + ...authoredPrimary.slice(MAX_PRIMARY_COLUMNS), + ...block.columns.filter((c) => c.priority === "secondary"), + ]; // A table with only secondary columns still needs something visible. const effectivePrimary = - primaryColumns.length > 0 ? primaryColumns : block.columns; - const effectiveSecondary = primaryColumns.length > 0 ? secondaryColumns : []; - const hasActionColumn = block.rows.some((row) => row.action); + primaryColumns.length > 0 + ? primaryColumns + : block.columns.slice(0, MAX_PRIMARY_COLUMNS); + const effectiveSecondary = + primaryColumns.length > 0 + ? secondaryColumns + : block.columns.slice(MAX_PRIMARY_COLUMNS); + const hasActionColumn = block.rows.some((row) => row.actions?.length); + const isKeyValue = + block.columns.length === 2 && + effectiveSecondary.length === 0 && + !hasActionColumn; + return (
    -
    -
    - - - - {effectiveSecondary.length > 0 ? ( - - ))} - {hasActionColumn ? ( - - ) : null} - - - - {block.rows.map((row) => ( - + {isKeyValue ? ( + + ) : ( +
    - ) : null} - {effectivePrimary.map((column) => ( - - {column.label} - - Action -
    + + + {effectiveSecondary.length > 0 ? ( + ))} - -
    + ) : null} + {effectivePrimary.map((column) => ( + + {column.label} +
    -
    -
    + {hasActionColumn ? : null} + + + + {block.rows.map((row) => ( + + ))} + + + )}
    ); } diff --git a/apps/web/src/components/app/agent-surfaces/blocks/text-block.tsx b/apps/web/src/components/app/agent-surfaces/blocks/text-block.tsx index 30354295d..babd42a7b 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/text-block.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/text-block.tsx @@ -1,14 +1,50 @@ import { Markdown } from "@/components/ui/markdown"; +import { cn } from "@/lib/utils"; import type { TextBlock } from "@/components/app/agent-surfaces/types"; import { BlockHeader } from "@/components/app/agent-surfaces/blocks/block-header"; +import { TONE_CLASSES } from "@/components/app/agent-surfaces/tone"; +/** Plain prose by default; a toned text block renders as a callout — the one + * primitive for "this sentence changes a decision". */ export function TextBlockView({ block }: { block: TextBlock }): JSX.Element { + const tone = block.tone; + if (!tone || tone === "neutral") { + return ( +
    + + + {block.text} + +
    + ); + } return ( -
    - - - {block.text} - +
    +
    + {block.title ? ( +

    + {block.title} +

    + ) : null} + {block.description ? ( + + {block.description} + + ) : null} + + {block.text} + +
    ); } diff --git a/apps/web/src/components/app/agent-surfaces/format.ts b/apps/web/src/components/app/agent-surfaces/format.ts new file mode 100644 index 000000000..6c6155b1a --- /dev/null +++ b/apps/web/src/components/app/agent-surfaces/format.ts @@ -0,0 +1,62 @@ +/** Renderer-owned formatting for surface values. Agents supply meaning + * (ISO timestamps, enum-ish tokens); the renderer owns how they read. */ + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** Relative under 7 days, compact absolute beyond, never seconds. */ +export function formatSurfaceTime( + iso: string, + now: Date = new Date() +): { text: string; absolute: string } { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return { text: iso, absolute: iso }; + const absolute = parsed.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + const delta = now.getTime() - parsed.getTime(); + if (delta < 0) { + // Future instants (deadlines, scheduled work) read as absolutes. + return { text: absolute, absolute }; + } + if (delta < MINUTE) return { text: "just now", absolute }; + if (delta < HOUR) + return { text: `${Math.floor(delta / MINUTE)}m ago`, absolute }; + if (delta < DAY) + return { text: `${Math.floor(delta / HOUR)}h ago`, absolute }; + if (delta < 7 * DAY) + return { text: `${Math.floor(delta / DAY)}d ago`, absolute }; + const sameYear = parsed.getFullYear() === now.getFullYear(); + return { + text: parsed.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + ...(sameYear ? {} : { year: "numeric" }), + }), + absolute, + }; +} + +const SNAKE_CASE = /^[A-Za-z][A-Za-z0-9]*(_[A-Za-z0-9]+)+$/; +const ALL_CAPS_WORD = /^[A-Z][A-Z0-9]{2,23}$/; + +/** + * Humanizes machine tokens that leak into visible labels: snake_case and + * single ALL-CAPS words become sentence case ("IN_PROGRESS" → "In progress"). + * The guard is deliberately narrow so real values pass through verbatim — + * "v0.38.0-rc.2", "CI PASSING", and anything with whitespace are untouched. + */ +export function humanizeLabel(value: string): string { + if (/\s/.test(value)) return value; + if (SNAKE_CASE.test(value)) { + const words = value.split("_").join(" ").toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); + } + if (ALL_CAPS_WORD.test(value)) { + const lower = value.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); + } + return value; +} diff --git a/apps/web/src/components/app/agent-surfaces/surface-panel.test.tsx b/apps/web/src/components/app/agent-surfaces/surface-panel.test.tsx index 828757ee9..2f5925038 100644 --- a/apps/web/src/components/app/agent-surfaces/surface-panel.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/surface-panel.test.tsx @@ -20,7 +20,7 @@ afterEach(() => { function surface(overrides: Partial = {}): Surface { return { - schemaVersion: 1, + schemaVersion: 2, id: "surface-1", ownerAgentId: "agent-1", title: "Details", @@ -42,7 +42,7 @@ function summary( return { id: "ix_1", tabRevision: 1, - blockId: "actions-1", + blockId: "footer", actionId: "go", kind: "action", status: "queued", @@ -63,11 +63,7 @@ function renderPanel(value: Surface) { ); } -const actionsBlock = { - id: "actions-1", - type: "actions" as const, - actions: [{ id: "go", label: "Go", intent: "go" }], -}; +const footer = { actions: [{ id: "go", label: "Go", intent: "go" }] }; const sectionBlock = { id: "section-1", @@ -76,7 +72,25 @@ const sectionBlock = { blocks: [{ id: "note-1", type: "text" as const, text: "Ready to ship." }], }; -describe("SurfacePanel block fallback", () => { +describe("SurfacePanel schema gate", () => { + it("shows a re-create notice for a v1 document instead of rendering it", () => { + renderPanel( + surface({ + schemaVersion: 1, + blocks: [ + { id: "old-1", type: "actions", actions: [] }, + ] as unknown as Surface["blocks"], + }) + ); + + expect( + screen.getByText( + "This tab uses an older surface format. Ask the agent to recreate it." + ) + ).toBeTruthy(); + expect(document.querySelector("[data-block-id]")).toBeNull(); + }); + it("shows a visible message for an unsupported wire block", () => { renderPanel( surface({ @@ -93,11 +107,36 @@ describe("SurfacePanel block fallback", () => { }); }); -describe("SurfacePanel interaction hydration", () => { - it("routes each block's durable record to the matching action on first render", () => { +describe("SurfacePanel document slots", () => { + it("renders the header strip before the blocks and the footer actions last", () => { + renderPanel( + surface({ + header: { + status: { + id: "hs", + type: "status", + status: "Canary deployed", + tone: "info", + }, + progress: { id: "hp", type: "progress", value: 3, max: 10 }, + }, + blocks: [sectionBlock], + footer, + }) + ); + + expect(screen.getByTestId("surface-header").textContent).toContain( + "Canary deployed" + ); + expect(screen.getByRole("progressbar")).toBeTruthy(); + const footerRegion = screen.getByTestId("surface-footer"); + expect(footerRegion.querySelector('[data-action-id="go"]')).toBeTruthy(); + }); + + it("routes a footer action's durable record to the matching button", () => { renderPanel( surface({ - blocks: [actionsBlock], + footer, latestInteractions: [summary({ status: "claimed" })], }) ); @@ -110,10 +149,10 @@ describe("SurfacePanel interaction hydration", () => { ).toContain("In progress"); }); - it("leaves an action untouched when the payload's record belongs to a different block", () => { + it("leaves a footer action untouched when the record belongs to a block", () => { renderPanel( surface({ - blocks: [actionsBlock], + footer, latestInteractions: [ summary({ blockId: "some-other-block", status: "claimed" }), ], @@ -126,11 +165,25 @@ describe("SurfacePanel interaction hydration", () => { expect(screen.queryByTestId("interaction-status-caption")).toBeNull(); }); + it("submits a footer action with the reserved footer block id", () => { + renderPanel(surface({ footer })); + + fireEvent.click(screen.getByRole("button", { name: "Go" })); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "action", + blockId: "footer", + actionId: "go", + }), + expect.any(Object) + ); + }); + it("shows a resolved outcome for a frozen tab, with the control locked", () => { renderPanel( surface({ lifecycle: "frozen", - blocks: [actionsBlock], + footer, latestInteractions: [ summary({ status: "completed", @@ -194,7 +247,7 @@ describe("SurfacePanel sections", () => { expect(screen.getByText("Ready to ship.")).toBeTruthy(); }); - it("starts initially collapsed and preserves durable interaction state when reopened", () => { + it("renders section footer actions and preserves durable state when reopened", () => { renderPanel( surface({ blocks: [ @@ -203,10 +256,13 @@ describe("SurfacePanel sections", () => { type: "section", title: "Deploy", collapse: { initiallyCollapsed: true }, - blocks: [actionsBlock], + blocks: [{ id: "note-1", type: "text", text: "Ready to ship." }], + actions: [{ id: "go", label: "Go", intent: "go" }], }, ] as Surface["blocks"], - latestInteractions: [summary({ status: "claimed" })], + latestInteractions: [ + summary({ blockId: "section-1", status: "claimed" }), + ], }) ); @@ -217,7 +273,6 @@ describe("SurfacePanel sections", () => { expect(screen.getByRole("heading", { name: "Deploy" })).toBeTruthy(); expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(content.hidden).toBe(true); - expect(screen.queryByRole("button", { name: "Go" })).toBeNull(); fireEvent.click(toggle); expect(content.hidden).toBe(false); @@ -229,6 +284,29 @@ describe("SurfacePanel sections", () => { ).toContain("In progress"); }); + it("submits a section action with the section's block id", () => { + renderPanel( + surface({ + blocks: [ + { + ...sectionBlock, + actions: [{ id: "refresh", label: "Refresh", intent: "refresh" }], + }, + ] as Surface["blocks"], + }) + ); + + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "action", + blockId: "section-1", + actionId: "refresh", + }), + expect.any(Object) + ); + }); + it("preserves an unsubmitted nested form draft when the section is reopened", () => { renderPanel( surface({ diff --git a/apps/web/src/components/app/agent-surfaces/surface-panel.tsx b/apps/web/src/components/app/agent-surfaces/surface-panel.tsx index 9d62b3a14..89efbebbd 100644 --- a/apps/web/src/components/app/agent-surfaces/surface-panel.tsx +++ b/apps/web/src/components/app/agent-surfaces/surface-panel.tsx @@ -5,6 +5,10 @@ import type { Surface, SurfaceBlock, } from "@/components/app/agent-surfaces/types"; +import { + SURFACE_FOOTER_BLOCK_ID, + SURFACE_SCHEMA_VERSION, +} from "@/components/app/agent-surfaces/types"; import { indexInteractions, type SurfaceInteractionIndex, @@ -14,9 +18,9 @@ import { ListBlockView } from "@/components/app/agent-surfaces/blocks/list-block import { TableBlockView } from "@/components/app/agent-surfaces/blocks/table-block"; import { StatusBlockView } from "@/components/app/agent-surfaces/blocks/status-block"; import { ProgressBlockView } from "@/components/app/agent-surfaces/blocks/progress-block"; -import { ActionsBlockView } from "@/components/app/agent-surfaces/blocks/actions-block"; import { FormBlockView } from "@/components/app/agent-surfaces/blocks/form-block"; import { SectionBlockView } from "@/components/app/agent-surfaces/blocks/section-block"; +import { SlotActions } from "@/components/app/agent-surfaces/blocks/slot-actions"; function UnsupportedBlockView({ blockType, @@ -82,9 +86,9 @@ function BlockRenderer({ return ; case "progress": return ; - case "actions": + case "form": return ( - ); - case "form": + case "section": return ( - - ); - case "section": - return ( - + > {block.blocks.map((child) => ( + +

    + This tab uses an older surface format. Ask the agent to recreate it. +

    +
    + ); + } + + const readOnly = surface.lifecycle === "frozen"; + const shared = { + agentId, + surfaceId: surface.id, + surfaceRevision: surface.revision, + interactions, + onRequestRefresh, + readOnly, + idPrefix, + }; + const hasHeader = !!(surface.header?.status || surface.header?.progress); + return (
    - {surface.lifecycle === "frozen" ? ( -

    + {readOnly ? ( +

    This tab is archived and read-only.

    ) : null} - {surface.blocks.length === 0 ? ( + {hasHeader ? ( +
    + {surface.header?.status ? ( + + ) : null} + {surface.header?.progress ? ( + + ) : null} +
    + ) : null} + {surface.blocks.length === 0 && !hasHeader ? (

    Nothing here yet.

    ) : ( - surface.blocks.map((block) => ( - - )) +
    + {surface.blocks.map((block, index) => ( +
    0 ? "mt-5 border-t border-border/30 pt-5" : undefined + } + > + +
    + ))} +
    )} + {surface.footer?.actions.length ? ( +
    + +
    + ) : null}
    ); } diff --git a/apps/web/src/components/app/agent-surfaces/tone.ts b/apps/web/src/components/app/agent-surfaces/tone.ts index 02af6d38e..591a0d178 100644 --- a/apps/web/src/components/app/agent-surfaces/tone.ts +++ b/apps/web/src/components/app/agent-surfaces/tone.ts @@ -5,38 +5,52 @@ export type ToneClasses = { dot: string; text: string; bar: string; + /** + * Badge weights are deliberately uneven: color budget belongs to the + * exception. `danger`/`warning` keep the filled pill, `info` is outlined, + * `success` is a dim label with no pill chrome, and `neutral` is plain + * muted text — so a category or a healthy state never competes with the + * one failing value the user is scanning for. + */ badge: string; + /** Left-rule callout treatment used by toned text blocks. */ + callout: string; }; export const TONE_CLASSES: Record = { neutral: { dot: "bg-foreground/50", text: "text-foreground", - bar: "bg-foreground/60", - badge: "border-border bg-muted text-muted-foreground", + bar: "bg-foreground/40", + badge: "border-transparent bg-transparent px-0 text-muted-foreground", + callout: "border-l-foreground/30 bg-muted/40", }, info: { dot: "bg-status-done", text: "text-status-done", bar: "bg-status-done", - badge: "border-status-done/40 bg-status-done/10 text-status-done", + badge: "border-status-done/30 bg-transparent text-status-done/80", + callout: "border-l-status-done bg-status-done/[0.06]", }, success: { dot: "bg-status-working", text: "text-status-working", bar: "bg-status-working", - badge: "border-status-working/40 bg-status-working/10 text-status-working", + badge: "border-transparent bg-transparent px-0 text-status-working/70", + callout: "border-l-status-working bg-status-working/[0.06]", }, warning: { dot: "bg-status-waiting", text: "text-status-waiting", bar: "bg-status-waiting", badge: "border-status-waiting/40 bg-status-waiting/10 text-status-waiting", + callout: "border-l-status-waiting bg-status-waiting/[0.06]", }, danger: { dot: "bg-status-blocked", text: "text-status-blocked", bar: "bg-status-blocked", badge: "border-status-blocked/40 bg-status-blocked/10 text-status-blocked", + callout: "border-l-status-blocked bg-status-blocked/[0.06]", }, }; diff --git a/apps/web/src/components/app/agent-surfaces/types.ts b/apps/web/src/components/app/agent-surfaces/types.ts index 7f7f13c62..6b3d66b68 100644 --- a/apps/web/src/components/app/agent-surfaces/types.ts +++ b/apps/web/src/components/app/agent-surfaces/types.ts @@ -1,6 +1,5 @@ export type { ActionRef, - ActionsBlock, FormBlock, FormField, FormFieldOption, @@ -11,6 +10,8 @@ export type { StatusBlock, Surface, SurfaceBlock, + SurfaceFooter, + SurfaceHeader, SurfaceIcon, SurfaceInteraction, SurfaceInteractionRequest, @@ -20,9 +21,14 @@ export type { SurfaceItemAction, SurfaceLifecycle, SurfaceListItem, + SurfaceSubmitAction, TableBlock, TableColumn, TableRow, TextBlock, Tone, } from "@dispatch/shared"; +export { + SURFACE_FOOTER_BLOCK_ID, + SURFACE_SCHEMA_VERSION, +} from "@dispatch/shared"; diff --git a/apps/web/src/components/app/docs-sections/agent-surfaces.tsx b/apps/web/src/components/app/docs-sections/agent-surfaces.tsx index 523601d6c..5a8cdc30e 100644 --- a/apps/web/src/components/app/docs-sections/agent-surfaces.tsx +++ b/apps/web/src/components/app/docs-sections/agent-surfaces.tsx @@ -26,20 +26,35 @@ export function AgentSurfacesContent() {

    +
    +

    Document layout

    +

    + Every surface shares one geography: an optional header{" "} + (the headline status and progress strip) renders first, the{" "} + blocks body follows, and an optional footer{" "} + holds the surface's action buttons — rendered as a compact split + button whose extra and destructive verbs live in an overflow menu. + Sections can carry their own footer actions scoped to their group. +

    +
    +

    Block types

    - A surface is built from blocks: text for short Markdown,{" "} - status for one current state with a tone,{" "} - progress for a bounded progress bar, list{" "} - for bullets, steps, or checklist-style items (each optionally carrying - a status, a link, or its own action), table for compact - repeated data with badge-formatted cells, actions for up - to six one-click commands, form for text, textarea, - number, checkbox, radio, and select fields submitted together, and{" "} - section to group related blocks under a title, optionally - collapsed. Secondary table columns stay hidden behind a per-row - disclosure until you expand it. + The body is built from blocks: text for short Markdown (a{" "} + tone renders it as a callout), status for + one current state with a tone, progress for a bounded + progress bar, list for bullets, steps, or checklist-style + items (each optionally carrying a status, a link, or its own actions — + one renders inline, more become a per-item menu), table{" "} + for compact repeated data with badge-formatted cells (up to three + visible columns; two-column tables render as a key/value list),{" "} + form for text, textarea, number, checkbox, radio, and + select fields submitted together, and section to group + related blocks under a title, optionally collapsed. Secondary table + columns stay hidden behind a per-row disclosure until you expand it. A + tab authored under an older surface format shows a notice asking the + agent to recreate it.

    diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index e81ce86a4..c6a0c9b35 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -31,6 +31,18 @@ const DropdownMenuContent = React.forwardRef< )); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -50,5 +62,6 @@ export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, }; diff --git a/e2e/agent-surfaces.spec.ts b/e2e/agent-surfaces.spec.ts index ee66fd67b..c67c8cf29 100644 --- a/e2e/agent-surfaces.spec.ts +++ b/e2e/agent-surfaces.spec.ts @@ -29,19 +29,17 @@ async function seedSurfaces(agentId: string): Promise { tone: "info", detail: "Both paths passed CI.", }, - { - id: "actions", - type: "actions", - actions: [ - { - id: "canary", - label: "Use canary", - intent: "choose_canary", - style: "primary", - }, - ], - }, ], + footer: { + actions: [ + { + id: "canary", + label: "Use canary", + intent: "choose_canary", + style: "primary", + }, + ], + }, }, { id: `${agentId}-feedback`, @@ -79,7 +77,6 @@ async function seedSurfaces(agentId: string): Promise { id: "submit", label: "Send feedback", intent: "submit_feedback", - style: "primary", }, submitMode: "repeatable", }, @@ -127,11 +124,13 @@ async function seedSurfaces(agentId: string): Promise { tone: "warning", group: "Next steps", url: "https://example.com/runbooks/migration", - action: { - id: "queue-migration", - label: "Queue migration", - intent: "queue_release_migration", - }, + actions: [ + { + id: "queue-migration", + label: "Queue migration", + intent: "queue_release_migration", + }, + ], }, { id: "a11y", @@ -176,11 +175,13 @@ async function seedSurfaces(agentId: string): Promise { { id: "r2", cells: { name: "Deploy", state: "blocked", risk: "high" }, - action: { - id: "retry-deploy", - label: "Retry deploy", - intent: "retry_release_deploy", - }, + actions: [ + { + id: "retry-deploy", + label: "Retry deploy", + intent: "retry_release_deploy", + }, + ], }, ], }, @@ -208,16 +209,19 @@ async function seedSurfaces(agentId: string): Promise { const pool = new Pool({ connectionString, max: 1 }); try { for (const [sortOrder, document] of documents.entries()) { + const withSlots = document as { footer?: unknown; header?: unknown }; await pool.query( - `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, blocks) - VALUES ($1, $2, $3, $4, $5, $6::jsonb)`, + `INSERT INTO agent_surfaces (id, agent_id, title, icon, sort_order, schema_version, header, blocks, footer) + VALUES ($1, $2, $3, $4, $5, 2, $6::jsonb, $7::jsonb, $8::jsonb)`, [ document.id, agentId, document.title, document.icon, sortOrder, + withSlots.header ? JSON.stringify(withSlots.header) : null, JSON.stringify(document.blocks), + withSlots.footer ? JSON.stringify(withSlots.footer) : null, ] ); } @@ -316,7 +320,7 @@ test.describe("Agent-authored sidebar surfaces", () => { // have moved queued -> notified -> claimed by the time this runs. const canaryCaption = sidebar .locator( - '[data-block-type="actions"] [data-testid="interaction-status-caption"]' + '[data-slot-actions="footer"] [data-testid="interaction-status-caption"]' ) .first(); await expect(canaryCaption).toBeVisible(); @@ -355,12 +359,13 @@ test.describe("Agent-authored sidebar surfaces", () => { await tabRow.getByRole("button", { name: "Release work" }).click(); await expect(sidebar.getByText("5 of 8 complete")).toBeVisible(); - // Progress block with no explicit tone renders success (green), not - // neutral gray. + // Progress block with no explicit tone renders a neutral fill — color + // is reserved for explicitly authored states, and the saturated hues + // would read as interactive. const progressBar = sidebar.locator( '[data-block-type="progress"] [role="progressbar"] > div' ); - await expect(progressBar).toHaveClass(/bg-status-working/); + await expect(progressBar).toHaveClass(/bg-foreground\/40/); const releaseDetails = sidebar.getByRole("button", { name: "Release details", @@ -374,19 +379,13 @@ test.describe("Agent-authored sidebar surfaces", () => { await expect(table.getByText("done")).toHaveClass(/text-status-working/); await expect(table.getByText("blocked")).toHaveClass(/text-status-blocked/); - // Primary and disclosure rows use symmetric cell padding and center their - // contents, including mixed text/badge detail lines. + // Rows align on the vertical middle and the disclosure reveals the + // secondary columns without breaking the left alignment spine. const primaryCell = table.locator("tbody tr").first().locator("td").nth(1); await expect(primaryCell).toHaveCSS("vertical-align", "middle"); - await expect(primaryCell).toHaveCSS("padding", "8px"); await table.getByRole("button", { name: "Show details" }).first().click(); const detailCell = table.locator("tbody tr").nth(1).locator("td"); - await expect(detailCell).toHaveCSS("vertical-align", "middle"); - await expect(detailCell).toHaveCSS("padding", "8px"); - await expect(detailCell.locator("dl > div")).toHaveCSS( - "align-items", - "center" - ); + await expect(detailCell.locator("dl > div").first()).toBeVisible(); await tabRow.getByTestId("surface-tabs-more").click(); const releaseRow = page @@ -597,7 +596,7 @@ test.describe("Agent-authored sidebar surfaces", () => { const approvalBadge = list.getByText("Needs approval", { exact: true }); await expect(approvalBadge).toHaveClass(/text-status-waiting/); const migrationLink = list.getByRole("link", { - name: "Open Apply migration", + name: /Apply migration/, }); await expect(migrationLink).toHaveAttribute( "href", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5ae90e728..3b318a172 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,7 +19,6 @@ export type { } from "./diff-types.js"; export type { ActionRef, - ActionsBlock, FormBlock, FormField, FormFieldOption, @@ -31,6 +30,8 @@ export type { SurfaceBlock, SurfaceChangedEvent, SurfaceDocumentInput, + SurfaceFooter, + SurfaceHeader, SurfaceIcon, SurfaceInteraction, SurfaceInteractionRecord, @@ -42,12 +43,17 @@ export type { SurfaceLifecycle, SurfaceListItem, SurfaceSectionBlock, + SurfaceSubmitAction, TableBlock, TableColumn, TableRow, TextBlock, Tone, } from "./surface-types.js"; +export { + SURFACE_FOOTER_BLOCK_ID, + SURFACE_SCHEMA_VERSION, +} from "./surface-types.js"; export type { InjectionHoldState, SharedUiEvent, diff --git a/packages/shared/src/surface-types.ts b/packages/shared/src/surface-types.ts index 0c8edba0c..993f17de2 100644 --- a/packages/shared/src/surface-types.ts +++ b/packages/shared/src/surface-types.ts @@ -1,4 +1,4 @@ -/** Runtime-free wire contract for agent-authored surfaces. */ +/** Runtime-free wire contract for agent-authored surfaces (schema v2). */ export type Scalar = string | number | boolean | null; @@ -21,7 +21,13 @@ type BlockBase = { description?: string; }; -export type TextBlock = BlockBase & { type: "text"; text: string }; +export type TextBlock = BlockBase & { + type: "text"; + text: string; + /** Renders the block as a callout in the tone's color. Reserve for the + * sentence that changes a decision; plain prose omits it. */ + tone?: Tone; +}; /** A compact action scoped to one list item or table row. */ export type SurfaceItemAction = Pick; @@ -39,7 +45,9 @@ export type SurfaceListItem = { url?: string; /** A small subheading that groups adjacent items in this list. */ group?: string; - action?: SurfaceItemAction; + /** One action renders inline on the item's title row; more collapse into a + * per-item overflow menu. Placement and weight are renderer-owned. */ + actions?: SurfaceItemAction[]; }; export type ListBlock = BlockBase & { @@ -57,13 +65,13 @@ export type TableColumn = { label: string; format?: "text" | "number" | "date" | "badge" | "code" | "url"; badgeVariants?: Record; - align?: "left" | "center" | "right"; + align?: "left" | "right"; /** * `secondary` always renders behind a per-row disclosure — the sidebar - * rail is a fixed width, not a responsive breakpoint. Reserve it for - * verbose diagnostics; a decision-critical value (a risk/status badge, - * anything the user needs to compare at a glance) belongs in the - * `primary` default so it's visible without an extra click. + * rail is a fixed width, not a responsive breakpoint. At most 3 columns + * may be primary; reserve `secondary` for verbose diagnostics, and keep + * a decision-critical value (a risk/status badge, anything the user + * compares at a glance) in the `primary` default. */ priority?: "primary" | "secondary"; }; @@ -71,7 +79,9 @@ export type TableColumn = { export type TableRow = { id: string; cells: Record; - action?: SurfaceItemAction; + /** One action renders inline at the row's end; more collapse into a + * per-row overflow menu. */ + actions?: SurfaceItemAction[]; }; export type TableBlock = BlockBase & { @@ -103,6 +113,9 @@ export type ActionRef = { id: string; label: string; intent: string; + /** Semantic weight only — the renderer owns visual treatment. One primary + * per surface; destructive is reserved for irreversible verbs and renders + * de-emphasized (in the overflow menu when other actions exist). */ style?: "default" | "primary" | "destructive"; icon?: SurfaceIcon; confirm?: { title: string; description?: string }; @@ -110,11 +123,9 @@ export type ActionRef = { disabledReason?: string; }; -export type ActionsBlock = BlockBase & { - type: "actions"; - layout?: "auto" | "stack"; - actions: ActionRef[]; -}; +/** A form's submit control. Always rendered as the form's primary action, so + * it carries no style knob. */ +export type SurfaceSubmitAction = Omit; export type FormFieldOption = { value: string; @@ -161,7 +172,7 @@ export type FormField = export type FormBlock = BlockBase & { type: "form"; fields: FormField[]; - submit: ActionRef; + submit: SurfaceSubmitAction; resetLabel?: string; submitMode?: "once" | "repeatable"; }; @@ -172,6 +183,9 @@ export type SurfaceSectionBlock = BlockBase & { /** Required so a collapsed section always retains a visible header. */ title: string; blocks: SurfaceBlock[]; + /** The section's footer slot: verbs that act on this group. Rendered + * right-aligned and compact at the group's bottom edge. */ + actions?: ActionRef[]; collapse?: { initiallyCollapsed?: boolean }; }; @@ -181,14 +195,28 @@ export type SurfaceBlock = | TableBlock | StatusBlock | ProgressBlock - | ActionsBlock | FormBlock | SurfaceSectionBlock; +/** The always-first summary strip: the surface's headline state. */ +export type SurfaceHeader = { + status?: StatusBlock; + progress?: ProgressBlock; +}; + +/** The surface's verbs. Rendered at the document's bottom edge as a compact + * split button with an overflow menu; interactions address it with the + * reserved block id "footer". */ +export type SurfaceFooter = { + actions: ActionRef[]; +}; + export type SurfaceDocumentInput = { title: string; icon?: SurfaceIcon; + header?: SurfaceHeader; blocks: SurfaceBlock[]; + footer?: SurfaceFooter; }; export type SurfaceLifecycle = "active" | "frozen"; @@ -206,6 +234,8 @@ export type SurfaceInteractionRequest = | { idempotencyKey: string; kind: "action"; + /** A block id, a section id, or the reserved id "footer" for the + * document's footer actions. */ blockId: string; actionId: string; /** Required for actions scoped to a list item or table row. */ @@ -237,7 +267,10 @@ export type SurfaceInteractionSummary = { }; export type Surface = { - schemaVersion: 1; + /** Stored documents carry the version they were authored under; the + * sidebar renders only the current version and shows a re-create notice + * for older ones. */ + schemaVersion: number; id: string; ownerAgentId: string; title: string; @@ -245,15 +278,22 @@ export type Surface = { revision: number; lifecycle: SurfaceLifecycle; sortOrder: number; + header?: SurfaceHeader; blocks: SurfaceBlock[]; + footer?: SurfaceFooter; createdAt: string; updatedAt: string; unresolvedInteractionCount: number; latestInteractions: SurfaceInteractionSummary[]; }; +export const SURFACE_SCHEMA_VERSION = 2; + +/** Reserved interaction address for document footer actions. */ +export const SURFACE_FOOTER_BLOCK_ID = "footer"; + export type SurfaceInteractionRecord = { - schemaVersion: 1; + schemaVersion: number; id: string; agentId: string; tabId: string; diff --git a/plugins/dispatch/skills/surfaces/SKILL.md b/plugins/dispatch/skills/surfaces/SKILL.md index 3f1bdf9bd..f6dd18d7d 100644 --- a/plugins/dispatch/skills/surfaces/SKILL.md +++ b/plugins/dispatch/skills/surfaces/SKILL.md @@ -18,19 +18,63 @@ The owning agent authors the document and handles every submitted interaction. - Keep chat for explanation and conversation; do not mirror the transcript into a surface. -Prefer one useful tab over several narrow tabs. A surface is a vertical pane, so -keep tables compact, stack long actions, and avoid dashboard layouts that assume -desktop width. There is no kanban or drag-and-drop primitive; express -lightweight workflow with lists, statuses, tables, and actions. +Prefer one useful tab over several narrow tabs. A surface is a fixed 400px +vertical pane. There is no kanban or drag-and-drop primitive; express +lightweight workflow with lists, statuses, tables, and slot actions. + +## Document geography (slots) + +Every surface shares one layout, so users always know where to look: + +- `header` _(optional)_ — the headline: a `status` and/or `progress` block. + It renders first, above everything. +- `blocks` — the body content. +- `footer` _(optional)_ — `{ actions: [...] }`, THE home for the surface's + verbs. The renderer draws it as a compact split button: the primary verb is + the visible button and the rest live in a chevron menu, destructive verbs + last. + +Sections carry their own optional `actions` footer for verbs scoped to that +group ("Queue migration" at the bottom of the release-details card). List +items and table rows take `actions` too: one renders as a quiet inline +affordance on the title row, two or more collapse into a per-item ⋯ menu. +There is no standalone actions block — actions always live in one of these +slots, and the renderer owns their placement and visual weight. + +## The design contract + +The renderer owns styling — supply meaning, not layout: + +- **One `primary` action per surface.** Leave the rest `default`. Use + `destructive` only for irreversible verbs, always with `confirm`; it renders + quietly and sits in the overflow menu on purpose. +- **Color means state.** Use tone `neutral` for categories (environment, repo, + owner) — it renders as plain text. Reserve `danger`/`warning` for + exceptions; healthy states render dim so the one failing value owns the + color budget. +- **Write human labels** ("Rolled back"), never enum tokens (`ROLLED_BACK`). +- **Tables fit 3 visible columns.** Mark extras `priority: "secondary"` — they + collapse behind a per-row disclosure. A 2-column table renders as a + key/value stat list automatically, so use one for metrics. +- **Repeating one action label down a list is fine** — identical verbs render + as a compact column of affordances, not a stack of buttons. +- **`text` takes a `tone`.** Use it for the one sentence that changes a + decision (risk notes, blocking caveats); leave ordinary prose untoned. +- **Timestamps are ISO strings**; the renderer formats them (relative under a + week, absolute on hover). Don't pre-format dates into prose. +- **Collapse finished work, never the thesis.** A "Done" section starts + `collapse: { initiallyCollapsed: true }`; the root cause of a postmortem + does not. ## Authoring workflow -1. Call `dispatch_surface_create` with a short title, optional icon, and stable, - unique block/item/action/field IDs. +1. Call `dispatch_surface_create` with a short title, optional icon, optional + `header`/`footer`, and stable, unique block/item/action/field IDs. 2. Keep the returned `tabId` and `revision`. 3. Call `dispatch_surface_get` before an update if the current revision is not known, then call `dispatch_surface_update` with `expectedRevision`. Updates - replace the complete `blocks` array. + replace the complete `blocks` array; `header` and `footer` accept `null` to + clear the slot. 4. Treat interaction notices as wake-ups only. Read durable values with `dispatch_surface_interactions`, claim them with `dispatch_surface_claim`, perform the work, then call `dispatch_surface_resolve`. @@ -39,8 +83,8 @@ lightweight workflow with lists, statuses, tables, and actions. Interactions remain queued when the owner is idle or stopped and are surfaced when it resumes. Never infer submitted values from the injected notice. - -Use these interaction shapes: +Footer actions arrive with the reserved `blockId` `"footer"`; section actions +carry the section's id; item and row actions carry `blockId` + `itemId`. ```javascript dispatch_surface_interactions({ tabId: "", status: "queued" }); @@ -54,61 +98,62 @@ dispatch_surface_resolve({ Freeze a surface when it should remain readable but stop accepting input. Delete only when it no longer has durable value. Owners can edit their own surfaces; -parents may read a direct child's surfaces but cannot edit them. +parents may read a direct child's surfaces but cannot edit them. A tab authored +under an older schema version renders a "recreate this tab" notice — recreate it +with a current document rather than patching it. ## Block palette -- `text`: short Markdown explanation or result. -- `status`: one current state, semantic tone, detail, and optional timestamp. -- `progress`: bounded progress; omit `tone` for normal success-green progress. -- `list`: bullets, numbered steps, or checklist-like workflow states. Items may - carry a freeform `status` plus semantic `tone` (not a closed state enum), an - independent `checked` boolean for check-style lists, a safe `url`, a `group` - subheading, and one `{ id, label, intent }` action. - Use `collapse: { after, label? }` for long lists and `showItemCount: true` - when the total matters. -- `table`: compact repeated data; use badge variants for semantic values. - Set `showItemCount: true` when the total row count is useful context. - Secondary columns are always collapsed behind a per-row disclosure, so mark - a column `secondary` only for verbose diagnostics the user doesn't need to - compare at a glance — a decision-critical value (a risk/status badge, the - thing the user is choosing between) stays `primary` (the default) so it - renders without an extra click. -- `actions`: up to six immediate commands with stable `intent` values; require - confirmation for consequential actions. -- `form`: text, textarea, number, checkbox, radio, and single/multi-select input - submitted together. Use `submitMode: "once"` for decisions and approvals, - `"repeatable"` for intake or ongoing feedback. -- `section`: a titled grouping container for related blocks. Its `title` is - required; add `description` for context. Set - `collapse: { initiallyCollapsed: true }` when the renderer may hide its - body while keeping the header visible. Without `collapse`, it is a static - group. Sections may nest four levels, contain up to 20 direct children, and - surfaces allow up to 100 nested blocks in addition to the 100 top-level - blocks. These are independent limits; nested descendants do not consume the - top-level allowance. +- `text`: short Markdown explanation or result; optional `tone` renders a + callout for decision-critical prose. +- `status`: one current state, semantic tone, detail, and optional ISO + timestamp (rendered inline, relative). +- `progress`: bounded progress; the bar is neutral by default and shows its + percentage — pair it with a `label` like "5 of 8 complete". +- `list`: bullets, numbered steps, or checklist items. Items may carry a + freeform `status` plus semantic `tone` (not a closed state enum), an + independent `checked` boolean for check-style lists, a safe `url` (the title + becomes the link), a `group` subheading, and `actions` (1 inline, 2+ in a + ⋯ menu). Use `collapse: { after, label? }` for long lists and + `showItemCount: true` when the total matters. +- `table`: compact repeated data; at most 3 primary columns, badge variants + for semantic values, `priority: "secondary"` for verbose diagnostics behind + the per-row disclosure. Two plain columns render as a key/value list. Rows + take `actions` like list items. +- `form`: text, textarea, number, checkbox, radio, and single/multi-select + input submitted together. The submit is always the form's primary action + (no style knob) and renders full width; a trailing run of checkboxes + renders as a grouped attestation gate. Use `submitMode: "once"` for + decisions and approvals, `"repeatable"` for intake or ongoing feedback. +- `section`: a titled grouping container for related blocks; its label + renders as a small-caps container heading. `title` is required; add + `description` for context, `actions` for a group-scoped footer, and + `collapse: { initiallyCollapsed: true }` to start closed. Sections nest + four levels, hold 20 direct children, and surfaces allow 100 nested blocks + in addition to the 100 top-level blocks. ## Use-case recipes ### Present options -Pair context with actions. A table is useful when the choices have comparable -attributes. If the user may also enter a rationale, use one form with a radio -choice and optional textarea instead of adding an action that promises to open -text entry. +Put the pending state in the header, the comparison in a table, and the +decision in the footer — or use one form when the user should also explain +their choice. ```json { "title": "Release decision", "icon": "flag", - "blocks": [ - { + "header": { + "status": { "id": "state", "type": "status", "status": "Ready for decision", "tone": "info", "detail": "Both rollout paths passed CI." - }, + } + }, + "blocks": [ { "id": "paths", "type": "table", @@ -133,54 +178,32 @@ text entry. "cells": { "option": "Direct", "time": "~8 min", "risk": "Higher" } } ] - }, - { - "id": "decision-form", - "type": "form", - "title": "Choose a rollout path", - "fields": [ - { - "id": "path", - "type": "radio", - "label": "Rollout path", - "required": true, - "options": [ - { - "value": "canary", - "label": "Canary (~30 min, lower risk)" - }, - { - "value": "direct", - "label": "Direct (~8 min, higher risk)" - } - ] - }, - { - "id": "explanation", - "type": "textarea", - "label": "Why this choice? (optional)", - "required": false, - "placeholder": "Add context for the decision…" - } - ], - "submit": { - "id": "submit-decision", - "label": "Save decision", - "intent": "submit_rollout_decision", + } + ], + "footer": { + "actions": [ + { + "id": "choose-canary", + "label": "Use canary", + "intent": "choose_release_canary", "style": "primary" }, - "resetLabel": "Clear", - "submitMode": "once" - } - ] + { + "id": "choose-direct", + "label": "Release directly", + "intent": "choose_release_direct", + "style": "destructive", + "confirm": { "title": "Skip the canary observation window?" } + } + ] + } } ``` -### Collect feedback or intake +### Collect a decision with rationale -Use a repeatable form when the user must enter actual data rather than choose one -shortcut. A form's submit action sends all field values together; ordinary -action buttons carry intent but do not collect text. +Use a repeatable form when the user must enter actual data rather than choose +one shortcut. A form's submit sends all field values together. ```json { @@ -213,8 +236,7 @@ action buttons carry intent but do not collect text. "submit": { "id": "send", "label": "Send feedback", - "intent": "submit_feedback", - "style": "primary" + "intent": "submit_feedback" }, "resetLabel": "Clear draft", "submitMode": "repeatable" @@ -225,25 +247,25 @@ action buttons carry intent but do not collect text. ### Show progress and workflow -Combine `progress`, a check-style `list`, and targeted actions. Give an item a -freeform `status` such as `Waiting for approval` and a `tone` such as `warning`; -use `checked: true` only when that item is complete. Do not infer completion from -the status label or tone, and do not use a closed state enum. Use `group` for small list subheadings and an -item action when the next step belongs to exactly one item. This supports release -checklists, incident handoffs, onboarding, and review queues without pretending -to be a full board. +Header `progress` for the aggregate, a check-style `list` for the work, item +`actions` for the next step that belongs to exactly one item, and a section +`actions` footer for group verbs. Give an item a freeform `status` such as +`Waiting for approval` with `tone: "warning"`; use `checked: true` only when +that item is complete — never infer completion from the status label or tone. ### Request approval -Use a status block for the pending state, one primary action, and a destructive -action only when it is genuinely available. Set `disabled: true` with a visible +Header `status` for the pending state, a `text` block with +`tone: "warning"` for the risk note, a form whose trailing checkboxes act as +the attestation gate, or a footer with one primary action and a destructive +alternative behind `confirm`. Set `disabled: true` with a visible `disabledReason` when an action cannot run. ### Monitor a compact system -Use a status block for the headline, progress for one meaningful aggregate, and -a table for services or checks. Map badge cell values to semantic tones with -`badgeVariants`; put verbose diagnostics in secondary columns. +Header `status` for the headline, a table for services or checks with badge +tones reserved for the exceptions, secondary columns for diagnostics, and row +`actions` for per-service verbs (retry, mute, inspect). ## Interaction discipline From 492d2468e39529d664ce752de4de5b5bb45ea53d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Wed, 2 Sep 2026 22:23:56 -0600 Subject: [PATCH 2/6] Surfaces v2 polish: chip-style item actions, bordered destructive, inline link arrow - Single item/row actions render as compact bordered chips (they read as buttons again, per feedback) instead of bare ghost text. - A standalone destructive button keeps a visible border. - List item link arrows flow inline after the last word instead of wrapping to their own line. - Checkbox labels use the muted required marker like other fields. Co-Authored-By: Claude Fable 5 --- .../app/agent-surfaces/blocks/action-ref-button.tsx | 4 ++++ .../app/agent-surfaces/blocks/form-fields.tsx | 6 +++++- .../app/agent-surfaces/blocks/item-actions.tsx | 5 +++-- .../app/agent-surfaces/blocks/list-block.tsx | 11 ++++++++--- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx index dc6ed7112..dc2db27a3 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/action-ref-button.tsx @@ -72,6 +72,10 @@ export function ActionRefButton({ // Compact by default; grows to the 44px touch-target minimum only // for coarse (touch) pointers. "h-7 px-2.5 text-xs [@media(pointer:coarse)]:min-h-11", + // A destructive verb rendered as a standalone button keeps a visible + // border so it still reads as a button, just a quiet one. + (variantOverride ?? actionButtonVariant(action.style)) === + "ghost-destructive" && "border border-status-blocked/30", authoredDisabled && "opacity-50", className )} diff --git a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx index d83ee1e47..2c3d6aecf 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx @@ -142,7 +142,11 @@ export function FormFieldControl({ > {field.label} {field.required ? ( - * + // Muted like FieldShell's marker — required is not an error. + ) : null}
    diff --git a/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx index 33b9bdf69..2335b511a 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx @@ -113,10 +113,11 @@ export function ItemActions({
    diff --git a/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx b/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx index fd5818447..06d867e15 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/list-block.test.tsx @@ -89,7 +89,7 @@ describe("ListBlockView v2", () => { ], }); expect(screen.getByRole("heading", { name: "Work" })).toBeTruthy(); - expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText("(2)")).toBeTruthy(); expect(screen.getByText("Now").closest("li")).toBeTruthy(); expect(screen.getByText("Needs input").className).toContain( "text-status-waiting" diff --git a/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx b/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx index 3cd659845..ed205f18a 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/table-block.test.tsx @@ -185,7 +185,7 @@ describe("TableBlockView URL cells", () => { ], }; renderTable(block); - expect(screen.getByText("2")).toBeTruthy(); + expect(screen.getByText("(2)")).toBeTruthy(); const rows = document.querySelectorAll("tbody tr[data-row-id]"); expect(rows[0].querySelectorAll("td").length).toBe( rows[1].querySelectorAll("td").length diff --git a/e2e/agent-surfaces.spec.ts b/e2e/agent-surfaces.spec.ts index c67c8cf29..29fe9d14f 100644 --- a/e2e/agent-surfaces.spec.ts +++ b/e2e/agent-surfaces.spec.ts @@ -587,7 +587,7 @@ test.describe("Agent-authored sidebar surfaces", () => { // rows render initially. This prevents a long status list from taking over // the sidebar without hiding the amount of outstanding work. await expect(list.getByText("Release work", { exact: true })).toBeVisible(); - await expect(list.getByText("4", { exact: true })).toBeVisible(); + await expect(list.getByText("(4)", { exact: true })).toBeVisible(); await expect(list.locator("[data-item-id]")).toHaveCount(2); await expect(list.getByText("Completed", { exact: true })).toBeVisible(); await expect(list.getByText("Next steps", { exact: true })).toBeVisible(); diff --git a/plugins/dispatch/skills/surfaces/SKILL.md b/plugins/dispatch/skills/surfaces/SKILL.md index f6dd18d7d..7854b71f4 100644 --- a/plugins/dispatch/skills/surfaces/SKILL.md +++ b/plugins/dispatch/skills/surfaces/SKILL.md @@ -118,8 +118,10 @@ with a current document rather than patching it. `showItemCount: true` when the total matters. - `table`: compact repeated data; at most 3 primary columns, badge variants for semantic values, `priority: "secondary"` for verbose diagnostics behind - the per-row disclosure. Two plain columns render as a key/value list. Rows - take `actions` like list items. + the per-row disclosure. When recency matters (deploy history, run logs), + keep a relative-time column primary rather than demoting it — a history + with no visible time cue is worse than one fewer data column. Two plain + columns render as a key/value list. Rows take `actions` like list items. - `form`: text, textarea, number, checkbox, radio, and single/multi-select input submitted together. The submit is always the form's primary action (no style knob) and renders full width; a trailing run of checkboxes From ff32c90bfb3748dcda470cd8d5d3f0e89afe91d7 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Wed, 2 Sep 2026 23:03:57 -0600 Subject: [PATCH 4/6] Surfaces v2: address frontend-ux review findings - Nested action menus claim Escape (preventDefault + controlled close) and GlassSidebar ignores claimed Escapes, so the first press dismisses only the menu in the mobile drawer (#2479); e2e regression covers it. - Confirm dialogs opened from an overflow menu item return focus to the split trigger via a returnFocusRef instead of falling to (#2480); e2e asserts cancel refocuses the trigger. - Menu items get the coarse-pointer 44px minimum (#2481). - Authored-disabled overflow actions stay focusable via aria-disabled with their reason rendered inside the item and aria-describedby wired (#2482). - humanizeLabel only rewrites separator-bearing tokens; plain all-caps values (API, AWS, NASA) pass through, with unit regression tests (#2483). - Checkbox, radiogroup, select trigger, and multi-select group carry aria-required now that the visual asterisk is aria-hidden (#2484). Co-Authored-By: Claude Fable 5 --- .../blocks/action-confirm-dialog.tsx | 8 +- .../app/agent-surfaces/blocks/form-fields.tsx | 6 ++ .../agent-surfaces/blocks/item-actions.tsx | 17 ++++- .../agent-surfaces/blocks/slot-actions.tsx | 76 +++++++++++++++---- .../app/agent-surfaces/format.test.ts | 48 ++++++++++++ .../components/app/agent-surfaces/format.ts | 16 ++-- apps/web/src/components/ui/glass-sidebar.tsx | 5 +- e2e/agent-surfaces.spec.ts | 64 ++++++++++++++++ 8 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/app/agent-surfaces/format.test.ts diff --git a/apps/web/src/components/app/agent-surfaces/blocks/action-confirm-dialog.tsx b/apps/web/src/components/app/agent-surfaces/blocks/action-confirm-dialog.tsx index c5e2f74a8..fdf931d36 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/action-confirm-dialog.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/action-confirm-dialog.tsx @@ -20,10 +20,16 @@ export function ActionConfirmDialog({ action, onCancel, onConfirm, + returnFocusRef, }: { action: ActionRef | null; onCancel: () => void; onConfirm: (action: ActionRef) => void; + /** Explicit close-focus target for openers whose triggering element + * unmounts before the dialog opens (a menu item inside a Radix dropdown + * that closes on select) — the captured activeElement is already + * by then. */ + returnFocusRef?: React.MutableRefObject; }): JSX.Element { // Radix restores focus to whatever was focused before Content mounted — // but only when Radix's own Presence controls the mount/unmount. Rendering @@ -64,7 +70,7 @@ export function ActionConfirmDialog({ }} onCloseAutoFocus={(event) => { event.preventDefault(); - triggerRef.current?.focus(); + (returnFocusRef?.current ?? triggerRef.current)?.focus(); }} > diff --git a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx index 2c3d6aecf..8a0e267c0 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/form-fields.tsx @@ -133,6 +133,9 @@ export function FormFieldControl({ onChange(checked === true)} /> @@ -169,6 +172,7 @@ export function FormFieldControl({
    {field.options.map((option) => { @@ -205,6 +209,7 @@ export function FormFieldControl({
    {field.options.map((option) => { @@ -246,6 +251,7 @@ export function FormFieldControl({ > diff --git a/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx index 2335b511a..536ff00db 100644 --- a/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx +++ b/apps/web/src/components/app/agent-surfaces/blocks/item-actions.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Loader2, MoreHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -57,6 +58,7 @@ export function ItemActions({ /** Accessible context: " for ". */ itemLabel?: string; }): JSX.Element | null { + const [menuOpen, setMenuOpen] = useState(false); const mutation = useSubmitSurfaceInteraction(agentId, surfaceId); const { states, submit, clear } = useKeyedInteractionState( surfaceRevision, @@ -139,7 +141,7 @@ export function ItemActions({ return (
    - +