Skip to content

Commit 1fba6c5

Browse files
author
nessie
committed
fix(frontend): refine agent activity transcript
1 parent f5dc9d8 commit 1fba6c5

14 files changed

Lines changed: 531 additions & 105 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { ThemeProvider, createTheme } from "@mui/material/styles";
3+
import { describe, expect, it } from "vitest";
4+
5+
import ActivitySummary, { formatActivityDuration } from "./ActivitySummary";
6+
7+
const renderSummary = (isStreaming = false) =>
8+
render(
9+
<ThemeProvider theme={createTheme({ palette: { mode: "dark" } })}>
10+
<ActivitySummary
11+
durationMs={125000}
12+
hasActivity
13+
isStreaming={isStreaming}
14+
startedAt={Date.now() - 125000}
15+
>
16+
<div>all activity</div>
17+
</ActivitySummary>
18+
</ThemeProvider>,
19+
);
20+
21+
describe("ActivitySummary", () => {
22+
it("formats elapsed durations compactly", () => {
23+
expect(formatActivityDuration(0)).toBe("0s");
24+
expect(formatActivityDuration(65000)).toBe("1m 5s");
25+
});
26+
27+
it("keeps activity collapsed while leaving the completion summary visible", () => {
28+
renderSummary();
29+
30+
expect(screen.getByText("Worked for 2m 5s")).toBeInTheDocument();
31+
expect(screen.queryByText("all activity")).not.toBeInTheDocument();
32+
expect(screen.getByRole("button", { name: "Show work log" })).toBeInTheDocument();
33+
34+
fireEvent.click(screen.getByRole("button", { name: "Show work log" }));
35+
36+
expect(screen.getByText("all activity")).toBeInTheDocument();
37+
expect(screen.getByRole("button", { name: "Hide work log" })).toBeInTheDocument();
38+
});
39+
40+
it("shows the live working label and activity indicator", () => {
41+
renderSummary(true);
42+
43+
expect(screen.getByText("Working… 2m 5s")).toBeInTheDocument();
44+
expect(screen.getByTestId("streaming-indicator")).toBeInTheDocument();
45+
});
46+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
2+
import Box from "@mui/material/Box";
3+
import IconButton from "@mui/material/IconButton";
4+
import Typography from "@mui/material/Typography";
5+
import { useTheme } from "@mui/material/styles";
6+
import { ChevronDown, ChevronUp } from "lucide-react";
7+
8+
import StreamingIndicator from "./StreamingIndicator";
9+
import { preserveDisclosureExpansion } from "./disclosureScroll";
10+
11+
export const formatActivityDuration = (durationMs: number) => {
12+
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
13+
const minutes = Math.floor(totalSeconds / 60);
14+
const seconds = totalSeconds % 60;
15+
16+
if (minutes === 0) return `${seconds}s`;
17+
return `${minutes}m ${seconds}s`;
18+
};
19+
20+
interface ActivitySummaryProps {
21+
children?: ReactNode;
22+
durationMs?: number;
23+
hasActivity: boolean;
24+
isStreaming: boolean;
25+
startedAt?: number;
26+
}
27+
28+
/**
29+
* Keeps reasoning and tool activity behind one response-level disclosure.
30+
* The final assistant prose is rendered outside this component and remains
31+
* visible when the activity is collapsed.
32+
*/
33+
const ActivitySummary: FC<ActivitySummaryProps> = ({
34+
children,
35+
durationMs = 0,
36+
hasActivity,
37+
isStreaming,
38+
startedAt,
39+
}) => {
40+
const theme = useTheme();
41+
const [expanded, setExpanded] = useState(false);
42+
const [elapsedMs, setElapsedMs] = useState(durationMs);
43+
const lastElapsedMsRef = useRef(durationMs);
44+
const textColor =
45+
theme.palette.mode === "dark" ? "rgba(255,255,255,0.55)" : "text.secondary";
46+
47+
useEffect(() => {
48+
if (!isStreaming) {
49+
const completedDuration = durationMs || lastElapsedMsRef.current;
50+
setElapsedMs(completedDuration);
51+
lastElapsedMsRef.current = completedDuration;
52+
return;
53+
}
54+
55+
const started = startedAt || Date.now();
56+
const updateElapsed = () => {
57+
const nextElapsed = Math.max(0, Date.now() - started);
58+
setElapsedMs(nextElapsed);
59+
lastElapsedMsRef.current = nextElapsed;
60+
};
61+
updateElapsed();
62+
const interval = window.setInterval(updateElapsed, 1000);
63+
return () => window.clearInterval(interval);
64+
}, [durationMs, isStreaming, startedAt]);
65+
66+
const toggleExpanded = (event: React.MouseEvent<HTMLElement>) => {
67+
if (!expanded) preserveDisclosureExpansion(event.currentTarget);
68+
setExpanded((value) => !value);
69+
};
70+
71+
return (
72+
<Box sx={{ my: 0.75 }}>
73+
<Box
74+
sx={{
75+
display: "flex",
76+
alignItems: "center",
77+
minHeight: 24,
78+
color: textColor,
79+
}}
80+
>
81+
{isStreaming && <StreamingIndicator />}
82+
<Typography
83+
variant="body2"
84+
sx={{
85+
flex: 1,
86+
fontSize: "0.76rem",
87+
color: "inherit",
88+
fontFamily: "monospace",
89+
}}
90+
>
91+
{isStreaming
92+
? `Working… ${formatActivityDuration(elapsedMs)}`
93+
: `Worked for ${formatActivityDuration(elapsedMs)}`}
94+
</Typography>
95+
{hasActivity && (
96+
<IconButton
97+
size="small"
98+
onClick={toggleExpanded}
99+
aria-expanded={expanded}
100+
aria-label={expanded ? "Hide work log" : "Show work log"}
101+
sx={{
102+
p: 0,
103+
color: "inherit",
104+
"&:hover": { backgroundColor: "transparent" },
105+
}}
106+
>
107+
{expanded ? (
108+
<ChevronUp size={15} strokeWidth={1.8} />
109+
) : (
110+
<ChevronDown size={15} strokeWidth={1.8} />
111+
)}
112+
</IconButton>
113+
)}
114+
</Box>
115+
{expanded ? children : null}
116+
</Box>
117+
);
118+
};
119+
120+
export default ActivitySummary;

frontend/src/components/session/CollapsibleToolCall.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,21 @@ import { fireEvent, render, screen } from '@testing-library/react'
22
import { ThemeProvider, createTheme } from '@mui/material/styles'
33
import { describe, expect, it } from 'vitest'
44

5-
import { CollapsibleToolCall } from './CollapsibleToolCall'
5+
import { CollapsibleToolCall, getToolCallPresentation } from './CollapsibleToolCall'
66

77
describe('CollapsibleToolCall', () => {
8+
it('presents shell calls as a compact command row', () => {
9+
const presentation = getToolCallPresentation(
10+
'Bash',
11+
'| | |\n|---|---|\n| Command | `git status --short` |\n| Exit | 0 |',
12+
)
13+
14+
expect(presentation).toEqual({
15+
label: 'Ran command',
16+
preview: 'Bash: git status --short',
17+
})
18+
})
19+
820
it('marks disclosure growth so the chat keeps the header in place', () => {
921
const { container } = render(
1022
<div data-session-scroll-container>

frontend/src/components/session/CollapsibleToolCall.tsx

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
ChevronUp,
1010
CircleAlert,
1111
LoaderCircle,
12-
Wrench,
12+
Terminal,
1313
} from "lucide-react";
1414
import { preserveDisclosureExpansion } from "./disclosureScroll";
1515

@@ -121,6 +121,41 @@ const statusIcon = (status: string) => {
121121
return <LoaderCircle size={15} strokeWidth={1.8} color="#ffb74d" aria-hidden="true" />;
122122
};
123123

124+
const commandToolPattern = /^(bash|sh|zsh|shell|terminal|command)(?::\s*(.*))?$/i;
125+
126+
const extractCommand = (body: string) => {
127+
const tableCommand = body.match(/\|\s*Command\s*\|\s*`([^`]+)`\s*\|/i);
128+
if (tableCommand?.[1]) return tableCommand[1].trim();
129+
130+
const fieldCommand = body.match(/(?:^|\n)Command:\s*(.+)$/im);
131+
if (fieldCommand?.[1]) return fieldCommand[1].trim();
132+
133+
const firstLine = body
134+
.split("\n")
135+
.map((line) => line.trim())
136+
.find((line) => line && !line.startsWith("```") && !line.startsWith("|"));
137+
return firstLine || "";
138+
};
139+
140+
export const getToolCallPresentation = (toolName: string, body: string) => {
141+
const toolMatch = toolName.match(commandToolPattern);
142+
const bodyCommand = extractCommand(body);
143+
const isCommand = Boolean(toolMatch || body.match(/\|\s*Command\s*\|/i));
144+
145+
if (!isCommand) {
146+
return { label: toolName, preview: "" };
147+
}
148+
149+
const namedCommand = toolMatch?.[2]?.trim();
150+
const preview = namedCommand
151+
? toolName.trim()
152+
: bodyCommand
153+
? `${toolName.trim()}: ${bodyCommand}`
154+
: toolName.trim();
155+
156+
return { label: "Ran command", preview };
157+
};
158+
124159
interface CollapsibleToolCallProps {
125160
toolName: string;
126161
status: string;
@@ -141,6 +176,7 @@ export const CollapsibleToolCall: FC<CollapsibleToolCallProps> = ({
141176
const [expanded, setExpanded] = useState(defaultExpanded);
142177
const theme = useTheme();
143178
const isDark = theme.palette.mode === "dark";
179+
const presentation = getToolCallPresentation(toolName, body);
144180

145181
return (
146182
<Box
@@ -168,23 +204,57 @@ export const CollapsibleToolCall: FC<CollapsibleToolCallProps> = ({
168204
userSelect: "none",
169205
}}
170206
>
171-
<Wrench
207+
<Terminal
172208
size={15}
173209
strokeWidth={1.8}
174210
color={isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)"}
175211
aria-hidden="true"
176212
/>
177-
<Typography
178-
variant="body2"
213+
<Box
179214
sx={{
215+
display: "flex",
216+
alignItems: "baseline",
217+
gap: 0.75,
218+
minWidth: 0,
180219
flex: 1,
181-
fontSize: dense ? "0.76rem" : "0.82rem",
182-
color: isDark ? "rgba(255,255,255,0.65)" : "text.secondary",
183-
fontFamily: "monospace",
220+
whiteSpace: "nowrap",
221+
overflow: "hidden",
184222
}}
185223
>
186-
{toolName}
187-
</Typography>
224+
<Typography
225+
variant="body2"
226+
sx={{
227+
flexShrink: 0,
228+
fontSize: dense ? "0.76rem" : "0.82rem",
229+
color: presentation.preview
230+
? isDark
231+
? "#f5f5f5"
232+
: "text.primary"
233+
: isDark
234+
? "rgba(255,255,255,0.65)"
235+
: "text.secondary",
236+
fontWeight: presentation.preview ? 600 : 400,
237+
fontFamily: "monospace",
238+
}}
239+
>
240+
{presentation.label}
241+
</Typography>
242+
{presentation.preview && (
243+
<Typography
244+
variant="body2"
245+
sx={{
246+
minWidth: 0,
247+
overflow: "hidden",
248+
textOverflow: "ellipsis",
249+
fontSize: dense ? "0.72rem" : "0.78rem",
250+
color: isDark ? "rgba(255,255,255,0.42)" : "text.secondary",
251+
fontFamily: "monospace",
252+
}}
253+
>
254+
{presentation.preview}
255+
</Typography>
256+
)}
257+
</Box>
188258
{statusIcon(status)}
189259
<IconButton
190260
size="small"

frontend/src/components/session/Interaction.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ export const Interaction: FC<InteractionProps> = ({
478478
>
479479
<InteractionContainer
480480
buttons={headerButtons}
481-
background={false}
481+
background={true}
482482
align="left"
483483
border={false}
484484
isAssistant={true}

frontend/src/components/session/InteractionContainer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,12 @@ export const InteractionContainer: FC<{
2929
sx={{
3030
px: 2,
3131
py: 0.5,
32-
borderRadius: 4,
33-
backgroundColor: background ? theme.palette.background.default : 'transparent',
32+
borderRadius: isAssistant ? 0 : 4,
33+
backgroundColor: background
34+
? isAssistant && theme.palette.mode === 'dark'
35+
? '#0d0d0d'
36+
: theme.palette.background.default
37+
: 'transparent',
3438
border: border ? '1px solid #33373a' : 'none',
3539
// User messages: fit content but don't exceed container width
3640
// Assistant messages: take full width
@@ -58,4 +62,4 @@ export const InteractionContainer: FC<{
5862
);
5963
};
6064

61-
export default InteractionContainer;
65+
export default InteractionContainer;

0 commit comments

Comments
 (0)