Skip to content
Merged
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
15 changes: 13 additions & 2 deletions src/features/chat/ui/AgentWorkPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
ToolCallStatus,
} from "@/shared/types/messages";
import type { TranscriptAgentWorkPayload } from "@/features/chat/transcript/projection/transcriptItemTypes";
import { useTranscriptRowStateAdapter } from "@/features/chat/transcript/row-state";
import { ToolCallAdapter } from "./ToolCallAdapter";

interface ToolTimelineItem {
Expand Down Expand Up @@ -351,6 +352,7 @@ export function AgentWorkPanel({
}) {
const { t } = useTranslation("chat");
const prefersReducedMotion = useReducedMotion();
const { markRowInteracted, pinScrollAnchor } = useTranscriptRowStateAdapter();
const items = useMemo(
() => buildAgentWorkTimeline(payload.content),
[payload.content],
Expand Down Expand Up @@ -423,7 +425,12 @@ export function AgentWorkPanel({
return (
<Collapsible
open={open}
onOpenChange={setOpen}
onOpenChange={(nextOpen) => {
markRowInteracted("agent-work-disclosure");
pinScrollAnchor();
setOpen(nextOpen);
}}
data-role="agent-work-panel"
className="mt-3 w-full min-w-0 max-w-full"
>
<div>
Expand Down Expand Up @@ -484,7 +491,11 @@ export function AgentWorkPanel({
>
<Collapsible
open={previousStepsOpen}
onOpenChange={setPreviousStepsOpen}
onOpenChange={(nextOpen) => {
markRowInteracted("agent-work-previous-steps");
pinScrollAnchor();
setPreviousStepsOpen(nextOpen);
}}
>
<CollapsibleTrigger asChild>
<Button
Expand Down
9 changes: 7 additions & 2 deletions src/features/chat/ui/ToolCallAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CodeBlock } from "@/shared/ui/ai-elements/code-block";
import {
Tool,
ToolContent,
ToolDetailsViewport,
ToolHeader,
ToolInput,
ToolOutput,
Expand Down Expand Up @@ -593,7 +594,11 @@ export function ToolCallAdapter({
className="text-muted-foreground [&_button]:text-muted-foreground [&_code]:text-muted-foreground [&_dd]:text-muted-foreground [&_dt]:text-muted-foreground [&_span]:text-muted-foreground"
>
{agentWorkLayout ? (
<div className="space-y-3 py-1">
<ToolDetailsViewport
data-role="agent-work-tool-details"
aria-label={t("tools.details")}
className="max-h-48 space-y-3 overflow-y-auto overscroll-contain py-1"
>
<AgentWorkToolSection
label={t("tools.inputSummary.command")}
value={commandRow?.value ?? null}
Expand All @@ -611,7 +616,7 @@ export function ToolCallAdapter({
label={t("tools.structuredContent")}
value={structuredDetails}
/>
</div>
</ToolDetailsViewport>
) : showCombinedSurface ? (
<ToolSurface tone="muted" className="bg-muted">
<ToolInput
Expand Down
7 changes: 6 additions & 1 deletion src/features/chat/ui/VirtualMessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3799,7 +3799,11 @@ function VirtualMessageTimelineSession({
) {
return;
}
if (resolvedScrollTargetMessageId || userDetachedRef.current) {
if (
resolvedScrollTargetMessageId ||
userDetachedRef.current ||
virtualTimelineSnapshot.controllerState.anchor.type !== "bottom"
) {
return;
}
requestBottomScroll();
Expand All @@ -3809,6 +3813,7 @@ function VirtualMessageTimelineSession({
requestBottomScroll,
resolvedScrollTargetMessageId,
streamingMessageId,
virtualTimelineSnapshot.controllerState.anchor.type,
]);

const messageList = (
Expand Down
23 changes: 23 additions & 0 deletions src/features/chat/ui/__tests__/ToolCallAdapter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,29 @@ describe("ToolCallAdapter — expanded body", () => {
renderAdapter({ open: true, isError: true, result: "Boom" });
expect(screen.getByText("Boom")).toHaveClass("text-destructive");
});

it("caps and scrolls an inner details viewport in the agent work layout", () => {
const { container } = renderAdapter({ open: true, agentWorkLayout: true });

const details = screen.getByRole("region", { name: "Tool details" });
expect(details).toHaveAttribute("tabindex", "0");
expect(details).toHaveClass(
"max-h-48",
"overflow-y-auto",
"overscroll-contain",
);
expect(
container.querySelector('[data-role="tool-call-content"]'),
).not.toHaveClass("max-h-48", "overflow-y-auto");
});

it("does not add a capped details viewport outside the agent work layout", () => {
const { container } = renderAdapter({ open: true });

expect(
container.querySelector('[data-role="agent-work-tool-details"]'),
).not.toBeInTheDocument();
});
});

describe("ToolCallAdapter — text + structured de-dupe matrix", () => {
Expand Down
48 changes: 48 additions & 0 deletions src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,54 @@ describe("VirtualMessageTimeline", () => {
expect(screen.queryByTestId("message-assistant-work:answer")).toBeNull();
});

it("keeps a revealed agent-work row anchored as its measured height grows", () => {
mockTranscriptElementMeasurements();
const animationFrame = mockRequestAnimationFrame();
const assistant: Message = {
id: "assistant-work",
role: "assistant",
created: Date.UTC(2026, 5, 4, 12, 1, 0),
metadata: { userVisible: true },
content: [
{ type: "thinking", text: "Planning" },
{
type: "toolRequest",
id: "tool-1",
name: "scan",
arguments: {},
status: "completed",
},
{ type: "text", text: "Final answer" },
],
};
renderWithProviders(
<VirtualMessageTimeline sessionId="session-1" messages={[assistant]} />,
);
const scroller = screen.getByTestId("message-timeline-scroll");
const scrollTo = attachScrollTo(scroller);
setScrollMetrics(scroller, {
scrollTop: 500,
scrollHeight: 1000,
clientHeight: 500,
});
animationFrame.runAll(0);
scrollTo.mockClear();

fireEvent.click(screen.getByRole("button", { name: /previous steps?/ }));
animationFrame.runAll(100);
scrollTo.mockClear();

const agentWorkRow = screen.getByTestId(
"virtual-transcript-row-message:assistant-work:agent-work",
);
agentWorkRow.setAttribute("data-mock-row-height", "480");
triggerResizeObservers();
animationFrame.runAll(200);

expect(scroller.scrollTop).toBe(500);
expect(scrollTo).not.toHaveBeenCalled();
});

it("mounts a just-settled agent-work row open before collapsing", async () => {
const animationFrame = mockRequestAnimationFrame();
const user = textMessage("user-1", "user", "Please inspect");
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@
},
"tools": {
"content": "Content",
"details": "Tool details",
"error": "Error",
"input": "Input",
"result": "Result",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/es/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@
},
"tools": {
"content": "Contenido",
"details": "Detalles de la herramienta",
"error": "Error",
"input": "Entrada",
"result": "Resultado",
Expand Down
19 changes: 19 additions & 0 deletions src/shared/ui/ai-elements/tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,25 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
/>
);

export type ToolDetailsViewportProps = ComponentProps<"div">;

export const ToolDetailsViewport = ({
className,
...props
}: ToolDetailsViewportProps) => (
// Scrollable regions need a tab stop so keyboard users can operate them.
<div
role="region"
// biome-ignore lint/a11y/noNoninteractiveTabindex: focus transfers keyboard scrolling to this overflow viewport.
tabIndex={0}
className={cn(
"rounded-sm outline-none transition-[color,box-shadow] focus-visible:ring-1 focus-visible:ring-ring/50",
className,
)}
{...props}
/>
);

export type ToolSectionProps = ComponentProps<"div"> & {
label: string;
};
Expand Down