/** * @file ChatOutline.test.tsx * @description The Chat pane's activity panel renders a session's outline the * way Claude Desktop does: progress with done/current items, outputs by name * with an EXT tag (a click opens that file), and "Used in this session" rows — * uploads, files read, memory and skills open their list. * @author Nguyễn Ngọc Trí Vĩ */ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import { ChatOutline } from "../ChatOutline"; const outline = { progress: [ { id: "1", subject: "Parse plan", status: "completed" }, { id: "2", subject: "Build report", status: "in_progress" }, ], outputs: ["/w/out/Progress report.xlsx"], files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"], uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"], memory: ["/h/.claude/projects/-w/memory/release.md"], skills: ["xlsx"], mcp: ["vercel_vercel"], }; describe("ChatOutline", () => { it("shows progress, outputs and what the session used", () => { const onClose = vi.fn(); const onOpenFile = vi.fn(); const onOpenList = vi.fn(); render( ); expect(screen.getByText("Parse plan").className).toContain("line-through"); expect(screen.getByText("Build report").className).not.toContain("line-through"); // How far the plan is, and which step is being worked on. expect(screen.getByText("1/2")).toBeTruthy(); expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1"); expect(screen.getByTitle("Build report")).toHaveAttribute("aria-current", "step"); fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" })); expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx"); // An upload is named without the drop dir's random prefix. fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx")); expect(onOpenList).toHaveBeenCalledWith("uploads"); expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named fireEvent.click(screen.getByTitle("Memory: release")); expect(onOpenList).toHaveBeenCalledWith("memory"); fireEvent.click(screen.getByTitle("Skills: xlsx")); expect(onOpenList).toHaveBeenCalledWith("skills"); expect(screen.getByText("vercel_vercel")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Hide side panel" })); expect(onClose).toHaveBeenCalled(); }); it("drags its rows into the prompt box", () => { render( {}} onOpenFile={() => {}} onOpenList={() => {}} /> ); const dragged = (el: HTMLElement) => { const data: Record = {}; fireEvent.dragStart(el, { dataTransfer: { setData: (k: string, v: string) => (data[k] = v) }, }); return data; }; const attached = (el: HTMLElement) => JSON.parse(dragged(el)["application/x-agenthub-attach"] ?? "[]") as { path: string }[]; expect(attached(screen.getByRole("button", { name: "XLSX: Progress report" }))).toEqual([ { path: "@/w/out/Progress report.xlsx", name: "Progress report.xlsx" }, ]); expect(attached(screen.getByTitle(/a\.ts, /)).map((a) => a.path)).toEqual([ "@/w/src/a.ts", "@/w/src/b.ts", "@/w/src/c.ts", ]); // An upload goes as the composer attaches one: its bare path. expect(attached(screen.getByTitle("Uploads: minutes.xlsx"))[0]!.path).toBe(outline.uploads[0]); expect(dragged(screen.getByTitle("Skills: xlsx"))["application/x-agenthub-skill"]).toBe("xlsx"); }); it("hides progress and the used section when there is none", () => { render( {}} onOpenFile={() => {}} onOpenList={() => {}} /> ); expect(screen.queryByText("Progress")).toBeNull(); expect(screen.queryByText("Used in this session")).toBeNull(); expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy(); }); });