Skip to content

Commit 55ecdea

Browse files
authored
Merge pull request #5609 from nodetool-ai/claude/mobile-chat-scroll-lock-p6de88
fix(chat): stop the conversation panning sideways on a phone
2 parents 2ac69cd + 814e9e3 commit 55ecdea

3 files changed

Lines changed: 140 additions & 2 deletions

File tree

web/src/components/chat/containers/WelcomePlaceholder.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
BORDER_RADIUS,
1313
MOTION,
1414
SPACING,
15+
SPACING_PX,
1516
getSpacingPx
1617
} from "../../ui_primitives";
1718
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
@@ -31,6 +32,9 @@ const styles = (theme: Theme) =>
3132
minHeight: 0,
3233
width: "100%",
3334
overflowY: "auto",
35+
// `overflowY` alone leaves the other axis at `auto`, which lets a phone
36+
// pan the welcome screen sideways.
37+
overflowX: "hidden",
3438
display: "flex",
3539
flexDirection: "column",
3640
alignItems: "center",
@@ -77,7 +81,26 @@ const styles = (theme: Theme) =>
7781
flexWrap: "wrap",
7882
justifyContent: "center",
7983
gap: getSpacingPx(SPACING.md),
80-
marginTop: getSpacingPx(SPACING.xs)
84+
marginTop: getSpacingPx(SPACING.xs),
85+
// A chip's label does not wrap, so on a phone the longest opener is
86+
// wider than the column and pushes the whole screen sideways.
87+
maxWidth: "100%"
88+
},
89+
90+
// Rather than ellipsing the opener away on a narrow screen, let it run
91+
// onto a second line.
92+
".suggestions .MuiChip-root": {
93+
height: "auto",
94+
minHeight: `${SPACING_PX.xxxl}px`,
95+
maxWidth: "100%"
96+
},
97+
98+
".suggestions .MuiChip-label": {
99+
whiteSpace: "normal",
100+
overflow: "visible",
101+
textOverflow: "clip",
102+
paddingTop: getSpacingPx(SPACING.xs),
103+
paddingBottom: getSpacingPx(SPACING.xs)
81104
}
82105
});
83106

