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
97 changes: 97 additions & 0 deletions frontend/e2e/completed-turn-task-view.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* E2E: CompletedTurnTaskView — verify completed turn activity renders correctly
*
* Tests that:
* 1. The page loads without errors
* 2. If activity items exist, they don't show generic node names
* 3. CompletedTurnTaskView component renders a collapsible "Turn Activity" section
*/

import { test, expect } from "@playwright/test";

test.describe("CompletedTurnTaskView", () => {
test("page loads and CompletedTurnTaskView does not show generic node names", async ({
page,
}) => {
test.setTimeout(30_000);

await page.goto("/");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(3000);

// Check for activity items in the DOM
const activityItems = await page.evaluate(() => {
const items = document.querySelectorAll("[data-activity-item]");
return Array.from(items).map((el) => ({
kind: el.getAttribute("data-kind"),
status: el.getAttribute("data-status"),
depth: el.getAttribute("data-depth"),
text: (el.textContent || "").substring(0, 200),
}));
});

if (activityItems.length === 0) {
// No activity items on main page — this is expected when no thread is loaded
console.log(
"No activity items found — skipping assertions (no thread loaded)",
);
return;
}

// Verify no generic node names in LLM output items
const llmOutputs = activityItems.filter((i) => i.kind === "llm_output");
for (const item of llmOutputs) {
const textStart = item.text.trim().substring(0, 20);
expect(textStart).not.toMatch(/^Model\s/);
expect(textStart).not.toMatch(/^Tools\s/);
}

// Verify no generic names in subgraph depth=1 items
const depth1Items = activityItems.filter((i) => i.depth === "1");
const hasGenericNames = depth1Items.some(
(i) =>
i.text.startsWith("Model") ||
i.text.startsWith("Tools") ||
i.text.startsWith("Agent"),
);
expect(hasGenericNames).toBe(false);
});

test("Turn Activity section is collapsible when present", async ({
page,
}) => {
test.setTimeout(30_000);

await page.goto("/");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(3000);

// Look for "Turn Activity" text which indicates CompletedTurnTaskView is rendered
const turnActivityButton = page.locator("text=Turn Activity").first();

if (await turnActivityButton.isVisible().catch(() => false)) {
// Click to expand
await turnActivityButton.click();
await page.waitForTimeout(500);

// Take screenshot for visual verification
await page.screenshot({
path: "test-results/completed-turn-expanded.png",
fullPage: true,
});

// Click again to collapse
await turnActivityButton.click();
await page.waitForTimeout(500);

await page.screenshot({
path: "test-results/completed-turn-collapsed.png",
fullPage: true,
});
} else {
console.log(
"No Turn Activity section found — skipping (no completed turns)",
);
}
});
});
80 changes: 80 additions & 0 deletions frontend/src/features/chat/components/CompletedTurnTaskView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { useState, memo } from "react";
import type { Message } from "@langchain/langgraph-sdk";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronRight, ChevronDown, Activity } from "lucide-react";
import { useTaskProgress } from "@/features/chat/hooks/useTaskProgress";
import { StreamingTaskView } from "./StreamingTaskView";

interface CompletedTurnTaskViewProps {
turnMessages: Message[];
finalNodeNames: string[];
}

export const CompletedTurnTaskView = memo(function CompletedTurnTaskView({
turnMessages,
finalNodeNames,
}: CompletedTurnTaskViewProps) {
const [isCollapsed, setIsCollapsed] = useState(true);

const { progress, activityItems } = useTaskProgress({
messages: turnMessages,
isStreaming: false,
finalNodeNames,
});

const hasContent = progress.length > 0 || activityItems.length > 0;
if (!hasContent) return null;

const todoCount = progress.filter((p) => p.source === "todo").length;
const completedTodoCount = progress.filter(
(p) => p.source === "todo" && p.status === "completed",
).length;

const ChevronIcon = isCollapsed ? ChevronRight : ChevronDown;

return (
<div className="border-border/50 bg-card overflow-hidden rounded-lg border">
<div
className="bg-muted/30 border-border/50 hover:bg-muted/50 flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 transition-colors"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<ChevronIcon className="text-muted-foreground h-4 w-4" />
<Activity className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">Turn Activity</span>
{todoCount > 0 && (
<span className="text-muted-foreground text-xs">
({completedTodoCount}/{todoCount})
</span>
)}
{activityItems.length > 0 && (
<span className="text-muted-foreground text-xs">
{todoCount > 0 ? "· " : ""}
{activityItems.length} activities
</span>
)}
</div>

<AnimatePresence>
{!isCollapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="p-2">
<StreamingTaskView
progress={progress}
activeLeafTasks={[]}
isStreaming={false}
activityItems={activityItems}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
13 changes: 13 additions & 0 deletions frontend/src/features/chat/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/utils/ensure-tool-responses";
import { AssistantMessage, AssistantMessageLoading } from "./messages/ai";
import { HumanMessage } from "./messages/human";
import { StreamingTaskView } from "./StreamingTaskView";
import { CompletedTurnTaskView } from "./CompletedTurnTaskView";
import { shouldRenderMessage, buildSubagentContext } from "./utils";
import type { HierarchicalTask } from "@/types/task-hierarchy";
import type { TaskProgressItem, ActivityItem } from "@/types/task-progress";
Expand Down Expand Up @@ -319,6 +320,18 @@ export function MessageList({
return;
if (isIntermediateNodeMessage(message)) return;
if (!completedTurnFinalAiIds.has(message.id || "")) return;

// Show collapsible activity view before the final AI message of completed turns
const turnStart = humanIndices[owningHumanIdx];
const turnEnd = humanIndices[owningHumanIdx + 1];
const turnMessages = filteredMessages.slice(turnStart, turnEnd);
elements.push(
<CompletedTurnTaskView
key={`completed-turn-${owningHumanIdx}`}
turnMessages={turnMessages}
finalNodeNames={finalNodeNames}
/>,
);
}
} else {
// Current turn (or pre-turn messages): existing logic
Expand Down
Loading