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
65 changes: 65 additions & 0 deletions src/browser/features/Tools/Shared/toolUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,71 @@ export function useToolExpansion(initialExpanded = false, options?: UseStickyExp
return useStickyExpand("tools", initialExpanded, options);
}

interface AutoCollapsingToolExpansionOptions {
autoCollapsed: boolean;
resetKey: string | undefined;
}

/**
* Tool expansion with a non-persisted presentation-only auto-collapse layer.
* Header toggles still go through useToolExpansion and are the only path that
* updates the sticky per-tool preference.
*/
export function useAutoCollapsingToolExpansion(
initialExpanded: boolean,
options: AutoCollapsingToolExpansionOptions
) {
const { expanded: stickyExpanded, setExpanded: setStickyExpanded } =
useToolExpansion(initialExpanded);
const [userInteraction, setUserInteraction] = React.useState<{
key: string | undefined;
interacted: boolean;
}>(() => ({ key: options.resetKey, interacted: false }));
const [localExpanded, setLocalExpandedState] = React.useState<{
key: string | undefined;
expanded: boolean;
} | null>(null);
const localExpandedValue =
localExpanded != null && localExpanded.key === options.resetKey ? localExpanded.expanded : null;
const userInteracted =
localExpandedValue != null ||
(userInteraction.interacted && userInteraction.key === options.resetKey);
const expanded =
options.autoCollapsed && !userInteracted ? false : (localExpandedValue ?? stickyExpanded);

const expandedRef = React.useRef(expanded);
expandedRef.current = expanded;
const resetKeyRef = React.useRef(options.resetKey);
resetKeyRef.current = options.resetKey;

// These callbacks intentionally keep stable identities: WorkflowRunToolCall registers
// command-palette actions from an effect and relies on the expansion setter not changing
// unless the underlying sticky setter changes.
const markInteracted = React.useCallback((): void => {
setUserInteraction({ key: resetKeyRef.current, interacted: true });
}, []);
const setExpanded = React.useCallback(
(next: boolean): void => {
markInteracted();
setLocalExpandedState(null);
setStickyExpanded(next);
},
[markInteracted, setStickyExpanded]
);
const setLocalExpanded = React.useCallback(
(next: boolean): void => {
markInteracted();
setLocalExpandedState({ key: resetKeyRef.current, expanded: next });
},
[markInteracted]
);
const toggleExpanded = React.useCallback(() => {
setExpanded(!expandedRef.current);
}, [setExpanded]);

return { expanded, setExpanded, setLocalExpanded, toggleExpanded, markInteracted };
}

/**
* Get display element for tool status
*/
Expand Down
72 changes: 71 additions & 1 deletion src/browser/features/Tools/WorkflowActionListToolCall.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import type React from "react";

import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
import { ToolNameProvider } from "@/browser/features/Messages/ToolNameContext";
import { getAutoExpandPrefsKey } from "@/common/constants/storage";
import { WorkflowActionListToolCall } from "./WorkflowActionListToolCall";

const TEST_WORKSPACE_ID = "workflow-action-list-tool-test";

