Skip to content

Commit 30981af

Browse files
authored
🤖 fix: auto-collapse workflow tool cards (#3571)
## Summary Auto-collapse completed workflow lookup cards so workflow list, workflow read, and workflow action list results match the scannability behavior of the other workflow cards. ## Background Completed workflow definition/action payloads can be large, especially workflow source blocks and action schemas. Leaving them expanded made transcripts noisy compared with the rest of the tool UI. ## Implementation - Added a shared non-persisting auto-collapse layer for tool expansion state. - Updated `workflow_list`, `workflow_read`, and `workflow_action_list` cards to collapse completed results while keeping executing cards visible by default. - Kept manual header toggles as the only path that persists sticky expansion preference, so automatic collapse does not affect future tool cards. - Extended the same preference-safe behavior to workflow run cards after the simplify workflow caught the sticky-preference edge case. - Added regression coverage for workflow lookup/action list/run cards under workspace + tool-name providers. ## Validation - `bun test src/browser/features/Tools/WorkflowDefinitionToolCall.test.tsx src/browser/features/Tools/WorkflowActionListToolCall.test.tsx src/browser/features/Tools/WorkflowRunToolCall.test.tsx` - `MUX_ESLINT_CONCURRENCY=1 make static-check` - Storybook dogfood captured collapsed/expanded states for workflow list, workflow read, and workflow action list cards with screenshots and video. - Simplify workflow `wfr_9ab2221bb2f4656b` reviewed the initial implementation, applied the preference-safety fix, and validated with targeted tests plus static checks. ## Risks Low-to-medium UI state risk. The change touches shared expansion behavior and workflow run presentation, but tests cover the critical sticky-preference invariant: automatic collapse must not write persisted user preference, and executing cards should still mount expanded unless the user manually collapsed that tool. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$23.71`_ <!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=23.71 -->
1 parent 9f4f61c commit 30981af

8 files changed

Lines changed: 416 additions & 44 deletions

src/browser/features/Tools/Shared/toolUtils.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,71 @@ export function useToolExpansion(initialExpanded = false, options?: UseStickyExp
3232
return useStickyExpand("tools", initialExpanded, options);
3333
}
3434

35+
interface AutoCollapsingToolExpansionOptions {
36+
autoCollapsed: boolean;
37+
resetKey: string | undefined;
38+
}
39+
40+
/**
41+
* Tool expansion with a non-persisted presentation-only auto-collapse layer.
42+
* Header toggles still go through useToolExpansion and are the only path that
43+
* updates the sticky per-tool preference.
44+
*/
45+
export function useAutoCollapsingToolExpansion(
46+
initialExpanded: boolean,
47+
options: AutoCollapsingToolExpansionOptions
48+
) {
49+
const { expanded: stickyExpanded, setExpanded: setStickyExpanded } =
50+
useToolExpansion(initialExpanded);
51+
const [userInteraction, setUserInteraction] = React.useState<{
52+
key: string | undefined;
53+
interacted: boolean;
54+
}>(() => ({ key: options.resetKey, interacted: false }));
55+
const [localExpanded, setLocalExpandedState] = React.useState<{
56+
key: string | undefined;
57+
expanded: boolean;
58+
} | null>(null);
59+
const localExpandedValue =
60+
localExpanded != null && localExpanded.key === options.resetKey ? localExpanded.expanded : null;
61+
const userInteracted =
62+
localExpandedValue != null ||
63+
(userInteraction.interacted && userInteraction.key === options.resetKey);
64+
const expanded =
65+
options.autoCollapsed && !userInteracted ? false : (localExpandedValue ?? stickyExpanded);
66+
67+
const expandedRef = React.useRef(expanded);
68+
expandedRef.current = expanded;
69+
const resetKeyRef = React.useRef(options.resetKey);
70+
resetKeyRef.current = options.resetKey;
71+
72+
// These callbacks intentionally keep stable identities: WorkflowRunToolCall registers
73+
// command-palette actions from an effect and relies on the expansion setter not changing
74+
// unless the underlying sticky setter changes.
75+
const markInteracted = React.useCallback((): void => {
76+
setUserInteraction({ key: resetKeyRef.current, interacted: true });
77+
}, []);
78+
const setExpanded = React.useCallback(
79+
(next: boolean): void => {
80+
markInteracted();
81+
setLocalExpandedState(null);
82+
setStickyExpanded(next);
83+
},
84+
[markInteracted, setStickyExpanded]
85+
);
86+
const setLocalExpanded = React.useCallback(
87+
(next: boolean): void => {
88+
markInteracted();
89+
setLocalExpandedState({ key: resetKeyRef.current, expanded: next });
90+
},
91+
[markInteracted]
92+
);
93+
const toggleExpanded = React.useCallback(() => {
94+
setExpanded(!expandedRef.current);
95+
}, [setExpanded]);
96+
97+
return { expanded, setExpanded, setLocalExpanded, toggleExpanded, markInteracted };
98+
}
99+
35100
/**
36101
* Get display element for tool status
37102
*/

src/browser/features/Tools/WorkflowActionListToolCall.test.tsx

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ import type React from "react";
66

77
import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
88
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
9+
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
10+
import { ToolNameProvider } from "@/browser/features/Messages/ToolNameContext";
11+
import { getAutoExpandPrefsKey } from "@/common/constants/storage";
912
import { WorkflowActionListToolCall } from "./WorkflowActionListToolCall";
1013

14+
const TEST_WORKSPACE_ID = "workflow-action-list-tool-test";
15+
1116
function renderWithTooltip(ui: React.ReactElement) {
1217
return render(
1318
<ThemeProvider forcedTheme="dark">
@@ -16,6 +21,28 @@ function renderWithTooltip(ui: React.ReactElement) {
1621
);
1722
}
1823

24+
function renderWithStickyToolProviders(ui: React.ReactElement) {
25+
return render(
26+
<ThemeProvider forcedTheme="dark">
27+
<MessageListProvider value={{ workspaceId: TEST_WORKSPACE_ID, latestMessageId: null }}>
28+
<ToolNameProvider toolName="workflow_action_list">
29+
<TooltipProvider>{ui}</TooltipProvider>
30+
</ToolNameProvider>
31+
</MessageListProvider>
32+
</ThemeProvider>
33+
);
34+
}
35+
36+
function getStoredPrefs(): string | null {
37+
return globalThis.localStorage.getItem(getAutoExpandPrefsKey(TEST_WORKSPACE_ID));
38+
}
39+
40+
function clickToolHeader(view: ReturnType<typeof render>, label: string) {
41+
const header = view.getByText(label).closest('[data-scroll-intent="ignore"]');
42+
expect(header).toBeTruthy();
43+
fireEvent.click(header as HTMLElement);
44+
}
45+
1946
describe("WorkflowActionListToolCall", () => {
2047
let originalWindow: typeof globalThis.window;
2148
let originalDocument: typeof globalThis.document;
@@ -37,7 +64,44 @@ describe("WorkflowActionListToolCall", () => {
3764
globalThis.localStorage = originalLocalStorage;
3865
});
3966

40-
test("renders action rows with effect and blocked badges", () => {
67+
test("auto-collapses completed action lists without mutating sticky preferences", () => {
68+
const completedView = renderWithStickyToolProviders(
69+
<WorkflowActionListToolCall
70+
args={{}}
71+
status="completed"
72+
result={{
73+
actions: [
74+
{
75+
name: "git.changedFiles",
76+
scope: "built-in",
77+
sourcePath: "/__mux_builtin_workflow_actions__/git/changedFiles.js",
78+
executable: true,
79+
hasReconcile: false,
80+
metadata: {
81+
version: 1,
82+
description: "Return changed file lists.",
83+
effect: "read",
84+
},
85+
},
86+
],
87+
}}
88+
/>
89+
);
90+
91+
expect(completedView.getByText("1 action")).toBeTruthy();
92+
expect(completedView.queryByText("git.changedFiles")).toBeNull();
93+
expect(getStoredPrefs()).toBeNull();
94+
completedView.unmount();
95+
96+
const executingView = renderWithStickyToolProviders(
97+
<WorkflowActionListToolCall args={{}} status="executing" />
98+
);
99+
100+
expect(executingView.container.textContent).toContain("Waiting for workflow result");
101+
expect(getStoredPrefs()).toBeNull();
102+
});
103+
104+
test("renders action rows with effect and blocked badges after manual expansion", () => {
41105
const view = renderWithTooltip(
42106
<WorkflowActionListToolCall
43107
args={{}}
@@ -69,6 +133,11 @@ describe("WorkflowActionListToolCall", () => {
69133
);
70134

71135
expect(view.getByText("2 actions")).toBeTruthy();
136+
expect(view.queryByText("git.changedFiles")).toBeNull();
137+
expect(view.queryByText("Project is not trusted")).toBeNull();
138+
139+
clickToolHeader(view, "2 actions");
140+
72141
expect(view.getByText("git.changedFiles")).toBeTruthy();
73142
expect(view.getByText("read")).toBeTruthy();
74143
expect(view.getByText("blocked")).toBeTruthy();
@@ -104,6 +173,7 @@ describe("WorkflowActionListToolCall", () => {
104173

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

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

109179
expect(view.getByText("/__mux_builtin_workflow_actions__/git/changedFiles.js")).toBeTruthy();

src/browser/features/Tools/WorkflowActionListToolCall.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,14 @@ import {
1818
ToolIcon,
1919
ToolName,
2020
} from "./Shared/ToolPrimitives";
21-
import {
22-
getStatusDisplay,
23-
isToolErrorResult,
24-
type ToolStatus,
25-
useToolExpansion,
26-
} from "./Shared/toolUtils";
21+
import { getStatusDisplay, isToolErrorResult, type ToolStatus } from "./Shared/toolUtils";
2722
import {
2823
WorkflowBadge,
2924
WorkflowJsonBlock,
3025
WorkflowKindBadge,
3126
WorkflowLoadingState,
3227
WorkflowSection,
28+
useAutoCollapsingWorkflowLookup,
3329
} from "./WorkflowDefinitionToolCall";
3430

3531
interface WorkflowActionListToolCallProps {
@@ -167,7 +163,7 @@ export const WorkflowActionListToolCall: React.FC<WorkflowActionListToolCallProp
167163
result,
168164
status = "pending",
169165
}) => {
170-
const { expanded, toggleExpanded } = useToolExpansion(true);
166+
const { expanded, toggleExpanded } = useAutoCollapsingWorkflowLookup(status);
171167
const errorResult = isToolErrorResult(result) ? result : null;
172168
const successResult = isWorkflowActionListSuccessResult(result) ? result : null;
173169
const actions = successResult?.actions ?? [];

src/browser/features/Tools/WorkflowDefinitionToolCall.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ function NarrowContainerDecorator(Story: ComponentType) {
2828
);
2929
}
3030

31+
function expandToolCard(canvasElement: HTMLElement, summaryText: string) {
32+
const canvas = within(canvasElement);
33+
const header = canvas.getByText(summaryText).closest('[data-scroll-intent="ignore"]');
34+
if (header == null) throw new Error(`Could not find tool header for "${summaryText}"`);
35+
(header as HTMLElement).click();
36+
}
37+
3138
/**
3239
* Assert the narrow list layout engaged: the description must wrap onto its own
3340
* grid row below the name instead of sharing the single-line wide layout.
@@ -165,6 +172,7 @@ export const WorkflowActionListNarrow: Story = {
165172
chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } },
166173
},
167174
play: async ({ canvasElement }) => {
175+
expandToolCard(canvasElement, "4 actions");
168176
await expectDescriptionBelowName(
169177
canvasElement,
170178
"git.changedFiles",
@@ -218,6 +226,7 @@ export const WorkflowListNarrow: Story = {
218226
chromatic: { modes: { "dark-mobile": { theme: "dark", viewport: "mobile1", hasTouch: true } } },
219227
},
220228
play: async ({ canvasElement }) => {
229+
expandToolCard(canvasElement, "3 definitions");
221230
await expectDescriptionBelowName(canvasElement, "deep-research", /Coordinate staged research/);
222231
},
223232
};

src/browser/features/Tools/WorkflowDefinitionToolCall.test.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
22
import { GlobalWindow } from "happy-dom";
3-
import { cleanup, render } from "@testing-library/react";
3+
import { cleanup, fireEvent, render } from "@testing-library/react";
44

55
import type React from "react";
66

77
import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
88
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
9+
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
10+
import { ToolNameProvider } from "@/browser/features/Messages/ToolNameContext";
11+
import { getAutoExpandPrefsKey } from "@/common/constants/storage";
912
import { WorkflowListToolCall, WorkflowReadToolCall } from "./WorkflowDefinitionToolCall";
1013

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

19+
const TEST_WORKSPACE_ID = "workflow-definition-tool-test";
20+
1621
function renderWithTooltip(ui: React.ReactElement) {
1722
return render(
1823
<ThemeProvider forcedTheme="dark">
@@ -21,12 +26,34 @@ function renderWithTooltip(ui: React.ReactElement) {
2126
);
2227
}
2328

29+
function renderWithStickyToolProviders(ui: React.ReactElement, toolName: string) {
30+
return render(
31+
<ThemeProvider forcedTheme="dark">
32+
<MessageListProvider value={{ workspaceId: TEST_WORKSPACE_ID, latestMessageId: null }}>
33+
<ToolNameProvider toolName={toolName}>
34+
<TooltipProvider>{ui}</TooltipProvider>
35+
</ToolNameProvider>
36+
</MessageListProvider>
37+
</ThemeProvider>
38+
);
39+
}
40+
41+
function getStoredPrefs(): string | null {
42+
return globalThis.localStorage.getItem(getAutoExpandPrefsKey(TEST_WORKSPACE_ID));
43+
}
44+
2445
function expectWorkflowHeaderBadge(view: ReturnType<typeof render>, label: string) {
2546
const workflowBadge = view.getByText("Workflow");
2647
const headerText = workflowBadge.closest('[data-scroll-intent="ignore"]')?.textContent ?? "";
2748
expect(headerText.indexOf("Workflow")).toBeLessThan(headerText.indexOf(label));
2849
}
2950

51+
function clickToolHeader(view: ReturnType<typeof render>, label: string) {
52+
const header = view.getByText(label).closest('[data-scroll-intent="ignore"]');
53+
expect(header).toBeTruthy();
54+
fireEvent.click(header as HTMLElement);
55+
}
56+
3057
describe("WorkflowDefinitionToolCall", () => {
3158
let originalWindow: typeof globalThis.window;
3259
let originalDocument: typeof globalThis.document;
@@ -48,6 +75,38 @@ describe("WorkflowDefinitionToolCall", () => {
4875
globalThis.localStorage = originalLocalStorage;
4976
});
5077

78+
test("auto-collapses completed workflow_read without mutating sticky preferences", () => {
79+
const completedView = renderWithStickyToolProviders(
80+
<WorkflowReadToolCall
81+
args={{ name: "deep-research" }}
82+
status="completed"
83+
result={{
84+
descriptor: {
85+
name: "deep-research",
86+
description: "Deep research",
87+
scope: "built-in",
88+
executable: true,
89+
},
90+
source,
91+
}}
92+
/>,
93+
"workflow_read"
94+
);
95+
96+
expect(completedView.queryByText("Deep research")).toBeNull();
97+
expect(completedView.container.textContent).not.toContain("return agent");
98+
expect(getStoredPrefs()).toBeNull();
99+
completedView.unmount();
100+
101+
const executingView = renderWithStickyToolProviders(
102+
<WorkflowReadToolCall args={{ name: "deep-research" }} status="executing" />,
103+
"workflow_read"
104+
);
105+
106+
expect(executingView.container.textContent).toContain("Waiting for workflow result");
107+
expect(getStoredPrefs()).toBeNull();
108+
});
109+
51110
test("renders workflow_read metadata and highlighted source", () => {
52111
const view = renderWithTooltip(
53112
<WorkflowReadToolCall
@@ -66,11 +125,16 @@ describe("WorkflowDefinitionToolCall", () => {
66125
);
67126

68127
expectWorkflowHeaderBadge(view, "deep-research");
128+
expect(view.queryByText("Deep research")).toBeNull();
129+
expect(view.container.textContent).not.toContain("return agent");
130+
131+
clickToolHeader(view, "deep-research");
132+
69133
expect(view.getByText("Deep research")).toBeTruthy();
70134
expect(view.container.textContent).toContain("return agent");
71135
});
72136

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

98162
expectWorkflowHeaderBadge(view, "list");
99163
expect(view.getByText("2 definitions")).toBeTruthy();
164+
expect(view.queryByText("blocked")).toBeNull();
165+
expect(view.queryByText("Project is not trusted")).toBeNull();
166+
167+
clickToolHeader(view, "2 definitions");
168+
100169
expect(view.queryByText("executable")).toBeNull();
101170
expect(view.getByText("blocked")).toBeTruthy();
102171
expect(view.getByText("Project is not trusted")).toBeTruthy();

0 commit comments

Comments
 (0)