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}
+