diff --git a/src/components/task-stream-activity-row.tsx b/src/components/task-stream-activity-row.tsx new file mode 100644 index 0000000..a324132 --- /dev/null +++ b/src/components/task-stream-activity-row.tsx @@ -0,0 +1,146 @@ +import { useState, type ComponentType, type SVGProps } from "react"; +import { ChevronRight } from "lucide-react"; +import type { TaskStreamActivityItem } from "@/components/task-stream-activity"; +import { cn } from "@/lib/utils"; + +type ActivityIcon = ComponentType>; + +export function TaskStreamActivityRow({ + icon: Icon, + item, +}: { + icon: ActivityIcon; + item: TaskStreamActivityItem; +}) { + const [expanded, setExpanded] = useState(false); + const hasExpandableDetails = + (item.detailSections?.length ?? 0) > 0 || (item.details?.length ?? 0) > 0; + const { action, details } = splitActivityLabel(item.label); + + return ( +
+ +
+
+ {action} + {item.summary ? ( + hasExpandableDetails ? ( + + ) : ( + + {item.summary} + + ) + ) : details ? ( + {details} + ) : null} +
+ {item.badges && item.badges.length > 0 ? ( +
+ {item.badges.map((badge) => ( + + {badge} + + ))} +
+ ) : null} + {!item.summary && item.details && item.details.length > 0 ? ( +
+ {item.details.map((detail) => ( +
+ {detail} +
+ ))} +
+ ) : null} + {item.summary && hasExpandableDetails && expanded ? ( +
+ {item.detailSections?.map((section) => ( +
+
+ {section.label} +
+
+ {section.value} +
+
+ ))} + {item.details?.map((detail) => ( +
+ {detail} +
+ ))} +
+ ) : null} +
+
+ ); +} + +function splitActivityLabel(label: string): { action: string; details: string } { + const normalized = label.trim(); + if (normalized.length === 0) { + return { action: "", details: "" }; + } + + const colonIndex = normalized.indexOf(":"); + if (colonIndex > 0) { + return { + action: toSentenceCase(normalized.slice(0, colonIndex).trim()), + details: normalized.slice(colonIndex + 1).trim(), + }; + } + + const [firstWord, ...rest] = normalized.split(" "); + return { + action: toSentenceCase(firstWord), + details: rest.join(" "), + }; +} + +function toSentenceCase(value: string): string { + if (value.length === 0) { + return value; + } + + return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`; +} diff --git a/src/components/task-stream-activity.tsx b/src/components/task-stream-activity.tsx index 0dc5695..61c94b3 100644 --- a/src/components/task-stream-activity.tsx +++ b/src/components/task-stream-activity.tsx @@ -10,7 +10,7 @@ import { Wrench, } from "lucide-react"; import { AnimatedStreamItem } from "@/components/animated-stream-item"; -import { cn } from "@/lib/utils"; +import { TaskStreamActivityRow } from "@/components/task-stream-activity-row"; export type TaskStreamActivityIcon = | "thinking" @@ -27,7 +27,14 @@ export interface TaskStreamActivityItem { id: string; icon: TaskStreamActivityIcon; label: string; + summary?: string; + badges?: string[]; details?: string[]; + detailSections?: Array<{ + label: string; + value: string; + code?: boolean; + }>; tone?: "default" | "muted" | "error" | "success"; spinning?: boolean; } @@ -41,56 +48,9 @@ export function TaskStreamActivity({ items }: { items: TaskStreamActivityItem[]
{items.map((item) => { - const Icon = getActivityIcon(item.icon); - const { action, details } = splitActivityLabel(item.label); return ( -
- -
-
- - {action} - - {details ? ( - - {details} - - ) : null} -
- {item.details && item.details.length > 0 ? ( -
- {item.details.map((detail) => ( -
- {detail} -
- ))} -
- ) : null} -
-
+
); })} @@ -99,37 +59,6 @@ export function TaskStreamActivity({ items }: { items: TaskStreamActivityItem[] ); } -function splitActivityLabel(label: string): { action: string; details: string } { - const normalized = label.trim(); - if (normalized.length === 0) { - return { action: "", details: "" }; - } - - const colonIndex = normalized.indexOf(":"); - if (colonIndex > 0) { - const action = normalized.slice(0, colonIndex).trim(); - const details = normalized.slice(colonIndex + 1).trim(); - return { - action: toSentenceCase(action), - details: details.length > 0 ? ` ${details}` : "", - }; - } - - const [firstWord, ...rest] = normalized.split(" "); - return { - action: toSentenceCase(firstWord), - details: rest.length > 0 ? ` ${rest.join(" ")}` : "", - }; -} - -function toSentenceCase(value: string): string { - if (value.length === 0) { - return value; - } - - return `${value[0].toUpperCase()}${value.slice(1)}`; -} - function getActivityIcon(kind: TaskStreamActivityIcon) { switch (kind) { case "thinking": diff --git a/src/lib/task-activity-mapper.ts b/src/lib/task-activity-mapper.ts index 1b09a16..ccf4fa2 100644 --- a/src/lib/task-activity-mapper.ts +++ b/src/lib/task-activity-mapper.ts @@ -1,4 +1,5 @@ import type { TaskStreamActivityIcon } from "@/components/task-stream-activity"; +import { buildToolActivityPresentation } from "@/lib/tool-activity-summary"; import type { ChronologicalActivityItem } from "@/lib/task-timeline"; import { type TaskStreamEvent, @@ -252,38 +253,31 @@ function messagePartToActivityItem( if (part.type === "tool") { const toolName = part.tool; const status = part.state.status; - const details: string[] = []; const callId = part.callID ?? part.id; - appendDetail(details, "Input", part.state.input, 300); - - if (status === "pending") { - appendDetail(details, "Request", part.state.raw, 300); - } - - if (status === "running") { - appendDetail(details, "Action", part.state.title); - } - - if (status === "completed") { - appendDetail(details, "Result", part.state.title); - appendDetail(details, "Output", part.state.output, 320); - - const attachmentCount = part.state.attachments?.length ?? 0; - if (attachmentCount > 0) { - details.push(`Attachments: ${attachmentCount}`); - } - } - - if (status === "error") { - appendDetail(details, "Error", part.state.error, 320); + const presentation = buildToolActivityPresentation({ + toolName, + status, + state: part.state, + }); + const details = [...(presentation.details ?? [])]; + const attachmentCount = + status === "completed" && "attachments" in part.state + ? (part.state.attachments?.length ?? 0) + : 0; + + if (attachmentCount > 0) { + details.push(`Attachments: ${attachmentCount}`); } return { id: event.id, stateKey: `tool:${callId}`, icon: getToolActivityIcon(toolName), - label: `${toolName}: ${status}`, + label: presentation.label, + summary: presentation.summary, + badges: presentation.badges, details: details.length > 0 ? details : undefined, + detailSections: presentation.detailSections, tone: status === "error" ? "error" : status === "completed" ? "success" : "muted", spinning: status === "running", createdAt: event.createdAt, diff --git a/src/lib/task-timeline.ts b/src/lib/task-timeline.ts index 2570c41..15d0a4e 100644 --- a/src/lib/task-timeline.ts +++ b/src/lib/task-timeline.ts @@ -180,7 +180,10 @@ export function buildChronologicalTimeline(args: { id: activity.id, icon: activity.icon, label: activity.label, + summary: activity.summary, + badges: activity.badges, details: activity.details, + detailSections: activity.detailSections, tone: activity.tone, spinning: activity.spinning, }, diff --git a/src/lib/tool-activity-summary.ts b/src/lib/tool-activity-summary.ts new file mode 100644 index 0000000..62addf2 --- /dev/null +++ b/src/lib/tool-activity-summary.ts @@ -0,0 +1,360 @@ +import type { TaskStreamActivityItem } from "@/components/task-stream-activity"; + +type ToolActivityPresentation = Pick< + TaskStreamActivityItem, + "label" | "summary" | "badges" | "details" | "detailSections" +>; + +type ToolPartState = { + input?: unknown; + output?: unknown; + title?: string; + error?: unknown; + raw?: unknown; + attachments?: unknown[]; +}; + +type FileChangeSummary = { + action: "add" | "update" | "delete"; + path: string; + added: number; + removed: number; +}; + +export function buildToolActivityPresentation(args: { + toolName: string; + status: string; + state: ToolPartState; +}): ToolActivityPresentation { + const normalizedToolName = normalizeToolName(args.toolName); + + if (normalizedToolName === "bash") { + return buildBashPresentation(args.status, args.state); + } + + if (isFileChangeTool(normalizedToolName)) { + return buildFileChangePresentation(normalizedToolName, args.status, args.state); + } + + return buildGenericToolPresentation(args.toolName, args.status, args.state); +} + +function buildBashPresentation(status: string, state: ToolPartState): ToolActivityPresentation { + const input = asObject(state.input); + const description = getString(input?.description) ?? getString(state.title); + const command = getString(input?.command); + const workdir = getString(input?.workdir); + const output = formatDetailValue(state.output, 1200); + const error = formatDetailValue(state.error, 1200); + const request = formatDetailValue(state.raw, 800); + const summary = description ?? summarizeCommand(command) ?? statusLabel(status); + const badges: string[] = []; + + if (workdir) { + badges.push(shortenPath(workdir)); + } + + const outputLineCount = countLines(output); + if (outputLineCount > 0) { + badges.push(`${outputLineCount} ${outputLineCount === 1 ? "line" : "lines"}`); + } + + return { + label: `Bash ${statusLabel(status)}`, + summary, + badges: badges.length > 0 ? badges : undefined, + detailSections: compactSections([ + workdir ? { label: "Working directory", value: workdir } : null, + command ? { label: "Command", value: command, code: true } : null, + request ? { label: "Request", value: request, code: true } : null, + output ? { label: "Output", value: output, code: true } : null, + error ? { label: "Error", value: error, code: true } : null, + ]), + }; +} + +function buildFileChangePresentation( + normalizedToolName: string, + status: string, + state: ToolPartState, +): ToolActivityPresentation { + const input = asObject(state.input); + const patchText = getString(input?.patchText); + const filePath = getString(input?.filePath); + const content = getString(input?.content); + const changeSet = patchText ? parseApplyPatchSummary(patchText) : []; + const totalAdded = changeSet.reduce((sum, change) => sum + change.added, 0); + const totalRemoved = changeSet.reduce((sum, change) => sum + change.removed, 0); + + if (changeSet.length > 0) { + const primaryChange = changeSet[0]; + const summary = + changeSet.length === 1 + ? `${toActionLabel(primaryChange.action)} ${primaryChange.path}` + : `${toActionLabel(primaryChange.action)} ${changeSet.length} files`; + + return { + label: `${toDisplayToolName(normalizedToolName)} ${statusLabel(status)}`, + summary, + badges: compactBadges([ + `${changeSet.length} ${changeSet.length === 1 ? "file" : "files"}`, + totalAdded > 0 ? `+${totalAdded}` : null, + totalRemoved > 0 ? `-${totalRemoved}` : null, + ]), + detailSections: [ + { + label: "Files", + value: changeSet + .map( + (change) => + `${toActionLabel(change.action)} ${change.path} (+${change.added} -${change.removed})`, + ) + .join("\n"), + }, + ], + details: state.title ? [`Result: ${state.title}`] : undefined, + }; + } + + if (filePath) { + const lineCount = countLines(content); + return { + label: `${toDisplayToolName(normalizedToolName)} ${statusLabel(status)}`, + summary: `${toActionLabel(getFileToolAction(normalizedToolName))} ${filePath}`, + badges: compactBadges([ + lineCount > 0 ? `${lineCount} ${lineCount === 1 ? "line" : "lines"}` : null, + ]), + detailSections: compactSections([ + content ? { label: "Content", value: truncateText(content, 1200), code: true } : null, + state.title ? { label: "Result", value: state.title } : null, + ]), + }; + } + + return buildGenericToolPresentation(normalizedToolName, status, state); +} + +function buildGenericToolPresentation( + toolName: string, + status: string, + state: ToolPartState, +): ToolActivityPresentation { + const input = formatDetailValue(state.input, 800); + const output = formatDetailValue(state.output, 1000); + const error = formatDetailValue(state.error, 1000); + const request = formatDetailValue(state.raw, 600); + const summary = getString(state.title) ?? statusLabel(status); + + return { + label: `${toDisplayToolName(toolName)} ${statusLabel(status)}`, + summary, + detailSections: compactSections([ + input ? { label: "Input", value: input, code: true } : null, + request ? { label: "Request", value: request, code: true } : null, + output ? { label: "Output", value: output, code: true } : null, + error ? { label: "Error", value: error, code: true } : null, + ]), + }; +} + +function normalizeToolName(toolName: string): string { + return toolName.trim().toLowerCase().split(/[./]/).at(-1) ?? toolName.trim().toLowerCase(); +} + +function isFileChangeTool(toolName: string): boolean { + return ( + toolName.includes("apply_patch") || toolName.includes("write") || toolName.includes("edit") + ); +} + +function getFileToolAction(toolName: string): FileChangeSummary["action"] { + if (toolName.includes("delete")) { + return "delete"; + } + + if (toolName.includes("write") || toolName.includes("add")) { + return "add"; + } + + return "update"; +} + +function parseApplyPatchSummary(patchText: string): FileChangeSummary[] { + const lines = patchText.split(/\r?\n/); + const changes: FileChangeSummary[] = []; + let current: FileChangeSummary | null = null; + + for (const line of lines) { + const fileHeader = parseFileHeader(line); + if (fileHeader) { + current = { ...fileHeader, added: 0, removed: 0 }; + changes.push(current); + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith("+") && !line.startsWith("+++")) { + current.added += 1; + continue; + } + + if (line.startsWith("-") && !line.startsWith("---")) { + current.removed += 1; + } + } + + return changes; +} + +function parseFileHeader(line: string): Omit | null { + const trimmed = line.trim(); + + if (trimmed.startsWith("*** Add File: ")) { + return { action: "add", path: trimmed.replace("*** Add File: ", "") }; + } + + if (trimmed.startsWith("*** Update File: ")) { + return { action: "update", path: trimmed.replace("*** Update File: ", "") }; + } + + if (trimmed.startsWith("*** Delete File: ")) { + return { action: "delete", path: trimmed.replace("*** Delete File: ", "") }; + } + + return null; +} + +function toActionLabel(action: FileChangeSummary["action"]): string { + switch (action) { + case "add": + return "Add"; + case "delete": + return "Delete"; + case "update": + default: + return "Update"; + } +} + +function toDisplayToolName(toolName: string): string { + const normalized = normalizeToolName(toolName); + if (normalized === "apply_patch") { + return "File change"; + } + + return `${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}`; +} + +function statusLabel(status: string): string { + switch (status) { + case "completed": + return "completed"; + case "running": + return "running"; + case "pending": + return "queued"; + case "error": + return "failed"; + default: + return status; + } +} + +function summarizeCommand(command: string | null): string | null { + if (!command) { + return null; + } + + const normalized = command.trim().replace(/\s+/g, " "); + if (normalized.length === 0) { + return null; + } + + return truncateText(normalized, 120); +} + +function formatDetailValue(value: unknown, maxLength: number): string | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? truncateText(trimmed, maxLength) : null; + } + + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + + if (Array.isArray(value)) { + const rendered = value + .map((entry) => formatDetailValue(entry, Math.floor(maxLength / 2)) ?? "") + .filter((entry) => entry.length > 0) + .join("\n"); + return rendered.length > 0 ? truncateText(rendered, maxLength) : null; + } + + if (typeof value === "object") { + return truncateText(JSON.stringify(value, null, 2), maxLength); + } + + return null; +} + +function truncateText(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 3) { + return value.slice(0, maxLength); + } + + return `${value.slice(0, maxLength - 3)}...`; +} + +function asObject(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function compactSections( + sections: Array<{ label: string; value: string; code?: boolean } | null>, +): TaskStreamActivityItem["detailSections"] { + const filtered = sections.filter( + (section): section is { label: string; value: string; code?: boolean } => section !== null, + ); + return filtered.length > 0 ? filtered : undefined; +} + +function compactBadges(values: Array): string[] | undefined { + const filtered = values.filter((value): value is string => value !== null && value.length > 0); + return filtered.length > 0 ? filtered : undefined; +} + +function countLines(value: string | null | undefined): number { + if (!value) { + return 0; + } + + return value.split(/\r?\n/).filter((line) => line.length > 0).length; +} + +function shortenPath(value: string): string { + return ( + value + .split("/") + .filter((segment) => segment.length > 0) + .slice(-2) + .join("/") || value + ); +}