diff --git a/apps/web/src/components/features/terminal/terminal.test.ts b/apps/web/src/components/features/terminal/terminal.test.ts new file mode 100644 index 0000000..b86d44d --- /dev/null +++ b/apps/web/src/components/features/terminal/terminal.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { getTerminalScrollbackLimit } from "./terminal.tsx"; + +describe("getTerminalScrollbackLimit", () => { + it("retains normal-buffer history for custom mobile swipe scrolling", () => { + expect(getTerminalScrollbackLimit(true)).toBe(10_000); + }); + + it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => { + expect(getTerminalScrollbackLimit(false)).toBe(0); + }); +}); diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 079252b..604c044 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -53,6 +53,10 @@ const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x const BRACKETED_PASTE_CONTROL_TAIL_LENGTH = BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1; +export function getTerminalScrollbackLimit(isMobile: boolean): number { + return isMobile ? 10_000 : 0; +} + function matchesByteSequence( data: Uint8Array, start: number, @@ -326,14 +330,11 @@ export const TerminalComponent = React.forwardRef( lineHeight: 1.2, letterSpacing: 0, allowTransparency: false, - // This terminal only ever hosts full-screen TUI tools (pi-agent, - // opencode), which repaint in place in the normal buffer and do not - // use the alternate screen or mouse tracking. With scrollback, every - // repaint accumulates as history → a viewport scrollbar appears and - // the mouse-wheel scrolls through stale frames instead of the app. - // scrollback:0 keeps only the live viewport: no bar, no stale-frame - // wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.) - scrollback: 0, + // Desktop tools repaint in place, so retaining their normal buffer + // creates stale frames that native wheel scrolling can revisit. Mobile + // instead uses its custom touch handler to scroll normal-buffer output, + // which requires retained history. + scrollback: getTerminalScrollbackLimit(isMobile), ignoreBracketedPasteMode: false, fastScrollSensitivity: 0, scrollSensitivity: 0, diff --git a/apps/web/src/pages/ProjectsPage.test.tsx b/apps/web/src/pages/ProjectsPage.test.tsx index dc51c46..6f94793 100644 --- a/apps/web/src/pages/ProjectsPage.test.tsx +++ b/apps/web/src/pages/ProjectsPage.test.tsx @@ -1,17 +1,26 @@ +import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ProjectsPage } from "./ProjectsPage"; import * as projectsApi from "../api/projects"; +import { SessionsProvider } from "../state/sessions"; +import type { ProjectWithRepos } from "../types"; -const mockProjects = [ +vi.mock("../api/sessions", () => ({ + getUserSessions: vi.fn().mockResolvedValue([]), +})); + +const mockProjects: ProjectWithRepos[] = [ { id: "proj-1", name: "Alpha Project", description: "First project", owner_id: "user-1", default_ssh_key_id: null, + repositories: [], + created_at: "2026-07-01T00:00:00Z", }, { id: "proj-2", @@ -19,6 +28,8 @@ const mockProjects = [ description: null, owner_id: "user-1", default_ssh_key_id: null, + repositories: [], + created_at: "2026-07-01T00:00:00Z", }, ]; @@ -31,9 +42,7 @@ describe("ProjectsPage", () => { it("renders loading state initially", () => { vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {})); render( - - - + ); expect(screen.getByText(/loading projects/i)).toBeInTheDocument(); }); @@ -41,9 +50,7 @@ describe("ProjectsPage", () => { it("renders project list after loading", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); render( - - - + ); await waitFor(() => { @@ -56,9 +63,7 @@ describe("ProjectsPage", () => { it("renders empty state when no projects", async () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); render( - - - + ); await waitFor(() => { @@ -69,9 +74,7 @@ describe("ProjectsPage", () => { it("renders error state with retry button", async () => { vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail")); render( - - - + ); await waitFor(() => { @@ -85,9 +88,7 @@ describe("ProjectsPage", () => { const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]); render( - - - + ); await waitFor(() => { @@ -118,9 +119,7 @@ describe("ProjectsPage", () => { vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); render( - - - + ); await waitFor(() => { @@ -138,16 +137,14 @@ describe("ProjectsPage", () => { const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]); render( - - - + ); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); - const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null; + const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null; if (!alphaCard) throw new Error("Card not found"); fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i })); @@ -171,16 +168,14 @@ describe("ProjectsPage", () => { const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined); render( - - - + ); await waitFor(() => { expect(screen.getByText("Alpha Project")).toBeInTheDocument(); }); - const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null; + const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null; if (!alphaCard) throw new Error("Card not found"); fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i })); diff --git a/openspec/changes/fix-mobile-terminal-scrolling/change.md b/openspec/changes/fix-mobile-terminal-scrolling/change.md new file mode 100644 index 0000000..ad38721 --- /dev/null +++ b/openspec/changes/fix-mobile-terminal-scrolling/change.md @@ -0,0 +1,30 @@ +# Restore Mobile Terminal Scrolling + +## Summary + +The recent terminal scrollback optimization disabled scrollback for every viewport. Mobile terminal swipe handling still scrolls xterm's normal buffer programmatically, so swipes in normal-buffer tools no longer have retained output to move through. + +## Root Cause + +`468f342` changed the terminal configuration to `scrollback: 0` globally to prevent stale repaint frames and wheel scrolling on desktop. The mobile touch handler calls `term.scrollLines()` when the normal buffer is active. With zero scrollback, that call has no scrollable history and becomes a no-op. + +## Scope + +- `apps/web/src/components/features/terminal/terminal.tsx` +- Focused terminal configuration test + +## Fix + +Retain a bounded xterm scrollback buffer on mobile only (`10000` lines), while leaving desktop at zero scrollback and with wheel sensitivity disabled. Mobile's existing custom touch handler remains responsible for moving through normal-buffer history; alternate-screen swipes continue to send SGR wheel events to the active TUI. + +## Acceptance Criteria + +- [ ] A mobile terminal with normal-buffer output exceeding one screen scrolls via a vertical swipe. +- [ ] Alternate-screen terminal scrolling continues to use the existing SGR wheel-event path. +- [ ] Desktop keeps zero xterm scrollback and disabled native wheel scrolling, so stale repaint frames do not return. +- [ ] Focused unit test and frontend quality gates pass. + +## Related + +- `468f342 fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools` +- `openspec/changes/fix-terminal-container-overflow` diff --git a/openspec/changes/fix-mobile-terminal-scrolling/tasks.md b/openspec/changes/fix-mobile-terminal-scrolling/tasks.md new file mode 100644 index 0000000..b524a8c --- /dev/null +++ b/openspec/changes/fix-mobile-terminal-scrolling/tasks.md @@ -0,0 +1,8 @@ +# Restore Mobile Terminal Scrolling — Tasks + +- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop. +- [x] Add focused tests for the responsive scrollback configuration. +- [x] Run the full frontend test suite (89 tests passed after repairing the `ProjectsPage` test setup). +- [x] Run frontend typecheck, lint, focused tests, and production build. +- [ ] Perform mobile normal-buffer and alternate-screen manual QA. +- [ ] Update project maps for changed source files (the map patch tool currently fails with an unsupported `temperature` parameter).