Skip to content

Commit 62b6186

Browse files
committed
Improve running cell visibility and auto-follow streaming console output
1 parent b93da60 commit 62b6186

10 files changed

Lines changed: 259 additions & 16 deletions

File tree

examples/third_party/plotly/histogram.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88

99
import marimo
1010

11-
__generated_with = "0.20.2"
11+
__generated_with = "0.21.1"
1212
app = marimo.App(width="medium")
1313

1414

15+
@app.cell
16+
def _():
17+
for num in range(1000000000):
18+
print(f'number: {num}')
19+
return
20+
21+
1522
@app.cell
1623
def _():
1724
import random
@@ -73,7 +80,6 @@ def _(numeric_hist, values):
7380
for row in selected_rows[:10]
7481
if isinstance(row.get("pointIndex"), int)
7582
]
76-
7783
return mapped_original_values, selected_rows
7884

7985

frontend/src/__tests__/__snapshots__/CellStatus.test.tsx.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,11 @@ exports[`CellStatusComponent > renders running state 1`] = `
343343
data-status="running"
344344
data-testid="cell-status"
345345
>
346+
<span
347+
class="running-state-label"
348+
>
349+
Running
350+
</span>
346351
<span>
347352
0ms
348353
</span>

frontend/src/components/editor/cell/CellStatus.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const CellStatusComponent: React.FC<CellStatusComponentProps> = ({
176176
data-testid="cell-status"
177177
data-status="running"
178178
>
179+
<span className="running-state-label">Running</span>
179180
<CellTimer
180181
startTime={Time.fromSeconds(runStartTimestamp) || Time.now()}
181182
/>

frontend/src/components/editor/cell/cell-status.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
@keyframes running-cell-status-pulse {
2+
0%,
3+
100% {
4+
opacity: 0.65;
5+
}
6+
7+
50% {
8+
opacity: 1;
9+
}
10+
}
11+
112
.cell-status-icon {
213
margin-top: 4px;
314
margin-left: 3px;
@@ -10,6 +21,35 @@
1021
color: var(--gray-11);
1122
}
1223

24+
.elapsed-time.running {
25+
display: inline-flex;
26+
align-items: center;
27+
gap: 0.35rem;
28+
padding: 0.15rem 0.5rem;
29+
border-radius: 999px;
30+
color: var(--accent-foreground);
31+
border: 1px solid
32+
color-mix(in srgb, var(--accent-foreground), transparent 78%);
33+
background: color-mix(in srgb, var(--accent), transparent 22%);
34+
}
35+
36+
.elapsed-time.running::before {
37+
content: "";
38+
width: 0.45rem;
39+
height: 0.45rem;
40+
border-radius: 999px;
41+
background: currentColor;
42+
box-shadow: 0 0 0 0 color-mix(in srgb, currentColor, transparent 100%);
43+
animation: running-cell-status-pulse 1600ms ease-in-out infinite;
44+
}
45+
46+
.running-state-label {
47+
font-size: 0.7rem;
48+
font-weight: 700;
49+
letter-spacing: 0.02em;
50+
text-transform: uppercase;
51+
}
52+
1353
#App.disconnected .elapsed-time {
1454
visibility: hidden;
1555
}

frontend/src/components/editor/notebook-cell.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ const EditableCellComponent = ({
534534
interactive: true,
535535
"needs-run": needsRun,
536536
"has-error": cellRuntime.errored,
537+
running: cellRuntime.status === "running",
537538
stopped: cellRuntime.stopped,
538539
disabled: cellData.config.disabled,
539540
stale: cellRuntime.status === "disabled-transitively",
@@ -731,6 +732,7 @@ const EditableCellComponent = ({
731732
<ConsoleOutput
732733
consoleOutputs={cellRuntime.consoleOutputs}
733734
stale={consoleOutputStale}
735+
running={cellRuntime.status === "running"}
734736
// Empty name if serialization triggered
735737
cellName={cellRuntime.serialization ? "_" : cellData.name}
736738
onRefactorWithAI={handleRefactorWithAI}
@@ -1036,6 +1038,7 @@ const SetupCellComponent = ({
10361038
interactive: true,
10371039
"needs-run": needsRun,
10381040
"has-error": cellRuntime.errored,
1041+
running: cellRuntime.status === "running",
10391042
stopped: cellRuntime.stopped,
10401043
});
10411044

@@ -1191,6 +1194,7 @@ const SetupCellComponent = ({
11911194
<ConsoleOutput
11921195
consoleOutputs={cellRuntime.consoleOutputs}
11931196
stale={consoleOutputStale}
1197+
running={cellRuntime.status === "running"}
11941198
// Don't show name
11951199
cellName={"_"}
11961200
onRefactorWithAI={handleRefactorWithAI}

frontend/src/components/editor/output/console/ConsoleOutput.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ interface Props {
8585
className?: string;
8686
consoleOutputs: WithResponse<OutputMessage>[];
8787
stale: boolean;
88+
running?: boolean;
8889
debuggerActive: boolean;
8990
onRefactorWithAI?: OnRefactorWithAI;
9091
onClear?: () => void;
@@ -101,6 +102,8 @@ export const ConsoleOutput = (props: Props) => {
101102

102103
const ConsoleOutputInternal = (props: Props): React.ReactNode => {
103104
const ref = React.useRef<HTMLDivElement>(null);
105+
const shouldFollowOutputRef = useRef(true);
106+
const prevRenderedOutputCountRef = useRef(0);
104107
const { wrapText, setWrapText } = useWrapText();
105108
const [isExpanded, setIsExpanded] = useExpandedConsoleOutput(props.cellId);
106109
const [stdinValue, setStdinValue] = React.useState("");
@@ -111,6 +114,7 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
111114
const {
112115
consoleOutputs: rawConsoleOutputs,
113116
stale,
117+
running = false,
114118
cellName,
115119
cellId,
116120
onSubmitDebugger,
@@ -141,27 +145,37 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
141145
// Detect overflow on resize
142146
const isOverflowing = useOverflowDetection(ref, hasOutputs);
143147

144-
// Keep scroll at the bottom if it is within 120px of the bottom,
145-
// so when we add new content, it will lock to the bottom
146-
//
147-
// We use flex flex-col-reverse to handle this, but it doesn't
148-
// always work perfectly when moved form the bottom and back.
148+
const isNearBottom = (el: HTMLDivElement) => {
149+
const threshold = 24;
150+
const scrollOffset = el.scrollHeight - el.clientHeight;
151+
const distanceFromBottom = scrollOffset - el.scrollTop;
152+
return distanceFromBottom <= threshold;
153+
};
154+
155+
// Follow newly appended console output while the cell is actively running,
156+
// but stop following when the user scrolls away from the bottom.
149157
useLayoutEffect(() => {
150158
const el = ref.current;
151159
if (!el) {
152160
return;
153161
}
154-
// N.B. This won't handle large jumps in the scroll position
155-
// if there is a lot of content added at once.
156-
// This is 'good enough' for now.
157-
const threshold = 120;
158162

159-
const scrollOffset = el.scrollHeight - el.clientHeight;
160-
const distanceFromBottom = scrollOffset - el.scrollTop;
161-
if (distanceFromBottom < threshold) {
162-
el.scrollTop = scrollOffset;
163+
if (!hasOutputs) {
164+
shouldFollowOutputRef.current = true;
165+
prevRenderedOutputCountRef.current = 0;
166+
return;
163167
}
164-
});
168+
169+
const appendedOutput =
170+
consoleOutputs.length > prevRenderedOutputCountRef.current;
171+
prevRenderedOutputCountRef.current = consoleOutputs.length;
172+
173+
if (!running || !appendedOutput || !shouldFollowOutputRef.current) {
174+
return;
175+
}
176+
177+
el.scrollTop = el.scrollHeight - el.clientHeight;
178+
}, [consoleOutputs, hasOutputs, running]);
165179

166180
if (!hasOutputs && isInternalCellName(cellName)) {
167181
return null;
@@ -236,6 +250,9 @@ const ConsoleOutputInternal = (props: Props): React.ReactNode => {
236250
{...selectAllProps}
237251
// biome-ignore lint/a11y/noNoninteractiveTabindex: Needed to capture keypress events
238252
tabIndex={0}
253+
onScroll={(e) => {
254+
shouldFollowOutputRef.current = isNearBottom(e.currentTarget);
255+
}}
239256
className={cn(
240257
"console-output-area overflow-hidden rounded-b-lg flex flex-col-reverse w-full gap-1 focus:outline-hidden",
241258
stale && "marimo-output-stale",

frontend/src/components/editor/output/console/__tests__/ConsoleOutput.test.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("ConsoleOutput integration", () => {
2828
cellName: "test_cell",
2929
consoleOutputs: [] as WithResponse<OutputMessage>[],
3030
stale: false,
31+
running: false,
3132
debuggerActive: false,
3233
onSubmitDebugger: () => {
3334
// noop
@@ -59,6 +60,7 @@ describe("ConsoleOutput pdb history", () => {
5960
cellName: "test_cell",
6061
consoleOutputs: [] as WithResponse<OutputMessage>[],
6162
stale: false,
63+
running: false,
6264
debuggerActive: false,
6365
onSubmitDebugger: vi.fn(),
6466
};
@@ -219,6 +221,7 @@ describe("ConsoleOutput debounced clearing", () => {
219221
cellName: "test_cell",
220222
consoleOutputs: [] as WithResponse<OutputMessage>[],
221223
stale: false,
224+
running: false,
222225
debuggerActive: false,
223226
onSubmitDebugger: vi.fn(),
224227
};
@@ -284,3 +287,101 @@ describe("ConsoleOutput debounced clearing", () => {
284287
expect(screen.queryByText("old output")).not.toBeInTheDocument();
285288
});
286289
});
290+
291+
describe("ConsoleOutput auto-scroll", () => {
292+
const createOutput = (data: string): WithResponse<OutputMessage> => ({
293+
channel: "stdout",
294+
mimetype: "text/plain",
295+
data,
296+
timestamp: 0,
297+
response: undefined,
298+
});
299+
300+
const defaultProps = {
301+
cellId: cellId("cell-1"),
302+
cellName: "test_cell",
303+
consoleOutputs: [] as WithResponse<OutputMessage>[],
304+
stale: false,
305+
running: true,
306+
debuggerActive: false,
307+
onSubmitDebugger: vi.fn(),
308+
};
309+
310+
const setScrollMetrics = (
311+
element: HTMLElement,
312+
{
313+
clientHeight,
314+
scrollHeight,
315+
scrollTop,
316+
}: { clientHeight: number; scrollHeight: number; scrollTop: number },
317+
) => {
318+
Object.defineProperty(element, "clientHeight", {
319+
configurable: true,
320+
value: clientHeight,
321+
});
322+
Object.defineProperty(element, "scrollHeight", {
323+
configurable: true,
324+
value: scrollHeight,
325+
});
326+
Object.defineProperty(element, "scrollTop", {
327+
configurable: true,
328+
writable: true,
329+
value: scrollTop,
330+
});
331+
};
332+
333+
it("follows newly appended output while running when already at the bottom", () => {
334+
const { rerender } = renderWithProvider(
335+
<ConsoleOutput
336+
{...defaultProps}
337+
consoleOutputs={[createOutput("line 1")]}
338+
/>,
339+
);
340+
341+
const consoleArea = screen.getByTestId("console-output-area");
342+
setScrollMetrics(consoleArea, {
343+
clientHeight: 100,
344+
scrollHeight: 300,
345+
scrollTop: 200,
346+
});
347+
348+
rerender(
349+
<TooltipProvider>
350+
<ConsoleOutput
351+
{...defaultProps}
352+
consoleOutputs={[createOutput("line 1"), createOutput("line 2")]}
353+
/>
354+
</TooltipProvider>,
355+
);
356+
357+
expect(consoleArea.scrollTop).toBe(200);
358+
});
359+
360+
it("does not auto-scroll when the user has scrolled away from the bottom", () => {
361+
const { rerender } = renderWithProvider(
362+
<ConsoleOutput
363+
{...defaultProps}
364+
consoleOutputs={[createOutput("line 1")]}
365+
/>,
366+
);
367+
368+
const consoleArea = screen.getByTestId("console-output-area");
369+
setScrollMetrics(consoleArea, {
370+
clientHeight: 100,
371+
scrollHeight: 300,
372+
scrollTop: 60,
373+
});
374+
fireEvent.scroll(consoleArea);
375+
376+
rerender(
377+
<TooltipProvider>
378+
<ConsoleOutput
379+
{...defaultProps}
380+
consoleOutputs={[createOutput("line 1"), createOutput("line 2")]}
381+
/>
382+
</TooltipProvider>,
383+
);
384+
385+
expect(consoleArea.scrollTop).toBe(60);
386+
});
387+
});

frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ const VerticalCell = memo(
418418
<ConsoleOutput
419419
consoleOutputs={consoleOutputs}
420420
stale={outputStale}
421+
running={false}
421422
cellName={name}
422423
onSubmitDebugger={() => null}
423424
cellId={cellId}

frontend/src/components/scratchpad/scratchpad.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ export const ScratchPad: React.FC = () => {
289289
consoleOutputs={consoleOutputs}
290290
className="overflow-auto"
291291
stale={false}
292+
running={status === "running"}
292293
cellName={DEFAULT_CELL_NAME}
293294
onSubmitDebugger={Functions.NOOP}
294295
cellId={cellId}

0 commit comments

Comments
 (0)