diff --git a/.changeset/session-comments.md b/.changeset/session-comments.md new file mode 100644 index 0000000000..7efe8d0f86 --- /dev/null +++ b/.changeset/session-comments.md @@ -0,0 +1,7 @@ +--- +"@electric-ax/agents-runtime": patch +"@electric-ax/agents-server": patch +"@electric-ax/agents-server-ui": patch +--- + +Add session comments to agent timelines, including synced comment events, server comment writes, reply targets, comments-only timeline display, and timeline controls for showing or hiding comments. diff --git a/packages/agents-runtime/src/client.ts b/packages/agents-runtime/src/client.ts index 0d467fd7f1..8bdfcaac9c 100644 --- a/packages/agents-runtime/src/client.ts +++ b/packages/agents-runtime/src/client.ts @@ -4,6 +4,7 @@ export { compareTimelineOrders, buildEntityTimelineData, createPendingTimelineOrder, + TIMELINE_ORDER_FALLBACK, createEntityErrorsQuery, createEntityIncludesQuery, createEntityTimelineQuery, @@ -44,6 +45,9 @@ export type { AttachmentStatus, AttachmentSubject, AttachmentSubjectType, + Comment, + CommentSnapshot, + CommentTarget, Manifest, ManifestAttachmentEntry, } from './entity-schema' @@ -64,6 +68,7 @@ export type { export type { EntityTimelineContentItem, EntityTimelineData, + EntityTimelineCommentRow, EntityTimelineInboxMode, EntityTimelineQueryOptions, EntityTimelineQueryRow, diff --git a/packages/agents-runtime/src/entity-schema.ts b/packages/agents-runtime/src/entity-schema.ts index 7d70d3cef2..06dfeaa18f 100644 --- a/packages/agents-runtime/src/entity-schema.ts +++ b/packages/agents-runtime/src/entity-schema.ts @@ -203,6 +203,43 @@ type MessageReceivedValue = { processed_at?: string cancelled_at?: string } +type CommentTargetCollectionValue = + | `inbox` + | `run` + | `text` + | `tool_call` + | `wake` + | `signal` + | `manifest` +type CommentTargetValue = + | { + kind: `comment` + key: string + } + | { + kind: `timeline` + collection: CommentTargetCollectionValue + key: string + run_id?: string + } +type CommentSnapshotValue = { + label: string + text?: string + from?: string + timestamp?: string + collection?: string +} +type CommentValue = { + key?: string + body: string + from_principal: string + timestamp: string + reply_to?: CommentTargetValue + target_snapshot?: CommentSnapshotValue + edited_at?: string + deleted_at?: string + deleted_by?: string +} type WakeEntryValue = { key?: string timestamp: string @@ -552,6 +589,54 @@ function createMessageReceivedSchema(): Schema { }) } +function createCommentTargetSchema(): Schema { + return z.union([ + z.object({ + kind: z.literal(`comment`), + key: z.string(), + }), + z.object({ + kind: z.literal(`timeline`), + collection: z.enum([ + `inbox`, + `run`, + `text`, + `tool_call`, + `wake`, + `signal`, + `manifest`, + ]), + key: z.string(), + run_id: z.string().optional(), + }), + ]) +} + +function createCommentSnapshotSchema(): Schema { + return z.object({ + label: z.string(), + text: z.string().optional(), + from: z.string().optional(), + timestamp: z.string().optional(), + collection: z.string().optional(), + }) +} + +function createCommentSchema(): Schema { + return z.object({ + key: z.string().optional(), + ...timelineOrderField, + body: z.string(), + from_principal: z.string(), + timestamp: z.string(), + reply_to: createCommentTargetSchema().optional(), + target_snapshot: createCommentSnapshotSchema().optional(), + edited_at: z.string().optional(), + deleted_at: z.string().optional(), + deleted_by: z.string().optional(), + }) +} + function createWakeSchema(): Schema { return z.object({ key: z.string().optional(), @@ -851,6 +936,9 @@ export type ToolCall = SequencedPersistedRow export type Reasoning = SequencedPersistedRow export type ErrorEvent = SequencedPersistedRow export type MessageReceived = SequencedPersistedRow +export type CommentTarget = CommentTargetValue +export type CommentSnapshot = CommentSnapshotValue +export type Comment = SequencedPersistedRow export type WakeEntry = SequencedPersistedRow export type EntityCreated = SequencedPersistedRow export type EntityStopped = SequencedPersistedRow @@ -941,6 +1029,7 @@ export const ENTITY_COLLECTIONS = { reasoning: `reasoning`, errors: `errors`, inbox: `inbox`, + comments: `comments`, wakes: `wakes`, entityCreated: `entityCreated`, entityStopped: `entityStopped`, @@ -966,6 +1055,7 @@ export const BUILT_IN_EVENT_SCHEMAS = { error: createErrorEventSchema() as unknown as BuiltInEntitySchema, inbox: createMessageReceivedSchema() as unknown as BuiltInEntitySchema, + comment: createCommentSchema() as unknown as BuiltInEntitySchema, wake: createWakeSchema() as unknown as BuiltInEntitySchema, entity_created: createEntityCreatedSchema() as unknown as BuiltInEntitySchema, @@ -1000,6 +1090,7 @@ type EntityCollectionsDefinition = { reasoning: CollectionDefinition errors: CollectionDefinition inbox: CollectionDefinition + comments: CollectionDefinition wakes: CollectionDefinition entityCreated: CollectionDefinition entityStopped: CollectionDefinition @@ -1060,6 +1151,11 @@ export const builtInCollections: EntityCollectionsDefinition = { type: `inbox`, primaryKey: `key`, }, + comments: { + schema: BUILT_IN_EVENT_SCHEMAS.comment as StandardSchemaV1, + type: `comment`, + primaryKey: `key`, + }, wakes: { schema: BUILT_IN_EVENT_SCHEMAS.wake as StandardSchemaV1, type: `wake`, diff --git a/packages/agents-runtime/src/entity-timeline.ts b/packages/agents-runtime/src/entity-timeline.ts index 0520982298..b27c0ac563 100644 --- a/packages/agents-runtime/src/entity-timeline.ts +++ b/packages/agents-runtime/src/entity-timeline.ts @@ -19,7 +19,12 @@ import type { } from '@tanstack/db' import type { EntityStreamDB } from './entity-stream-db' import { formatPointerOrderToken, type EventPointer } from './event-pointer' -import type { ChildStatusEntry, MessageReceived, Signal } from './entity-schema' +import type { + ChildStatusEntry, + Comment, + MessageReceived, + Signal, +} from './entity-schema' import type { ManifestEntry, Wake, WakeMessage } from './types' export type EntityTimelineState = @@ -30,6 +35,8 @@ export type EntityTimelineState = | `error` export type TimelineOrder = string | number +export const TIMELINE_ORDER_FALLBACK = `zzzz:timeline-end` +const PENDING_TIMELINE_ORDER_PREFIX = `zzzz:pending:` export type EntityTimelineContentItem = | { kind: `text`; text: string } @@ -248,6 +255,12 @@ export interface EntityTimelineRunRow { } export type EntityTimelineInboxRow = IncludesInboxMessage +export type EntityTimelineCommentRow = Omit< + Comment, + `_seq` | `_timeline_order` +> & { + order: TimelineOrder +} export type EntityTimelineWakeRow = IncludesWakeMessage export type EntityTimelineSignalRow = IncludesSignal @@ -256,6 +269,7 @@ export type EntityTimelineQueryRow = $key: string inbox: EntityTimelineInboxRow run?: undefined + comment?: undefined wake?: undefined signal?: undefined manifest?: undefined @@ -264,6 +278,16 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run: EntityTimelineRunRow + comment?: undefined + wake?: undefined + signal?: undefined + manifest?: undefined + } + | { + $key: string + inbox?: undefined + run?: undefined + comment: EntityTimelineCommentRow wake?: undefined signal?: undefined manifest?: undefined @@ -272,6 +296,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment?: undefined wake: EntityTimelineWakeRow signal?: undefined manifest?: undefined @@ -280,6 +305,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment?: undefined wake?: undefined signal: EntityTimelineSignalRow manifest?: undefined @@ -288,6 +314,7 @@ export type EntityTimelineQueryRow = $key: string inbox?: undefined run?: undefined + comment?: undefined wake?: undefined signal?: undefined manifest: ManifestEntry @@ -462,7 +489,7 @@ function readTimelineOrder(row: object): string | undefined { } export function createPendingTimelineOrder(index: number): string { - return `~pending:${index.toString().padStart(12, `0`)}` + return `${PENDING_TIMELINE_ORDER_PREFIX}${index.toString().padStart(12, `0`)}` } function toSeqOrderToken(seq: number): string { @@ -632,7 +659,7 @@ function withOrderFromOrderIndex( ): Array>> { return rows.map((row) => ({ ...withoutOrderToken(row), - order: orderIndex.get(row._orderToken) ?? `~`, + order: orderIndex.get(row._orderToken) ?? TIMELINE_ORDER_FALLBACK, })) } @@ -644,7 +671,7 @@ function withOrderAndHistoryOffsetFromOrderIndex< ): Array> & { historyOffset: string }> { return rows.map((row) => ({ ...withoutOrderToken(row), - order: orderIndex.get(row._orderToken) ?? `~`, + order: orderIndex.get(row._orderToken) ?? TIMELINE_ORDER_FALLBACK, historyOffset: orderTokenToHistoryOffset(row._orderToken), })) } @@ -661,7 +688,7 @@ function withOptionalOrderFromOrderIndex< ? { ...value } : { ...value, - order: orderIndex.get(row._orderToken) ?? `~`, + order: orderIndex.get(row._orderToken) ?? TIMELINE_ORDER_FALLBACK, } }) } @@ -1230,14 +1257,20 @@ function buildEntityTimelineQuery( and( eq(inbox.$synced, false), eq(coalesce(inbox.status, `pending`), `pending`), - like(coalesce(inbox._timeline_order, ``), `~pending:%`) + or( + like( + coalesce(inbox._timeline_order, ``), + `${PENDING_TIMELINE_ORDER_PREFIX}%` + ), + like(coalesce(inbox._timeline_order, ``), `~pending:%`) + ) ) ) ) } const inboxSource = inbox.select(({ inbox }) => ({ - order: coalesce(inbox._timeline_order, `~`), + order: coalesce(inbox._timeline_order, TIMELINE_ORDER_FALLBACK), key: inbox.key, from: coalesce(inbox.from, `unknown`), from_principal: inbox.from_principal, @@ -1251,11 +1284,26 @@ function buildEntityTimelineQuery( cancelled_at: inbox.cancelled_at, })) + const commentSource = q + .from({ comment: db.collections.comments }) + .select(({ comment }) => ({ + order: coalesce(comment._timeline_order, TIMELINE_ORDER_FALLBACK), + key: comment.key, + body: comment.body, + from_principal: comment.from_principal, + timestamp: comment.timestamp, + reply_to: comment.reply_to, + target_snapshot: comment.target_snapshot, + edited_at: comment.edited_at, + deleted_at: comment.deleted_at, + deleted_by: comment.deleted_by, + })) + const wakeSource = q .from({ wake: db.collections.wakes }) .select(({ wake }) => ({ key: wake.key, - order: coalesce(wake._timeline_order, `~`), + order: coalesce(wake._timeline_order, TIMELINE_ORDER_FALLBACK), payload: { type: `wake` as const, timestamp: wake.timestamp, @@ -1271,7 +1319,7 @@ function buildEntityTimelineQuery( .from({ signal: db.collections.signals }) .select(({ signal }) => ({ key: signal.key, - order: coalesce(signal._timeline_order, `~`), + order: coalesce(signal._timeline_order, TIMELINE_ORDER_FALLBACK), signal: signal.signal, status: signal.status, sender: signal.sender, @@ -1291,12 +1339,16 @@ function buildEntityTimelineQuery( toolCall: db.collections.toolCalls, }) .select(({ text, toolCall }) => ({ - order: coalesce(text._timeline_order, toolCall._timeline_order, `~`), + order: coalesce( + text._timeline_order, + toolCall._timeline_order, + TIMELINE_ORDER_FALLBACK + ), run_id: coalesce(text.run_id, toolCall.run_id, ``), text: caseWhen(text.key, { key: text.key, run_id: text.run_id, - order: coalesce(text._timeline_order, `~`), + order: coalesce(text._timeline_order, TIMELINE_ORDER_FALLBACK), status: text.status, }), textContent: concat( @@ -1304,7 +1356,9 @@ function buildEntityTimelineQuery( q .from({ chunk: db.collections.textDeltas }) .where(({ chunk }) => eq(chunk.text_id, text.key)) - .orderBy(({ chunk }) => coalesce(chunk._timeline_order, `~`)) + .orderBy(({ chunk }) => + coalesce(chunk._timeline_order, TIMELINE_ORDER_FALLBACK) + ) .orderBy(({ chunk }) => chunk.key) .select(({ chunk }) => chunk.delta) ) @@ -1312,7 +1366,7 @@ function buildEntityTimelineQuery( toolCall: caseWhen(toolCall.key, { key: toolCall.key, run_id: toolCall.run_id, - order: coalesce(toolCall._timeline_order, `~`), + order: coalesce(toolCall._timeline_order, TIMELINE_ORDER_FALLBACK), tool_call_id: toolCall.tool_call_id, tool_name: toolCall.tool_name, status: toolCall.status, @@ -1324,7 +1378,7 @@ function buildEntityTimelineQuery( const runSource = q.from({ run: db.collections.runs }).select(({ run }) => ({ key: run.key, - order: coalesce(run._timeline_order, `~`), + order: coalesce(run._timeline_order, TIMELINE_ORDER_FALLBACK), status: run.status, finish_reason: run.finish_reason, items: q @@ -1353,12 +1407,14 @@ function buildEntityTimelineQuery( .from({ step: db.collections.steps }) .where(({ step }) => eq(step.run_id, run.key)) .orderBy(({ step }) => step.step_number) - .orderBy(({ step }) => coalesce(step._timeline_order, `~`)) + .orderBy(({ step }) => + coalesce(step._timeline_order, TIMELINE_ORDER_FALLBACK) + ) .orderBy(({ step }) => step.key) .select(({ step }) => ({ key: step.key, run_id: step.run_id, - order: coalesce(step._timeline_order, `~`), + order: coalesce(step._timeline_order, TIMELINE_ORDER_FALLBACK), step_number: step.step_number, status: step.status, model_id: step.model_id, @@ -1380,32 +1436,43 @@ function buildEntityTimelineQuery( .unionAll({ inbox: inboxSource, run: runSource, + comment: commentSource, wake: wakeSource, signal: signalSource, manifest: db.collections.manifests, }) - .orderBy(({ inbox, run, wake, signal, manifest }) => + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => coalesce( inbox.order, run.order, + comment.order, wake.order, signal.order, manifest._timeline_order, - `~` + TIMELINE_ORDER_FALLBACK ) ) - .orderBy(({ inbox, run, wake, signal, manifest }) => + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => coalesce( caseWhen(inbox.key, `inbox`), caseWhen(run.key, `run`), + caseWhen(comment.key, `comment`), caseWhen(wake.key, `wake`), caseWhen(signal.key, `signal`), caseWhen(manifest.key, `manifest`), `` ) ) - .orderBy(({ inbox, run, wake, signal, manifest }) => - coalesce(inbox.key, run.key, wake.key, signal.key, manifest.key, ``) + .orderBy(({ inbox, run, comment, wake, signal, manifest }) => + coalesce( + inbox.key, + run.key, + comment.key, + wake.key, + signal.key, + manifest.key, + `` + ) ) } diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index 3275e31be3..e1e53ae7ea 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -96,6 +96,9 @@ export type { Reasoning, ErrorEvent, MessageReceived, + Comment, + CommentSnapshot, + CommentTarget, WakeEntry, EntityCreated, EntityStopped, @@ -173,6 +176,7 @@ export { createEntityErrorsQuery, buildEntityTimelineData, createPendingTimelineOrder, + TIMELINE_ORDER_FALLBACK, getEntityState, normalizeEntityTimelineData, normalizeTimelineEntities, @@ -184,6 +188,7 @@ export type { EntityTimelineInboxMode, EntityTimelineQueryOptions, EntityTimelineQueryRow, + EntityTimelineCommentRow, EntityTimelineRunRow, EntityTimelineRunItem, EntityTimelineTextChunk, diff --git a/packages/agents-runtime/test/entity-timeline.test.ts b/packages/agents-runtime/test/entity-timeline.test.ts index e5eb60eca2..ddba5cb303 100644 --- a/packages/agents-runtime/test/entity-timeline.test.ts +++ b/packages/agents-runtime/test/entity-timeline.test.ts @@ -7,6 +7,8 @@ import { buildEntityTimelineData, compareTimelineOrders, createEntityIncludesQuery, + createEntityTimelineQuery, + createPendingTimelineOrder, getEntityState, normalizeEntityTimelineData, } from '../src/entity-timeline' @@ -20,6 +22,7 @@ import type { EventPointer } from '../src/event-pointer' import type { EntityTimelineContentItem, EntityTimelineData, + EntityTimelineQueryRow, IncludesInboxMessage, IncludesRun, IncludesWakeMessage, @@ -1592,6 +1595,7 @@ describe(`entity includes query`, () => { const steps = createSyncCollection(`test-steps`, takeOffset) const errors = createSyncCollection(`test-errors`, takeOffset) const inbox = createSyncCollection(`test-inbox`, takeOffset) + const comments = createSyncCollection(`test-comments`, takeOffset) const wakes = createSyncCollection(`test-wakes`, takeOffset) const signals = createSyncCollection(`test-signals`, takeOffset) const contextInserted = createSyncCollection( @@ -1613,6 +1617,7 @@ describe(`entity includes query`, () => { steps: steps.collection, errors: errors.collection, inbox: inbox.collection, + comments: comments.collection, wakes: wakes.collection, signals: signals.collection, contextInserted: contextInserted.collection, @@ -1628,6 +1633,7 @@ describe(`entity includes query`, () => { steps: withSeqInjection(steps, takeSeq), errors: withSeqInjection(errors, takeSeq), inbox: withSeqInjection(inbox, takeSeq), + comments: withSeqInjection(comments, takeSeq), wakes: withSeqInjection(wakes, takeSeq), signals: withSeqInjection(signals, takeSeq), contextInserted: withSeqInjection(contextInserted, takeSeq), @@ -1647,6 +1653,15 @@ describe(`entity includes query`, () => { return data ? normalizeEntityTimelineData(data) : undefined } + function timelineRowLabel(row: EntityTimelineQueryRow): string { + if (row.inbox) return `inbox:${row.inbox.key}` + if (row.run) return `run:${row.run.key}` + if (row.comment) return `comment:${row.comment.key}` + if (row.wake) return `wake:${row.wake.key}` + if (row.signal) return `signal:${row.signal.key}` + return `manifest:${row.manifest.key}` + } + function trackChanges(collection: any) { let changeCount = 0 collection.subscribeChanges(() => { @@ -1791,6 +1806,55 @@ describe(`entity includes query`, () => { }) }) + it(`interleaves comments by timeline order and keeps pending comments at the end`, async () => { + const { collections, sync } = createEntityCollections() + const liveQuery = createLiveQueryCollection({ + query: createEntityTimelineQuery({ collections } as any), + startSync: true, + }) + await liveQuery.preload() + + sync.inbox.insert({ + key: `msg-1`, + _timeline_order: order(1), + from: `user`, + payload: `start`, + timestamp: `2026-04-15T18:00:00.000Z`, + status: `processed`, + }) + sync.comments.insert({ + key: `comment-1`, + _timeline_order: order(2), + body: `between prompt and wake`, + from_principal: `/principal/user%3Ame`, + timestamp: `2026-04-15T18:00:05.000Z`, + }) + sync.wakes.insert({ + key: `wake-1`, + _timeline_order: order(3), + timestamp: `2026-04-15T18:00:10.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }) + sync.comments.insert({ + key: `comment-2`, + _timeline_order: createPendingTimelineOrder(1), + body: `pending tail comment`, + from_principal: `/principal/user%3Ame`, + timestamp: `2026-04-15T18:00:15.000Z`, + }) + await new Promise((r) => setTimeout(r, 50)) + + const rows = getData(liveQuery) as Array + expect(rows.map(timelineRowLabel)).toEqual([ + `inbox:msg-1`, + `comment:comment-1`, + `wake:wake-1`, + `comment:comment-2`, + ]) + }) + it(`reacts to toolCall updates`, async () => { const { collections, sync } = createEntityCollections() const queryFn = createEntityIncludesQuery({ collections } as any) diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index 312e5d0fc5..314bc5a51c 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -1,4 +1,4 @@ -import { Check, Copy, GitFork } from 'lucide-react' +import { Check, Copy, GitFork, Reply } from 'lucide-react' import { memo, useEffect, @@ -385,6 +385,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({ timestamp, renderWidth = 0, forkFromHere, + onReply, + onReplyToToolCall, onSearchTextChange, }: { rowKey: string @@ -393,6 +395,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({ timestamp?: number | null renderWidth?: number forkFromHere?: ForkFromHereAction + onReply?: () => void + onReplyToToolCall?: (item: EntityTimelineToolCallItem) => void onSearchTextChange?: (rowKey: string, text: string) => void }): React.ReactElement { const { data: items = [] } = useLiveQuery( @@ -502,6 +506,11 @@ export const AgentResponseLive = memo(function AgentResponseLive({ onReplyToToolCall(item.toolCall!) + : undefined + } /> ) })} @@ -551,6 +560,7 @@ export const AgentResponseLive = memo(function AgentResponseLive({ copied={copied} onCopy={() => void copyResponseText()} forkFromHere={done ? forkFromHere : undefined} + onReply={onReply} /> @@ -562,20 +572,37 @@ function ResponseMetaActions({ copied, onCopy, forkFromHere, + onReply, }: { showCopy: boolean copied: boolean onCopy: () => void forkFromHere?: ForkFromHereAction + onReply?: () => void }): React.ReactElement | null { const showFork = forkFromHere !== undefined - if (!showCopy && !showFork) return null + if (!showCopy && !showFork && !onReply) return null const forkDisabled = forkFromHere?.disabled === true || !forkFromHere?.onFork const forkLabel = forkDisabled ? `Fork permission required` : `Fork from here` return ( + {onReply && ( + + + + + + )} {showFork && ( @@ -622,12 +649,14 @@ export const AgentResponse = memo(function AgentResponse({ timestamp, renderWidth = 0, forkFromHere, + onReply, }: { section: AgentResponseSection isStreaming: boolean timestamp?: number | null renderWidth?: number forkFromHere?: ForkFromHereAction + onReply?: () => void }): React.ReactElement { const canCache = !isStreaming && section.done === true const [copied, setCopied] = useState(false) @@ -755,6 +784,7 @@ export const AgentResponse = memo(function AgentResponse({ copied={copied} onCopy={() => void copyResponseText()} forkFromHere={section.done ? forkFromHere : undefined} + onReply={onReply} /> diff --git a/packages/agents-server-ui/src/components/CommentBubble.module.css b/packages/agents-server-ui/src/components/CommentBubble.module.css new file mode 100644 index 0000000000..9beebc9298 --- /dev/null +++ b/packages/agents-server-ui/src/components/CommentBubble.module.css @@ -0,0 +1,176 @@ +.root { + display: flex; + width: 100%; + box-sizing: border-box; +} + +.root[data-own='true'] { + justify-content: flex-end; +} + +.root[data-own='false'] { + justify-content: flex-start; +} + +.column { + display: grid; + gap: 4px; + max-width: min(72%, 640px); +} + +.root[data-own='true'] .column { + justify-items: end; +} + +.root[data-own='false'] .column { + justify-items: start; +} + +.message { + display: grid; + gap: 4px; + width: fit-content; + max-width: 100%; +} + +.root[data-own='true'] .message { + justify-self: end; +} + +.root[data-own='false'] .message { + justify-self: start; +} + +.bubble { + min-width: 0; + padding: 9px 12px; + border: 1px solid #000; + border-radius: var(--ds-radius-4); + background: #000; + color: #fff; + box-shadow: var(--ds-shadow-1); +} + +.root[data-single-line='true'] .bubble { + border-radius: var(--ds-radius-5); + padding-block: 7px; +} + +:global(html[data-theme='dark']) .bubble { + border-color: #fff; + background: #fff; + color: #000; +} + +.body, +.deletedBody { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: var(--ds-chat-text); + line-height: var(--ds-chat-text-lh); +} + +.deletedBody { + color: inherit; + opacity: 0.64; + font-style: italic; +} + +.preview { + display: flex; + align-items: flex-start; + gap: 2px; + max-width: 100%; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--ds-text-3); + font: inherit; + text-align: left; +} + +.previewButton { + border-radius: var(--ds-radius-2); + cursor: pointer; +} + +.previewButton:hover .previewContent { + border-left-color: var(--ds-text-3); +} + +.previewButton:focus-visible { + outline: 2px solid var(--ds-focus-ring); + outline-offset: 2px; +} + +.previewIcon { + flex: 0 0 auto; + margin-top: 1px; + color: var(--ds-text-4); +} + +.previewContent { + display: grid; + gap: 2px; + min-width: 0; + padding-left: 6px; + border-left: 2px solid var(--ds-gray-a8); +} + +.previewLabel, +.previewText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.previewLabel { + white-space: nowrap; + font-size: var(--ds-text-xs); + font-weight: 600; + line-height: var(--ds-text-xs-lh); +} + +.previewText { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: var(--ds-text-xs); + line-height: var(--ds-text-xs-lh); +} + +.meta { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + box-sizing: border-box; + padding-inline: 8px; +} + +.meta > :not(.metaActions) { + opacity: 0.5; +} + +.metaActions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.metaActionButton { + color: var(--ds-text-4); + opacity: 0.7; +} + +.metaActionButton:hover { + opacity: 1; +} + +@media (max-width: 700px) { + .column { + max-width: min(88%, 640px); + } +} diff --git a/packages/agents-server-ui/src/components/CommentBubble.tsx b/packages/agents-server-ui/src/components/CommentBubble.tsx new file mode 100644 index 0000000000..85d6fda020 --- /dev/null +++ b/packages/agents-server-ui/src/components/CommentBubble.tsx @@ -0,0 +1,180 @@ +import { memo } from 'react' +import { Reply } from 'lucide-react' +import type { + CommentSnapshot, + CommentTarget, + EntityTimelineCommentRow, +} from '@electric-ax/agents-runtime/client' +import { Icon, IconButton, Text, Tooltip } from '../ui' +import { principalKeyFromInput } from '../lib/principals' +import { TimeText } from './TimeText' +import type { ElectricUser } from '../lib/ElectricAgentsProvider' +import styles from './CommentBubble.module.css' + +export const CommentBubble = memo(function CommentBubble({ + comment, + currentPrincipal, + usersById, + showMeta = true, + onReply, + onTargetClick, +}: { + comment: EntityTimelineCommentRow + currentPrincipal?: string + usersById?: Map + showMeta?: boolean + onReply?: (comment: EntityTimelineCommentRow) => void + onTargetClick?: (target: CommentTarget) => void +}): React.ReactElement { + const isOwn = + principalKeyFromInput(comment.from_principal) === + principalKeyFromInput(currentPrincipal) + const sender = formatSender(comment.from_principal, { + currentPrincipal, + usersById, + }) + const timestamp = Date.parse(comment.timestamp) + const deleted = Boolean(comment.deleted_at) + const singleLine = !deleted && !/[\r\n]/.test(comment.body) + + return ( +
+
+ {comment.target_snapshot && ( + onTargetClick(comment.reply_to!) + : undefined + } + /> + )} +
+
+
+ {deleted ? `Comment deleted` : comment.body} +
+
+ {showMeta && ( +
+ + {sender.label} + + {Number.isFinite(timestamp) && ( + <> + + - + + + + )} + {comment.edited_at && !deleted && ( + <> + + - + + + edited + + + )} + {onReply && !deleted && ( + + + onReply(comment)} + > + + + + + )} +
+ )} +
+
+
+ ) +}) + +function ReplyPreview({ + snapshot, + onClick, +}: { + snapshot: CommentSnapshot + onClick?: () => void +}): React.ReactElement { + const content = ( + <> + +
+
{snapshot.label}
+ {snapshot.text && ( +
{snapshot.text}
+ )} +
+ + ) + + if (onClick) { + return ( + + ) + } + + return
{content}
+} + +function formatSender( + from: string | null | undefined, + options: { + currentPrincipal?: string + usersById?: Map + } = {} +): { + label: string + title?: string +} { + const key = principalKeyFromInput(from) + if (!key) return { label: from || `user` } + if (key === principalKeyFromInput(options.currentPrincipal)) { + return { label: `Me`, title: key } + } + const colon = key.indexOf(`:`) + if (colon <= 0) return { label: key, title: key } + const kind = key.slice(0, colon) + const id = key.slice(colon + 1) + if (kind === `user`) { + const user = options.usersById?.get(id) + const label = user?.display_name || user?.email + if (label) return { label, title: key } + } + return { + label: `${kind}:${formatPrincipalId(id)}`, + title: key, + } +} + +function formatPrincipalId(id: string): string { + if (id.length <= 18) return id + return `${id.slice(0, 8)}...${id.slice(-6)}` +} diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index ca9e59c46d..3e38026550 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -191,6 +191,20 @@ box-sizing: border-box; } +.virtualRow[data-highlighted='true'] { + animation: targetPulse 1600ms ease-out; +} + +@keyframes targetPulse { + 0%, + 65% { + background: color-mix(in oklab, var(--ds-accent-a4) 70%, transparent); + } + 100% { + background: transparent; + } +} + /* Jump-to-bottom affordance — centered over the chat column so it relates to the conversation rather than the viewport edge. It stays mounted and toggles visibility with opacity/translate for a soft diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index 214b806066..1835844d63 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -23,6 +23,7 @@ import { FileJson, GitBranch, Radio, + Reply, } from 'lucide-react' import { loadTimelineRowHeights, @@ -46,6 +47,7 @@ import { Icon, IconButton, ScrollArea, Stack, Text, Tooltip } from '../ui' import { UserMessage } from './UserMessage' import type { ForkFromHereAction, UserMessageAttachment } from './UserMessage' import { AgentResponseLive } from './AgentResponse' +import { CommentBubble } from './CommentBubble' import { InlineEventCard } from './InlineEventCard' import { InlineStatusBadge } from './InlineStatusBadge' import { @@ -57,13 +59,17 @@ import { formatChatTimestamp, } from '../lib/formatTime' import { readTextPayload } from '../lib/sendMessage' +import { principalKeyFromInput } from '../lib/principals' import styles from './EntityTimeline.module.css' import type { ElectricUser } from '../lib/ElectricAgentsProvider' +import type { SelectedCommentTarget } from '../lib/comments' import type { + CommentTarget, EntityTimelineSection, EntityTimelineQueryRow, EntityTimelineRunItem, EntityTimelineRunRow, + EntityTimelineToolCallItem, IncludesEntity, Manifest, } from '@electric-ax/agents-runtime/client' @@ -72,6 +78,10 @@ import type { PaneFindAdapter, PaneFindMatch } from '../hooks/usePaneFind' type RenderTimelineRow = EntityTimelineQueryRow type WakeSection = Extract +export type TimelineRowAdjacency = { + previousRow?: EntityTimelineQueryRow + nextRow?: EntityTimelineQueryRow +} function renderRowKey(row: RenderTimelineRow): string { return row.$key @@ -212,7 +222,8 @@ class TimelineRowErrorBoundary extends Component< */ function estimateRowHeight( row: RenderTimelineRow | undefined, - contentWidth: number + contentWidth: number, + nextRow?: RenderTimelineRow ): number { if (!row) return 120 @@ -227,12 +238,16 @@ function estimateRowHeight( 1, Math.ceil(readInboxText(row.inbox.payload).length / charsPerLine) ) - return Math.max(64, 48 + lines * lineHeight) + timelineRowGap(row) + return Math.max(64, 48 + lines * lineHeight) + timelineRowGap(row, nextRow) + } + if (row.comment) { + const lines = Math.max(1, Math.ceil(row.comment.body.length / charsPerLine)) + return Math.max(58, 42 + lines * lineHeight) + timelineRowGap(row, nextRow) } if (row.wake || row.signal || row.manifest) { - return 76 + timelineRowGap(row) + return 76 + timelineRowGap(row, nextRow) } - return 120 + timelineRowGap(row) + return 120 + timelineRowGap(row, nextRow) } const BOTTOM_PIN_THRESHOLD = 8 @@ -242,10 +257,37 @@ const MANIFEST_ROW_GAP = 10 const ROW_SETTLE_MS = 500 type EntityStatus = NonNullable -function timelineRowGap(row: RenderTimelineRow): number { +function timelineRowGap( + row: RenderTimelineRow, + nextRow?: RenderTimelineRow +): number { + if (shouldCollapseCommentMeta(row, nextRow)) return 6 return row.manifest || row.wake || row.signal ? MANIFEST_ROW_GAP : ROW_GAP } +function isPlainCommentRow(row: RenderTimelineRow | undefined): boolean { + const comment = row?.comment + if (!comment) return false + return !comment.deleted_at && !comment.reply_to && !comment.target_snapshot +} + +function shouldCollapseCommentMeta( + row: RenderTimelineRow | undefined, + nextRow: RenderTimelineRow | undefined +): boolean { + if (!isPlainCommentRow(row) || !isPlainCommentRow(nextRow)) return false + const principal = principalKeyFromInput(row?.comment?.from_principal) + if (!principal) return false + return principal === principalKeyFromInput(nextRow?.comment?.from_principal) +} + +function shouldShowCommentMeta( + row: RenderTimelineRow, + nextRow: RenderTimelineRow | undefined +): boolean { + return !shouldCollapseCommentMeta(row, nextRow) +} + type TimelinePaneFindMatch = PaneFindMatch & { rowKey: string rowIndex: number @@ -256,6 +298,7 @@ function timelineRowSearchText( row: RenderTimelineRow, runSearchTextByKey: Map ): string { + if (row.comment) return row.comment.body if (row.inbox) return readInboxText(row.inbox.payload) if (row.wake) { return wakeSectionText({ @@ -270,6 +313,7 @@ function timelineRowSearchText( } function timelineRowLabel(row: RenderTimelineRow): string { + if (row.comment) return `Comment` if (row.inbox?.from_agent) return `Agent message` if (row.inbox) return `User message` if (row.wake) return `Wake` @@ -278,6 +322,160 @@ function timelineRowLabel(row: RenderTimelineRow): string { return `Agent response` } +function truncateCommentPreview(text: string, maxLength = 280): string { + const compact = text.replace(/\s+/g, ` `).trim() + return compact.length <= maxLength + ? compact + : `${compact.slice(0, maxLength - 3)}...` +} + +function createReplyTargetForRow( + row: RenderTimelineRow, + runSearchTextByKey: Map +): SelectedCommentTarget | null { + if (row.comment) { + return { + target: { kind: `comment`, key: row.comment.key }, + snapshot: { + label: `Comment`, + text: truncateCommentPreview(row.comment.body), + from: row.comment.from_principal, + timestamp: row.comment.timestamp, + collection: `comment`, + }, + } + } + + if (row.inbox) { + return { + target: { kind: `timeline`, collection: `inbox`, key: row.inbox.key }, + snapshot: { + label: row.inbox.from_agent ? `Agent message` : `User message`, + text: truncateCommentPreview(readInboxText(row.inbox.payload)), + from: row.inbox.from, + timestamp: row.inbox.timestamp, + collection: `inbox`, + }, + } + } + + if (row.run) { + return { + target: { kind: `timeline`, collection: `run`, key: row.run.key }, + snapshot: { + label: `Assistant response`, + text: truncateCommentPreview( + runSearchTextByKey.get(row.$key) ?? runSearchTextFromSnapshot(row.run) + ), + collection: `run`, + }, + } + } + + if (row.wake) { + return { + target: { kind: `timeline`, collection: `wake`, key: row.wake.key }, + snapshot: { + label: `Wake`, + text: truncateCommentPreview(stringifyPayload(row.wake.payload)), + timestamp: row.wake.payload.timestamp, + collection: `wake`, + }, + } + } + + if (row.signal) { + return { + target: { kind: `timeline`, collection: `signal`, key: row.signal.key }, + snapshot: { + label: `Signal`, + text: truncateCommentPreview(signalSearchText(row.signal)), + timestamp: row.signal.timestamp, + collection: `signal`, + }, + } + } + + if (row.manifest) { + return { + target: { + kind: `timeline`, + collection: `manifest`, + key: row.manifest.key, + }, + snapshot: { + label: manifestKindLabel(row.manifest), + text: truncateCommentPreview(manifestSearchText(row.manifest)), + collection: `manifest`, + }, + } + } + + return null +} + +function createReplyTargetForToolCall( + row: RenderTimelineRow, + toolCall: EntityTimelineToolCallItem +): SelectedCommentTarget { + const runId = row.run?.key ?? toolCall.run_id + return { + target: { + kind: `timeline`, + collection: `tool_call`, + key: toolCall.key, + ...(runId ? { run_id: runId } : {}), + }, + snapshot: { + label: `Tool call`, + text: truncateCommentPreview( + [ + toolCall.tool_name, + stringifySearchPayload(toolCall.args), + stringifySearchPayload(toolCall.result), + stringifySearchPayload(toolCall.error), + ] + .filter((text) => text.length > 0) + .join(` `) + ), + collection: `tool_call`, + }, + } +} + +function timelineRowMatchesCommentTarget( + row: RenderTimelineRow, + target: CommentTarget +): boolean { + if (target.kind === `comment`) { + return row.comment?.key === target.key + } + + switch (target.collection) { + case `inbox`: + return row.inbox?.key === target.key + case `run`: + return row.run?.key === target.key + case `wake`: + return row.wake?.key === target.key + case `signal`: + return row.signal?.key === target.key + case `manifest`: + return row.manifest?.key === target.key + case `text`: + case `tool_call`: { + const run = row.run + if (!run) return false + if (target.run_id && run.key === target.run_id) return true + return run.items.toArray.some((item) => + target.collection === `text` + ? item.text?.key === target.key + : item.toolCall?.key === target.key + ) + } + } +} + function firstSelfSendWakeChange( section: WakeSection, entityUrl?: string | null @@ -336,9 +534,11 @@ function wakeSectionText( function WakeTimelineRow({ section, entityUrl, + onReply, }: { section: WakeSection entityUrl?: string | null + onReply?: () => void }): React.ReactElement { const reason = wakeReason(section, entityUrl) const details = wakeDetails(section, entityUrl) @@ -350,7 +550,13 @@ function WakeTimelineRow({ icon={Radio} title="woke" summary={`${reason} · ${formatChatTimestamp(section.timestamp)}`} + actions={ + onReply ? ( + + ) : undefined + } defaultExpanded={false} + collapsible headerSurface >
@@ -375,9 +581,11 @@ function WakeTimelineRow({ function AgentInboxMessageRow({ inbox, entityUrl, + onReply, }: { inbox: NonNullable entityUrl?: string | null + onReply?: () => void }): React.ReactElement { const parsed = Date.parse(inbox.timestamp) const timestamp = Number.isFinite(parsed) ? parsed : Date.now() @@ -398,7 +606,16 @@ function AgentInboxMessageRow({ icon={Radio} title={isSelfSend ? `sent to itself` : `agent message`} summary={`${isSelfSend ? `self-send` : fromAgent} · ${formatChatTimestamp(timestamp)}`} + actions={ + onReply ? ( + + ) : undefined + } defaultExpanded={false} + collapsible headerSurface >
@@ -419,8 +636,10 @@ function AgentInboxMessageRow({ function SignalTimelineRow({ signal, + onReply, }: { signal: NonNullable + onReply?: () => void }): React.ReactElement { return (
@@ -428,6 +647,11 @@ function SignalTimelineRow({ icon={CircleStop} title={`signal ${signal.signal}`} summary={signalSummary(signal)} + actions={ + onReply ? ( + + ) : undefined + } headerSurface />
@@ -574,11 +798,13 @@ function ManifestTimelineRow({ manifest, entityUrl, entityStatus, + onReply, }: { manifest: Manifest entityUrl: string | null tileId: string | null entityStatus?: EntityStatus + onReply?: () => void }): React.ReactElement { const workspace = useOptionalWorkspace() const navigate = useNavigate() @@ -648,10 +874,17 @@ function ManifestTimelineRow({ ) : null + const replyAction = onReply ? ( + + ) : null const actions = - statusBadge || openAction ? ( + statusBadge || openAction || replyAction ? ( <> {statusBadge} + {replyAction} {openAction} ) : undefined @@ -891,6 +1124,32 @@ function titleCase(value: string): string { .join(` `) } +function TimelineReplyAction({ + label, + onReply, +}: { + label: string + onReply?: () => void +}): React.ReactElement | null { + if (!onReply) return null + return ( + + + + + + ) +} + function stableEntityUrlKey(urls: Iterable): string { return Array.from(new Set(urls)).sort().join(`\0`) } @@ -901,6 +1160,8 @@ function entityUrlsFromKey(key: string): Array { const TimelineRow = memo(function TimelineRow({ row, + previousRow, + nextRow, responseTimestamp, isInitialUserMessage, entityStopped, @@ -917,8 +1178,13 @@ const TimelineRow = memo(function TimelineRow({ onStopGeneration, onForkFromHere, onRunSearchTextChange, + onReplyToRow, + onReplyToToolCall, + onCommentTargetClick, }: { row: RenderTimelineRow + previousRow?: RenderTimelineRow + nextRow?: RenderTimelineRow responseTimestamp: number | null isInitialUserMessage: boolean entityStopped: boolean @@ -938,10 +1204,34 @@ const TimelineRow = memo(function TimelineRow({ * we just invoke. */ onForkFromHere?: ForkFromHereAction onRunSearchTextChange: (rowKey: string, text: string) => void + onReplyToRow?: () => void + onReplyToToolCall?: (toolCall: EntityTimelineToolCallItem) => void + onCommentTargetClick?: (target: CommentTarget) => void }): React.ReactElement { + void previousRow + + if (row.comment) { + return ( + onReplyToRow() : undefined} + onTargetClick={onCommentTargetClick} + /> + ) + } + if (row.inbox) { if (row.inbox.from_agent) { - return + return ( + + ) } const timestamp = Date.parse(row.inbox.timestamp) return ( @@ -961,6 +1251,7 @@ const TimelineRow = memo(function TimelineRow({ } stopPending={stopPending} onStop={onStopGeneration} + onReply={onReplyToRow} /> ) } @@ -974,12 +1265,13 @@ const TimelineRow = memo(function TimelineRow({ timestamp: Date.parse(row.wake.payload.timestamp), }} entityUrl={entityUrl} + onReply={onReplyToRow} /> ) } if (row.signal) { - return + return } if (row.manifest) { @@ -993,6 +1285,7 @@ const TimelineRow = memo(function TimelineRow({ ? entityStatusByUrl.get(getManifestEntityUrl(row.manifest)!) : undefined } + onReply={onReplyToRow} /> ) } @@ -1006,12 +1299,15 @@ const TimelineRow = memo(function TimelineRow({ renderWidth={renderWidth} forkFromHere={onForkFromHere} onSearchTextChange={onRunSearchTextChange} + onReply={onReplyToRow} + onReplyToToolCall={onReplyToToolCall} /> ) }) export function EntityTimeline({ rows, + rowAdjacency, loading, error, entityStopped, @@ -1024,8 +1320,13 @@ export function EntityTimeline({ stopPending = false, onStopGeneration, forkFromHereByRunKey, + onReplyToRow, + focusTarget, + onFocusTargetHandled, + onCommentTargetClick, }: { rows: Array + rowAdjacency?: Array loading: boolean error: string | null entityStopped: boolean @@ -1044,6 +1345,10 @@ export function EntityTimeline({ * the fork pointer and runs the fork → navigate flow. */ forkFromHereByRunKey?: Map + onReplyToRow?: (target: SelectedCommentTarget) => void + focusTarget?: CommentTarget | null + onFocusTargetHandled?: () => void + onCommentTargetClick?: (target: CommentTarget) => void }): React.ReactElement { const { entitiesCollection, runnersCollection, usersCollection } = useElectricAgents() @@ -1133,6 +1438,9 @@ export function EntityTimeline({ const spawnMarkerRef = useRef(null) const [showJumpToBottom, setShowJumpToBottom] = useState(false) const [showTopDivider, setShowTopDivider] = useState(false) + const [highlightedRowKey, setHighlightedRowKey] = useState( + null + ) const [runSearchTextByKey, setRunSearchTextByKey] = useState( () => new Map() ) @@ -1140,6 +1448,7 @@ export function EntityTimeline({ const lastMeasureAtRef = useRef(new Map()) const settledKeysRef = useRef(new Set()) const settleCheckTimerRef = useRef | null>(null) + const highlightTimerRef = useRef | null>(null) const handledScrollSignalRef = useRef(scrollToBottomSignal) const previousStreamingAgentKeyRef = useRef(null) const textColumnWidth = Math.max(0, contentWidth - CHAT_SURFACE_GUTTER) @@ -1309,7 +1618,14 @@ export function EntityTimeline({ estimateSize: (index) => cachedSizeMapRef.current.get( displayRows[index] ? renderRowKey(displayRows[index]!) : `` - ) ?? estimateRowHeight(displayRows[index], textColumnWidth), + ) ?? + estimateRowHeight( + displayRows[index], + textColumnWidth, + displayRows[index] + ? (rowAdjacency?.[index]?.nextRow ?? displayRows[index + 1]) + : undefined + ), getItemKey: (index) => displayRows[index] ? renderRowKey(displayRows[index]!) : index, gap: 0, @@ -1318,6 +1634,50 @@ export function EntityTimeline({ enabled: displayRows.length > 0, }) + const revealCommentTarget = useCallback( + (target: CommentTarget): boolean => { + const targetIndex = displayRows.findIndex((row) => + timelineRowMatchesCommentTarget(row, target) + ) + if (targetIndex < 0) return false + + const row = displayRows[targetIndex] + if (!row) return false + + const rowKey = renderRowKey(row) + isNearBottom.current = false + setShowJumpToBottom(true) + rowVirtualizer.scrollToIndex(targetIndex, { align: `center` }) + setHighlightedRowKey(rowKey) + + if (highlightTimerRef.current !== null) { + clearTimeout(highlightTimerRef.current) + } + highlightTimerRef.current = setTimeout(() => { + highlightTimerRef.current = null + setHighlightedRowKey((current) => (current === rowKey ? null : current)) + }, 1600) + + return true + }, + [displayRows, rowVirtualizer] + ) + + const handleCommentTargetClick = useCallback( + (target: CommentTarget) => { + if (revealCommentTarget(target)) return + onCommentTargetClick?.(target) + }, + [onCommentTargetClick, revealCommentTarget] + ) + + useEffect(() => { + if (!focusTarget) return + if (revealCommentTarget(focusTarget)) { + onFocusTargetHandled?.() + } + }, [focusTarget, onFocusTargetHandled, revealCommentTarget]) + const paneFindAdapter = useMemo(() => { const getHighlightRoot = (match: PaneFindMatch): HTMLElement | null => { if (!contentElement || !isTimelineFindMatch(match)) return null @@ -1605,6 +1965,9 @@ export function EntityTimeline({ if (settleCheckTimerRef.current !== null) { clearTimeout(settleCheckTimerRef.current) } + if (highlightTimerRef.current !== null) { + clearTimeout(highlightTimerRef.current) + } }, [] ) @@ -1704,6 +2067,12 @@ export function EntityTimeline({ {rowVirtualizer.getVirtualItems().map((virtualRow) => { const row = displayRows[virtualRow.index] const rowKey = renderRowKey(row) + const previousRow = + rowAdjacency?.[virtualRow.index]?.previousRow ?? + displayRows[virtualRow.index - 1] + const nextRow = + rowAdjacency?.[virtualRow.index]?.nextRow ?? + displayRows[virtualRow.index + 1] // Stable row key. The previous implementation appended // `:${contentWidth}` to force remount on every column-width @@ -1721,15 +2090,20 @@ export function EntityTimeline({ data-index={virtualRow.index} data-item-key={rowKey} data-pane-find-row-key={rowKey} + data-highlighted={ + highlightedRowKey === rowKey ? `true` : undefined + } className={styles.virtualRow} style={{ transform: `translateY(${virtualRow.start}px)`, - paddingBottom: timelineRowGap(row), + paddingBottom: timelineRowGap(row, nextRow), }} > { + const target = createReplyTargetForRow( + row, + runSearchTextByKey + ) + if (target) onReplyToRow(target) + } + : undefined + } + onReplyToToolCall={ + onReplyToRow && row.run + ? (toolCall) => + onReplyToRow( + createReplyTargetForToolCall(row, toolCall) + ) + : undefined + } />
diff --git a/packages/agents-server-ui/src/components/InlineEventCard.test.tsx b/packages/agents-server-ui/src/components/InlineEventCard.test.tsx new file mode 100644 index 0000000000..959ff16a53 --- /dev/null +++ b/packages/agents-server-ui/src/components/InlineEventCard.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { Wrench } from 'lucide-react' +import { InlineEventCard } from './InlineEventCard' + +function hasNestedButton(markup: string): boolean { + let buttonDepth = 0 + const buttonTag = /<\/?button\b[^>]*>/g + for (const match of markup.matchAll(buttonTag)) { + const tag = match[0] + if (tag.startsWith(` 0) return true + buttonDepth += 1 + } + return false +} + +describe(`InlineEventCard`, () => { + it(`keeps header actions outside the expandable header button`, () => { + const markup = renderToStaticMarkup( + Reply} + collapsible + defaultExpanded + > +
result
+
+ ) + + expect(hasNestedButton(markup)).toBe(false) + expect(markup).toContain(`aria-label="Collapse details"`) + }) +}) diff --git a/packages/agents-server-ui/src/components/InlineEventCard.tsx b/packages/agents-server-ui/src/components/InlineEventCard.tsx index 2956052305..9d7691ff8c 100644 --- a/packages/agents-server-ui/src/components/InlineEventCard.tsx +++ b/packages/agents-server-ui/src/components/InlineEventCard.tsx @@ -32,7 +32,17 @@ export function InlineEventCard({ const [expanded, setExpanded] = useState(defaultExpanded) const showBody = children !== undefined && (!expandable || expanded) const headerOnly = children === undefined - const headerContent = ( + const toggle = () => setExpanded((value) => !value) + const toggleIcon = expandable ? ( + + ) : null + const headerLeadContent = ( <> {summary ? {summary} : null} {badge} + + ) + const headerContent = ( + <> + {headerLeadContent} {actions ? ( {actions} ) : null} - {expandable ? ( - - ) : null} + {toggleIcon} ) @@ -67,10 +74,36 @@ export function InlineEventCard({ className={toolBlock.card} data-header-surface={headerOnly || headerSurface ? `true` : undefined} > - {expandable ? ( + {expandable && actions ? ( + + + {actions} + + + ) : expandable ? (
+ ) : isCommentMode && commentTarget ? ( +
+
+ + {replyPreviewLabel} + + {replyPreviewText && ( + + {replyPreviewText} + + )} +
+ +
) : null } attachments={ - imageAttachmentsEnabled ? ( + imageAttachmentsEnabled && !isCommentMode ? ( + + + )} ) } + +function formatReplyBannerLabel(target: SelectedCommentTarget | null): string { + const label = target?.snapshot.label.trim() + if (!label) return `Reply` + return `Reply to ${label.charAt(0).toLowerCase()}${label.slice(1)}` +} diff --git a/packages/agents-server-ui/src/components/ToolCallView.module.css b/packages/agents-server-ui/src/components/ToolCallView.module.css index 6424f7f4a4..69df58a083 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.module.css +++ b/packages/agents-server-ui/src/components/ToolCallView.module.css @@ -18,6 +18,19 @@ white-space: pre-wrap; } +.actionButton { + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 4px; + color: var(--ds-gray-11); +} + +.actionButton:hover { + background: var(--ds-gray-a5); + color: var(--ds-gray-12); +} + .diffBlock { padding: 0; white-space: pre; diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index 30ce7c7f55..d736e58c8c 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -1,6 +1,6 @@ -import { Wrench } from 'lucide-react' +import { Reply, Wrench } from 'lucide-react' import type { EntityTimelineContentItem } from '@electric-ax/agents-runtime/client' -import { Badge, Stack, Text } from '../ui' +import { Badge, Icon, IconButton, Stack, Text, Tooltip } from '../ui' import type { BadgeTone } from '../ui' import { InlineEventCard } from './InlineEventCard' import { InlineStatusBadge } from './InlineStatusBadge' @@ -257,9 +257,28 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { export function ToolCallView({ item, + onReply, }: { item: ToolCallItem + onReply?: () => void }): React.ReactElement { + const replyAction = onReply ? ( + + + + + + ) : undefined + // send_message: same container style but always expanded with the message text if (item.toolName === `send_message` && typeof item.args.text === `string`) { const badge = statusBadge(item) @@ -270,6 +289,7 @@ export function ToolCallView({ title="send_message" titleFont="mono" collapsible={false} + actions={replyAction} badge={ badge ? ( @@ -299,6 +319,8 @@ export function ToolCallView({ titleFont="mono" summary={summary} defaultExpanded={shouldDefaultExpand} + collapsible + actions={replyAction} badge={ badge ? ( {badge.label} diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index c6eeed92c4..ffb672fcdd 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -77,6 +77,26 @@ } } +.meta { + width: 100%; +} + +.metaActions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 2px; +} + +.metaActionButton { + color: var(--ds-text-4); + opacity: 0.7; +} + +.metaActionButton:hover { + opacity: 1; +} + .body { font-size: var(--ds-chat-text); line-height: var(--ds-chat-text-lh); @@ -205,9 +225,12 @@ } .meta { - opacity: 0.4; /* Match the bubble's 12px horizontal padding so the timestamp/sender * row aligns with the agent text column rather than with the wider * bubble background. */ padding-inline: 12px; } + +.meta > :not(.metaActions) { + opacity: 0.4; +} diff --git a/packages/agents-server-ui/src/components/UserMessage.tsx b/packages/agents-server-ui/src/components/UserMessage.tsx index f3317142d0..5ef2c70a47 100644 --- a/packages/agents-server-ui/src/components/UserMessage.tsx +++ b/packages/agents-server-ui/src/components/UserMessage.tsx @@ -4,10 +4,11 @@ import { Download, File as FileIcon, Image as ImageIcon, + Reply, Square, } from 'lucide-react' import type { EntityTimelineSection } from '@electric-ax/agents-runtime/client' -import { Icon, Stack, Text } from '../ui' +import { Icon, IconButton, Stack, Text, Tooltip } from '../ui' import { downloadAttachment, formatAttachmentSize } from '../lib/attachments' import { streamdownComponents, @@ -49,6 +50,7 @@ export const UserMessage = memo(function UserMessage({ currentPrincipal, usersById, onStop, + onReply, }: { section: UserMessageSection attachments?: Array @@ -57,6 +59,7 @@ export const UserMessage = memo(function UserMessage({ currentPrincipal?: string usersById?: Map onStop?: () => void + onReply?: () => void }): React.ReactElement { const sender = formatSender(section.from, { currentPrincipal, usersById }) @@ -112,6 +115,23 @@ export const UserMessage = memo(function UserMessage({ )} + {onReply && ( + + + + + + + + )} ) diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index dca3ea9c04..4ef2aea5cd 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -92,6 +92,59 @@ background: var(--ds-accent-a2); } +.headerWithActions { + padding: 0; + gap: 0; +} + +.headerContentToggle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + align-self: stretch; + padding: 7px 0 7px 10px; + background: none; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + outline: none; + transition: background 0.08s ease; +} + +.headerContentToggle:hover, +.headerChevronButton:hover { + background: var(--ds-bg-hover); +} + +.headerContentToggle:focus-visible, +.headerChevronButton:focus-visible { + background: var(--ds-accent-a2); +} + +.headerChevronButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + align-self: stretch; + flex-shrink: 0; + padding: 0; + background: none; + border: none; + color: inherit; + cursor: pointer; + outline: none; + transition: background 0.08s ease; +} + +.headerChevronButton .toggleArrow { + margin-left: 0; +} + /* Chevron lives in a fixed-size 16px slot so the row height doesn't jiggle when expand/collapse swaps glyphs. */ .toggleArrow { @@ -130,6 +183,11 @@ margin-left: 0; } +.headerWithActions .headerActions { + margin-left: 0; + margin-right: 4px; +} + /* Tool name is the only mono token in the row — it's an identifier, so reading it as code helps. Summary + everything else stays in the body font for legibility at small sizes. */ diff --git a/packages/agents-server-ui/src/components/views/ChatView.test.ts b/packages/agents-server-ui/src/components/views/ChatView.test.ts new file mode 100644 index 0000000000..26a60e66b1 --- /dev/null +++ b/packages/agents-server-ui/src/components/views/ChatView.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { + buildCommentsTimeline, + commentFocusViewParams, + decodeCommentTargetParam, +} from './ChatView' +import type { + CommentTarget, + EntityTimelineQueryRow, +} from '@electric-ax/agents-runtime/client' + +function commentRow( + key: string, + fromPrincipal = `/principal/user%3Ame` +): EntityTimelineQueryRow { + return { + $key: `comment:${key}`, + comment: { + key, + order: key, + body: key, + from_principal: fromPrincipal, + timestamp: `2026-04-15T18:00:00.000Z`, + }, + } as EntityTimelineQueryRow +} + +function wakeRow(key: string): EntityTimelineQueryRow { + return { + $key: `wake:${key}`, + wake: { + key, + order: key, + payload: { + type: `wake`, + timestamp: `2026-04-15T18:00:00.000Z`, + source: `/chat/test`, + timeout: false, + changes: [], + }, + }, + } as EntityTimelineQueryRow +} + +function attachmentRow(key: string): EntityTimelineQueryRow { + return { + $key: `manifest:${key}`, + manifest: { + key, + kind: `attachment`, + id: key, + streamPath: `/chat/test/attachments/${key}`, + status: `complete`, + subject: { type: `inbox`, key: `msg-1` }, + mimeType: `text/plain`, + byteLength: 12, + createdAt: `2026-04-15T18:00:00.000Z`, + }, + } as EntityTimelineQueryRow +} + +describe(`buildCommentsTimeline`, () => { + it(`keeps comments in stream order while using full-timeline adjacency`, () => { + const first = commentRow(`first`) + const wake = wakeRow(`wake-1`) + const second = commentRow(`second`) + const third = commentRow(`third`) + const attachment = attachmentRow(`att-1`) + const fourth = commentRow(`fourth`) + + const timeline = buildCommentsTimeline([ + first, + wake, + second, + third, + attachment, + fourth, + ]) + + expect(timeline.rows.map((row) => row.comment?.key)).toEqual([ + `first`, + `second`, + `third`, + `fourth`, + ]) + expect(timeline.adjacency[0]).toEqual({ + previousRow: undefined, + nextRow: wake, + }) + expect(timeline.adjacency[1]).toEqual({ + previousRow: wake, + nextRow: third, + }) + expect(timeline.adjacency[2]).toEqual({ + previousRow: second, + nextRow: fourth, + }) + expect(timeline.adjacency[3]).toEqual({ + previousRow: third, + }) + }) +}) + +describe(`comment focus view params`, () => { + it(`round-trips timeline targets for comments-view navigation`, () => { + const target: CommentTarget = { + kind: `timeline`, + collection: `tool_call`, + key: `tool-call-1`, + run_id: `run-1`, + } + + const params = commentFocusViewParams(target) + + expect(decodeCommentTargetParam(params.focus)).toEqual(target) + }) + + it(`rejects invalid encoded target collections`, () => { + const encoded = encodeURIComponent( + JSON.stringify({ + kind: `timeline`, + collection: `unknown`, + key: `thing-1`, + }) + ) + + expect(decodeCommentTargetParam(encoded)).toBeNull() + }) +}) diff --git a/packages/agents-server-ui/src/components/views/ChatView.tsx b/packages/agents-server-ui/src/components/views/ChatView.tsx index 0f64c6c239..68dcabd6b7 100644 --- a/packages/agents-server-ui/src/components/views/ChatView.tsx +++ b/packages/agents-server-ui/src/components/views/ChatView.tsx @@ -2,18 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { eq, useLiveQuery } from '@tanstack/react-db' import { useEntityTimeline } from '../../hooks/useEntityTimeline' -import { EntityTimeline } from '../EntityTimeline' +import { EntityTimeline, type TimelineRowAdjacency } from '../EntityTimeline' import { MessageInput } from '../MessageInput' import { EntityContextDrawer } from '../EntityContextDrawer' import { useElectricAgents } from '../../lib/ElectricAgentsProvider' +import { useWorkspace } from '../../hooks/useWorkspace' +import { isAttachmentManifest } from '../../lib/attachments' import { schemaModelSupportsImageInput } from '../../lib/modelCapabilities' +import type { SelectedCommentTarget } from '../../lib/comments' import { useEntityPermission, useEntityPermissions, type EntityPermission, } from '../../hooks/useEntityPermission' import type { ViewProps } from '../../lib/workspace/viewRegistry' -import type { EntityTimelineQueryRow } from '@electric-ax/agents-runtime/client' +import type { + CommentTarget, + EntityTimelineQueryRow, +} from '@electric-ax/agents-runtime/client' import type { EventPointer } from '@electric-ax/agents-runtime' import type { OptimisticInboxMessage } from '../../lib/sendMessage' import type { SlashCommandRow } from '@electric-ax/agents-runtime/client' @@ -24,6 +30,95 @@ const CHAT_VIEW_PERMISSIONS: ReadonlyArray = [ `signal`, `fork`, ] +const COMMENT_FOCUS_PARAM = `focus` +const COMMENT_TARGET_COLLECTIONS = new Set([ + `inbox`, + `run`, + `text`, + `tool_call`, + `wake`, + `signal`, + `manifest`, +]) + +export function encodeCommentTargetParam(target: CommentTarget): string { + return encodeURIComponent(JSON.stringify(target)) +} + +export function decodeCommentTargetParam( + value: string | undefined +): CommentTarget | null { + if (!value) return null + try { + const decoded = JSON.parse(decodeURIComponent(value)) as unknown + if (!isCommentTarget(decoded)) return null + return decoded + } catch { + return null + } +} + +export function commentFocusViewParams( + target: CommentTarget +): Record { + return { [COMMENT_FOCUS_PARAM]: encodeCommentTargetParam(target) } +} + +function isCommentTarget(value: unknown): value is CommentTarget { + if (!value || typeof value !== `object`) return false + const target = value as Partial + if (target.kind === `comment`) { + return typeof target.key === `string` + } + if (target.kind !== `timeline`) return false + const timelineTarget = target as Partial< + Extract + > + return ( + typeof timelineTarget.key === `string` && + typeof timelineTarget.collection === `string` && + COMMENT_TARGET_COLLECTIONS.has(timelineTarget.collection) && + (timelineTarget.run_id === undefined || + typeof timelineTarget.run_id === `string`) + ) +} + +export function buildCommentsTimeline( + timelineRows: Array +): { + rows: Array + adjacency: Array +} { + const rows: Array = [] + const adjacency: Array = [] + let previousRenderableRow: EntityTimelineQueryRow | undefined + let pendingCommentAdjacencyIndex: number | null = null + + for (const row of timelineRows) { + if (isAttachmentManifest(row.manifest)) continue + + if (pendingCommentAdjacencyIndex !== null) { + const pendingAdjacency = adjacency[pendingCommentAdjacencyIndex]! + adjacency[pendingCommentAdjacencyIndex] = { + ...pendingAdjacency, + nextRow: row, + } + pendingCommentAdjacencyIndex = null + } + + if (row.comment) { + rows.push(row) + adjacency.push({ + previousRow: previousRenderableRow, + }) + pendingCommentAdjacencyIndex = adjacency.length - 1 + } + + previousRenderableRow = row + } + + return { rows, adjacency } +} /** * The default view: chat / timeline + message composer. @@ -40,6 +135,7 @@ export function ChatView({ entityStopped, isSpawning, tileId, + viewParams, }: ViewProps): React.ReactElement { // While `spawning`, the entity has no inbox yet — `connectUrl` is null // so `useEntityTimeline` doesn't try to subscribe and we render an empty @@ -54,6 +150,7 @@ export function ChatView({ entityStopped={entityStopped} isSpawning={isSpawning} tileId={tileId} + viewParams={viewParams} /> ) } @@ -171,6 +268,84 @@ export function ChatLogView({ ) } +export function CommentsView({ + baseUrl, + entityUrl, + entity, + entityStopped, + isSpawning, + tileId, +}: ViewProps): React.ReactElement { + const connectUrl = isSpawning ? null : entityUrl + const { timelineRows, entities, db, loading, error } = useEntityTimeline( + baseUrl || null, + connectUrl + ) + const navigate = useNavigate() + const { helpers } = useWorkspace() + const canWrite = useEntityPermission(entity, `write`) + const [sentCommentSignal, setSentCommentSignal] = useState(0) + const [selectedCommentTarget, setSelectedCommentTarget] = + useState(null) + const commentsTimeline = useMemo( + () => buildCommentsTimeline(timelineRows), + [timelineRows] + ) + + useEffect(() => { + if (error && !isSpawning) { + void navigate({ to: `/` }) + } + }, [error, navigate, isSpawning]) + + useEffect(() => { + setSelectedCommentTarget(null) + }, [connectUrl]) + + const openFullTimelineTarget = useCallback( + (target: CommentTarget) => { + helpers.setTileView(tileId, `chat`, { + viewParams: commentFocusViewParams(target), + }) + }, + [helpers, tileId] + ) + + return ( + <> + + setSelectedCommentTarget(null)} + onSend={() => setSentCommentSignal((value) => value + 1)} + /> + + ) +} + function GenericChatBody({ baseUrl, entityUrl, @@ -178,6 +353,7 @@ function GenericChatBody({ entityStopped, isSpawning, tileId, + viewParams, }: { baseUrl: string entityUrl: string | null @@ -185,6 +361,7 @@ function GenericChatBody({ entityStopped: boolean isSpawning: boolean tileId: string + viewParams?: ViewProps[`viewParams`] }): React.ReactElement { const { timelineRows, @@ -202,8 +379,11 @@ function GenericChatBody({ const canSignal = permissions.signal const canFork = permissions.fork const navigate = useNavigate() + const { helpers } = useWorkspace() const [sentMessageSignal, setSentMessageSignal] = useState(0) const [stopPending, setStopPending] = useState(false) + const [selectedCommentTarget, setSelectedCommentTarget] = + useState(null) const { data: matchingEntityTypes = [] } = useLiveQuery( (query) => { if (!entityTypesCollection) return undefined @@ -248,6 +428,29 @@ function GenericChatBody({ : timelineRows, [inlinePendingInbox, timelineRows] ) + const showComments = viewParams?.comments !== `hidden` + const displayTimelineRows = useMemo>( + () => + showComments + ? timelineRowsWithInlinePending + : timelineRowsWithInlinePending.filter((row) => !row.comment), + [showComments, timelineRowsWithInlinePending] + ) + const focusTarget = useMemo( + () => decodeCommentTargetParam(viewParams?.[COMMENT_FOCUS_PARAM]), + [viewParams] + ) + const clearFocusTarget = useCallback(() => { + if (!viewParams?.[COMMENT_FOCUS_PARAM]) return + const nextParams = { ...viewParams } + delete nextParams[COMMENT_FOCUS_PARAM] + helpers.setTileView(tileId, `chat`, { + viewParams: Object.keys(nextParams).length > 0 ? nextParams : undefined, + }) + }, [helpers, tileId, viewParams]) + useEffect(() => { + if (!showComments) setSelectedCommentTarget(null) + }, [showComments]) const drawerPendingInbox = inlinePendingInbox ? visiblePendingInbox.slice(1) : visiblePendingInbox @@ -308,7 +511,7 @@ function GenericChatBody({ if (!runOffsets) return undefined const map = new Map() let anchor: { rowKey: string; pointer: EventPointer } | null = null - for (const row of timelineRowsWithInlinePending) { + for (const row of displayTimelineRows) { if (row.run && row.run.status === `completed`) { const pointer = runOffsets.get(row.run.key) anchor = pointer ? { rowKey: row.$key, pointer } : null @@ -338,24 +541,17 @@ function GenericChatBody({ } } return map - }, [ - timelineRowsWithInlinePending, - canFork, - db, - forkEntity, - entityUrl, - navigate, - ]) + }, [displayTimelineRows, canFork, db, forkEntity, entityUrl, navigate]) return ( <> setSelectedCommentTarget(null)} drawer={(pending) => ( setMenuOpen(false) /** Wraps a handler so it dispatches and then closes the menu. */ @@ -272,6 +275,15 @@ export function SplitMenu({ void navigator.clipboard.writeText(url.toString()) } + const setChatCommentsVisible = (visible: boolean) => { + const nextParams = { ...(tile.viewParams ?? {}) } + if (visible) delete nextParams.comments + else nextParams.comments = `hidden` + helpers.setTileView(tile.id, tile.viewId, { + viewParams: Object.keys(nextParams).length > 0 ? nextParams : undefined, + }) + } + // The menu and the dialogs are siblings — keeping them in the same // portal subtree caused focus / unmount races (Base UI // tears the menu popup down on close, and any dialog mounted inside @@ -328,6 +340,39 @@ export function SplitMenu({ )} + {showDisplayOptions && ( + <> + + + + Display options + + + + + setChatCommentsVisible(!chatCommentsVisible) + )} + > + + Show comments + + + + + + + )} + helpers.splitTile(tile.id, `right`)}> Split right diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index eba430f748..66d2e59c08 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -4,6 +4,7 @@ import { compareTimelineOrders, createEntityTimelineQuery, normalizeTimelineEntities, + TIMELINE_ORDER_FALLBACK, } from '@electric-ax/agents-runtime/client' import { coalesce, eq } from '@durable-streams/state/db' import { connectEntityStream } from '../lib/entity-connection' @@ -125,7 +126,8 @@ export function useEntityTimeline( .from({ inbox: db.collections.inbox as any }) .where(({ inbox }: any) => eq(inbox.status, `pending`)) .orderBy( - ({ inbox }: any) => coalesce(inbox._timeline_order, `~`), + ({ inbox }: any) => + coalesce(inbox._timeline_order, TIMELINE_ORDER_FALLBACK), `asc` ) .orderBy(({ inbox }: any) => diff --git a/packages/agents-server-ui/src/lib/comments.test.ts b/packages/agents-server-ui/src/lib/comments.test.ts new file mode 100644 index 0000000000..5b806ecd06 --- /dev/null +++ b/packages/agents-server-ui/src/lib/comments.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' +import { compareTimelineOrders } from '@electric-ax/agents-runtime/client' +import { registerActiveServerHeaders } from './auth-fetch' +import { createSendCommentAction } from './comments' +import type { + CommentSnapshot, + CommentTarget, + EntityStreamDBWithActions, +} from '@electric-ax/agents-runtime/client' +import type { OptimisticComment } from './comments' + +function createCommentsDb() { + const comments = createCollection( + localOnlyCollectionOptions({ + id: `test-comments-${Math.random().toString(36).slice(2)}`, + getKey: (comment: OptimisticComment) => comment.key, + }) + ) + return { + db: { + collections: { + comments, + }, + } as unknown as EntityStreamDBWithActions, + comments, + } +} + +describe(`createSendCommentAction`, () => { + afterEach(() => { + vi.restoreAllMocks() + registerActiveServerHeaders(null) + }) + + it(`inserts optimistic comments at increasing pending timeline orders`, async () => { + const fetchMock = vi + .spyOn(globalThis, `fetch`) + .mockResolvedValue(new Response(`{}`, { status: 201 })) + const { db } = createCommentsDb() + const optimistic: Array = [] + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + from: `/principal/user%3Ame`, + onOptimisticComment: (comment) => optimistic.push(comment), + }) + + const firstTx = sendComment({ body: `first` }) + const secondTx = sendComment({ body: `second` }) + await Promise.all([ + firstTx.isPersisted.promise, + secondTx.isPersisted.promise, + ]) + + expect(optimistic).toHaveLength(2) + expect(optimistic[0]?._timeline_order).toMatch(/^zzzz:pending:/) + expect(optimistic[1]?._timeline_order).toMatch(/^zzzz:pending:/) + expect( + compareTimelineOrders( + optimistic[0]!._timeline_order, + optimistic[1]!._timeline_order + ) + ).toBeLessThan(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it(`posts reply metadata with the same key as the optimistic row`, async () => { + const fetchMock = vi + .spyOn(globalThis, `fetch`) + .mockResolvedValue(new Response(`{}`, { status: 201 })) + const { db } = createCommentsDb() + const optimistic: Array = [] + const replyTo: CommentTarget = { + kind: `timeline`, + collection: `run`, + key: `run-1`, + } + const targetSnapshot: CommentSnapshot = { + label: `Assistant response`, + text: `Draft reply`, + collection: `run`, + } + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + from: `/principal/user%3Ame`, + onOptimisticComment: (comment) => optimistic.push(comment), + }) + + const tx = sendComment({ + body: `looks right`, + replyTo, + targetSnapshot, + }) + await tx.isPersisted.promise + + expect(optimistic).toHaveLength(1) + expect(optimistic[0]).toMatchObject({ + body: `looks right`, + from_principal: `/principal/user%3Ame`, + reply_to: replyTo, + target_snapshot: targetSnapshot, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe( + `http://localhost:4437/_electric/entities/chat/test/comments` + ) + expect(init?.method).toBe(`POST`) + expect(new Headers(init?.headers).get(`content-type`)).toBe( + `application/json` + ) + expect(JSON.parse(String(init?.body))).toEqual({ + key: optimistic[0]!.key, + body: `looks right`, + reply_to: replyTo, + target_snapshot: targetSnapshot, + }) + }) + + it(`rejects the persistence promise when the server rejects the comment`, async () => { + vi.spyOn(globalThis, `fetch`).mockResolvedValue( + new Response(JSON.stringify({ message: `No write access` }), { + status: 403, + }) + ) + const { db } = createCommentsDb() + const sendComment = createSendCommentAction({ + db, + baseUrl: `http://localhost:4437`, + entityUrl: `/chat/test`, + }) + + const tx = sendComment({ body: `blocked` }) + + await expect(tx.isPersisted.promise).rejects.toThrow(`No write access`) + }) +}) diff --git a/packages/agents-server-ui/src/lib/comments.ts b/packages/agents-server-ui/src/lib/comments.ts new file mode 100644 index 0000000000..d84e1bd3ea --- /dev/null +++ b/packages/agents-server-ui/src/lib/comments.ts @@ -0,0 +1,126 @@ +import { createOptimisticAction } from '@tanstack/db' +import { createPendingTimelineOrder } from '@electric-ax/agents-runtime/client' +import { getActivePrincipal, serverFetch } from './auth-fetch' +import { entityApiUrl } from './entity-api' +import type { + CommentSnapshot, + CommentTarget, + EntityStreamDBWithActions, + EntityTimelineCommentRow, +} from '@electric-ax/agents-runtime/client' + +const OPTIMISTIC_COMMENT_ORDER_START = Number.MAX_SAFE_INTEGER - 2_000_000 + +let optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START + +export type OptimisticComment = EntityTimelineCommentRow & { + _timeline_order: string +} + +export type SelectedCommentTarget = { + target: CommentTarget + snapshot: CommentSnapshot +} + +type SendCommentInput = { + key: string + body: string + replyTo?: CommentTarget + targetSnapshot?: CommentSnapshot + pendingOrderIndex: number +} + +function nextOptimisticCommentOrderIndex(): number { + optimisticCommentOrderIndex += 1 + if (optimisticCommentOrderIndex >= Number.MAX_SAFE_INTEGER) { + optimisticCommentOrderIndex = OPTIMISTIC_COMMENT_ORDER_START + } + return optimisticCommentOrderIndex +} + +function createClientCommentKey(pendingOrderIndex: number): string { + return `client-comment-${Date.now()}-${pendingOrderIndex}` +} + +function readCommentError(status: number, body: string): Error { + let message = `Failed to post comment (${status})` + if (body) { + try { + const data = JSON.parse(body) as Record + if (data.message) message = String(data.message) + } catch { + message = body + } + } + return new Error(message) +} + +export function createSendCommentAction({ + db, + baseUrl, + entityUrl, + from, + onOptimisticComment, +}: { + db: EntityStreamDBWithActions + baseUrl: string + entityUrl: string + from?: string + onOptimisticComment?: (comment: OptimisticComment) => void +}) { + const action = createOptimisticAction({ + onMutate: ({ key, body, replyTo, targetSnapshot, pendingOrderIndex }) => { + const now = new Date().toISOString() + const comment: OptimisticComment = { + key, + order: createPendingTimelineOrder(pendingOrderIndex), + _timeline_order: createPendingTimelineOrder(pendingOrderIndex), + body, + from_principal: from ?? getActivePrincipal(), + timestamp: now, + ...(replyTo ? { reply_to: replyTo } : {}), + ...(targetSnapshot ? { target_snapshot: targetSnapshot } : {}), + } + onOptimisticComment?.(comment) + db.collections.comments.insert(comment) + }, + mutationFn: async ({ key, body, replyTo, targetSnapshot }) => { + const res = await serverFetch( + entityApiUrl(baseUrl, entityUrl, `/comments`), + { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ + key, + body, + ...(replyTo ? { reply_to: replyTo } : {}), + ...(targetSnapshot ? { target_snapshot: targetSnapshot } : {}), + }), + } + ) + if (!res.ok) { + const body = await res.text().catch(() => ``) + throw readCommentError(res.status, body) + } + }, + }) + + return ({ + body, + replyTo, + targetSnapshot, + }: { + body: string + replyTo?: CommentTarget + targetSnapshot?: CommentSnapshot + }) => { + const pendingOrderIndex = nextOptimisticCommentOrderIndex() + return action({ + key: createClientCommentKey(pendingOrderIndex), + body, + replyTo, + targetSnapshot, + pendingOrderIndex, + }) + } +} diff --git a/packages/agents-server-ui/src/lib/workspace/registerViews.ts b/packages/agents-server-ui/src/lib/workspace/registerViews.ts index e887aeb9bb..7667f5f816 100644 --- a/packages/agents-server-ui/src/lib/workspace/registerViews.ts +++ b/packages/agents-server-ui/src/lib/workspace/registerViews.ts @@ -1,7 +1,7 @@ -import { Database, MessageSquare, SquarePen } from 'lucide-react' +import { Database, MessageCircle, MessageSquare, SquarePen } from 'lucide-react' import { registerView } from './viewRegistry' import { NEW_SESSION_VIEW_ID } from './types' -import { ChatView } from '../../components/views/ChatView' +import { ChatView, CommentsView } from '../../components/views/ChatView' import { StateExplorerView } from '../../components/views/StateExplorerView' import { NewSessionView } from '../../components/views/NewSessionView' @@ -23,6 +23,15 @@ registerView({ Component: ChatView, }) +registerView({ + kind: `entity`, + id: `comments`, + label: `Comments`, + icon: MessageCircle, + description: `Comment-only timeline`, + Component: CommentsView, +}) + registerView({ kind: `entity`, id: `state-explorer`, diff --git a/packages/agents-server/src/entity-manager.ts b/packages/agents-server/src/entity-manager.ts index c16b4cd08e..ae81baa7ee 100644 --- a/packages/agents-server/src/entity-manager.ts +++ b/packages/agents-server/src/entity-manager.ts @@ -16,6 +16,10 @@ import { validateSlashCommandDefinitions, } from '@electric-ax/agents-runtime' import type { EventPointer } from '@electric-ax/agents-runtime' +import type { + CommentSnapshot, + CommentTarget, +} from '@electric-ax/agents-runtime' import { ErrCodeDuplicateURL, ErrCodeEntityPersistFailed, @@ -131,6 +135,18 @@ export interface CreateAttachmentRequest { meta?: Record } +export interface CreateCommentRequest { + key?: string + body: string + fromPrincipal: string + replyTo?: CommentTarget + targetSnapshot?: CommentSnapshot +} + +export interface CreateCommentResult { + key: string +} + export interface ReadAttachmentResult { attachment: ManifestAttachmentEntry bytes: Uint8Array @@ -2269,6 +2285,72 @@ export class EntityManager { } } + async createComment( + entityUrl: string, + req: CreateCommentRequest + ): Promise { + const entity = await this.registry.getEntity(entityUrl) + if (!entity) { + throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404) + } + if (rejectsNormalWrites(entity.status)) { + throw new ElectricAgentsError( + ErrCodeNotRunning, + `Entity is not accepting writes`, + 409 + ) + } + if (this.isForkWorkLockedEntity(entityUrl)) { + this.assertEntityNotForkWorkLocked(entityUrl) + } + + const body = req.body.trim() + if (body.length === 0) { + throw new ElectricAgentsError( + ErrCodeInvalidRequest, + `Comment body is required`, + 400 + ) + } + + const key = + req.key ?? + `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const now = new Date().toISOString() + const value: Record = { + body, + from_principal: req.fromPrincipal, + timestamp: now, + } + if (req.replyTo) { + value.reply_to = req.replyTo + } + if (req.targetSnapshot) { + value.target_snapshot = req.targetSnapshot + } + + const envelope = entityStateSchema.comments.insert({ + key, + value, + } as any) + const encoded = this.encodeChangeEvent(envelope as Record) + + try { + await this.streamClient.append(entity.streams.main, encoded) + } catch (err) { + if (this.isClosedStreamError(err)) { + throw new ElectricAgentsError( + ErrCodeNotRunning, + `Entity is stopped`, + 409 + ) + } + throw err + } + + return { key } + } + async updateInboxMessage( entityUrl: string, key: string, diff --git a/packages/agents-server/src/routing/entities-router.ts b/packages/agents-server/src/routing/entities-router.ts index efa5c79afa..f75d77b454 100644 --- a/packages/agents-server/src/routing/entities-router.ts +++ b/packages/agents-server/src/routing/entities-router.ts @@ -167,6 +167,54 @@ const sendBodySchema = Type.Object({ from_agent: Type.Optional(Type.String()), }) +const commentTargetSchema = Type.Union([ + Type.Object( + { + kind: Type.Literal(`comment`), + key: Type.String(), + }, + { additionalProperties: false } + ), + Type.Object( + { + kind: Type.Literal(`timeline`), + collection: Type.Union([ + Type.Literal(`inbox`), + Type.Literal(`run`), + Type.Literal(`text`), + Type.Literal(`tool_call`), + Type.Literal(`wake`), + Type.Literal(`signal`), + Type.Literal(`manifest`), + ]), + key: Type.String(), + run_id: Type.Optional(Type.String()), + }, + { additionalProperties: false } + ), +]) + +const commentSnapshotSchema = Type.Object( + { + label: Type.String(), + text: Type.Optional(Type.String()), + from: Type.Optional(Type.String()), + timestamp: Type.Optional(Type.String()), + collection: Type.Optional(Type.String()), + }, + { additionalProperties: false } +) + +const createCommentBodySchema = Type.Object( + { + key: Type.Optional(Type.String()), + body: Type.String(), + reply_to: Type.Optional(commentTargetSchema), + target_snapshot: Type.Optional(commentSnapshotSchema), + }, + { additionalProperties: false } +) + function agentUrlForPrincipal(principal: { kind: string id: string @@ -293,6 +341,7 @@ const eventSourceSubscriptionBodySchema = Type.Object({ type SpawnBody = Static type SendBody = Static +type CreateCommentBody = Static type InboxMessageBody = Static type ForkBody = Static type SetTagBody = Static @@ -372,6 +421,13 @@ entitiesRouter.post( withEntityPermission(`write`), sendEntity ) +entitiesRouter.post( + `/:type/:instanceId/comments`, + withExistingEntity, + withSchema(createCommentBodySchema), + withEntityPermission(`write`), + createComment +) entitiesRouter.post( `/:type/:instanceId/attachments`, withExistingEntity, @@ -1198,6 +1254,23 @@ async function sendEntity( return status(204) } +async function createComment( + request: AgentsRouteRequest, + ctx: TenantContext +): Promise { + const parsed = routeBody(request) + await ctx.entityManager.ensurePrincipal(ctx.principal) + const { entityUrl } = requireExistingEntityRoute(request) + const result = await ctx.entityManager.createComment(entityUrl, { + key: parsed.key, + body: parsed.body, + fromPrincipal: ctx.principal.url, + replyTo: parsed.reply_to, + targetSnapshot: parsed.target_snapshot, + }) + return json(result, { status: 201 }) +} + async function createAttachment( request: AgentsRouteRequest, ctx: TenantContext diff --git a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts index 4cab89f72b..b6418dbf6b 100644 --- a/packages/agents-server/test/electric-agents-manager-write-validation.test.ts +++ b/packages/agents-server/test/electric-agents-manager-write-validation.test.ts @@ -94,6 +94,11 @@ function attachmentManifest(value: Record) { } } +function decodeAppendEvent(value: unknown): Record { + expect(value).toBeInstanceOf(Uint8Array) + return JSON.parse(new TextDecoder().decode(value as Uint8Array)) +} + describe(`ElectricAgentsManager.validateWriteEvent`, () => { it(`validates delete events against old_value instead of value`, async () => { const manager = createManager() @@ -203,6 +208,69 @@ describe(`ElectricAgentsManager attachments`, () => { }) }) +describe(`ElectricAgentsManager comments`, () => { + it(`appends trimmed comment rows with reply metadata`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ + streamClient: { append }, + }) + const replyTo = { + kind: `timeline`, + collection: `run`, + key: `run-1`, + } as const + const targetSnapshot = { + label: `Assistant response`, + text: `Summary`, + collection: `run`, + } + + await expect( + manager.createComment(`/chat/session-1`, { + key: `comment-1`, + body: ` Looks good. `, + fromPrincipal: `/principal/user%3Ame`, + replyTo, + targetSnapshot, + }) + ).resolves.toEqual({ key: `comment-1` }) + + expect(append).toHaveBeenCalledWith( + `/chat/session-1`, + expect.any(Uint8Array) + ) + expect(decodeAppendEvent(append.mock.calls[0]?.[1])).toMatchObject({ + type: `comment`, + key: `comment-1`, + headers: { operation: `insert` }, + value: { + body: `Looks good.`, + from_principal: `/principal/user%3Ame`, + reply_to: replyTo, + target_snapshot: targetSnapshot, + }, + }) + }) + + it(`rejects empty comments before appending`, async () => { + const append = vi.fn() + const { manager } = createAttachmentManager({ + streamClient: { append }, + }) + + await expect( + manager.createComment(`/chat/session-1`, { + body: ` `, + fromPrincipal: `/principal/user%3Ame`, + }) + ).rejects.toMatchObject({ + status: 400, + message: `Comment body is required`, + }) + expect(append).not.toHaveBeenCalled() + }) +}) + describe(`ElectricAgentsManager composer input validation`, () => { it(`accepts composer_input without an entity-declared inbox schema`, async () => { const manager = new EntityManager({ diff --git a/packages/agents-server/test/electric-agents-routes.test.ts b/packages/agents-server/test/electric-agents-routes.test.ts index 6e136dd75f..0d7ea49f54 100644 --- a/packages/agents-server/test/electric-agents-routes.test.ts +++ b/packages/agents-server/test/electric-agents-routes.test.ts @@ -959,6 +959,58 @@ describe(`ElectricAgentsRoutes send endpoint`, () => { }) }) +describe(`ElectricAgentsRoutes comments endpoint`, () => { + it(`routes comment creation to the manager with the authenticated principal`, async () => { + const replyTo = { + kind: `timeline`, + collection: `tool_call`, + key: `tool-call-1`, + run_id: `run-1`, + } + const targetSnapshot = { + label: `Tool call`, + text: `bash pwd`, + collection: `tool_call`, + } + const manager = { + registry: { + getEntity: vi.fn().mockResolvedValue({ url: `/chat/test` }), + getEntityType: vi.fn(), + }, + ensurePrincipal: vi.fn().mockResolvedValue(undefined), + createComment: vi.fn().mockResolvedValue({ key: `comment-1` }), + } as any + + const response = await routeResponse( + manager, + `POST`, + `/_electric/entities/chat/test/comments`, + { + key: `client-comment-1`, + body: ` Reply body `, + reply_to: replyTo, + target_snapshot: targetSnapshot, + } + ) + + expect(response.status).toBe(201) + expect(await responseJson(response)).toEqual({ key: `comment-1` }) + expect(manager.ensurePrincipal).toHaveBeenCalledWith({ + kind: `system`, + id: `dev-local`, + key: `system:dev-local`, + url: `/principal/system:dev-local`, + }) + expect(manager.createComment).toHaveBeenCalledWith(`/chat/test`, { + key: `client-comment-1`, + body: ` Reply body `, + fromPrincipal: `/principal/system:dev-local`, + replyTo, + targetSnapshot, + }) + }) +}) + describe(`ElectricAgentsRoutes spawn endpoint request validation`, () => { it(`rejects malformed JSON before spawning`, async () => { const manager = {