diff --git a/apps/web/e2e/support/conversation-work-cell-fixture.ts b/apps/web/e2e/support/conversation-work-cell-fixture.ts index 48ed44925..7eeb5b26e 100644 --- a/apps/web/e2e/support/conversation-work-cell-fixture.ts +++ b/apps/web/e2e/support/conversation-work-cell-fixture.ts @@ -94,6 +94,31 @@ export function installActiveChannelWorkCellFixture(state: MockState): { return result; } +export function installExplorationWorkCellFixture(state: MockState, readCount = 3): { + readonly runId: string; + readonly threadId: string; +} { + const result = installChannelWorkCellFixture(state, "exploration"); + const readPaths = [ + "apps/web/src/lib/conversation-work-cell.ts", + "apps/web/src/lib/workflow-progress.ts", + "apps/web/src/shared/conversation/blocks/ConversationWorkCell.tsx", + "packages/conversation-ui-core/src/activity-projection.ts", + "packages/conversation-ui-core/src/command-batching.ts", + "apps/web/src/styles/shared/conversation-work-cell-activity.css", + ]; + state.executionActions = [ + ...Array.from({ length: readCount }, (_, index) => readAction( + index < 3 ? ["one", "two", "three"][index]! : `item-${index + 1}`, + result.threadId, + result.runId, + readPaths[index % readPaths.length]!, + index, + )), + ]; + return result; +} + export function installNormalWorkCellFixture(state: MockState): string { const title = "Focused normal WorkCell"; const threadId = "thread_ui2_normal_work_cell"; @@ -235,6 +260,35 @@ function completedCommandActions(threadId: string, runId: string, suffix: string ]; } +function readAction( + id: string, + threadId: string, + runId: string, + path: string, + index: number, +): MockExecutionAction { + const actionTime = new Date(Date.parse(startedAt) + ((index + 1) * 8_000)).toISOString(); + return { + id: `execution_action_read_${id}`, + workspaceId, + threadId, + runId, + toolCallId: `tool_read_${id}`, + actionType: "read", + risk: "low", + status: "succeeded", + title: "Read file", + summary: `Read ${path}`, + targetRefs: [{ type: "file", path, label: path }], + previewArtifactIds: [], + resultArtifactIds: [], + createdAt: actionTime, + updatedAt: actionTime, + executedAt: actionTime, + completedAt: actionTime, + }; +} + function commandAction( id: string, threadId: string, diff --git a/apps/web/e2e/ui2-conversation-work-cell-activity.spec.ts b/apps/web/e2e/ui2-conversation-work-cell-activity.spec.ts index 8306a9f39..3585a6085 100644 --- a/apps/web/e2e/ui2-conversation-work-cell-activity.spec.ts +++ b/apps/web/e2e/ui2-conversation-work-cell-activity.spec.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { installActiveChannelWorkCellFixture, installChannelWorkCellFixture, + installExplorationWorkCellFixture, installNormalWorkCellFixture, installTeammateWorkCellFixture, } from "./support/conversation-work-cell-fixture"; @@ -195,6 +196,74 @@ test("Normal conversation keeps the completed command batch in the shared WorkCe expect(runtimeErrors).toEqual([]); }); +test("Completed WorkCell collapses adjacent read actions into an expandable exploration batch", async ({ page }) => { + await prepareSurface(page, { theme: "light", width: 1440, height: 960 }); + const runtimeErrors = collectRuntimeErrors(page); + const state = createBaseState({ approvalStatus: "expired" }); + const { threadId } = installExplorationWorkCellFixture(state); + await mockDaemon(page, state); + + await page.goto("/"); + await openEngineeringChannel(page); + + const feed = page.getByLabel("Channel message feed"); + const workCell = feed.locator(`.conversation-work-cell[data-thread-id="${threadId}"]`); + await expect(workCell).toBeVisible(); + await expect(workCell.locator(".conversation-operation-shell-status")).toContainText("Read 3 items"); + await expandOperationShell(workCell); + + const batch = workCell.locator(".conversation-exploration-batch"); + const trigger = batch.locator(".conversation-exploration-batch-header"); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await trigger.focus(); + await trigger.press("Space"); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await expect(batch.locator(".conversation-exploration-batch-list .conversation-work-cell-row")).toHaveCount(3); + await expectContained(page, [feed, workCell, batch]); + expect(runtimeErrors).toEqual([]); +}); + +test("Dense exploration activity stays scrollable after ten atomic rows", async ({ page }) => { + await prepareSurface(page, { theme: "light", width: 1440, height: 600 }); + const runtimeErrors = collectRuntimeErrors(page); + const state = createBaseState({ approvalStatus: "expired" }); + const { threadId } = installExplorationWorkCellFixture(state, 11); + await mockDaemon(page, state); + + await page.goto("/"); + await openEngineeringChannel(page); + + const feed = page.getByLabel("Channel message feed"); + const workCell = feed.locator(`.conversation-work-cell[data-thread-id="${threadId}"]`); + await expect(workCell).toBeVisible(); + await expect(workCell.locator(".conversation-operation-shell-status")).toContainText("Read 11 items"); + await expandOperationShell(workCell); + + const stream = workCell.locator('[data-conversation-work-stream="primary"]'); + await expect(stream).toHaveAttribute("data-activity-density", "dense"); + await expect(stream).toHaveCSS("overflow-y", "auto"); + await expect.poll(() => stream.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + }))).toEqual(expect.objectContaining({ + scrollHeight: expect.any(Number), + })); + const dimensions = await stream.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(dimensions.scrollHeight).toBeGreaterThan(dimensions.clientHeight); + + const batch = workCell.locator(".conversation-exploration-batch"); + const trigger = batch.locator(".conversation-exploration-batch-header"); + await trigger.focus(); + await trigger.press("Space"); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + await expect(batch.locator(".conversation-exploration-batch-list .conversation-work-cell-row")).toHaveCount(11); + await expectContained(page, [feed, workCell, stream, batch]); + expect(runtimeErrors).toEqual([]); +}); + test("Teammate conversation keeps command activity contained in dark reduced motion", async ({ page }) => { await prepareSurface(page, { theme: "dark", width: 1280, height: 800, reducedMotion: true }); const runtimeErrors = collectRuntimeErrors(page); diff --git a/apps/web/src/i18n/locales/en-US.ts b/apps/web/src/i18n/locales/en-US.ts index 2ce87350a..0f6e9383f 100644 --- a/apps/web/src/i18n/locales/en-US.ts +++ b/apps/web/src/i18n/locales/en-US.ts @@ -1048,8 +1048,11 @@ export const enUS = { "Editing {target}": "Editing {target}", "Ran command": "Ran command", "Ran {count} commands": "Ran {count} commands", + "Read {count} items": "Read {count} items", "Expand command batch": "Expand command batch", "Collapse command batch": "Collapse command batch", + "Expand read records": "Expand read records", + "Collapse read records": "Collapse read records", "Running command": "Running command", "Searched": "Searched", "Searched {target}": "Searched {target}", diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index a38aadea2..9821fd435 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -3462,8 +3462,11 @@ export const zhCN = { "Editing {target}": "正在编辑 {target}", "Ran command": "已运行命令", "Ran {count} commands": "已运行 {count} 个命令", + "Read {count} items": "已读取 {count} 项", "Expand command batch": "展开命令批次", "Collapse command batch": "折叠命令批次", + "Expand read records": "展开读取记录", + "Collapse read records": "折叠读取记录", "Running command": "正在运行命令", "Searched": "已搜索", "Searched {target}": "已搜索 {target}", diff --git a/apps/web/src/lib/conversation-work-cell.ts b/apps/web/src/lib/conversation-work-cell.ts index 798c483c1..db7bd4e44 100644 --- a/apps/web/src/lib/conversation-work-cell.ts +++ b/apps/web/src/lib/conversation-work-cell.ts @@ -1,17 +1,24 @@ import { normalizeConversationOperationCell, + projectConversationActivity, projectConversationCommandBatches, + summarizeConversationActivity, terminalReportToViewModel, type ConversationAgentActivitySpan, + type ConversationActivitySummary, type ConversationArtifactStripChangeArtifact, type ConversationArtifactStripItem, type ConversationCommandBatch, type ConversationCommandBatchItem, + type ConversationExplorationBatch, type ConversationOperationCellMode, type ConversationOperationCellSourceKind, type ConversationOperationCellStatus, type ConversationOperationCellViewModel, + type ConversationOperationExecutionView, type ConversationOperationKind, + type ConversationOperationSourceRef, + type ConversationTurnRunRole, type ConversationWorkCellDetailsModel, type ConversationWorkCellChangePreview, type ConversationWorkCellEvidenceLink, @@ -32,21 +39,28 @@ import { artifactLooksLikeDiff } from "./diff-summary.js"; export { normalizeConversationOperationCell, + projectConversationActivity, projectConversationCommandBatches, + summarizeConversationActivity, }; export type { ConversationAgentActivitySpan, ConversationAgentActivitySpanKind, + ConversationActivitySummary, ConversationArtifactStripChangeArtifact, ConversationArtifactStripItem, ConversationArtifactStripItemKind, ConversationCommandBatch, ConversationCommandBatchItem, + ConversationExplorationBatch, ConversationOperationCellMode, ConversationOperationCellSourceKind, ConversationOperationCellStatus, ConversationOperationCellViewModel, + ConversationOperationExecutionView, ConversationOperationKind, + ConversationOperationSourceRef, + ConversationTurnRunRole, ConversationWorkCellBranch, ConversationWorkCellChangePreview, ConversationWorkCellDetailsModel, @@ -491,6 +505,7 @@ export function buildConversationWorkCellViewModel(input: BuildConversationWorkC const mergedEvidenceLinks = terminalReport ? combinedEvidenceLinks.filter((link) => !link.artifactId || !representedArtifactIds.has(link.artifactId)) : combinedEvidenceLinks; + const operationRows = buildActiveRows({ t: input.t, latestRun, runs, turnRunLinks: input.turnRunLinks, pendingApprovals, artifacts, actions }); return normalizeConversationOperationCell({ id: turnContext ? `work-cell:turn:${turnContext.turn.id}` : `work-cell:${input.threadId}`, @@ -515,7 +530,9 @@ export function buildConversationWorkCellViewModel(input: BuildConversationWorkC { id: "approvals", label: input.t("Approvals"), value: String(visibleApprovals.length) }, ], elapsedMs: workCellElapsedMs(runs), - operationRows: buildActiveRows({ t: input.t, latestRun, runs, pendingApprovals, artifacts, actions }), + operationRows, + operationItems: projectConversationActivity(operationRows), + activitySummary: summarizeConversationActivity(operationRows), agentBranches: [], terminalReportId: terminalReport?.reportId, terminalReportRevisionKey: terminalReport?.revisionKey, @@ -1028,16 +1045,20 @@ function buildActiveRows(input: { readonly t: Translate; readonly latestRun: RunSession; readonly runs: readonly RunSession[]; + readonly turnRunLinks?: readonly LingTurnRun[]; readonly pendingApprovals: readonly ApprovalRequest[]; readonly artifacts: readonly RunArtifact[]; readonly actions: readonly ExecutionAction[]; }): readonly ConversationWorkCellRow[] { const rows: ConversationWorkCellRow[] = []; const artifactsById = artifactsByIdMap(input.artifacts); + const turnRunRoleByRunId = new Map((input.turnRunLinks ?? []).map((link) => [link.runId, link.role])); const actionRows = [...input.actions] .filter(isMainFeedActionRow) .sort(compareExecutionActionTranscriptOrder) - .flatMap((action) => executionActionTranscriptRows(action, artifactsById, input.t)); + .flatMap((action) => executionActionTranscriptRows(action, artifactsById, input.t, { + turnRunRole: action.runId ? turnRunRoleByRunId.get(action.runId) ?? "primary" : undefined, + })); if (actionRows.length > 0) { rows.push(...actionRows); @@ -1193,12 +1214,20 @@ export function buildConversationExecutionActionEvidenceRows(input: { readonly artifactsById?: ReadonlyMap; readonly t: Translate; readonly limit?: number; + readonly participantId?: string; + readonly turnRunRoleByRunId?: ReadonlyMap; + readonly workflowStepId?: string; }): readonly ConversationWorkCellRow[] { const artifactsById = input.artifactsById ?? artifactsByIdMap(input.artifacts ?? []); - return input.actions - .filter(isMainFeedActionRow) - .slice(0, input.limit ?? 8) - .flatMap((action) => executionActionTranscriptRows(action, artifactsById, input.t)); + const actions = input.actions + .filter(isMainFeedActionRow); + const visibleActions = input.limit === undefined ? actions : actions.slice(0, input.limit); + return visibleActions + .flatMap((action) => executionActionTranscriptRows(action, artifactsById, input.t, { + participantId: input.participantId, + turnRunRole: action.runId ? input.turnRunRoleByRunId?.get(action.runId) ?? "primary" : undefined, + workflowStepId: input.workflowStepId, + })); } function artifactsByIdMap(artifacts: readonly RunArtifact[]): ReadonlyMap { @@ -1218,9 +1247,15 @@ function executionActionEvidenceRow( action: ExecutionAction, artifactsById: ReadonlyMap, t: Translate, + context: { + readonly participantId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly workflowStepId?: string; + } = {}, ): ConversationWorkCellRow { const commandText = executionActionCommandText(action); const detailLines = executionActionDetailLines(action, artifactsById, t); + const operationExecution = executionActionExecutionView(action, context); return { id: `action:${action.id}`, kind: operationKindForExecutionAction(action), @@ -1246,6 +1281,7 @@ function executionActionEvidenceRow( stdoutArtifactId: action.commandEvidence.stdoutArtifactId, stderrArtifactId: action.commandEvidence.stderrArtifactId, } : undefined, + operationExecution, changePreview: executionActionChangePreview(action, artifactsById), detailLines, meta: operationStatusMeta(action.status, t), @@ -1278,8 +1314,14 @@ function executionActionTranscriptRows( action: ExecutionAction, artifactsById: ReadonlyMap, t: Translate, + context: { + readonly participantId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly workflowStepId?: string; + } = {}, ): readonly ConversationWorkCellRow[] { const rows: ConversationWorkCellRow[] = []; + const operationExecution = executionActionExecutionView(action, context); const scopeSummary = action.scopeSummary?.trim(); if (scopeSummary) { rows.push({ @@ -1288,12 +1330,79 @@ function executionActionTranscriptRows( presentation: "note", title: truncatePreview(scopeSummary, 260), tone: "neutral", + operationExecution, }); } - rows.push(executionActionEvidenceRow(action, artifactsById, t)); + rows.push(executionActionEvidenceRow(action, artifactsById, t, context)); return rows; } +function executionActionExecutionView( + action: ExecutionAction, + context: { + readonly participantId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly workflowStepId?: string; + } = {}, +): ConversationOperationExecutionView { + const workflowStepId = context.workflowStepId ?? action.targetRefs.find((ref) => ref.type === "workflow_step")?.id; + return { + actionId: action.id, + toolCallId: action.toolCallId, + runId: action.runId, + turnRunRole: context.turnRunRole, + participantId: context.participantId, + workUnitId: action.workUnitId, + workflowStepId, + actionType: action.actionType, + status: action.status, + updatedAt: action.updatedAt ?? action.completedAt ?? action.executedAt ?? action.createdAt, + sourceRefs: executionActionSourceRefs(action, workflowStepId), + }; +} + +function executionActionSourceRefs(action: ExecutionAction, workflowStepId: string | undefined): readonly ConversationOperationSourceRef[] { + const refs: ConversationOperationSourceRef[] = [ + { kind: "execution_action", id: action.id }, + ]; + if (action.runId) { + refs.push({ kind: "run", id: action.runId }); + } + if (action.workUnitId) { + refs.push({ kind: "work_unit", id: action.workUnitId }); + } + if (action.toolCallId) { + refs.push({ kind: "tool_call", id: action.toolCallId }); + } + if (workflowStepId) { + refs.push({ kind: "workflow_step", id: workflowStepId }); + } + for (const target of action.targetRefs) { + const id = target.path ?? target.id ?? target.label; + if (!id || target.type === "run" || target.type === "work_unit" || target.type === "workflow_step") { + continue; + } + refs.push({ + kind: target.type, + id, + label: target.label ?? target.path, + }); + } + return uniqueOperationSourceRefs(refs); +} + +function uniqueOperationSourceRefs(refs: readonly ConversationOperationSourceRef[]): readonly ConversationOperationSourceRef[] { + const seen = new Set(); + return refs.filter((ref) => { + const key = `${ref.kind}:${ref.id}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + function executionActionDisplayTitle( action: ExecutionAction, artifactsById: ReadonlyMap, diff --git a/apps/web/src/lib/workflow-progress.ts b/apps/web/src/lib/workflow-progress.ts index c2b36583f..861f84eff 100644 --- a/apps/web/src/lib/workflow-progress.ts +++ b/apps/web/src/lib/workflow-progress.ts @@ -1,8 +1,9 @@ import type { ApprovalRequest, ChannelWorkflow, ChannelWorkflowStep, ExecutionAction, RunArtifact, RunSession, TerminalReportDto, WorkflowEvidenceResponse, WorkflowHandoffIntent } from "../types.js"; -import { terminalReportToViewModel } from "@ling-stack/conversation-ui-core"; +import { projectConversationActivity, summarizeConversationActivity, terminalReportToViewModel } from "@ling-stack/conversation-ui-core"; import type { Translate } from "../i18n.js"; import type { ConversationAgentActivitySpan, + ConversationActivitySummary, ConversationArtifactStripItem, ConversationWorkCellDetailsModel, ConversationWorkCellEvidenceLink, @@ -35,6 +36,7 @@ export interface WorkflowProgressModel { readonly summary: string; readonly currentRows: readonly ConversationWorkCellRow[]; readonly agentSpans: readonly ConversationAgentActivitySpan[]; + readonly activitySummary: ConversationActivitySummary; readonly stats: readonly WorkflowProgressStat[]; readonly elapsedMs?: number; readonly groups: readonly ConversationWorkCellGroup[]; @@ -104,6 +106,13 @@ export function buildWorkflowProgressModel(input: BuildWorkflowProgressInput): W buildWorkflowProgressGroups({ t: input.t, steps: orderedSteps, artifacts, approvals: workflowApprovals, actions: workflowActions }), input.t, ); + const agentSpans = buildWorkflowAgentActivitySpans({ + t: input.t, + steps: orderedSteps, + actions: workflowActions, + artifactsByRun: input.artifactsByRun, + runsByStepId: workflowRunsByStepId, + }); return { id: `workflow-progress:${input.workflow.id}`, @@ -129,13 +138,8 @@ export function buildWorkflowProgressModel(input: BuildWorkflowProgressInput): W steps: orderedSteps, pendingApprovalCount: pendingApprovals.length, }), - agentSpans: buildWorkflowAgentActivitySpans({ - t: input.t, - steps: orderedSteps, - actions: workflowActions, - artifactsByRun: input.artifactsByRun, - runsByStepId: workflowRunsByStepId, - }), + agentSpans, + activitySummary: summarizeConversationActivity(agentSpans.flatMap((span) => span.rows)), stats: [ { id: "steps", label: input.t("Steps"), value: `${completedSteps}/${orderedSteps.length}` }, { id: "evidence", label: input.t("Evidence"), value: String(artifacts.length) }, @@ -212,6 +216,8 @@ export function workflowProgressToConversationWorkCell(model: WorkflowProgressMo operationRows: model.agentSpans.length > 0 ? [] : model.currentRows, agentSpans: model.agentSpans, agentBranches: model.agentSpans, + ...(model.agentSpans.length === 0 ? { operationItems: projectConversationActivity(model.currentRows) } : {}), + activitySummary: model.activitySummary, terminalReportId: terminalReport?.reportId, terminalReportRevisionKey: terminalReport?.revisionKey, terminalReportRows: terminalReport?.rows ?? [], @@ -559,13 +565,23 @@ function buildWorkflowAgentActivitySpans(input: { const latestStep = workflowAgentFocusStep(orderedSteps); const groupStatus = workflowAgentGroupStatus(orderedSteps); const canonicalParticipantId = orderedSteps.find((step) => step.toParticipantId.trim())?.toParticipantId.trim(); + const participantScope = canonicalParticipantId ?? participantId; const latestActionRow = latestStep ? workflowLatestActionRowForStep({ step: latestStep, actionsByRun, artifactsByRun: input.artifactsByRun, runsByStepId: input.runsByStepId, t: input.t, + participantId: participantScope, }) : undefined; + const rows = buildWorkflowAgentActivityRows({ + steps: orderedSteps, + actionsByRun, + artifactsByRun: input.artifactsByRun, + runsByStepId: input.runsByStepId, + t: input.t, + participantId: participantScope, + }); return { sortIndex: index, id: `agent:${participantId}`, @@ -575,13 +591,8 @@ function buildWorkflowAgentActivitySpans(input: { tone: workflowStepTone(groupStatus), stateLabel: workflowStepStatusMeta(groupStatus, input.t), latestRow: latestActionRow ?? (latestStep ? workflowStepToAgentRow(latestStep, input.t, "latest") : undefined), - rows: buildWorkflowAgentActivityRows({ - steps: orderedSteps, - actionsByRun, - artifactsByRun: input.artifactsByRun, - runsByStepId: input.runsByStepId, - t: input.t, - }), + rows, + items: projectConversationActivity(rows, { participantScope }), } satisfies ConversationAgentActivitySpan & { readonly sortIndex: number }; }) .sort((left, right) => left.sortIndex - right.sortIndex) @@ -594,6 +605,7 @@ function buildWorkflowAgentActivityRows(input: { readonly artifactsByRun: Readonly>; readonly runsByStepId: ReadonlyMap; readonly t: Translate; + readonly participantId: string; }): readonly ConversationWorkCellRow[] { return input.steps.flatMap((step) => [ workflowStepToAgentRow(step, input.t, "history"), @@ -603,6 +615,7 @@ function buildWorkflowAgentActivityRows(input: { artifactsByRun: input.artifactsByRun, runsByStepId: input.runsByStepId, t: input.t, + participantId: input.participantId, scope: "history", }), ]); @@ -614,6 +627,7 @@ function workflowLatestActionRowForStep(input: { readonly artifactsByRun: Readonly>; readonly runsByStepId: ReadonlyMap; readonly t: Translate; + readonly participantId: string; }): ConversationWorkCellRow | undefined { return workflowActionRowsForStep({ ...input, scope: "latest" }).at(-1); } @@ -624,6 +638,7 @@ function workflowActionRowsForStep(input: { readonly artifactsByRun: Readonly>; readonly runsByStepId: ReadonlyMap; readonly t: Translate; + readonly participantId: string; readonly scope: "latest" | "history"; }): readonly ConversationWorkCellRow[] { const runIds = uniqueStrings([ @@ -643,7 +658,9 @@ function workflowActionRowsForStep(input: { actions, artifacts: runIds.flatMap((runId) => input.artifactsByRun[runId] ?? []), t: input.t, - limit: 8, + participantId: input.participantId, + workflowStepId: input.step.id, + limit: input.scope === "latest" ? 8 : undefined, }).map((row) => ({ ...row, id: `${input.scope}:workflow-step:${input.step.id}:${row.id}`, diff --git a/apps/web/src/shared/conversation/blocks/ConversationExplorationBatch.tsx b/apps/web/src/shared/conversation/blocks/ConversationExplorationBatch.tsx new file mode 100644 index 000000000..e70113c09 --- /dev/null +++ b/apps/web/src/shared/conversation/blocks/ConversationExplorationBatch.tsx @@ -0,0 +1,44 @@ +import { BookOpen, ChevronDown } from "lucide-react"; +import { useI18n } from "../../../i18n.js"; +import type { ConversationExplorationBatch as ConversationExplorationBatchModel, ConversationWorkCellRow } from "../../../lib/conversation-work-cell.js"; +import { useConversationBlockView } from "../view-store/use-conversation-block-view.js"; + +export function ConversationExplorationBatch({ + batch, + renderRow, +}: { + readonly batch: ConversationExplorationBatchModel; + readonly renderRow: (row: ConversationWorkCellRow) => React.ReactNode; +}): React.JSX.Element { + const { t } = useI18n(); + const disclosure = useConversationBlockView({ + blockId: batch.id, + revisionKey: batch.revisionKey, + defaultExpanded: batch.defaultExpanded, + preserveTouchedStateOnRevision: true, + }); + const batchLabel = t("Read {count} items", { count: batch.targetCount ?? batch.operationCount }); + + return ( +
+ + {disclosure.expanded ? ( +
+ {batch.rows.map((row) => renderRow(row))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/shared/conversation/blocks/ConversationWorkCell.tsx b/apps/web/src/shared/conversation/blocks/ConversationWorkCell.tsx index 05be4a999..1d0402eca 100644 --- a/apps/web/src/shared/conversation/blocks/ConversationWorkCell.tsx +++ b/apps/web/src/shared/conversation/blocks/ConversationWorkCell.tsx @@ -2,12 +2,13 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { BookOpen, CheckCircle2, ChevronDown, CircleAlert, Clock3, FilePenLine, FileText, GitBranch, History, ListChecks, Loader2, RefreshCw, Search, ShieldCheck, SquareTerminal } from "lucide-react"; import { useI18n, type Translate } from "../../../i18n.js"; import { parseUnifiedDiff, summarizeFileChanges, type ConversationFileChange } from "../../../lib/diff-summary.js"; -import { projectConversationCommandBatches, type ConversationAgentActivitySpan, type ConversationArtifactStripItem, type ConversationCommandBatchItem, type ConversationOperationCellViewModel, type ConversationWorkCellEvidenceLink, type ConversationWorkCellGroup, type ConversationWorkCellItem, type ConversationWorkCellRow, type ConversationWorkCellRowDetailLine } from "../../../lib/conversation-work-cell.js"; +import { projectConversationActivity, type ConversationAgentActivitySpan, type ConversationArtifactStripItem, type ConversationCommandBatchItem, type ConversationOperationCellViewModel, type ConversationWorkCellEvidenceLink, type ConversationWorkCellGroup, type ConversationWorkCellItem, type ConversationWorkCellRow, type ConversationWorkCellRowDetailLine } from "../../../lib/conversation-work-cell.js"; import type { PreviewContentResponse } from "../../../types.js"; import type { ArtifactPreviewLoader } from "../preview/ArtifactPreviewDrawer.js"; import type { ConversationSurfaceKind, TerminalReportProjectionPolicy } from "../tokens/conversation-surface-policy.js"; import { ConversationArtifactStrip, type ConversationWorkspaceReviewTarget } from "./ConversationArtifactStrip.js"; import { ConversationCommandBatch } from "./ConversationCommandBatch.js"; +import { ConversationExplorationBatch } from "./ConversationExplorationBatch.js"; import { DiffViewer } from "./DiffViewer.js"; import { TerminalReportBlock, type TerminalReportFileTarget } from "./TerminalReportBlock.js"; import { useConversationBlockView } from "../view-store/use-conversation-block-view.js"; @@ -75,7 +76,7 @@ export function ConversationWorkCell({ : singleAgentRows.length > 0 ? singleAgentRows : fallbackRows; - const operationItems = model.operationItems ?? projectConversationCommandBatches(operationRows); + const operationItems = model.operationItems ?? projectConversationActivity(operationRows); const showOperationRows = operationItems.length > 0; const terminalReportRendered = showTerminalReport && model.terminalReportRows.length > 0; const statusLabel = model.kicker ?? workCellStatusLabel(model.status, active, t); @@ -368,6 +369,10 @@ function operationShellStatusLabel( const elapsed = formatElapsedDuration(model.elapsedMs); const suffix = elapsed ? ` ${elapsed}` : ""; if (model.status === "succeeded") { + const activitySummary = completedActivitySummary(model, t); + if (activitySummary) { + return activitySummary; + } return t("Processed{duration}", { duration: suffix }); } if (model.status === "failed") { @@ -385,6 +390,22 @@ function operationShellStatusLabel( return active ? t("Processing") : t("Processed{duration}", { duration: suffix }); } +function completedActivitySummary( + model: ConversationOperationCellViewModel, + t: Translate, +): string | undefined { + if (model.mode !== "completed" || !model.activitySummary?.hasStructuredActions) { + return undefined; + } + const summary = model.activitySummary; + const pieces = [ + summary.editedFileCount > 0 ? t("Edited {count} files", { count: summary.editedFileCount }) : undefined, + summary.readItemCount > 0 ? t("Read {count} items", { count: summary.readItemCount }) : undefined, + summary.commandCount > 0 ? t("Ran {count} commands", { count: summary.commandCount }) : undefined, + ].filter((piece): piece is string => Boolean(piece)); + return pieces.length > 0 ? pieces.join(" · ") : undefined; +} + function formatElapsedDuration(elapsedMs: number | undefined): string | undefined { if (!elapsedMs || elapsedMs < 1000) { return undefined; @@ -413,6 +434,8 @@ function operationBubbleRevisionKey( model.agentSpans.length, model.artifactStripItems.length, evidenceSummary ?? "", + activitySummaryRevisionKey(model), + operationItemsRevisionKey(model.operationItems), ].join("|"); } @@ -431,9 +454,31 @@ function operationShellRevisionKey( model.operationRows.length, model.agentSpans.length, model.artifactStripItems.length, + activitySummaryRevisionKey(model), + operationItemsRevisionKey(model.operationItems), ].join("|"); } +function activitySummaryRevisionKey(model: ConversationOperationCellViewModel): string { + const summary = model.activitySummary; + return summary + ? [summary.editedFileCount, summary.readItemCount, summary.commandCount, summary.hasStructuredActions ? "structured" : "plain"].join(":") + : ""; +} + +function operationItemsRevisionKey(items: readonly ConversationWorkCellItem[] | undefined): string { + if (!items) { + return ""; + } + return items.map((item) => { + if (item.kind === "operation") { + const execution = item.row.operationExecution; + return `operation:${item.row.id}:${execution?.actionId ?? ""}:${execution?.status ?? ""}:${execution?.updatedAt ?? ""}`; + } + return `${item.kind}:${item.batch.id}:${item.batch.revisionKey}`; + }).join("|"); +} + function operationShellExpandable( status: ConversationOperationCellViewModel["status"], active: boolean, @@ -489,7 +534,7 @@ function AgentActivitySpanItem({ readonly renderAgentAvatar?: (span: ConversationAgentActivitySpan) => ReactNode; }): React.JSX.Element { const { t } = useI18n(); - const items = span.items ?? projectConversationCommandBatches(span.rows, { participantScope: span.participantId ?? span.id }); + const items = span.items ?? projectConversationActivity(span.rows, { participantScope: span.participantId ?? span.id }); const hasDetail = items.length > 0; const avatar = renderAgentAvatar?.(span); const headContent = ( @@ -657,33 +702,78 @@ function ActiveTurnItemStream({ ? "conversation-work-cell-agent-stream" : "conversation-work-cell-active-stream"; const ariaLabel = scope === "agent" ? t("Agent activity detail") : t("Active work stream"); + const atomicItemCount = items.reduce((count, item) => { + if (item.kind === "operation") { + return count + 1; + } + if (item.kind === "exploration_batch") { + return count + item.batch.rows.length; + } + return count + item.batch.commands.reduce( + (commandCount, command) => commandCount + 1 + (command.leadingRows?.length ?? 0), + 0, + ); + }, 0); + const density = atomicItemCount > 10 ? "dense" : "default"; if (items.length === 0) { return ( -
+
{t("Waiting for activity")}
); } return ( -
- {items.map((item) => item.kind === "command_batch" ? ( - ( - + {items.map((item) => { + if (item.kind === "command_batch") { + return ( + [ + ...(command.leadingRows ?? []).map((row) => ( + + )), + , + ]} /> - )} - /> - ) : ( - - ))} + ); + } + if (item.kind === "exploration_batch") { + return ( + ( + + )} + /> + ); + } + return ; + })}
); } diff --git a/apps/web/src/styles/shared/conversation-work-cell-activity.css b/apps/web/src/styles/shared/conversation-work-cell-activity.css index b6fe985ab..e72f61c22 100644 --- a/apps/web/src/styles/shared/conversation-work-cell-activity.css +++ b/apps/web/src/styles/shared/conversation-work-cell-activity.css @@ -55,6 +55,15 @@ background: transparent; } +.conversation-work-cell-active-stream[data-activity-density="dense"], +.conversation-work-cell-agent-stream[data-activity-density="dense"] { + max-height: min(360px, 50vh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + padding-right: 4px; +} + .conversation-work-cell-active-stream.empty, .conversation-work-cell-agent-stream.empty { display: flex; @@ -622,11 +631,89 @@ white-space: normal; } +.conversation-exploration-batch { + display: grid; + gap: 3px; + min-width: 0; + animation: conversation-settle-in var(--ling-motion-fast) var(--ling-ease-enter) both; +} + +.conversation-exploration-batch-header { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) 16px; + align-items: center; + gap: 7px; + width: 100%; + min-width: 0; + min-height: 20px; + padding: 0; + border: 0; + background: transparent; + color: var(--conversation-text-muted); + font-size: 12px; + line-height: 18px; + text-align: left; + cursor: pointer; +} + +.conversation-exploration-batch-header > svg { + width: 13px; + height: 13px; +} + +.conversation-exploration-batch-header > span:not(.conversation-exploration-batch-toggle) { + min-width: 0; + overflow-wrap: anywhere; +} + +.conversation-exploration-batch-header:hover, +.conversation-exploration-batch-header:focus-visible { + color: var(--conversation-text-strong); +} + +.conversation-exploration-batch-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + opacity: 0; + visibility: hidden; + transition: + opacity var(--ling-motion-fast) var(--ling-ease-standard), + visibility var(--ling-motion-fast) var(--ling-ease-standard); +} + +.conversation-exploration-batch-toggle svg { + transition: transform var(--ling-motion-standard) var(--ling-ease-standard); +} + +.conversation-exploration-batch-header:hover .conversation-exploration-batch-toggle, +.conversation-exploration-batch-header:focus-visible .conversation-exploration-batch-toggle, +.conversation-exploration-batch.expanded .conversation-exploration-batch-toggle { + opacity: 1; + visibility: visible; +} + +.conversation-exploration-batch.expanded .conversation-exploration-batch-toggle svg { + transform: rotate(180deg); +} + +.conversation-exploration-batch-list { + display: grid; + gap: 3px; + min-width: 0; +} + @media (hover: none) { .conversation-command-batch-toggle { opacity: 1; visibility: visible; } + + .conversation-exploration-batch-toggle { + opacity: 1; + visibility: visible; + } } .conversation-work-cell-group { @@ -709,6 +796,7 @@ .conversation-work-cell, .conversation-work-cell-row, .conversation-command-batch, + .conversation-exploration-batch, .conversation-work-cell-agent-span { animation: none; } @@ -724,7 +812,9 @@ .conversation-work-cell-row-toggle, .conversation-work-cell-row-toggle svg, .conversation-command-batch-toggle, - .conversation-command-batch-toggle svg { + .conversation-command-batch-toggle svg, + .conversation-exploration-batch-toggle, + .conversation-exploration-batch-toggle svg { transition: none; } } diff --git a/packages/conversation-ui-core/src/activity-projection.ts b/packages/conversation-ui-core/src/activity-projection.ts new file mode 100644 index 000000000..d148b0666 --- /dev/null +++ b/packages/conversation-ui-core/src/activity-projection.ts @@ -0,0 +1,241 @@ +import type { + ConversationActivitySummary, + ConversationExplorationBatch, + ConversationOperationExecutionView, + ConversationOperationSourceRef, + ConversationWorkCellItem, + ConversationWorkCellRow, +} from "./work-cell-view-model.js"; +import { projectConversationCommandBatches } from "./command-batching.js"; + +const MINIMUM_EXPLORATION_BATCH_SIZE = 3; +const READ_ACTION_TYPES = new Set(["read", "grep", "find", "ls"]); + +export function projectConversationActivity( + rows: readonly ConversationWorkCellRow[], + options: { readonly participantScope?: string } = {}, +): readonly ConversationWorkCellItem[] { + const commandItems = projectConversationCommandBatches(rows, options); + const output: ConversationWorkCellItem[] = []; + let nonCommandRows: ConversationWorkCellRow[] = []; + + const flushNonCommandRows = (): void => { + if (nonCommandRows.length === 0) { + return; + } + output.push(...projectConversationExplorationBatches(nonCommandRows, options)); + nonCommandRows = []; + }; + + for (const item of commandItems) { + if (item.kind === "command_batch") { + flushNonCommandRows(); + output.push(item); + continue; + } + if (item.kind === "operation" && item.row.commandExecution) { + flushNonCommandRows(); + output.push(item); + continue; + } + if (item.kind === "operation") { + nonCommandRows.push(item.row); + } + } + flushNonCommandRows(); + return output; +} + +export function projectConversationExplorationBatches( + rows: readonly ConversationWorkCellRow[], + options: { readonly participantScope?: string } = {}, +): readonly ConversationWorkCellItem[] { + const output: ConversationWorkCellItem[] = []; + let pendingRows: ConversationWorkCellRow[] = []; + let pendingActions: ConversationWorkCellRow[] = []; + let leadingRows: ConversationWorkCellRow[] = []; + + const flush = (): void => { + if (pendingRows.length === 0) { + return; + } + if (pendingActions.length < MINIMUM_EXPLORATION_BATCH_SIZE) { + output.push(...pendingRows.map((row) => ({ kind: "operation" as const, row }))); + } else { + output.push({ + kind: "exploration_batch", + batch: buildExplorationBatch(pendingRows, pendingActions, options.participantScope), + }); + } + pendingRows = []; + pendingActions = []; + }; + + const flushLeadingRows = (): void => { + if (leadingRows.length === 0) { + return; + } + output.push(...leadingRows.map((row) => ({ kind: "operation" as const, row }))); + leadingRows = []; + }; + + for (const row of rows) { + const execution = row.operationExecution; + if (row.presentation === "note") { + if (execution?.actionId) { + leadingRows.push(row); + } else { + flush(); + flushLeadingRows(); + output.push({ kind: "operation", row }); + } + continue; + } + + if (!isExplorationBatchEligible(row)) { + flush(); + flushLeadingRows(); + output.push({ kind: "operation", row }); + continue; + } + + if (pendingActions.length > 0 && !sameExplorationScope(pendingActions[0], row)) { + flush(); + } + pendingRows.push(...leadingRows, row); + pendingActions.push(row); + leadingRows = []; + } + + flush(); + flushLeadingRows(); + return output; +} + +export function isExplorationBatchEligible(row: ConversationWorkCellRow): boolean { + const execution = row.operationExecution; + return Boolean( + row.presentation !== "note" && + execution?.actionId && + execution.runId && + execution.sourceRefs.length > 0 && + READ_ACTION_TYPES.has(execution.actionType) && + execution.status === "succeeded", + ); +} + +export function summarizeConversationActivity( + rows: readonly ConversationWorkCellRow[], +): ConversationActivitySummary { + const seen = new Set(); + let editedFileCount = 0; + let readItemCount = 0; + let commandCount = 0; + + for (const row of rows) { + if (row.presentation === "note") { + continue; + } + const execution = row.operationExecution; + if (!execution?.actionId || seen.has(execution.actionId) || execution.status !== "succeeded") { + continue; + } + seen.add(execution.actionId); + + if (execution.actionType === "write" || execution.actionType === "edit") { + const files = uniqueRefs(execution.sourceRefs, "file"); + editedFileCount += files.length; + continue; + } + if (READ_ACTION_TYPES.has(execution.actionType)) { + const files = uniqueRefs(execution.sourceRefs, "file"); + readItemCount += files.length > 0 ? files.length : 1; + continue; + } + if (execution.actionType === "bash" && row.commandExecution?.risk === "low") { + commandCount += 1; + } + } + + return { + editedFileCount, + readItemCount, + commandCount, + hasStructuredActions: editedFileCount > 0 || readItemCount > 0 || commandCount > 0, + }; +} + +function sameExplorationScope(left: ConversationWorkCellRow, right: ConversationWorkCellRow): boolean { + const leftExecution = left.operationExecution; + const rightExecution = right.operationExecution; + if (!leftExecution || !rightExecution) { + return false; + } + return leftExecution.runId === rightExecution.runId && + leftExecution.attemptId === rightExecution.attemptId && + leftExecution.turnRunRole === rightExecution.turnRunRole && + leftExecution.participantId === rightExecution.participantId && + leftExecution.workUnitId === rightExecution.workUnitId && + leftExecution.workflowStepId === rightExecution.workflowStepId; +} + +function buildExplorationBatch( + rows: readonly ConversationWorkCellRow[], + actionRows: readonly ConversationWorkCellRow[], + participantScope = "primary", +): ConversationExplorationBatch { + const first = actionRows[0]?.operationExecution; + if (!first?.runId) { + throw new Error("Exploration batches require a run identity."); + } + const actionIds = actionRows.map((row) => row.operationExecution?.actionId).filter((id): id is string => Boolean(id)); + const sourceRefs = uniqueSourceRefs(actionRows.flatMap((row) => row.operationExecution?.sourceRefs ?? [])); + const targetCount = actionRows.reduce((count, row) => { + const files = uniqueRefs(row.operationExecution?.sourceRefs ?? [], "file"); + return count + (files.length > 0 ? files.length : 1); + }, 0); + return { + id: `exploration-batch:${stableIdPart(participantScope)}:${stableIdPart(first.runId)}:${stableIdPart(first.actionId)}`, + revisionKey: actionRows.map((row) => { + const execution = row.operationExecution; + return `${execution?.actionId ?? row.id}:${execution?.status ?? "unknown"}:${execution?.updatedAt ?? ""}`; + }).join("|"), + actionIds, + runId: first.runId, + attemptId: first.attemptId, + turnRunRole: first.turnRunRole, + participantId: first.participantId, + workUnitId: first.workUnitId, + workflowStepId: first.workflowStepId, + sourceRefs, + operationCount: actionRows.length, + targetCount, + defaultExpanded: false, + rows, + }; +} + +function uniqueRefs( + refs: readonly ConversationOperationSourceRef[], + kind: string, +): readonly ConversationOperationSourceRef[] { + return uniqueSourceRefs(refs.filter((ref) => ref.kind === kind)); +} + +function uniqueSourceRefs(refs: readonly ConversationOperationSourceRef[]): readonly ConversationOperationSourceRef[] { + const seen = new Set(); + const unique: ConversationOperationSourceRef[] = []; + for (const ref of refs) { + const key = `${ref.kind}:${ref.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(ref); + } + return unique; +} + +function stableIdPart(value: string): string { + return value.trim().replace(/[^A-Za-z0-9._-]+/gu, "-") || "unknown"; +} diff --git a/packages/conversation-ui-core/src/command-batching.ts b/packages/conversation-ui-core/src/command-batching.ts index 5814efa7d..69f24841a 100644 --- a/packages/conversation-ui-core/src/command-batching.ts +++ b/packages/conversation-ui-core/src/command-batching.ts @@ -1,6 +1,7 @@ import type { ConversationCommandBatch, ConversationCommandBatchItem, + ConversationOperationSourceRef, ConversationWorkCellItem, ConversationWorkCellRow, } from "./work-cell-view-model.js"; @@ -12,14 +13,18 @@ export function projectConversationCommandBatches( options: { readonly participantScope?: string } = {}, ): readonly ConversationWorkCellItem[] { const output: ConversationWorkCellItem[] = []; - let pending: ConversationWorkCellRow[] = []; + let pending: Array<{ readonly row: ConversationWorkCellRow; readonly leadingRows: readonly ConversationWorkCellRow[] }> = []; + let leadingRows: ConversationWorkCellRow[] = []; const flush = (): void => { if (pending.length === 0) { return; } if (pending.length < MINIMUM_COMMAND_BATCH_SIZE) { - output.push(...pending.map((row) => ({ kind: "operation" as const, row }))); + for (const entry of pending) { + output.push(...entry.leadingRows.map((row) => ({ kind: "operation" as const, row }))); + output.push({ kind: "operation", row: entry.row }); + } } else { output.push({ kind: "command_batch", @@ -30,17 +35,32 @@ export function projectConversationCommandBatches( }; for (const row of rows) { + if (row.presentation === "note") { + if (row.operationExecution?.actionId) { + leadingRows.push(row); + } else { + flush(); + output.push(...leadingRows.map((leadingRow) => ({ kind: "operation" as const, row: leadingRow }))); + leadingRows = []; + output.push({ kind: "operation", row }); + } + continue; + } if (!isCommandBatchEligible(row)) { flush(); + output.push(...leadingRows.map((leadingRow) => ({ kind: "operation" as const, row: leadingRow }))); + leadingRows = []; output.push({ kind: "operation", row }); continue; } - if (pending.length > 0 && !sameCommandScope(pending[0], row)) { + if (pending.length > 0 && !sameCommandScope(pending[0]?.row, row)) { flush(); } - pending.push(row); + pending.push({ row, leadingRows }); + leadingRows = []; } flush(); + output.push(...leadingRows.map((row) => ({ kind: "operation" as const, row }))); return output; } @@ -58,28 +78,42 @@ export function isCommandBatchEligible(row: ConversationWorkCellRow): boolean { ); } -function sameCommandScope(left: ConversationWorkCellRow, right: ConversationWorkCellRow): boolean { +function sameCommandScope(left: ConversationWorkCellRow | undefined, right: ConversationWorkCellRow): boolean { + if (!left) { + return false; + } const leftCommand = left.commandExecution; const rightCommand = right.commandExecution; + const leftOperation = left.operationExecution; + const rightOperation = right.operationExecution; return Boolean( leftCommand && rightCommand && leftCommand.runId === rightCommand.runId && leftCommand.workUnitId === rightCommand.workUnitId && - leftCommand.workflowStepId === rightCommand.workflowStepId, + leftCommand.workflowStepId === rightCommand.workflowStepId && + (!leftOperation || !rightOperation || ( + leftOperation.runId === rightOperation.runId && + leftOperation.attemptId === rightOperation.attemptId && + leftOperation.turnRunRole === rightOperation.turnRunRole && + leftOperation.participantId === rightOperation.participantId && + leftOperation.workUnitId === rightOperation.workUnitId && + leftOperation.workflowStepId === rightOperation.workflowStepId + )), ); } function buildCommandBatch( - rows: readonly ConversationWorkCellRow[], + entries: readonly { readonly row: ConversationWorkCellRow; readonly leadingRows: readonly ConversationWorkCellRow[] }[], participantScope = "primary", ): ConversationCommandBatch { - const commands = rows.map(toCommandBatchItem); + const commands = entries.map((entry) => toCommandBatchItem(entry.row, entry.leadingRows)); const first = commands[0]; if (!first) { throw new Error("Command batches require at least one command."); } const runId = first.row.commandExecution?.runId ?? "run"; + const operation = first.row.operationExecution; const durationValues = commands.map((command) => command.durationMs); const durationMs = durationValues.every((value): value is number => value !== undefined) ? durationValues.reduce((sum, value) => sum + value, 0) @@ -93,11 +127,19 @@ function buildCommandBatch( commandCount: commands.length, durationMs, defaultExpanded: false, + actionIds: commands.map((command) => command.actionId), + runId: operation?.runId ?? runId, + attemptId: operation?.attemptId, + turnRunRole: operation?.turnRunRole, + participantId: operation?.participantId, + workUnitId: operation?.workUnitId ?? first.row.commandExecution?.workUnitId, + workflowStepId: operation?.workflowStepId ?? first.row.commandExecution?.workflowStepId, + sourceRefs: uniqueSourceRefs(commands.flatMap((command) => command.row.operationExecution?.sourceRefs ?? [])), commands, }; } -function toCommandBatchItem(row: ConversationWorkCellRow): ConversationCommandBatchItem { +function toCommandBatchItem(row: ConversationWorkCellRow, leadingRows: readonly ConversationWorkCellRow[] = []): ConversationCommandBatchItem { const command = row.commandExecution; if (!command) { throw new Error("Command batch rows require command execution metadata."); @@ -113,9 +155,22 @@ function toCommandBatchItem(row: ConversationWorkCellRow): ConversationCommandBa outputTruncated: command.outputTruncated, stdoutArtifactId: command.stdoutArtifactId, stderrArtifactId: command.stderrArtifactId, + leadingRows, }; } +function uniqueSourceRefs(refs: readonly ConversationOperationSourceRef[]): readonly ConversationOperationSourceRef[] { + const seen = new Set(); + return refs.filter((ref) => { + const key = `${ref.kind}:${ref.id}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + function stableIdPart(value: string): string { return value.trim().replace(/[^A-Za-z0-9._-]+/gu, "-") || "unknown"; } diff --git a/packages/conversation-ui-core/src/index.ts b/packages/conversation-ui-core/src/index.ts index 9fdbba127..14b139f71 100644 --- a/packages/conversation-ui-core/src/index.ts +++ b/packages/conversation-ui-core/src/index.ts @@ -10,6 +10,7 @@ export type { export type { ConversationAgentActivitySpan, ConversationAgentActivitySpanKind, + ConversationActivitySummary, ConversationCommandBatch, ConversationCommandBatchItem, ConversationCommandExecutionView, @@ -22,6 +23,10 @@ export type { ConversationOperationCellStatus, ConversationOperationCellViewModel, ConversationOperationKind, + ConversationOperationExecutionView, + ConversationOperationSourceRef, + ConversationExplorationBatch, + ConversationTurnRunRole, ConversationWorkCellBranch, ConversationWorkCellChangePreview, ConversationWorkCellDetailsModel, @@ -41,6 +46,12 @@ export { isCommandBatchEligible, projectConversationCommandBatches, } from "./command-batching.js"; +export { + isExplorationBatchEligible, + projectConversationActivity, + projectConversationExplorationBatches, + summarizeConversationActivity, +} from "./activity-projection.js"; export type { TerminalReportViewInput, TerminalReportViewModel, diff --git a/packages/conversation-ui-core/src/work-cell-view-model.ts b/packages/conversation-ui-core/src/work-cell-view-model.ts index a6f9a1281..894ed6172 100644 --- a/packages/conversation-ui-core/src/work-cell-view-model.ts +++ b/packages/conversation-ui-core/src/work-cell-view-model.ts @@ -13,6 +13,36 @@ export type ConversationOperationKind = | "watch"; export type ConversationWorkCellGroupKind = "changed" | "checked" | "context" | "evidence" | "attention"; export type ConversationWorkCellRowTone = "neutral" | "running" | "waiting" | "success" | "failed"; +export type ConversationTurnRunRole = "primary" | "retry" | "resume" | "repair" | "validation"; + +export interface ConversationOperationSourceRef { + readonly kind: string; + readonly id: string; + readonly label?: string; + readonly visibility?: "conversation" | "private"; +} + +export interface ConversationOperationExecutionView { + readonly actionId: string; + readonly toolCallId?: string; + readonly runId?: string; + readonly attemptId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly participantId?: string; + readonly workUnitId?: string; + readonly workflowStepId?: string; + readonly actionType: string; + readonly status: string; + readonly updatedAt?: string; + readonly sourceRefs: readonly ConversationOperationSourceRef[]; +} + +export interface ConversationActivitySummary { + readonly editedFileCount: number; + readonly readItemCount: number; + readonly commandCount: number; + readonly hasStructuredActions: boolean; +} export interface ConversationCommandExecutionView { readonly actionId: string; @@ -51,6 +81,7 @@ export interface ConversationWorkCellRow { readonly meta?: string; readonly commandText?: string; readonly commandExecution?: ConversationCommandExecutionView; + readonly operationExecution?: ConversationOperationExecutionView; readonly changePreview?: ConversationWorkCellChangePreview; readonly detailLines?: readonly ConversationWorkCellRowDetailLine[]; readonly tone?: ConversationWorkCellRowTone; @@ -164,6 +195,7 @@ export interface ConversationCommandBatchItem { readonly outputTruncated: boolean; readonly stdoutArtifactId?: string; readonly stderrArtifactId?: string; + readonly leadingRows?: readonly ConversationWorkCellRow[]; } export interface ConversationCommandBatch { @@ -172,9 +204,34 @@ export interface ConversationCommandBatch { readonly commandCount: number; readonly durationMs?: number; readonly defaultExpanded: boolean; + readonly actionIds: readonly string[]; + readonly runId?: string; + readonly attemptId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly participantId?: string; + readonly workUnitId?: string; + readonly workflowStepId?: string; + readonly sourceRefs: readonly ConversationOperationSourceRef[]; readonly commands: readonly ConversationCommandBatchItem[]; } +export interface ConversationExplorationBatch { + readonly id: string; + readonly revisionKey: string; + readonly actionIds: readonly string[]; + readonly runId: string; + readonly attemptId?: string; + readonly turnRunRole?: ConversationTurnRunRole; + readonly participantId?: string; + readonly workUnitId?: string; + readonly workflowStepId?: string; + readonly sourceRefs: readonly ConversationOperationSourceRef[]; + readonly operationCount: number; + readonly targetCount?: number; + readonly defaultExpanded: boolean; + readonly rows: readonly ConversationWorkCellRow[]; +} + export type ConversationWorkCellItem = | { readonly kind: "operation"; @@ -183,6 +240,10 @@ export type ConversationWorkCellItem = | { readonly kind: "command_batch"; readonly batch: ConversationCommandBatch; + } + | { + readonly kind: "exploration_batch"; + readonly batch: ConversationExplorationBatch; }; export interface ConversationOperationCellViewModel { @@ -203,6 +264,7 @@ export interface ConversationOperationCellViewModel { readonly elapsedMs?: number; readonly operationRows: readonly ConversationWorkCellRow[]; readonly operationItems?: readonly ConversationWorkCellItem[]; + readonly activitySummary?: ConversationActivitySummary; readonly agentSpans: readonly ConversationAgentActivitySpan[]; /** @deprecated Use agentSpans. Kept while call sites migrate. */ readonly agentBranches: readonly ConversationWorkCellBranch[]; diff --git a/packages/conversation-ui-core/test/work-cell-view-model.test.mjs b/packages/conversation-ui-core/test/work-cell-view-model.test.mjs index c2c7cdc0c..f8c9a74ce 100644 --- a/packages/conversation-ui-core/test/work-cell-view-model.test.mjs +++ b/packages/conversation-ui-core/test/work-cell-view-model.test.mjs @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizeConversationOperationCell, projectConversationCommandBatches } from "../dist/index.js"; +import { + normalizeConversationOperationCell, + projectConversationActivity, + projectConversationCommandBatches, + summarizeConversationActivity, +} from "../dist/index.js"; test("work cell view models normalize legacy row aliases", () => { const operationRows = [ @@ -113,6 +118,92 @@ test("command batching requires stable run and tool-call identities", () => { assert.deepEqual(items.map((item) => item.kind), ["operation", "operation", "command_batch"]); }); +test("activity projection groups three adjacent successful read actions", () => { + const items = projectConversationActivity([ + readRow("one", "run-1", "src/one.ts"), + readRow("two", "run-1", "src/two.ts"), + readRow("three", "run-1", "src/three.ts"), + ]); + + assert.deepEqual(items.map((item) => item.kind), ["exploration_batch"]); + assert.equal(items[0].batch.operationCount, 3); + assert.equal(items[0].batch.targetCount, 3); + assert.deepEqual(items[0].batch.actionIds, ["one", "two", "three"]); + assert.match(items[0].batch.id, /^exploration-batch:primary:run-1:one$/u); +}); + +test("activity projection leaves one or two reads visible", () => { + for (const count of [1, 2]) { + const items = projectConversationActivity( + ["one", "two"].slice(0, count).map((id) => readRow(id, "run-1", `src/${id}.ts`)), + ); + assert.deepEqual(items.map((item) => item.kind), Array.from({ length: count }, () => "operation")); + } +}); + +test("activity projection does not batch a read without source references", () => { + const items = projectConversationActivity([ + readRow("one", "run-1", "src/one.ts", { sourceRefs: [] }), + readRow("two", "run-1", "src/two.ts"), + readRow("three", "run-1", "src/three.ts"), + ]); + + assert.deepEqual(items.map((item) => item.kind), ["operation", "operation", "operation"]); +}); + +test("activity projection keeps a logical action note with its evidence row", () => { + const noteExecution = operationExecution("one", "run-1", "read", "succeeded", [fileRef("src/one.ts")]); + const items = projectConversationActivity([ + { id: "note:one", presentation: "note", title: "Read scope", operationExecution: noteExecution }, + readRow("one", "run-1", "src/one.ts"), + readRow("two", "run-1", "src/two.ts"), + readRow("three", "run-1", "src/three.ts"), + ]); + + assert.deepEqual(items.map((item) => item.kind), ["exploration_batch"]); + assert.equal(items[0].batch.rows.length, 4); + assert.equal(items[0].batch.actionIds.length, 3); + assert.equal(items[0].batch.actionIds[0], "one"); +}); + +test("activity projection splits reads at consequential actions and lineage boundaries", () => { + const items = projectConversationActivity([ + readRow("one", "run-1", "src/one.ts"), + readRow("two", "run-1", "src/two.ts"), + readRow("three", "run-1", "src/three.ts"), + operationRow("edit", "run-1", "edit", "succeeded", [fileRef("src/three.ts")]), + readRow("four", "run-1", "src/four.ts"), + readRow("five", "run-1", "src/five.ts"), + readRow("six", "run-2", "src/six.ts"), + readRow("seven", "run-2", "src/seven.ts"), + readRow("eight", "run-2", "src/eight.ts"), + ]); + + assert.deepEqual(items.map((item) => item.kind), ["exploration_batch", "operation", "operation", "operation", "exploration_batch"]); + assert.deepEqual(items.filter((item) => item.kind === "exploration_batch").map((item) => item.batch.runId), ["run-1", "run-2"]); + assert.equal(items[1].row.operationExecution.actionId, "edit"); +}); + +test("activity summary counts logical actions once and excludes notes and failures", () => { + const readExecution = operationExecution("read-1", "run-1", "read", "succeeded", [fileRef("src/one.ts")]); + const editExecution = operationExecution("edit-1", "run-1", "edit", "succeeded", [fileRef("src/one.ts"), fileRef("src/two.ts")]); + const commandExecution = operationExecution("command-1", "run-1", "bash", "succeeded"); + const summary = summarizeConversationActivity([ + { id: "note:read-1", presentation: "note", title: "Read scope", operationExecution: readExecution }, + { id: "read:read-1", title: "Read one", operationExecution: readExecution }, + { id: "edit:edit-1", title: "Edit files", operationExecution: editExecution }, + { id: "command:command-1", presentation: "shell", title: "Run command", commandExecution: { risk: "low" }, operationExecution: commandExecution }, + { id: "failed", title: "Failed read", operationExecution: operationExecution("failed", "run-1", "read", "failed", [fileRef("src/failed.ts")]) }, + ]); + + assert.deepEqual(summary, { + editedFileCount: 2, + readItemCount: 1, + commandCount: 1, + hasStructuredActions: true, + }); +}); + function commandRow(id, runId, commandText, durationMs, overrides = {}) { const status = overrides.status ?? "succeeded"; const exitCode = "exitCode" in overrides ? overrides.exitCode : 0; @@ -136,3 +227,31 @@ function commandRow(id, runId, commandText, durationMs, overrides = {}) { }, }; } + +function readRow(id, runId, path, overrides = {}) { + return operationRow(id, runId, "read", "succeeded", "sourceRefs" in overrides ? overrides.sourceRefs : [fileRef(path)], overrides); +} + +function operationRow(id, runId, actionType, status, sourceRefs = [], overrides = {}) { + return { + id: `row-${id}`, + title: `${actionType} ${id}`, + operationExecution: operationExecution(id, runId, actionType, status, sourceRefs, overrides), + }; +} + +function operationExecution(actionId, runId, actionType, status, sourceRefs = [], overrides = {}) { + return { + actionId, + runId, + actionType, + status, + sourceRefs, + updatedAt: "2026-08-25T00:00:00.000Z", + ...overrides, + }; +} + +function fileRef(path) { + return { kind: "file", id: path, label: path }; +}