Skip to content
Open
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
119 changes: 118 additions & 1 deletion apps/portal/src/components/TaskPromptBadge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: <span>Build a benchmarking dashboard…</span>,
},
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) => (
<div>
<TaskPromptBadge {...args} />
<button type="button" className="ml-4">
Next control
</button>
</div>
),
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<HTMLElement>("[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());
},
};
78 changes: 78 additions & 0 deletions apps/portal/src/components/TaskPromptBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TaskPromptBadge fallbackText={fullTask}>
<span>Build an accessible dashboard…</span>
</TaskPromptBadge>,
);
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(
<div onClick={onRowClick}>
<TaskPromptBadge fallbackText="Full task text">
<span>Task…</span>
</TaskPromptBadge>
</div>,
);
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(
<div onClick={onRowClick}>
<TaskPromptBadge fallbackText="Full task text">
<span>Task…</span>
</TaskPromptBadge>
</div>,
);
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(
<TaskPromptBadge
taskPromptId={basePrompt._id}
prompt={basePrompt}
fallbackText={fallbackText}
/>,
);

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(<TaskPromptBadge taskPromptId="abcdef1234567890" prompt={basePrompt} />);
Expand Down
63 changes: 61 additions & 2 deletions apps/portal/src/components/TaskPromptBadge.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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). */
Expand All @@ -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 `<type label> · <short id>` badge. */
children?: ReactNode;
/** When true (default) the trigger links to the task prompt detail page. */
Expand All @@ -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<HTMLDivElement>(null);

const { data: fetched, isLoading: loadingPrompt } = useQuery({
queryKey: ["task-prompt", taskPromptId],
Expand All @@ -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 (
<TooltipProvider delayDuration={200}>
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<span
tabIndex={0}
className={cn("inline-flex max-w-full", className)}
onKeyDown={(event) => {
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<Record<string, number>> = {
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}
</span>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent
Comment thread
Gile987 marked this conversation as resolved.
Resolved
ref={fallbackContentRef}
side="top"
collisionPadding={8}
className="max-w-sm max-h-[var(--radix-tooltip-content-available-height)] overflow-y-auto whitespace-pre-wrap break-words text-xs"
Comment thread
Gile987 marked this conversation as resolved.
Resolved
onClick={(event) => event.stopPropagation()}
>
{fallbackText}
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
);
}
return children ? (
<span className={cn("inline-flex max-w-full", className)}>{children}</span>
) : null;
Expand Down
18 changes: 16 additions & 2 deletions apps/portal/src/pages/RunsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,17 @@ function NumericComparatorRow({
}) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<span className="w-12 shrink-0 truncate text-xs font-medium text-muted-foreground" title={label}>{label}</span>
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
className="w-12 shrink-0 truncate text-xs font-medium text-muted-foreground"
>
{label}
</span>
</TooltipTrigger>
<TooltipContent className="text-xs">{label}</TooltipContent>
</Tooltip>
<Select value={op} onValueChange={(v) => onChange({ op: v as IterationOp, value })}>
<SelectTrigger
aria-label={`${ariaLabel} comparator`}
Expand Down Expand Up @@ -1361,7 +1371,11 @@ export function RunsList() {
hidden: columnVisibility.isHidden("task"),
cell: (r) =>
r.scenario?.task ? (
<TaskPromptBadge taskPromptId={r.taskPromptId} className="block truncate">
<TaskPromptBadge
taskPromptId={r.taskPromptId}
fallbackText={r.scenario.task}
className="block truncate"
>
<span className="text-sm cursor-pointer hover:underline">
{truncate(r.scenario.task, 60)}
</span>
Expand Down
17 changes: 16 additions & 1 deletion docs/architecture/app-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,22 @@ Both follow the same rules:
- **`TaskPromptBadge` is type-agnostic.** Gate prompts (`select`/`build`/`test`/`run`/`deploy`),
`agents.md`, and legacy untyped prompts all render the same hover + the same
`/task-prompts/:id` navigation; only the human label differs (via `promptTypeLabel`). It also
renders content plainly (no link/tooltip) when no `taskPromptId` is available.
renders content plainly when no `taskPromptId` is available, unless the caller
supplies `fallbackText`. The Runs list supplies the inline task for legacy runs:
a portaled Radix tooltip shows the full text on hover or keyboard focus, uses
the same 200 ms hover delay as existing metadata tooltips, wraps long values,
and scrolls if needed. This fallback has no detail link and never fetches
prompt metadata or content. Runs with a task-prompt reference keep their
existing linked preview, even when fallback text is supplied.
When the fallback preview overflows, Arrow Up/Down, Page Up/Down, Home/End,
and Space/Shift+Space scroll it while keyboard focus stays on the trigger.
Escape dismisses the tooltip; Tab moves to the next control and dismisses it.
Clicks inside the fallback preview stop propagation so they do not navigate
the enclosing row; clicking the trigger keeps the row's normal behavior.
The `LegacyRunWithOverflowingTask` Storybook interaction checks real overflow,
scrolling to the final visible line, and keyboard dismissal in a browser.
The Runs list's truncated numeric filter labels also use Radix tooltips
instead of native `title` attributes, inheriting the page's 200 ms hover delay.
- **Detected-features list + explicit navigate button.** `TaskPromptBadge`'s preview lists only
the prompt's **detected** features by id (it never shows undetected features or an `x/y` count)
and ends with an obvious button-styled **Open details** `Link` (not plain text). Because the
Expand Down