diff --git a/apps/portal/src/components/TaskPromptBadge.stories.tsx b/apps/portal/src/components/TaskPromptBadge.stories.tsx index 401495088..239505f40 100644 --- a/apps/portal/src/components/TaskPromptBadge.stories.tsx +++ b/apps/portal/src/components/TaskPromptBadge.stories.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect } from "storybook/test"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; import { TaskPromptBadge } from "./TaskPromptBadge"; import type { TaskPrompt } from "@/types"; @@ -84,3 +84,120 @@ export const NonLinking: Story = { await expect(canvas.queryByRole("link")).toBeNull(); }, }; + +const legacyTask = + "Build a benchmarking dashboard that compares coding agents, displays evaluation criteria, streams logs, supports project filters, and explains all results with clear accessible interactions."; + +export const LegacyRunWithoutPromptReference: Story = { + parameters: { + docs: { + description: { + story: "Shows the full inline task with the same 200 ms hover delay as the existing run metadata tooltips.", + }, + }, + }, + args: { + taskPromptId: undefined, + prompt: undefined, + fallbackText: legacyTask, + children: Build a benchmarking dashboard…, + }, + play: async ({ canvas }) => { + await expect(canvas.queryByRole("link")).toBeNull(); + const trigger = canvas.getByText("Build a benchmarking dashboard…"); + await userEvent.hover(trigger); + await expect(await screen.findByRole("tooltip")).toHaveTextContent(legacyTask); + await userEvent.unhover(trigger); + }, +}; + +const finalLegacyTaskLine = "Final step: Verify every result is accessible."; +const longLegacyTask = [ + ...Array.from( + { length: 100 }, + (_, index) => `Step ${index + 1}: Preserve the evaluation details and project filters.`, + ), + finalLegacyTaskLine, +].join("\n"); + +export const LegacyRunWithOverflowingTask: Story = { + parameters: { + layout: "centered", + docs: { + description: { + story: "Tab to the task and use Arrow keys, Page Up/Down, Home/End, or Space/Shift+Space to scroll the preview. Escape dismisses it; Tab continues to the next control.", + }, + }, + }, + args: { + taskPromptId: undefined, + prompt: undefined, + fallbackText: longLegacyTask, + children: "Read the long task…", + }, + render: (args) => ( +
+ + +
+ ), + play: async ({ canvas }) => { + const trigger = canvas.getByText("Read the long task…"); + await userEvent.tab(); + await expect(trigger).toHaveFocus(); + + const tooltip = await screen.findByRole("tooltip"); + const content = tooltip.closest("[data-state]"); + if (!content) throw new Error("Expected the visible tooltip content"); + + // This regression needs browser layout: the task must really overflow. + await waitFor(() => expect(content.scrollHeight).toBeGreaterThan(content.clientHeight)); + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => expect(content.scrollTop).toBeGreaterThan(0)); + + const firstScrollTop = content.scrollTop; + await userEvent.keyboard("{PageDown}"); + await waitFor(() => expect(content.scrollTop).toBeGreaterThan(firstScrollTop)); + + await userEvent.keyboard("{End}"); + await waitFor(() => { + expect(content.scrollTop + content.clientHeight).toBeGreaterThanOrEqual(content.scrollHeight - 1); + }); + const text = content.firstChild; + if (!(text instanceof Text)) throw new Error("Expected the full task text"); + const finalLineRange = document.createRange(); + finalLineRange.setStart(text, text.length - finalLegacyTaskLine.length); + finalLineRange.setEnd(text, text.length); + await waitFor(() => { + const finalLineBounds = finalLineRange.getBoundingClientRect(); + const previewBounds = content.getBoundingClientRect(); + expect(finalLineBounds.top).toBeGreaterThanOrEqual(previewBounds.top); + expect(finalLineBounds.bottom).toBeLessThanOrEqual(previewBounds.bottom); + }); + await expect(trigger).toHaveFocus(); + + for (const key of ["{ArrowUp}", "{PageUp}"]) { + const previousScrollTop = content.scrollTop; + await userEvent.keyboard(key); + await waitFor(() => expect(content.scrollTop).toBeLessThan(previousScrollTop)); + } + await userEvent.keyboard("{Home}"); + await waitFor(() => expect(content.scrollTop).toBe(0)); + await userEvent.keyboard(" "); + await waitFor(() => expect(content.scrollTop).toBeGreaterThan(0)); + await userEvent.keyboard("{Shift>} {/Shift}"); + await waitFor(() => expect(content.scrollTop).toBe(0)); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + await userEvent.tab(); + await expect(canvas.getByRole("button", { name: "Next control" })).toHaveFocus(); + + await userEvent.tab({ shift: true }); + await screen.findByRole("tooltip"); + await userEvent.tab(); + await expect(canvas.getByRole("button", { name: "Next control" })).toHaveFocus(); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + }, +}; diff --git a/apps/portal/src/components/TaskPromptBadge.test.tsx b/apps/portal/src/components/TaskPromptBadge.test.tsx index 47c8f9230..830647de2 100644 --- a/apps/portal/src/components/TaskPromptBadge.test.tsx +++ b/apps/portal/src/components/TaskPromptBadge.test.tsx @@ -110,6 +110,84 @@ describe("TaskPromptBadge is type-agnostic", () => { }); }); +describe("TaskPromptBadge legacy run tooltip", () => { + it.each(["hover", "keyboard"])("reveals the full inline task on %s without fetching or linking", async (interaction) => { + const fullTask = `Build an accessible dashboard.\n${"Preserve all evaluation details and project filters. ".repeat(10)}`; + renderBadge( + + Build an accessible dashboard… + , + ); + const trigger = screen.getByText("Build an accessible dashboard…"); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.queryByRole("tooltip")).toBeNull(); + + if (interaction === "hover") { + await userEvent.hover(trigger); + } else { + await userEvent.tab(); + } + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toBe(fullTask); + expect(getTaskPrompt).not.toHaveBeenCalled(); + expect(getTaskPromptContent).not.toHaveBeenCalled(); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + }); + + it("preserves the parent row's click action", async () => { + const onRowClick = vi.fn(); + renderBadge( +
+ + Task… + +
, + ); + await userEvent.click(screen.getByText("Task…")); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + + it("does not trigger the parent row's click action when clicking the preview", async () => { + const onRowClick = vi.fn(); + renderBadge( +
+ + Task… + +
, + ); + await userEvent.hover(screen.getByText("Task…")); + await screen.findByRole("tooltip"); + await userEvent.click(screen.getByText("Full task text", { selector: "[data-state]" })); + + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it("preserves the linked prompt preview when fallback text is also supplied", async () => { + const fallbackText = "Inline task from the run"; + renderBadge( + , + ); + + const link = screen.getByRole("link"); + expect(link.getAttribute("href")).toBe(`/task-prompts/${basePrompt._id}`); + await userEvent.hover(link); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toContain(basePrompt.text); + expect(tooltip.textContent).not.toContain(fallbackText); + expect(getTaskPrompt).not.toHaveBeenCalled(); + expect(getTaskPromptContent).not.toHaveBeenCalled(); + }); +}); + describe("TaskPromptBadge lazy fetch (no request fan-out)", () => { it("never fetches when a preloaded prompt is supplied", async () => { renderBadge(); diff --git a/apps/portal/src/components/TaskPromptBadge.tsx b/apps/portal/src/components/TaskPromptBadge.tsx index aaaab9292..53c5e0377 100644 --- a/apps/portal/src/components/TaskPromptBadge.tsx +++ b/apps/portal/src/components/TaskPromptBadge.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; @@ -16,6 +16,7 @@ import { cn, formatDate, formatId, truncate } from "@/lib/utils"; import type { TaskPrompt } from "@/types"; const SNIPPET_LEN = 280; +const TOOLTIP_SCROLL_STEP = 40; interface TaskPromptBadgeProps { /** Content-addressed task prompt id (used for the link target and lazy fetch). */ @@ -25,6 +26,8 @@ interface TaskPromptBadgeProps { * When omitted, the prompt is fetched lazily the first time the tooltip opens. */ prompt?: TaskPrompt; + /** Full text to show when a legacy run has no task prompt reference. */ + fallbackText?: string; /** Custom trigger content. Defaults to a ` · ` badge. */ children?: ReactNode; /** When true (default) the trigger links to the task prompt detail page. */ @@ -48,11 +51,13 @@ interface TaskPromptBadgeProps { export function TaskPromptBadge({ taskPromptId, prompt, + fallbackText, children, link = true, className, }: TaskPromptBadgeProps) { const [open, setOpen] = useState(false); + const fallbackContentRef = useRef(null); const { data: fetched, isLoading: loadingPrompt } = useQuery({ queryKey: ["task-prompt", taskPromptId], @@ -76,8 +81,62 @@ export function TaskPromptBadge({ const snippet = resolved?.text ?? content?.text; const detectedFeatures = resolved?.features?.filter((f) => f.detected) ?? []; - // Without an id there is nothing to preview or link to — render content plainly. + // Legacy runs can still preview their inline task without a prompt reference. if (!taskPromptId) { + if (fallbackText) { + return ( + + + + { + const preview = fallbackContentRef.current; + if ( + !open || !preview || + preview.scrollHeight <= preview.clientHeight || + event.altKey || event.ctrlKey || event.metaKey + ) { + return; + } + + const pageStep = Math.max(TOOLTIP_SCROLL_STEP, preview.clientHeight - TOOLTIP_SCROLL_STEP); + const scrollOffsets: Partial> = { + ArrowDown: TOOLTIP_SCROLL_STEP, + ArrowUp: -TOOLTIP_SCROLL_STEP, + PageDown: pageStep, + PageUp: -pageStep, + Home: -preview.scrollHeight, + End: preview.scrollHeight, + " ": event.shiftKey ? -pageStep : pageStep, + }; + const offset = scrollOffsets[event.key]; + if (offset === undefined) return; + + event.preventDefault(); + event.stopPropagation(); + preview.scrollTop += offset; + }} + > + {children ?? fallbackText} + + + + event.stopPropagation()} + > + {fallbackText} + + + + + ); + } return children ? ( {children} ) : null; diff --git a/apps/portal/src/pages/RunsList.tsx b/apps/portal/src/pages/RunsList.tsx index 27b685df5..1fef68298 100644 --- a/apps/portal/src/pages/RunsList.tsx +++ b/apps/portal/src/pages/RunsList.tsx @@ -407,7 +407,17 @@ function NumericComparatorRow({ }) { return (
- {label} + + + + {label} + + + {label} +