@@ -155,7 +178,7 @@ const WelcomePlaceholder: React.FC<WelcomePlaceholderProps> = ({
155178
const noProvider = !isLoading && !error && providers.length === 0;
156179

157180
return (
158-
<div css={cssStyles}>
181+
<div css={cssStyles} className="chat-welcome">
159182
<div className="welcome-inner">
160183
{noProvider ? (
161184
<FlexColumn align="center" gap={SPACING.sm} sx={{ textAlign: "center" }}>

web/src/components/chat/thread/ChatThreadView.styles.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export const createStyles = (theme: Theme) => ({
3636
justifyContent: "flex-start",
3737
alignItems: "center",
3838
overflowY: "auto",
39+
// Without this the axis computes to `auto` alongside `overflowY`, and a
40+
// single wide turn lets a phone pan the whole conversation sideways.
41+
// Content that genuinely needs the width (code blocks, tables, JSON
42+
// dumps) scrolls inside its own box.
43+
overflowX: "hidden",
3944
overflowAnchor: "none",
4045
padding: theme.spacing(2, 0),
4146
marginTop: 0,
@@ -79,6 +84,15 @@ export const createStyles = (theme: Theme) => ({
7984
alignItems: "flex-start",
8085
gap: theme.spacing(1)
8186
},
87+
// The turn's body. `.chat-message` aligns its children to the start, so
88+
// without a width this box takes its content's min-content size — an
89+
// unbreakable URL or a wide table then makes the turn wider than the
90+
// column instead of wrapping or scrolling inside it.
91+
".message-body": {
92+
width: "100%",
93+
minWidth: 0,
94+
maxWidth: "100%"
95+
},
8296
".chat-message.assistant": {
8397
padding: theme.spacing(3, 4),
8498
borderRadius: BORDER_RADIUS.xl
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Journey: the chat on a phone.
3+
*
4+
* A conversation scrolls up and down, never sideways. Content that genuinely
5+
* needs the width — a code block, a table, a JSON dump — scrolls inside its
6+
* own box; the thread itself stays put. Both checks drive the real surface at
7+
* phone width and try to pan it, so a layout regression that widens a turn
8+
* fails here rather than on someone's phone.
9+
*/
10+
11+
import { test, expect, FIXTURES } from "./fixtures";
12+
import { ChatPage } from "./pages";
13+
14+
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
15+
16+
/** What a real answer puts in a turn: an unbreakable URL, a wide table, a long command. */
17+
const WIDE_MARKDOWN = [
18+
"https://example.com/a/very/long/path/that/never/breaks/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
19+
"",
20+
"| column one | column two | column three | column four | column five | column six |",
21+
"| --- | --- | --- | --- | --- | --- |",
22+
"| aaaaaaaaaa | bbbbbbbbbb | cccccccccc | dddddddddd | eeeeeeeeee | ffffffffff |",
23+
"",
24+
"```sh",
25+
"docker run --rm -it --name nodetool --network host -e NODETOOL_API_URL=https://api.example.com/v1/endpoint ghcr.io/example/nodetool:latest",
26+
"```"
27+
].join("\\n");
28+
29+
/** Text in the seeded assistant turn this suite rewrites into `WIDE_MARKDOWN`. */
30+
const SEEDED_TAIL = "That night, they dreamed together.";
31+
32+
/** Try to pan `selector` right; report where it actually ended up. */
33+
async function pan(page: import("@playwright/test").Page, selector: string) {
34+
return page.evaluate((sel) => {
35+
const el = document.querySelector(sel) as HTMLElement | null;
36+
if (!el) {
37+
throw new Error(`no element for ${sel}`);
38+
}
39+
el.scrollLeft = 500;
40+
return {
41+
scrollLeft: el.scrollLeft,
42+
scrollWidth: el.scrollWidth,
43+
clientWidth: el.clientWidth
44+
};
45+
}, selector);
46+
}
47+
48+
test.describe("Chat on a phone", () => {
49+
test("a turn wider than the column does not pan the conversation", async ({
50+
page
51+
}) => {
52+
// The fixture backend has no model configured, so the wide turn is
53+
// injected into the seeded history rather than sent.
54+
await page.route("**/trpc/**", async (route) => {
55+
const response = await route.fetch();
56+
const body = await response.text();
57+
return route.fulfill({
58+
response,
59+
body: body.includes(SEEDED_TAIL)
60+
? body.replace(SEEDED_TAIL, WIDE_MARKDOWN)
61+
: body
62+
});
63+
});
64+
65+
const chat = new ChatPage(page);
66+
await page.goto(`/chat/${FIXTURES.thread}`, {
67+
waitUntil: "domcontentloaded"
68+
});
69+
await chat.waitForMessage("column three");
70+
// The fenced block is the last thing in the injected turn — once it is on
71+
// screen the turn has finished laying out and the widths are stable.
72+
await page
73+
.locator(".code-block-content")
74+
.waitFor({ state: "visible", timeout: 30_000 });
75+
76+
const thread = await pan(page, ".scrollable-message-wrapper");
77+
expect(thread.scrollLeft, "the conversation panned sideways").toBe(0);
78+
expect(thread.scrollWidth).toBeLessThanOrEqual(thread.clientWidth);
79+
80+
// The code block keeps its own horizontal scroll — locking the thread
81+
// must not make wide content unreachable.
82+
const code = await pan(page, ".code-block-content");
83+
expect(code.scrollWidth).toBeGreaterThan(code.clientWidth);
84+
expect(code.scrollLeft).toBeGreaterThan(0);
85+
});
86+
87+
// 320px is the narrowest phone still in use; the longest opener chip is
88+
// wider than the column there, which is where the screen used to pan.
89+
test("the welcome screen does not pan sideways", async ({ page }) => {
90+
await page.setViewportSize({ width: 320, height: 780 });
91+
await page.goto("/chat/thread-mobile-welcome", {
92+
waitUntil: "domcontentloaded"
93+
});
94+
const welcome = page.locator(".chat-welcome");
95+
await welcome.waitFor({ state: "visible", timeout: 30_000 });
96+
97+
const screen = await pan(page, ".chat-welcome");
98+
expect(screen.scrollLeft, "the welcome screen panned sideways").toBe(0);
99+
expect(screen.scrollWidth).toBeLessThanOrEqual(screen.clientWidth);
100+
});
101+
});

0 commit comments

Comments
 (0)