Skip to content

Commit 4602709

Browse files
authored
🤖 feat: init hook auto-scroll and duration display (#1240)
## Summary Two UX improvements for init hook output: ### Auto-scroll to bottom while running - Uses `flex-col-reverse` with reversed array for CSS-based scroll anchoring - No refs or effects needed - scroll naturally stays at bottom as lines are added ### Duration display on completion - Tracks `startTime` (from init-start) and `endTime` (from init-end) - Computes `durationMs` and adds it to the DisplayedMessage type - Shows human-friendly duration formatting: - `<1s`: milliseconds (e.g., "832ms") - `1-10s`: decimal seconds (e.g., "3.2s") - `10-60s`: whole seconds (e.g., "45s") - `>60s`: minutes and seconds (e.g., "2m 15s") - Duration shown for both success and error states --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent d0d1214 commit 4602709

File tree

3 files changed

+38
-10
lines changed

3 files changed

+38
-10
lines changed

src/browser/components/Messages/InitMessage.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,23 @@ interface InitMessageProps {
99
className?: string;
1010
}
1111

12+
function formatDuration(ms: number): string {
13+
if (ms < 1000) return `${Math.round(ms)}ms`;
14+
if (ms < 10000) return `${(ms / 1000).toFixed(1)}s`;
15+
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
16+
const mins = Math.floor(ms / 60000);
17+
const secs = Math.round((ms % 60000) / 1000);
18+
return `${mins}m ${secs}s`;
19+
}
20+
1221
export const InitMessage = React.memo<InitMessageProps>(({ message, className }) => {
1322
const isError = message.status === "error";
1423
const isRunning = message.status === "running";
1524
const isSuccess = message.status === "success";
1625

26+
const durationText =
27+
message.durationMs !== null ? ` in ${formatDuration(message.durationMs)}` : "";
28+
1729
return (
1830
<div
1931
className={cn(
@@ -43,23 +55,30 @@ export const InitMessage = React.memo<InitMessageProps>(({ message, className })
4355
{isRunning ? (
4456
<Shimmer colorClass="var(--color-accent)">Running init hook...</Shimmer>
4557
) : isSuccess ? (
46-
"Init hook completed"
58+
`Init hook completed${durationText}`
4759
) : (
48-
<span className="text-error">Init hook failed (exit code {message.exitCode})</span>
60+
<span className="text-error">
61+
Init hook failed (exit code {message.exitCode}){durationText}
62+
</span>
4963
)}
5064
</span>
5165
</div>
5266
<div className="text-muted mt-1 truncate font-mono text-[11px]">{message.hookPath}</div>
5367
{message.lines.length > 0 && (
54-
<pre
68+
<div
5569
className={cn(
56-
"m-0 mt-2.5 max-h-[120px] overflow-auto rounded-sm",
57-
"bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap",
70+
"m-0 mt-2.5 flex max-h-[120px] flex-col-reverse overflow-auto rounded-sm",
71+
"bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed",
5872
isError ? "text-danger-soft" : "text-light"
5973
)}
6074
>
61-
{message.lines.join("\n")}
62-
</pre>
75+
{/* flex-col-reverse with reversed array auto-scrolls to bottom */}
76+
{message.lines.toReversed().map((line, i) => (
77+
<div key={i} className="whitespace-pre-wrap">
78+
{line}
79+
</div>
80+
))}
81+
</div>
6382
)}
6483
</div>
6584
);

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ export class StreamingMessageAggregator {
200200
hookPath: string;
201201
lines: string[];
202202
exitCode: number | null;
203-
timestamp: number;
203+
startTime: number;
204+
endTime: number | null;
204205
} | null = null;
205206

206207
// Track when we're waiting for stream-start after user message
@@ -1288,7 +1289,8 @@ export class StreamingMessageAggregator {
12881289
hookPath: data.hookPath,
12891290
lines: [],
12901291
exitCode: null,
1291-
timestamp: data.timestamp,
1292+
startTime: data.timestamp,
1293+
endTime: null,
12921294
};
12931295
this.invalidateCache();
12941296
return;
@@ -1321,6 +1323,7 @@ export class StreamingMessageAggregator {
13211323
}
13221324
this.initState.exitCode = data.exitCode;
13231325
this.initState.status = data.exitCode === 0 ? "success" : "error";
1326+
this.initState.endTime = data.timestamp;
13241327
this.invalidateCache();
13251328
return;
13261329
}
@@ -1624,6 +1627,10 @@ export class StreamingMessageAggregator {
16241627

16251628
// Add init state if present (ephemeral, appears at top)
16261629
if (this.initState) {
1630+
const durationMs =
1631+
this.initState.endTime !== null
1632+
? this.initState.endTime - this.initState.startTime
1633+
: null;
16271634
const initMessage: DisplayedMessage = {
16281635
type: "workspace-init",
16291636
id: "workspace-init",
@@ -1632,7 +1639,8 @@ export class StreamingMessageAggregator {
16321639
hookPath: this.initState.hookPath,
16331640
lines: [...this.initState.lines], // Shallow copy for React.memo change detection
16341641
exitCode: this.initState.exitCode,
1635-
timestamp: this.initState.timestamp,
1642+
timestamp: this.initState.startTime,
1643+
durationMs,
16361644
};
16371645
displayedMessages.unshift(initMessage);
16381646
}

src/common/types/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export type DisplayedMessage =
261261
lines: string[]; // Accumulated output lines (stderr prefixed with "ERROR:")
262262
exitCode: number | null; // Final exit code (null while running)
263263
timestamp: number;
264+
durationMs: number | null; // Duration in milliseconds (null while running)
264265
}
265266
| {
266267
type: "plan-display"; // Ephemeral plan display from /plan command

0 commit comments

Comments
 (0)