Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion app/src/components/Actions/ActionOutputItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function ActionOutputItemView({
)
} else {
content = (
<pre className="whitespace-pre-wrap break-words text-xs leading-relaxed text-nb-text">
<pre className="whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-nb-text">
Comment thread
jlewi marked this conversation as resolved.
{text}
</pre>
)
Expand Down
6 changes: 3 additions & 3 deletions app/src/components/Actions/Actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1333,7 +1333,7 @@ describe('Actions tabs', () => {
})
})

it('enables horizontal scrolling for wide notebook content', () => {
it('bounds the notebook scroll area so wide blocks scroll within cells', () => {
const uri = 'local://file/wide-table.runme.md'
contextMocks.currentDoc = uri
contextMocks.workspaceDocuments = [
Expand Down Expand Up @@ -1361,9 +1361,9 @@ describe('Actions tabs', () => {

expect(scrollViewport).toBeTruthy()
expect(scrollRoot).toBeTruthy()
expect(scrollRoot?.className).not.toContain('overflow-x-hidden')
expect(scrollRoot?.className).toContain('notebook-scroll-area')
expect((scrollViewport as HTMLElement | undefined)?.style.overflowX).toBe(
'scroll'
'hidden'
)
})

Expand Down
4 changes: 2 additions & 2 deletions app/src/components/Actions/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3730,8 +3730,8 @@ function NotebookTabContent({
<ScrollArea
key={`scroll-${docUri}`}
type="auto"
scrollbars="both"
className="h-full min-w-0 max-w-full flex-1"
scrollbars="vertical"
className="notebook-scroll-area h-full min-w-0 max-w-full flex-1"
data-document-id={docUri}
>
{/* Full-width notebook column with horizontal padding for breathing room.
Expand Down
13 changes: 10 additions & 3 deletions app/src/components/Actions/MarkdownCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const markdownComponents: Components = {
}
return (
<code
className={`block bg-nb-surface-2 p-3 rounded-md text-[12.6px] font-mono overflow-x-auto ${className}`}
className={`block text-[12.6px] font-mono ${className}`}
{...props}
>
{children}
Expand All @@ -118,6 +118,8 @@ const markdownComponents: Components = {
pre: ({ children, ...props }) => (
<pre
className="bg-nb-surface-2 p-3 rounded-md overflow-x-auto mb-3"
tabIndex={0}
aria-label="Scrollable code block"
{...props}
>
{children}
Expand All @@ -132,7 +134,12 @@ const markdownComponents: Components = {
</blockquote>
),
table: ({ children, ...props }) => (
<div className="overflow-x-auto mb-3">
<div
className="overflow-x-auto mb-3"
tabIndex={0}
role="region"
aria-label="Scrollable table"
>
<table className="min-w-full border border-nb-border-strong" {...props}>
{children}
</table>
Expand Down Expand Up @@ -562,7 +569,7 @@ const MarkdownCell = memo(
// Rendered markdown view - double-click or keyboard to edit
<div
id={`markdown-rendered-${cell.refId}`}
className="cursor-text rounded-nb-md border border-transparent p-4 transition-[border-color,background-color,box-shadow] duration-200 hover:border-nb-border hover:bg-nb-surface-2/60 hover:shadow-nb-xs"
className="notebook-markdown cursor-text rounded-nb-md border border-transparent p-4 transition-[border-color,background-color,box-shadow] duration-200 hover:border-nb-border hover:bg-nb-surface-2/60 hover:shadow-nb-xs"
onDoubleClick={canOpenSource ? handleDoubleClick : undefined}
onKeyDown={canOpenSource ? handleRenderedKeyDown : undefined}
ref={renderedRef}
Expand Down
22 changes: 22 additions & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
@import url("https://fonts.googleapis.com/css2?family=PT+Sans:wght@400;700&family=Fira+Mono:wght@400;500&display=swap");
@import "tailwindcss";

/* Notebook width belongs to the viewport, never to the widest descendant.
Radix Themes gives its generated content wrapper width: fit-content; even a
locally scrollable table/output can then widen every sibling cell. Override
that wrapper only for notebook panes, leaving other ScrollAreas unchanged.
Keep this outside Tailwind layers so the unlayered Radix rule cannot win.
Wide blocks own their horizontal scrolling inside the bounded notebook. */
.notebook-scroll-area .rt-ScrollAreaViewport > div {
width: 100%;
max-width: 100%;
}

/* Unlike break-word, anywhere also reduces intrinsic minimum width. This is
for prose/inline code/links; fenced code retains whitespace and scrolls. */
.notebook-markdown {
overflow-wrap: anywhere;
}

.notebook-markdown pre,
.notebook-markdown table {
overflow-wrap: normal;
}

/* ═══════════════════════════════════════════════════════════════════
1. Breakpoints (build-time only — no CSS vars emitted)
═══════════════════════════════════════════════════════════════════ */
Expand Down
1 change: 1 addition & 0 deletions app/test/browser/run-cuj-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function fetchWithTimeout(

const SCENARIO_DRIVERS = [
join(SCRIPT_DIR, "test-scenario-colab-export-recovery.ts"),
join(SCRIPT_DIR, "test-scenario-horizontal-rendering.ts"),
join(SCRIPT_DIR, "test-scenario-drive-revision-recovery.ts"),
join(SCRIPT_DIR, "test-scenario-html-cell.ts"),
join(SCRIPT_DIR, "test-scenario-hello-world.ts"),
Expand Down
165 changes: 165 additions & 0 deletions app/test/browser/test-scenario-horizontal-rendering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/** CUJ: docs-dev/CUJs/horizontal-rendering.md.
Comment thread
jlewi marked this conversation as resolved.
Outdated
* Use stored output and synthetic Markdown: no runner or authenticated service
* is needed. Layout must be checked in Chromium; jsdom has no layout engine.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const dir = here.endsWith('/.generated') ? dirname(here) : here
const output = join(dir, 'test-output')
const url = process.env.FRONTEND_URL ?? 'http://127.0.0.1:5173'
Comment thread
jlewi marked this conversation as resolved.
Outdated
const session = `horizontal-rendering-${Date.now()}`
const uri = 'local://file/horizontal-rendering-regression'
const fixture = JSON.parse(
readFileSync(
resolve(dir, '../fixtures/notebooks/horizontal-rendering.json'),
'utf8'
)
)
mkdirSync(output, { recursive: true })
let passed = 0
let failed = 0

/** Invoke the existing CUJ browser tool with argument boundaries preserved. */
function browser(...args: string[]): string {
return execFileSync('agent-browser', ['--session', session, ...args], {
encoding: 'utf8',
timeout: 30000,
Comment thread
jlewi marked this conversation as resolved.
maxBuffer: 4 * 1024 * 1024,
}).trim()
}

/** Measure visible elements only: inactive tabs deliberately remain mounted. */
function inspectLayout() {
const column = document.querySelector<HTMLElement>(
'[role="tabpanel"][data-state="active"] #notebook-column'
)!
const viewport = column.closest<HTMLElement>('.rt-ScrollAreaViewport')!
const markdown = column.querySelector<HTMLElement>('.notebook-markdown')!
const paragraph = markdown.querySelector<HTMLElement>('p')!
const tables = [...markdown.querySelectorAll('table')]
const pre = markdown.querySelector<HTMLElement>('pre')!
const output = column.querySelector<HTMLElement>(
'[data-testid="cell-output-item"] pre'
)!
const rect = paragraph.getBoundingClientRect()
const pane = viewport.getBoundingClientRect()
return {
viewport: viewport.clientWidth,
column: column.clientWidth,
scroll: viewport.scrollWidth,
proseFits: rect.left >= pane.left && rect.right <= pane.right + 1,
proseWraps:
rect.height > parseFloat(getComputedStyle(paragraph).lineHeight) * 1.5,
smallTableFits:
tables[0].scrollWidth <= tables[0].parentElement!.clientWidth + 1,
wideTableScrolls:
tables[1].parentElement!.scrollWidth >
tables[1].parentElement!.clientWidth &&
getComputedStyle(tables[1].parentElement!).overflowX === 'auto',
codeScrolls:
pre.scrollWidth > pre.clientWidth &&
getComputedStyle(pre).overflowX === 'auto',
scrollRegionsFocusable:
pre.tabIndex === 0 && tables[1].parentElement!.tabIndex === 0,
outputFits: output.scrollWidth <= output.clientWidth + 1,
outputIntact: output.textContent!.includes(
'unbroken_output_identifier_'.repeat(500)
),
siblingFits:
column.querySelectorAll('.notebook-markdown').length === 2 &&
[...column.querySelectorAll<HTMLElement>('.notebook-markdown')].every(
(cell) => {
const bounds = cell.getBoundingClientRect()
return bounds.left >= pane.left && bounds.right <= pane.right + 1
}
),
}
}

/** Fail on missing elements rather than treating empty measurements as success. */
function check(name: string, ok: boolean) {
if (ok) passed++
else failed++
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`)
}

try {
browser('open', url)
browser('record', 'start', join(output, 'scenario-horizontal-rendering.webm'))
// Recording may replace the context. Seed only after recording has started.
browser('wait', '--fn', 'Boolean(window.app?.localNotebooks)')
browser(
'eval',
`(async () => {
const store = window.app?.localNotebooks;
if (!store) throw new Error('Local notebook store is not ready');
await store.files.put({
id:${JSON.stringify(uri)}, uri:${JSON.stringify(uri)},
name:'horizontal-rendering.json', remoteId:'',
doc:${JSON.stringify(JSON.stringify(fixture))},
parent:'local://folder/local', updatedAt:new Date().toISOString(),
lastSynced:'', lastRemoteChecksum:''
});
sessionStorage.setItem('runme/openNotebooks',JSON.stringify([{uri:${JSON.stringify(uri)},name:'horizontal-rendering.json',type:'file',children:[],parents:['local://folder/local']} ]));
sessionStorage.setItem('runme/currentDoc',${JSON.stringify(uri)});
})()`
)
browser('reload')
browser('wait', '#markdown-rendered-markup_horizontal_rendering')
browser('wait', '[data-testid="cell-output-item"] pre')
browser('wait', '--fn', 'document.fonts.status === "loaded"')
// Exercise a normal and a narrow viewport; the sidebar may consume width too.
for (const width of [1280, 900]) {
browser('set', 'viewport', String(width), '900')
browser('wait', '300')
const raw = browser('eval', `(${inspectLayout.toString()})()`)
let result = JSON.parse(raw)
if (typeof result === 'string') result = JSON.parse(result)
writeFileSync(
join(output, `scenario-horizontal-rendering-${width}.json`),
JSON.stringify(result, null, 2)
)
check(
`${width}: notebook width is bounded`,
result.column <= result.viewport + 1 &&
result.scroll <= result.viewport + 1
)
for (const key of [
'proseFits',
'proseWraps',
'smallTableFits',
'wideTableScrolls',
'codeScrolls',
'scrollRegionsFocusable',
'outputFits',
'outputIntact',
'siblingFits',
])
check(`${width}: ${key}`, result[key] === true)
browser(
'screenshot',
join(output, `scenario-horizontal-rendering-${width}.png`)
)
}
} catch (error) {
check(String(error), false)
} finally {
try {
browser('record', 'stop')
} catch {
/* The first failure is reported above. */
}
try {
browser('close')
} catch {
/* Preserve test result during cleanup. */
}
}
console.log(
`Assertions: ${passed + failed}, Passed: ${passed}, Failed: ${failed}`
)
process.exitCode = failed ? 1 : 0
36 changes: 36 additions & 0 deletions app/test/fixtures/notebooks/horizontal-rendering.json

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions docs-dev/CUJs/horizontal-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Read notebooks containing wide Markdown and outputs

Regression: a wide table, code block, or output expands Radix's fit-content
wrapper and makes every paragraph wrap beyond the visible pane.

## Design decisions

- The viewport owns notebook width. Constrain Radix's generated inner wrapper
to 100% width and max-width, scoped to `.notebook-scroll-area`. `w-full` on
the notebook alone refers to the already expanded wrapper and cannot fix it.
- The notebook scrolls vertically. Horizontal scrolling belongs to individual
tables and fenced code blocks, with keyboard-focusable scroll regions.
- Prose, links, inline identifiers and plain-text outputs use `overflow-wrap:
anywhere`. Unlike `break-word`, it also reduces intrinsic minimum width.
- Tables use normal wrapping so wide columns stay readable and scroll locally.
Fenced code preserves whitespace; the `pre`, not a second nested `code`
scroller, owns overflow. Do not clip content to conceal a sizing regression.
- Scope the unlayered CSS override to notebook panes; other Radix scroll areas,
including the tab rail and review panels, keep their existing behavior.

Full design and before/after evidence:
[20260910_horizontal_rendering.runme](https://runme.gateway.unified-0.internal.api.openai.org/?doc=https%3A%2F%2Fdrive.google.com%2Ffile%2Fd%2F1m4Zx1EPXjrrXKYdzgXBhTatI5nYLj-ES%2Fview).

## Fixture and acceptance criteria

`app/test/fixtures/notebooks/horizontal-rendering.json` includes ordinary prose,
a two-column table, long inline code, a long fenced command, a 12-column table,
a long stored output URL, and a sibling Markdown cell. It contains no real
incident data or service credentials and needs no backend execution.

Run `test-scenario-horizontal-rendering.ts` through the canonical CUJ runner.
At 1280px and 900px browser widths, require:

1. Notebook column and scroll width do not exceed the viewport (1px tolerance).
2. Paragraphs fit and wrap; the small table fits.
3. Wide table and code block overflow locally, with keyboard focus available.
4. Long output remains intact and wraps within its output panel.
5. The sibling cell is present; earlier wide content does not expand the column.

Record measurements, screenshots and video under `app/test/browser/test-output/`.
Also inspect the wide blocks after scrolling locally and test resizing with
Explorer/Comments panels open. A CSS-class test in jsdom cannot establish these
layout properties; use browser geometry and inspect screenshots.
3 changes: 3 additions & 0 deletions docs-dev/cujs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ from `docs-dev/cujs/`.

## Current CUJs

- [horizontal-rendering.md](../CUJs/horizontal-rendering.md) — viewport-bounded prose with locally scrolling wide
tables/code and wrapping outputs; verified at normal and narrow widths.

- `hello-world-local-notebook.md` — baseline notebook flow:
- configure local runner,
- open local notebook,
Expand Down
Loading