Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/components/task-stream-activity-row.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGProps<SVGSVGElement>>;

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 (
<div className="flex items-start gap-2.5 py-1 text-xs">
<Icon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
item.spinning ? "animate-spin" : "",
item.tone === "error" ? "text-destructive" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
<span className="text-[11px] font-medium text-muted-foreground">{action}</span>
{item.summary ? (
hasExpandableDetails ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
className="flex min-w-0 items-center gap-1 text-left text-foreground transition-colors hover:text-foreground/80"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
<span className="min-w-0 truncate font-medium sm:whitespace-pre-wrap sm:break-words sm:truncate-none">
{item.summary}
</span>
</button>
) : (
<span className="min-w-0 truncate font-medium text-foreground sm:whitespace-pre-wrap sm:break-words sm:truncate-none">
{item.summary}
</span>
)
) : details ? (
<span className="text-muted-foreground">{details}</span>
) : null}
</div>
{item.badges && item.badges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge}`}
className="rounded-sm bg-muted/50 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{badge}
</span>
))}
</div>
) : null}
{!item.summary && item.details && item.details.length > 0 ? (
<div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
{item.details.map((detail) => (
<div
key={`${item.id}-${detail}`}
className={cn(
"whitespace-pre-wrap break-words",
item.tone === "error" && "text-destructive/80",
)}
>
{detail}
</div>
))}
</div>
) : null}
{item.summary && hasExpandableDetails && expanded ? (
<div className="mt-2 space-y-2 border-l border-border/70 pl-3">
{item.detailSections?.map((section) => (
<div key={`${item.id}-${section.label}`} className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{section.label}
</div>
<div
className={cn(
"whitespace-pre-wrap break-words text-[11px] text-foreground",
section.code &&
"overflow-x-auto rounded-sm border border-border/60 bg-muted/30 px-2 py-1.5 font-mono",
)}
>
{section.value}
</div>
</div>
))}
{item.details?.map((detail) => (
<div
key={`${item.id}-${detail}`}
className="whitespace-pre-wrap break-words text-[11px] text-muted-foreground"
>
{detail}
</div>
))}
</div>
) : null}
</div>
</div>
);
}

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)}`;
}
89 changes: 9 additions & 80 deletions src/components/task-stream-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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;
}
Expand All @@ -41,56 +48,9 @@ export function TaskStreamActivity({ items }: { items: TaskStreamActivityItem[]
<div>
<div className="space-y-1.5">
{items.map((item) => {
const Icon = getActivityIcon(item.icon);
const { action, details } = splitActivityLabel(item.label);
return (
<AnimatedStreamItem key={item.id}>
<div className="flex items-start gap-2 py-0.5 text-xs">
<Icon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
item.spinning ? "animate-spin" : "",
item.tone === "error" ? "text-destructive" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start gap-1">
<span
className={cn(
"whitespace-pre-wrap",
item.tone === "error" ? "text-destructive" : "text-foreground",
)}
>
{action}
</span>
{details ? (
<span
className={cn(
"whitespace-pre-wrap text-muted-foreground",
item.tone === "error" && "text-destructive/80",
)}
>
{details}
</span>
) : null}
</div>
{item.details && item.details.length > 0 ? (
<div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
{item.details.map((detail) => (
<div
key={`${item.id}-${detail}`}
className={cn(
"whitespace-pre-wrap break-words",
item.tone === "error" && "text-destructive/80",
)}
>
{detail}
</div>
))}
</div>
) : null}
</div>
</div>
<TaskStreamActivityRow item={item} icon={getActivityIcon(item.icon)} />
</AnimatedStreamItem>
);
})}
Expand All @@ -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":
Expand Down
42 changes: 18 additions & 24 deletions src/lib/task-activity-mapper.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/task-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Loading
Loading