function renderWithTooltip(ui: React.ReactElement) {
return render(
<ThemeProvider forcedTheme="dark">
Expand All @@ -16,6 +21,28 @@ function renderWithTooltip(ui: React.ReactElement) {
);
}

function renderWithStickyToolProviders(ui: React.ReactElement) {
return render(
<ThemeProvider forcedTheme="dark">
<MessageListProvider value={{ workspaceId: TEST_WORKSPACE_ID, latestMessageId: null }}>
<ToolNameProvider toolName="workflow_action_list">
<TooltipProvider>{ui}</TooltipProvider>
</ToolNameProvider>
</MessageListProvider>
</ThemeProvider>
);
}

function getStoredPrefs(): string | null {
return globalThis.localStorage.getItem(getAutoExpandPrefsKey(TEST_WORKSPACE_ID));
}

function clickToolHeader(view: ReturnType<typeof render>, label: string) {
const header = view.getByText(label).closest('[data-scroll-intent="ignore"]');
expect(header).toBeTruthy();
fireEvent.click(header as HTMLElement);
}

describe("WorkflowActionListToolCall", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;
Expand All @@ -37,7 +64,44 @@ describe("WorkflowActionListToolCall", () => {
globalThis.localStorage = originalLocalStorage;
});

test("renders action rows with effect and blocked badges", () => {
test("auto-collapses completed action lists without mutating sticky preferences", () => {
const completedView = renderWithStickyToolProviders(
<WorkflowActionListToolCall
args={{}}
status="completed"
result={{
actions: [
{
name: "git.changedFiles",
scope: "built-in",
sourcePath: "/__mux_builtin_workflow_actions__/git/changedFiles.js",
executable: true,
hasReconcile: false,
metadata: {
version: 1,
description: "Return changed file lists.",
effect: "read",
},
},
],
}}
/>
);

expect(completedView.getByText("1 action")).toBeTruthy();
expect(completedView.queryByText("git.changedFiles")).toBeNull();
expect(getStoredPrefs()).toBeNull();
completedView.unmount();

const executingView = renderWithStickyToolProviders(
<WorkflowActionListToolCall args={{}} status="executing" />
);

expect(executingView.container.textContent).toContain("Waiting for workflow result");
expect(getStoredPrefs()).toBeNull();
});

test("renders action rows with effect and blocked badges after manual expansion", () => {
const view = renderWithTooltip(
<WorkflowActionListToolCall
args={{}}
Expand Down Expand Up @@ -69,6 +133,11 @@ describe("WorkflowActionListToolCall", () => {
);

expect(view.getByText("2 actions")).toBeTruthy();
expect(view.queryByText("git.changedFiles")).toBeNull();
expect(view.queryByText("Project is not trusted")).toBeNull();

clickToolHeader(view, "2 actions");

expect(view.getByText("git.changedFiles")).toBeTruthy();
expect(view.getByText("read")).toBeTruthy();
expect(view.getByText("blocked")).toBeTruthy();
Expand Down Expand Up @@ -104,6 +173,7 @@ describe("WorkflowActionListToolCall", () => {

expect(view.queryByText("Input schema")).toBeNull();

clickToolHeader(view, "1 action");
fireEvent.click(view.getByRole("button", { expanded: false }));

expect(view.getByText("/__mux_builtin_workflow_actions__/git/changedFiles.js")).toBeTruthy();
Expand Down
10 changes: 3 additions & 7 deletions src/browser/features/Tools/WorkflowActionListToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,14 @@ import {
ToolIcon,
ToolName,
} from "./Shared/ToolPrimitives";
import {
getStatusDisplay,
isToolErrorResult,
type ToolStatus,
useToolExpansion,
} from "./Shared/toolUtils";
import { getStatusDisplay, isToolErrorResult, type ToolStatus } from "./Shared/toolUtils";
import {
WorkflowBadge,
WorkflowJsonBlock,
WorkflowKindBadge,
WorkflowLoadingState,
WorkflowSection,
useAutoCollapsingWorkflowLookup,
} from "./WorkflowDefinitionToolCall";

interface WorkflowActionListToolCallProps {
Expand Down Expand Up @@ -167,7 +163,7 @@ export const WorkflowActionListToolCall: React.FC<WorkflowActionListToolCallProp
result,
status = "pending",
}) => {
const { expanded, toggleExpanded } = useToolExpansion(true);
const { expanded, toggleExpanded } = useAutoCollapsingWorkflowLookup(status);
const errorResult = isToolErrorResult(result) ? result : null;
const successResult = isWorkflowActionListSuccessResult(result) ? result : null;
const actions = successResult?.actions ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ function NarrowContainerDecorator(Story: ComponentType) {
);
}

function expandToolCard(canvasElement: HTMLElement, summaryText: string) {
const canvas = within(canvasElement);
const header = canvas.getByText(summaryText).closest('[data-scroll-intent="ignore"]');
if (header == null) throw new Error(`Could not find tool header for "${summaryText}"`);
(header as HTMLElement).click();
}

/**
* Assert the narrow list layout engaged: the description must wrap onto its own
* grid row below the name instead of sharing the single-line wide layout.
Expand Down Expand Up @@ -165,6 +172,7 @@ export const WorkflowActionListNarrow: Story = {
chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } },
},
play: async ({ canvasElement }) => {
expandToolCard(canvasElement, "4 actions");
await expectDescriptionBelowName(
canvasElement,
"git.changedFiles",
Expand Down Expand Up @@ -218,6 +226,7 @@ export const WorkflowListNarrow: Story = {
chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } },
},
play: async ({ canvasElement }) => {
expandToolCard(canvasElement, "3 definitions");
await expectDescriptionBelowName(canvasElement, "deep-research", /Coordinate staged research/);
},
};
73 changes: 71 additions & 2 deletions src/browser/features/Tools/WorkflowDefinitionToolCall.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, render } from "@testing-library/react";
import { cleanup, fireEvent, render } from "@testing-library/react";

import type React from "react";

import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
import { ToolNameProvider } from "@/browser/features/Messages/ToolNameContext";
import { getAutoExpandPrefsKey } from "@/common/constants/storage";
import { WorkflowListToolCall, WorkflowReadToolCall } from "./WorkflowDefinitionToolCall";

const source = `export default function workflow({ args, agent }) {
const topic = args.topic ?? "workflow UI";
return agent({ id: "review", prompt: "Review " + topic });
}`;

const TEST_WORKSPACE_ID = "workflow-definition-tool-test";

function renderWithTooltip(ui: React.ReactElement) {
return render(
<ThemeProvider forcedTheme="dark">
Expand All @@ -21,12 +26,34 @@ function renderWithTooltip(ui: React.ReactElement) {
);
}

function renderWithStickyToolProviders(ui: React.ReactElement, toolName: string) {
return render(
<ThemeProvider forcedTheme="dark">
<MessageListProvider value={{ workspaceId: TEST_WORKSPACE_ID, latestMessageId: null }}>
<ToolNameProvider toolName={toolName}>
<TooltipProvider>{ui}</TooltipProvider>
</ToolNameProvider>
</MessageListProvider>
</ThemeProvider>
);
}

function getStoredPrefs(): string | null {
return globalThis.localStorage.getItem(getAutoExpandPrefsKey(TEST_WORKSPACE_ID));
}

function expectWorkflowHeaderBadge(view: ReturnType<typeof render>, label: string) {
const workflowBadge = view.getByText("Workflow");
const headerText = workflowBadge.closest('[data-scroll-intent="ignore"]')?.textContent ?? "";
expect(headerText.indexOf("Workflow")).toBeLessThan(headerText.indexOf(label));
}

function clickToolHeader(view: ReturnType<typeof render>, label: string) {
const header = view.getByText(label).closest('[data-scroll-intent="ignore"]');
expect(header).toBeTruthy();
fireEvent.click(header as HTMLElement);
}

describe("WorkflowDefinitionToolCall", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;
Expand All @@ -48,6 +75,38 @@ describe("WorkflowDefinitionToolCall", () => {
globalThis.localStorage = originalLocalStorage;
});

test("auto-collapses completed workflow_read without mutating sticky preferences", () => {
const completedView = renderWithStickyToolProviders(
<WorkflowReadToolCall
args={{ name: "deep-research" }}
status="completed"
result={{
descriptor: {
name: "deep-research",
description: "Deep research",
scope: "built-in",
executable: true,
},
source,
}}
/>,
"workflow_read"
);

expect(completedView.queryByText("Deep research")).toBeNull();
expect(completedView.container.textContent).not.toContain("return agent");
expect(getStoredPrefs()).toBeNull();
completedView.unmount();

const executingView = renderWithStickyToolProviders(
<WorkflowReadToolCall args={{ name: "deep-research" }} status="executing" />,
"workflow_read"
);

expect(executingView.container.textContent).toContain("Waiting for workflow result");
expect(getStoredPrefs()).toBeNull();
});

test("renders workflow_read metadata and highlighted source", () => {
const view = renderWithTooltip(
<WorkflowReadToolCall
Expand All @@ -66,11 +125,16 @@ describe("WorkflowDefinitionToolCall", () => {
);

expectWorkflowHeaderBadge(view, "deep-research");
expect(view.queryByText("Deep research")).toBeNull();
expect(view.container.textContent).not.toContain("return agent");

clickToolHeader(view, "deep-research");

expect(view.getByText("Deep research")).toBeTruthy();
expect(view.container.textContent).toContain("return agent");
});

test("renders workflow_list as definition cards", () => {
test("renders workflow_list as definition cards after manual expansion", () => {
const view = renderWithTooltip(
<WorkflowListToolCall
args={{}}
Expand All @@ -97,6 +161,11 @@ describe("WorkflowDefinitionToolCall", () => {

expectWorkflowHeaderBadge(view, "list");
expect(view.getByText("2 definitions")).toBeTruthy();
expect(view.queryByText("blocked")).toBeNull();
expect(view.queryByText("Project is not trusted")).toBeNull();

clickToolHeader(view, "2 definitions");

expect(view.queryByText("executable")).toBeNull();
expect(view.getByText("blocked")).toBeTruthy();
expect(view.getByText("Project is not trusted")).toBeTruthy();
Expand Down
Loading
Loading