From 0388d241d24924ac02a1ee7be99ee9dd2bb8009e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Apr 2026 22:12:43 +0000 Subject: [PATCH 1/6] fix(mobile): toolbar layout and default code wrap on narrow viewports - Scope flex/ellipsis rules to toolbar buttons so labels do not clip; pair raw/rendered toggle on one row with horizontal scroll fallback. - Add overflow-x on code/diff/csv/json renderer toolbars at <=640px. - Default CodeMirror line wrapping on when viewport is <=640px (compact unchanged); desktop still starts with wrap off. Co-authored-by: Aanish Bhirud --- src/app/globals.css | 47 ++++++++++++++++++++-- src/components/renderers/code-renderer.tsx | 15 ++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index cc28b23..d254148 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1883,19 +1883,60 @@ select { .viewer-toolbar { width: 100%; + max-width: 100%; + min-width: 0; justify-content: flex-start; gap: 0.38rem; + flex-wrap: wrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; } - .artifact-action { - flex: 1 1 calc(50% - 0.25rem); + /* Avoid flex-shrink on toolbar buttons so labels are not clipped; pair rows only inside raw/rendered toggle */ + .artifact-action:not(a) { + flex: 0 0 auto; min-height: 2.55rem; padding: 0.46rem 0.65rem; font-size: 0.71rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + button.artifact-action { + max-width: 100%; + } + + .viewer-toolbar .diff-view-toggle { + display: flex; + flex: 1 1 100%; + flex-wrap: wrap; + gap: 0.38rem; + min-width: 0; + } + + .viewer-toolbar .diff-view-toggle .artifact-action { + flex: 1 1 calc(50% - 0.25rem); + min-width: 0; + max-width: 100%; } .viewer-toolbar > .artifact-action.is-primary { - flex-basis: 100%; + flex: 1 1 100%; + max-width: 100%; + } + + .code-renderer-toolbar, + .diff-renderer-toolbar, + .csv-renderer-toolbar, + .json-renderer-toolbar { + max-width: 100%; + min-width: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; } .viewer-frame-primary { diff --git a/src/components/renderers/code-renderer.tsx b/src/components/renderers/code-renderer.tsx index 6d0d663..d256b36 100644 --- a/src/components/renderers/code-renderer.tsx +++ b/src/components/renderers/code-renderer.tsx @@ -21,6 +21,19 @@ import { bracketMatching, defaultHighlightStyle, syntaxTree, syntaxHighlighting import { detectCodeLanguage, loadLanguageSupport } from "@/lib/code/language"; import type { CodeArtifact } from "@/lib/payload/schema"; +const NARROW_CODE_BREAKPOINT = 640; +const MOBILE_CODE_MEDIA_QUERY = `(max-width: ${NARROW_CODE_BREAKPOINT}px)`; + +function getDefaultWrapLines(compact: boolean) { + if (compact) { + return true; + } + if (typeof window === "undefined") { + return false; + } + return window.matchMedia(MOBILE_CODE_MEDIA_QUERY).matches; +} + type CodeRendererProps = { artifact: CodeArtifact; compact?: boolean; @@ -153,7 +166,7 @@ const rainbowBrackets = ViewPlugin.fromClass( */ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendererProps) { const hostRef = useRef(null); - const [wrapLines, setWrapLines] = useState(compact); + const [wrapLines, setWrapLines] = useState(() => getDefaultWrapLines(compact)); const [languageExtension, setLanguageExtension] = useState>>(null); const [isReady, setIsReady] = useState(false); const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]); From 09c544c70a2bcf3c7b4f8fee230a1f7e16f11e79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:32:04 +0000 Subject: [PATCH 2/6] fix: address review feedback - hydration mismatch, anchor mobile styles, code-renderer tests Agent-Logs-Url: https://github.com/baanish/agent-render/sessions/28d4f0a2-dd44-4cee-9c7d-7af53206edf5 Co-authored-by: baanish <47579874+baanish@users.noreply.github.com> --- package-lock.json | 1 - src/app/globals.css | 5 +- src/components/renderers/code-renderer.tsx | 24 ++-- tests/components/code-renderer.test.tsx | 148 +++++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 tests/components/code-renderer.test.tsx diff --git a/package-lock.json b/package-lock.json index b24977b..7b7430f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9709,7 +9709,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/globals.css b/src/app/globals.css index d254148..8cb8794 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1894,7 +1894,7 @@ select { } /* Avoid flex-shrink on toolbar buttons so labels are not clipped; pair rows only inside raw/rendered toggle */ - .artifact-action:not(a) { + .artifact-action { flex: 0 0 auto; min-height: 2.55rem; padding: 0.46rem 0.65rem; @@ -1903,9 +1903,6 @@ select { overflow: hidden; text-overflow: ellipsis; min-width: 0; - } - - button.artifact-action { max-width: 100%; } diff --git a/src/components/renderers/code-renderer.tsx b/src/components/renderers/code-renderer.tsx index d256b36..33f595d 100644 --- a/src/components/renderers/code-renderer.tsx +++ b/src/components/renderers/code-renderer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { WrapText } from "lucide-react"; import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { indentationMarkers } from "@replit/codemirror-indentation-markers"; @@ -21,18 +21,7 @@ import { bracketMatching, defaultHighlightStyle, syntaxTree, syntaxHighlighting import { detectCodeLanguage, loadLanguageSupport } from "@/lib/code/language"; import type { CodeArtifact } from "@/lib/payload/schema"; -const NARROW_CODE_BREAKPOINT = 640; -const MOBILE_CODE_MEDIA_QUERY = `(max-width: ${NARROW_CODE_BREAKPOINT}px)`; - -function getDefaultWrapLines(compact: boolean) { - if (compact) { - return true; - } - if (typeof window === "undefined") { - return false; - } - return window.matchMedia(MOBILE_CODE_MEDIA_QUERY).matches; -} +const MOBILE_CODE_MEDIA_QUERY = "(max-width: 640px)"; type CodeRendererProps = { artifact: CodeArtifact; @@ -166,11 +155,18 @@ const rainbowBrackets = ViewPlugin.fromClass( */ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendererProps) { const hostRef = useRef(null); - const [wrapLines, setWrapLines] = useState(() => getDefaultWrapLines(compact)); + const [wrapLines, setWrapLines] = useState(compact); const [languageExtension, setLanguageExtension] = useState>>(null); const [isReady, setIsReady] = useState(false); const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]); + useEffect(() => { + if (compact || !window.matchMedia) return; + if (window.matchMedia(MOBILE_CODE_MEDIA_QUERY).matches) { + setWrapLines(true); + } + }, [compact]); + useEffect(() => { let cancelled = false; diff --git a/tests/components/code-renderer.test.tsx b/tests/components/code-renderer.test.tsx new file mode 100644 index 0000000..fffe21b --- /dev/null +++ b/tests/components/code-renderer.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { CodeRenderer } from "@/components/renderers/code-renderer"; +import type { CodeArtifact } from "@/lib/payload/schema"; + +vi.mock("@codemirror/view", () => ({ + EditorView: class MockEditorView { + static theme() { + return {}; + } + static lineWrapping = {}; + static editable = { of: () => ({}) }; + constructor() {} + destroy() {} + }, + drawSelection: () => ({}), + highlightActiveLine: () => ({}), + keymap: { of: () => ({}) }, + lineNumbers: () => ({}), + ViewPlugin: { fromClass: () => ({}) }, + Decoration: { mark: () => ({}) }, +})); + +vi.mock("@codemirror/state", () => ({ + EditorState: { + create: () => ({}), + readOnly: { of: () => ({}) }, + }, + RangeSetBuilder: class {}, +})); + +vi.mock("@codemirror/language", () => ({ + bracketMatching: () => ({}), + defaultHighlightStyle: {}, + syntaxTree: () => ({ iterate: () => {} }), + syntaxHighlighting: () => ({}), +})); + +vi.mock("@codemirror/commands", () => ({ + defaultKeymap: [], +})); + +vi.mock("@codemirror/search", () => ({ + searchKeymap: [], +})); + +vi.mock("@replit/codemirror-indentation-markers", () => ({ + indentationMarkers: () => ({}), +})); + +vi.mock("@/lib/code/language", () => ({ + detectCodeLanguage: () => "text", + loadLanguageSupport: () => Promise.resolve(null), +})); + +function mockMatchMedia(matches: boolean) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function createArtifact(overrides: Partial = {}): CodeArtifact { + return { + id: "code-artifact", + kind: "code", + title: "hello.ts", + filename: "hello.ts", + content: 'export const hello = "world";', + ...overrides, + }; +} + +const originalMatchMedia = window.matchMedia; + +afterAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: originalMatchMedia, + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("CodeRenderer", () => { + describe("wrap default on wide viewport", () => { + beforeAll(() => { + mockMatchMedia(false); + }); + + it("shows Enable wrap button when viewport is wide", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /enable wrap/i })).toBeVisible(); + }); + }); + + it("toggling wrap changes the button label", async () => { + render(); + + const btn = await screen.findByRole("button", { name: /enable wrap/i }); + await userEvent.click(btn); + + expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); + }); + }); + + describe("wrap default on narrow viewport", () => { + beforeAll(() => { + mockMatchMedia(true); + }); + + it("shows Disable wrap button when viewport is narrow", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); + }); + }); + }); + + describe("compact mode", () => { + beforeAll(() => { + mockMatchMedia(false); + }); + + it("does not render a toolbar in compact mode", () => { + render(); + + expect(screen.queryByRole("button", { name: /wrap/i })).not.toBeInTheDocument(); + }); + }); +}); From 9dc349d3b04e60fc7b8295f53b4ff347e532eb9b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Apr 2026 18:58:54 +0000 Subject: [PATCH 3/6] fix(code-renderer): sync wrap with viewport via matchMedia listener - Track manual wrap toggles (on/off) vs auto so resize updates defaults until the user overrides. - Subscribe to (max-width: 640px) change events; compact mode resets to auto. - Extend tests with controllable matchMedia and resize scenarios. Co-authored-by: Aanish Bhirud --- src/components/renderers/code-renderer.tsx | 50 ++++++++- tests/components/code-renderer.test.tsx | 112 +++++++++++++++++---- 2 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/components/renderers/code-renderer.tsx b/src/components/renderers/code-renderer.tsx index 33f595d..be1fc97 100644 --- a/src/components/renderers/code-renderer.tsx +++ b/src/components/renderers/code-renderer.tsx @@ -23,6 +23,8 @@ import type { CodeArtifact } from "@/lib/payload/schema"; const MOBILE_CODE_MEDIA_QUERY = "(max-width: 640px)"; +type WrapPreference = "auto" | "on" | "off"; + type CodeRendererProps = { artifact: CodeArtifact; compact?: boolean; @@ -155,16 +157,48 @@ const rainbowBrackets = ViewPlugin.fromClass( */ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendererProps) { const hostRef = useRef(null); + const wrapPreferenceRef = useRef("auto"); const [wrapLines, setWrapLines] = useState(compact); const [languageExtension, setLanguageExtension] = useState>>(null); const [isReady, setIsReady] = useState(false); const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]); useEffect(() => { - if (compact || !window.matchMedia) return; - if (window.matchMedia(MOBILE_CODE_MEDIA_QUERY).matches) { + if (compact) { setWrapLines(true); + wrapPreferenceRef.current = "auto"; + return; + } + + if (typeof window === "undefined" || !window.matchMedia) { + return; } + + const mediaQuery = window.matchMedia(MOBILE_CODE_MEDIA_QUERY); + + const applyWrapFromPreference = () => { + const preference = wrapPreferenceRef.current; + if (preference === "on") { + setWrapLines(true); + return; + } + if (preference === "off") { + setWrapLines(false); + return; + } + setWrapLines(mediaQuery.matches); + }; + + applyWrapFromPreference(); + + const handleChange = () => { + applyWrapFromPreference(); + }; + + mediaQuery.addEventListener("change", handleChange); + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; }, [compact]); useEffect(() => { @@ -262,7 +296,17 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere read-only codemirror - diff --git a/tests/components/code-renderer.test.tsx b/tests/components/code-renderer.test.tsx index fffe21b..269ef4c 100644 --- a/tests/components/code-renderer.test.tsx +++ b/tests/components/code-renderer.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CodeRenderer } from "@/components/renderers/code-renderer"; import type { CodeArtifact } from "@/lib/payload/schema"; @@ -55,20 +55,47 @@ vi.mock("@/lib/code/language", () => ({ loadLanguageSupport: () => Promise.resolve(null), })); -function mockMatchMedia(matches: boolean) { +/** Shared controllable matchMedia for tests that need resize / change events. */ +function createControllableMatchMedia(initialMatches: boolean) { + let matches = initialMatches; + const narrowListeners = new Set<() => void>(); + Object.defineProperty(window, "matchMedia", { writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })), + configurable: true, + value: vi.fn().mockImplementation((query: string) => { + const isNarrowQuery = query === "(max-width: 640px)"; + return { + get matches() { + return isNarrowQuery ? matches : false; + }, + media: query, + onchange: null, + addEventListener: (type: string, listener: EventListener) => { + if (isNarrowQuery && type === "change" && typeof listener === "function") { + narrowListeners.add(listener as () => void); + } + }, + removeEventListener: (type: string, listener: EventListener) => { + if (isNarrowQuery && type === "change") { + narrowListeners.delete(listener as () => void); + } + }, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }; + }), }); + + return { + setNarrowMatches(next: boolean) { + matches = next; + for (const listener of narrowListeners) { + listener(); + } + }, + }; } function createArtifact(overrides: Partial = {}): CodeArtifact { @@ -87,6 +114,7 @@ const originalMatchMedia = window.matchMedia; afterAll(() => { Object.defineProperty(window, "matchMedia", { writable: true, + configurable: true, value: originalMatchMedia, }); }); @@ -98,8 +126,8 @@ afterEach(() => { describe("CodeRenderer", () => { describe("wrap default on wide viewport", () => { - beforeAll(() => { - mockMatchMedia(false); + beforeEach(() => { + createControllableMatchMedia(false); }); it("shows Enable wrap button when viewport is wide", async () => { @@ -118,11 +146,24 @@ describe("CodeRenderer", () => { expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); }); + + it("enables wrap when the viewport crosses to narrow without a prior manual toggle", async () => { + const media = createControllableMatchMedia(false); + render(); + + await screen.findByRole("button", { name: /enable wrap/i }); + + media.setNarrowMatches(true); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); + }); + }); }); describe("wrap default on narrow viewport", () => { - beforeAll(() => { - mockMatchMedia(true); + beforeEach(() => { + createControllableMatchMedia(true); }); it("shows Disable wrap button when viewport is narrow", async () => { @@ -132,11 +173,46 @@ describe("CodeRenderer", () => { expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); }); }); + + it("disables wrap when the viewport crosses to wide without a prior manual toggle", async () => { + const media = createControllableMatchMedia(true); + render(); + + await screen.findByRole("button", { name: /disable wrap/i }); + + media.setNarrowMatches(false); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /enable wrap/i })).toBeVisible(); + }); + }); + }); + + describe("wrap preference after manual toggle", () => { + beforeEach(() => { + createControllableMatchMedia(false); + }); + + it("keeps the user choice when the viewport changes after a manual toggle", async () => { + const media = createControllableMatchMedia(false); + render(); + + const enableBtn = await screen.findByRole("button", { name: /enable wrap/i }); + await userEvent.click(enableBtn); + + expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); + + media.setNarrowMatches(true); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /disable wrap/i })).toBeVisible(); + }); + }); }); describe("compact mode", () => { - beforeAll(() => { - mockMatchMedia(false); + beforeEach(() => { + createControllableMatchMedia(false); }); it("does not render a toolbar in compact mode", () => { From 95c856cce76b6b88c488ddd6d00e7d997b0ae0c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 18:39:14 +0000 Subject: [PATCH 4/6] fix: address code review (360px toggle layout, wrap sync, tests) - Stack raw/rendered toolbar buttons full-width at <=360px (override pair flex). - Run wrap preference + matchMedia listener in useLayoutEffect so first paint matches narrow viewports without an extra CodeMirror teardown. - Set wrap preference ref outside setState on toolbar click. - Restore window.matchMedia in code-renderer tests afterEach for isolation. Co-authored-by: Aanish Bhirud --- src/app/globals.css | 6 ++++++ src/components/renderers/code-renderer.tsx | 17 ++++++++--------- tests/components/code-renderer.test.tsx | 5 +++++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 8cb8794..869f2fe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1998,6 +1998,12 @@ select { .artifact-action { flex-basis: 100%; } + + /* Higher specificity than the 640px `.viewer-toolbar .diff-view-toggle .artifact-action` pair row */ + .viewer-toolbar .diff-view-toggle .artifact-action { + flex: 1 1 100%; + max-width: 100%; + } } @media (min-width: 768px) { diff --git a/src/components/renderers/code-renderer.tsx b/src/components/renderers/code-renderer.tsx index be1fc97..04d008e 100644 --- a/src/components/renderers/code-renderer.tsx +++ b/src/components/renderers/code-renderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { WrapText } from "lucide-react"; import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { indentationMarkers } from "@replit/codemirror-indentation-markers"; @@ -163,7 +163,8 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere const [isReady, setIsReady] = useState(false); const language = useMemo(() => detectCodeLanguage(artifact.filename, artifact.language), [artifact.filename, artifact.language]); - useEffect(() => { + // Runs before paint so the first CodeMirror mount matches the viewport (call sites use dynamic(..., { ssr: false })). + useLayoutEffect(() => { if (compact) { setWrapLines(true); wrapPreferenceRef.current = "auto"; @@ -299,13 +300,11 @@ export function CodeRenderer({ artifact, compact = false, onReady }: CodeRendere