Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb38b37ceb | |||
| 6698c20f25 |
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,10 @@ const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x
|
|||||||
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
||||||
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
||||||
|
|
||||||
|
export function getTerminalScrollbackLimit(isMobile: boolean): number {
|
||||||
|
return isMobile ? 10_000 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function matchesByteSequence(
|
function matchesByteSequence(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
start: number,
|
start: number,
|
||||||
@@ -326,14 +330,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
allowTransparency: false,
|
allowTransparency: false,
|
||||||
// This terminal only ever hosts full-screen TUI tools (pi-agent,
|
// Desktop tools repaint in place, so retaining their normal buffer
|
||||||
// opencode), which repaint in place in the normal buffer and do not
|
// creates stale frames that native wheel scrolling can revisit. Mobile
|
||||||
// use the alternate screen or mouse tracking. With scrollback, every
|
// instead uses its custom touch handler to scroll normal-buffer output,
|
||||||
// repaint accumulates as history → a viewport scrollbar appears and
|
// which requires retained history.
|
||||||
// the mouse-wheel scrolls through stale frames instead of the app.
|
scrollback: getTerminalScrollbackLimit(isMobile),
|
||||||
// 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,
|
|
||||||
ignoreBracketedPasteMode: false,
|
ignoreBracketedPasteMode: false,
|
||||||
fastScrollSensitivity: 0,
|
fastScrollSensitivity: 0,
|
||||||
scrollSensitivity: 0,
|
scrollSensitivity: 0,
|
||||||
@@ -699,7 +700,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
// Ignore disposal errors from partially torn-down terminal
|
// Ignore disposal errors from partially torn-down terminal
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [instanceId, connectWebSocket]);
|
}, [instanceId, connectWebSocket, isMobile]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fit: () => {
|
fit: () => {
|
||||||
|
|||||||
@@ -578,23 +578,19 @@ export const InstanceList = ({
|
|||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<div className="dialog-header">
|
<h2>Launch Tool</h2>
|
||||||
<h2>Launch Tool</h2>
|
<CreateSessionForm
|
||||||
</div>
|
projects={[]}
|
||||||
<div className="dialog-body">
|
repositories={[]}
|
||||||
<CreateSessionForm
|
toolTypes={toolTypes}
|
||||||
projects={[]}
|
fixedProjectId={projectId}
|
||||||
repositories={[]}
|
fixedRepoId={repoId}
|
||||||
toolTypes={toolTypes}
|
projectName={projectName}
|
||||||
fixedProjectId={projectId}
|
repoName={repoName}
|
||||||
fixedRepoId={repoId}
|
onSuccess={handleCreateSuccess}
|
||||||
projectName={projectName}
|
onCancel={() => setShowCreate(false)}
|
||||||
repoName={repoName}
|
submitLabel="Launch"
|
||||||
onSuccess={handleCreateSuccess}
|
/>
|
||||||
onCancel={() => setShowCreate(false)}
|
|
||||||
submitLabel="Launch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export function StartToolFAB() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
{workspacesLoading ? (
|
{workspacesLoading ? (
|
||||||
<p className="muted">Loading workspaces...</p>
|
<p className="muted">Loading workspaces...</p>
|
||||||
) : workspaces.length === 0 ? (
|
) : workspaces.length === 0 ? (
|
||||||
@@ -111,7 +110,6 @@ export function StartToolFAB() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { WorkspaceToolsPanel } from "./workspace-tools-panel";
|
|
||||||
import type { Workspace } from "../../../types/workspace";
|
|
||||||
|
|
||||||
vi.mock("../../../hooks/use-workspace-instances", () => ({
|
|
||||||
useWorkspaceInstances: () => ({
|
|
||||||
instances: [],
|
|
||||||
loading: false,
|
|
||||||
refresh: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../tool/tool-starter", () => ({
|
|
||||||
ToolStarter: () => <div data-testid="tool-starter" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const workspace: Workspace = {
|
|
||||||
id: "workspace-1",
|
|
||||||
name: "Main",
|
|
||||||
repo_id: "repo-1",
|
|
||||||
repo_name: "repository",
|
|
||||||
repo_ssh_key_id: null,
|
|
||||||
project_id: "project-1",
|
|
||||||
project_name: "Project",
|
|
||||||
user_id: "user-1",
|
|
||||||
branch: "main",
|
|
||||||
path: "/workspace",
|
|
||||||
status: "ready",
|
|
||||||
last_sync_at: null,
|
|
||||||
created_at: "2026-01-01T00:00:00Z",
|
|
||||||
updated_at: "2026-01-01T00:00:00Z",
|
|
||||||
instance_count: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("WorkspaceToolsPanel", () => {
|
|
||||||
it("places the tool launcher inside the shared scrollable dialog body", () => {
|
|
||||||
const { container } = render(<WorkspaceToolsPanel workspace={workspace} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Start Tool" }));
|
|
||||||
|
|
||||||
const dialogBody = container.querySelector(".dialog-body");
|
|
||||||
expect(dialogBody).toContainElement(screen.getByTestId("tool-starter"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -64,19 +64,15 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
|
|||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
||||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="dialog-header">
|
<h3>Start Tool</h3>
|
||||||
<h3>Start Tool</h3>
|
<ToolStarter
|
||||||
</div>
|
workspace={workspace}
|
||||||
<div className="dialog-body">
|
onStarted={() => {
|
||||||
<ToolStarter
|
setShowModal(false);
|
||||||
workspace={workspace}
|
void refresh();
|
||||||
onStarted={() => {
|
}}
|
||||||
setShowModal(false);
|
onCancel={() => setShowModal(false)}
|
||||||
void refresh();
|
/>
|
||||||
}}
|
|
||||||
onCancel={() => setShowModal(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -453,16 +453,12 @@
|
|||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog,
|
.dialog {
|
||||||
.modal-content,
|
|
||||||
.commit-dialog {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
max-height: calc(100vh - var(--space-8));
|
max-height: calc(100vh - var(--space-8));
|
||||||
max-height: calc(100dvh - var(--space-8));
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -472,6 +468,16 @@
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
/* deprecated alias */
|
/* deprecated alias */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - var(--space-8));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-lg {
|
.dialog-lg {
|
||||||
@@ -494,13 +500,9 @@
|
|||||||
line-height: var(--line-height-tight);
|
line-height: var(--line-height-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-body,
|
.dialog-body {
|
||||||
.modal-body {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,11 +543,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dialog,
|
.dialog,
|
||||||
.modal-content,
|
.modal-content {
|
||||||
.commit-dialog {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: calc(100vh - var(--space-6));
|
max-height: calc(100vh - var(--space-6));
|
||||||
max-height: calc(100dvh - var(--space-6));
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2328,10 +2328,8 @@ a.nav-item,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
max-height: 70dvh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
|
||||||
animation: slide-up 0.2s ease-out;
|
animation: slide-up 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2367,12 +2365,8 @@ a.nav-item,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-bottom-sheet-content {
|
.mobile-bottom-sheet-content {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-bottom-sheet-item {
|
.mobile-bottom-sheet-item {
|
||||||
@@ -3246,10 +3240,7 @@ a:active,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
max-height: 80dvh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
animation: slideUp 0.3s ease;
|
animation: slideUp 0.3s ease;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
}
|
}
|
||||||
@@ -3611,7 +3602,6 @@ a:active,
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
max-height: 70dvh;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# Constrain and Scroll Edit Dialogs and Popups
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Ensure every edit dialog, form popup, and modal popup remains usable on a viewport that is shorter than its content. Dialog chrome must stay within the visible viewport while the content area scrolls independently.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The shared dialog system constrains only components that follow its `dialog-header` / `dialog-body` structure. Several tool-launch and commit dialogs place form content directly inside the container or use a bespoke container, so long forms can be clipped. Existing viewport sizing also relies on `vh`, which is unreliable when mobile browser chrome changes height.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Shared dialog and modal CSS in `apps/web/src/styles/global.css`.
|
|
||||||
- Mobile notification dropdown sizing in `apps/web/src/styles/utilities.css`.
|
|
||||||
- Tool-launch and commit popup markup that does not currently provide a scrollable content region.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Dialogs and modal popups are bounded by the current visible viewport, including mobile dynamic viewport changes.
|
|
||||||
- [ ] Headers and footer/action bars remain visible while long form content scrolls independently.
|
|
||||||
- [ ] Tool-launch and commit popups use the shared scrollable content pattern.
|
|
||||||
- [ ] Mobile sheets, action sheets, and notification dropdowns remain scrollable without propagating scroll gestures to the page.
|
|
||||||
- [ ] Relevant frontend tests, typecheck, lint, and production build pass.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Redesigning dialog visuals or interaction flows.
|
|
||||||
- Changing page-level scrolling outside overlays.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Constrain and Scroll Edit Dialogs and Popups — Tasks
|
|
||||||
|
|
||||||
- [x] Audit all dialog, modal, sheet, action-sheet, and popup implementations.
|
|
||||||
- [x] Strengthen shared dialog/modal viewport and body scrolling rules.
|
|
||||||
- [x] Update bespoke tool-launch and commit popups to use scrollable content regions.
|
|
||||||
- [x] Add focused coverage for the shared scrollable dialog-body markup.
|
|
||||||
- [x] Run frontend typecheck, lint, tests (88 passed), and production build.
|
|
||||||
- [x] Update project maps for changed source files.
|
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Restore Mobile Terminal Scrolling — Tasks
|
||||||
|
|
||||||
|
- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop.
|
||||||
|
- [x] Reinitialize the terminal when the responsive mobile classification changes so its scrollback and touch handler match the active viewport.
|
||||||
|
- [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).
|
||||||
Reference in New Issue
Block a user