diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.test.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.test.tsx new file mode 100644 index 000000000..1f4c79180 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.test.tsx @@ -0,0 +1,141 @@ +import { Theme } from "@radix-ui/themes"; +import type { SignalReportStatus, Task } from "@shared/types"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reportTasksRef: { current: { task: Task; relationship: string }[] } = { + current: [], +}; + +vi.mock("@hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: () => ({ + data: reportTasksRef.current, + isLoading: false, + }), +})); + +vi.mock("@features/task-detail/components/TaskLogsPanel", () => ({ + TaskLogsPanel: ({ taskId }: { taskId: string }) => ( +
{`logs for ${taskId}`}
+ ), +})); + +vi.mock("@features/inbox/components/utils/ReportImplementationPrLink", () => ({ + ReportImplementationPrLink: ({ prUrl }: { prUrl: string }) => ( + e.stopPropagation()}> + pr-link + + ), +})); + +import { ReportTaskLogs } from "./ReportTaskLogs"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: overrides.id ?? "task-1", + latest_run: overrides.latest_run ?? { + status: "completed", + stage: null, + output: null, + }, + ...overrides, + } as Task; +} + +function renderWithTheme(ui: ReactElement) { + return render({ui}); +} + +describe("ReportTaskLogs", () => { + beforeEach(() => { + reportTasksRef.current = []; + }); + + it("clicking the same bar that was clicked to expand collapses it", async () => { + const user = userEvent.setup(); + reportTasksRef.current = [ + { + task: makeTask({ id: "research-1" }), + relationship: "research", + }, + { + task: makeTask({ id: "impl-1" }), + relationship: "implementation", + }, + ]; + + renderWithTheme( + , + ); + + const researchBar = screen.getByRole("button", { name: /Research task/i }); + + await user.click(researchBar); + expect(screen.getByTestId("task-logs-panel")).toHaveTextContent( + "logs for research-1", + ); + + // After expansion, the same bar should still be in the DOM and clickable. + const expandedResearchBar = screen.getByRole("button", { + name: /Research task/i, + }); + await user.click(expandedResearchBar); + expect(screen.queryByTestId("task-logs-panel")).not.toBeInTheDocument(); + }); + + it("research bar expands when implementation task is also present and completed", async () => { + const user = userEvent.setup(); + reportTasksRef.current = [ + { task: makeTask({ id: "research-1" }), relationship: "research" }, + { + task: makeTask({ id: "impl-1" }), + relationship: "implementation", + }, + ]; + + renderWithTheme( + , + ); + + await user.click(screen.getByRole("button", { name: /Research task/i })); + + expect(screen.getByTestId("task-logs-panel")).toHaveTextContent( + "logs for research-1", + ); + }); + + it("clicking a different bar while one is expanded switches to the new bar", async () => { + const user = userEvent.setup(); + reportTasksRef.current = [ + { task: makeTask({ id: "research-1" }), relationship: "research" }, + { task: makeTask({ id: "impl-1" }), relationship: "implementation" }, + ]; + + renderWithTheme( + , + ); + + await user.click(screen.getByRole("button", { name: /Research task/i })); + expect(screen.getByTestId("task-logs-panel")).toHaveTextContent( + "logs for research-1", + ); + + await user.click( + screen.getByRole("button", { name: /Implementation task/i }), + ); + expect(screen.getByTestId("task-logs-panel")).toHaveTextContent( + "logs for impl-1", + ); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx index c0e64fc2c..ae16e4f32 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx @@ -14,15 +14,17 @@ import type { SignalReportStatus, SignalReportTask, Task } from "@shared/types"; import { useState } from "react"; type Relationship = SignalReportTask["relationship"]; +type BarRelationship = Extract; -const RELATIONSHIP_LABELS: Record = { - repo_selection: "Repository selection", +const RELATIONSHIP_LABELS: Record = { research: "Research task", implementation: "Implementation task", }; -// We display relationships in this order, top to bottom. -const DISPLAYED_RELATIONSHIPS: Relationship[] = ["implementation", "research"]; +// Bars are rendered top-to-bottom in this order. +const BAR_ORDER: BarRelationship[] = ["research", "implementation"]; + +const BAR_HEIGHT = 38; interface ReportTaskData { task: Task; @@ -39,20 +41,15 @@ function useReportTasks(reportId: string, reportStatus: SignalReportStatus) { ["inbox", "report-tasks", reportId], async (client) => { const reportTasks = await client.getSignalReportTasks(reportId); - const relevant = reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), + const relevant = reportTasks.filter((rt): rt is SignalReportTask => + (BAR_ORDER as Relationship[]).includes(rt.relationship), ); - const tasks = await Promise.all( + return Promise.all( relevant.map(async (rt) => { const task = (await client.getTask(rt.task_id)) as unknown as Task; return { task, relationship: rt.relationship }; }), ); - return tasks.sort( - (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), - ); }, { enabled: !!reportId, @@ -165,20 +162,209 @@ export function getTaskPrUrl(task: Task): string | null { return null; } -const BAR_HEIGHT = 38; - interface Bar { - relationship: Relationship; + relationship: BarRelationship; task: Task | null; summary: BarSummary; /** Tooltip shown on hover (e.g. pipeline status explanation). */ tooltip?: string; - /** When set, render a run-action button with this label instead of (or alongside) the status label. */ + /** When set, render a run-action button with this label alongside (or instead of) the status label. */ runActionLabel?: string; /** PR URL produced by the implementation task, if available. */ prUrl?: string | null; } +function buildBars({ + researchTask, + implementationTask, + reportStatus, + isLoading, + onRunInCloud, +}: { + researchTask: Task | null; + implementationTask: Task | null; + reportStatus: SignalReportStatus; + isLoading: boolean; + onRunInCloud?: () => void; +}): Bar[] { + const isPendingInput = reportStatus === "pending_input"; + const runActionLabel = onRunInCloud + ? isPendingInput + ? "Provide input for PR" + : "Create PR" + : undefined; + + const bars: Bar[] = []; + for (const relationship of BAR_ORDER) { + if (relationship === "research") { + if (researchTask) { + bars.push({ + relationship: "research", + task: researchTask, + summary: getTaskStatusSummary(researchTask), + }); + } else { + const { summary, tooltip } = getResearchPendingSummary( + reportStatus, + isLoading, + ); + bars.push({ relationship: "research", task: null, summary, tooltip }); + } + } else if (implementationTask) { + bars.push({ + relationship: "implementation", + task: implementationTask, + summary: getTaskStatusSummary(implementationTask), + prUrl: getTaskPrUrl(implementationTask), + // Once the impl task exists, only surface the run button when the + // agent is awaiting user input (the user has to provide context to + // unstick the PR). Otherwise the bar is purely informational. + runActionLabel: isPendingInput ? runActionLabel : undefined, + }); + } else if (reportStatus === "ready" || isPendingInput) { + bars.push({ + relationship: "implementation", + task: null, + summary: { + label: "Not started", + color: "var(--gray-9)", + icon: , + }, + runActionLabel, + }); + } + } + return bars; +} + +interface BarRowProps { + bar: Bar; + index: number; + isExpanded: boolean; + onToggle: () => void; + onRunAction?: () => void; +} + +function BarRow({ + bar, + index, + isExpanded, + onToggle, + onRunAction, +}: BarRowProps) { + const { relationship, task, summary, tooltip, runActionLabel, prUrl } = bar; + const isInteractive = !!task; + const showRunAction = !!runActionLabel; + const hideStatusLabel = showRunAction && !task; + + const className = [ + "flex w-full items-center gap-2 bg-transparent px-2 @md:px-3 @lg:px-4 @xl:px-5 @2xl:px-6 @3xl:px-8 @4xl:px-10 @5xl:px-12 py-2 text-left transition-colors", + index > 0 ? "border-gray-5 border-t" : "", + isInteractive + ? "cursor-pointer hover:bg-gray-2" + : showRunAction + ? "cursor-default" + : "cursor-default opacity-70", + isExpanded && isInteractive ? "bg-gray-2" : "", + ] + .filter(Boolean) + .join(" "); + + const inner = ( + <> + {summary.icon} + + {RELATIONSHIP_LABELS[relationship]} + + {hideStatusLabel ? ( + + ) : ( + + {prUrl + ? summary.label + : relationship === "implementation" && + (task?.latest_run?.status === "queued" || + task?.latest_run?.status === "in_progress") + ? "Working on a PR…" + : summary.label} + + )} + {prUrl && } + {showRunAction && ( + + )} + {isInteractive && ( + + + + )} + + ); + + // Use a real + ); + } else if (isInteractive) { + row = ( + // biome-ignore lint/a11y/useSemanticElements: needs to nest the run-action - )} - {isInteractive && ( - - - - )} - - ); - - const row = isInteractive ? ( - showRunAction ? ( - // biome-ignore lint/a11y/useSemanticElements: a - ) - ) : ( -
- {rowInner} -
- ); - - return tooltip ? ( - - {row} - - ) : ( - row - ); - })} + {/* z-index: 1 + position: relative so the bars sit above anything + inside TaskLogsPanel that might try to render above the body + (e.g. an absolute overlay escaping its container). */} +
+ {bars.map((bar, index) => ( + toggle(bar.relationship)} + onRunAction={onRunInCloud} + /> + ))}
- {/* Expanded logs body — only rendered for the selected task. */} -
+
{expandedBar?.task && (