Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
## [Unreleased]

### Added
- **Settings → Appearance → Show terminal scrollbar** appears on phone touch layouts (up to
768px). It is on by default; off, the terminal regains the history bar's column while swipe,
paging and search keep working. The choice stays on this browser; desktop and wider tablet
layouts keep their scrollbar.
- **Settings → Phone & devices → Portal** gives this PC a public HTTPS address through
[Portal](https://github.com/gosuda/portal-tunnel), with no account and no domain of your own.
With Portal v2.6.1 or later installed, **Start** runs it on a relay you enter, dropping any
Expand Down Expand Up @@ -77,6 +81,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
([#860](https://github.com/devswha/herdr-web-ui/pull/860))

### Fixed
- Terminal search no longer duplicates its form on a phone when the terminal input line is
also shown; the two controls now have distinct React keys.
- First-device pairing waits for the browser to return its device cookie before restricting
access. A proxy that drops the cookie no longer locks out an otherwise open installation;
Settings reports the failed pairing instead. Already restricted installations stay restricted.
Expand Down
7 changes: 6 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,12 @@ One set for both themes: the card is island black wherever it shows.
an edge swipe) takes one step out and never leaves the app from inside Settings; the Back
control, the X, Escape and the scrim take the same entries off. Beside the list, turning pages
replaces the one entry. A reload steps out of the entries it finds; Forward reopens the page.
- Pages, in order: **Appearance** (theme, colors, density, language, sidebar rows), **Chat**
- Appearance's **Show terminal scrollbar** is on by default and shown only at up to 768px with
a coarse pointer (the phone touch layout, not a narrow desktop window). Off, the terminal's
shared-history bar is unmounted and its reserved column returns to the grid; swipe, paging
and search highlights remain active. The choice stays on this browser and does not affect
desktop or wider tablet layouts. Media-query changes update both the control and the bar.
- Pages, in order: **Appearance** (theme, colors, density, phone terminal scrollbar, language, sidebar rows), **Chat**
(panes open in, show thinking, chat width, chat font size and family, highlight code; then
**Composer**: optional plain PageUp/PageDown chat scrolling (off by default), Enter sends, files at once, suggestion chip; then **Quick replies**; then **File viewer**: wrap long
lines, Markdown width), **Terminal** (font size, 80 columns in a split, font family, wheel
Expand Down
119 changes: 118 additions & 1 deletion scripts/terminal-viewport-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,124 @@ try {
assert.deepEqual(errors, []);
if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "terminal-viewport.png") });
await context.close();
console.log("PASS native history scrollbar, search highlights, wide cells, non-jumping refresh and old bridge compatibility");

// A phone's Appearance switch hides only the bar, never the history/search machinery.
await herdrRpc("pane.scroll", { pane_id: paneId, offset_from_bottom: 0 });
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, locale: "en-US" });
await phone.addInitScript(() => {
if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "terminal", alertsOn: false }));
});
try {
const mobile = await phone.newPage();
mobile.setDefaultTimeout(10000);
mobile.on("pageerror", (error) => errors.push(error.message));
await mobile.goto(`http://127.0.0.1:${server.port}/?pane=${encodeURIComponent(paneId)}`);
const bar = mobile.getByRole("scrollbar", { name: "Terminal scrollback", exact: true });
const toggle = mobile.getByRole("switch", { name: "Show terminal scrollbar", exact: true });
const grid = mobile.locator(".pane-terminal");
const gridWidth = async () => (await grid.boundingBox())!.width;
await bar.waitFor();
const withBar = await gridWidth();
const screenWithBar = (await mobile.locator(".xterm-screen").boundingBox())!.width;
await mobile.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(mobile, "Appearance");
assert.equal(await toggle.getAttribute("aria-checked"), "true", "the phone bar defaults to on");
await toggle.click();
await bar.waitFor({ state: "detached" });
await mobile.keyboard.press("Escape");
await until(async () => Math.abs(await gridWidth() - withBar - 24) < 1, "hiding the phone bar returns its full 24px column");
assert.equal(await grid.getAttribute("class"), "pane-terminal", "no gutter remains when the bar is hidden");
await until(async () => (await paneScrollInfo(paneId))!.viewport_rows === await mobile.locator(".xterm-rows > div").count(), "the phone grid has refit");
await until(async () => (await mobile.locator(".xterm-screen").boundingBox())!.width > screenWithBar, "the rendered grid refits to its recovered width");
await mobile.reload();
await grid.waitFor();
await mobile.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(mobile, "Appearance");
assert.equal(await toggle.getAttribute("aria-checked"), "false", "the phone choice survives reload");
await mobile.keyboard.press("Escape");
assert.equal(await bar.count(), 0);

await until(async () => (await paneScrollInfo(paneId))!.viewport_rows === await mobile.locator(".xterm-rows > div").count(), "reload's native geometry is ready");
await mobile.keyboard.press("ControlOrMeta+Shift+PageDown");
await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom === 0, "hidden-bar paging returns to the native bottom");
const touch = await phone.newCDPSession(mobile);
const screenBox = (await mobile.locator(".xterm-screen").boundingBox())!;
const x = screenBox.x + screenBox.width / 2, y = screenBox.y + screenBox.height / 3;
await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
for (const delta of [30, 60, 90, 120]) await touch.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y: y + delta }] });
await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await touch.detach();
await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom > 0, "a real touch swipe still scrolls with no bar");
const beforePage = (await paneScrollInfo(paneId))!.offset_from_bottom;
await mobile.keyboard.press("ControlOrMeta+Shift+PageUp");
await until(async () => (await paneScrollInfo(paneId))!.offset_from_bottom > beforePage, "hidden-bar paging still scrolls native history");
const phoneRowsBeforeFind = (await paneScrollInfo(paneId))!.viewport_rows;
await mobile.keyboard.press("ControlOrMeta+Shift+f");
const mobileFind = mobile.getByRole("search", { name: "Find in terminal", exact: true });
await until(async () => {
const native = await paneScrollInfo(paneId);
return !!native && native.viewport_rows < phoneRowsBeforeFind
&& native.viewport_rows === await mobile.locator(".xterm-rows > div").count();
}, "Find's phone geometry is ready");
await mobileFind.getByRole("searchbox").fill("mark_needle");
const phoneFindResponse = mobile.waitForResponse((response) => response.url().endsWith("/api/pane/find") && response.request().postDataJSON()?.jump !== false);
await mobileFind.getByRole("searchbox").press("Enter");
const phoneFindResult = await (await phoneFindResponse).json();
try { await mobile.locator(".terminal-find-hit.is-current").first().waitFor(); }
catch (error) {
console.error("Phone search diagnostic", JSON.stringify({
result: phoneFindResult, native: await paneScrollInfo(paneId),
dom: await mobile.evaluate(() => ({
find: document.querySelector(".find-bar")?.textContent,
grid: document.querySelector(".xterm-rows")?.textContent,
hits: document.querySelectorAll(".terminal-find-hit").length,
forms: document.querySelectorAll(".find-bar").length,
height: document.querySelector(".pane-terminal")?.getBoundingClientRect().height,
})),
}));
if (process.env.UI_EVIDENCE_DIR) await mobile.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "phone-scrollbar-search-failure.png") });
throw error;
}
assert.equal(await mobileFind.count(), 1, "mobile search never duplicates the form when the input line also renders");
assert.equal(await bar.count(), 0, "Find remains decorated without reintroducing the bar");
await mobile.getByRole("button", { name: "Close search", exact: true }).click();

await mobile.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(mobile, "Appearance");
await mobile.setViewportSize({ width: 1024, height: 844 });
await toggle.waitFor({ state: "detached" });
await bar.waitFor();
assert.ok((await grid.getAttribute("class"))?.includes("has-history-scrollbar"), "wider touch layouts keep their bar regardless of the phone choice");
await mobile.setViewportSize({ width: 390, height: 844 });
await toggle.waitFor();
assert.equal(await toggle.getAttribute("aria-checked"), "false");
await bar.waitFor({ state: "detached" });
await toggle.click();
await bar.waitFor();
await mobile.keyboard.press("Escape");
assert.ok(Math.abs(await gridWidth() - withBar) < 1, "turning the bar back on restores its column");
} finally { await phone.close(); }

// The stored phone choice is ignored on desktop, even with a phone-width window.
const desktop = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" });
await desktop.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", defaultView: "terminal", alertsOn: false, showTerminalScrollbar: false })));
try {
const wide = await desktop.newPage();
wide.setDefaultTimeout(10000);
wide.on("pageerror", (error) => errors.push(error.message));
await wide.goto(`http://127.0.0.1:${server.port}/?pane=${encodeURIComponent(paneId)}`);
await wide.getByRole("scrollbar", { name: "Terminal scrollback", exact: true }).waitFor();
await wide.keyboard.press("ControlOrMeta+Shift+Comma");
await openSettingsPage(wide, "Appearance");
assert.equal(await wide.getByRole("switch", { name: "Show terminal scrollbar", exact: true }).count(), 0);
await wide.setViewportSize({ width: 390, height: 844 });
await until(async () => await wide.evaluate(() => matchMedia("(max-width: 768px)").matches), "desktop window becomes narrow");
assert.equal(await wide.getByRole("switch", { name: "Show terminal scrollbar", exact: true }).count(), 0, "a narrow non-touch desktop never shows the phone control");
await wide.getByRole("scrollbar", { name: "Terminal scrollback", exact: true }).waitFor();
assert.ok((await wide.locator(".pane-terminal").getAttribute("class"))?.includes("has-history-scrollbar"));
} finally { await desktop.close(); }
assert.deepEqual(errors, []);
console.log("PASS native history scrollbar, search highlights, wide cells, non-jumping refresh, old bridge compatibility and phone-only scrollbar setting");
} finally {
await browser.close(); server.stop();
if (workspace) await workspaceClose(workspace).catch(() => {});
Expand Down
3 changes: 2 additions & 1 deletion src/components/PaneTerminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -330,5 +330,6 @@
content-visibility: hidden;
}

.pane-terminal.has-viewport-tools { width: calc(100% - var(--terminal-history-gutter)); }
/* Reserve the history bar's column only while it is shown; search and paging stay active. */
.pane-terminal.has-history-scrollbar { width: calc(100% - var(--terminal-history-gutter)); }
@media (pointer: coarse) { .terminal-surface { --terminal-history-gutter: var(--space-6); } }
9 changes: 6 additions & 3 deletions src/components/PaneTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { fileUriPath, hyperlinkFilter, isWebLink, terminalFileLinkProvider } fro
import { adjustTerminalGlyphs } from "../lib/terminalGlyphs.ts";
import { terminalLabel } from "../lib/terminalLabel.ts";
import { useMediaQuery } from "../lib/useMediaQuery.ts";
import { PHONE_TERMINAL_QUERY } from "../lib/terminalViewport.ts";
import { modalOpen } from "../lib/focus.ts";
import { pinchFontSize } from "../lib/pinchFontSize.ts";
import { narrowPaneFontSize } from "../lib/narrowPaneFont.ts";
Expand Down Expand Up @@ -297,6 +298,8 @@ export function PaneTerminal({
// to raise, and the pane it picks takes the typing at once.
const coarseRef = useRef(coarse); coarseRef.current = coarse;
const { settings, update: updateSettings } = useSettings();
const phone = useMediaQuery(PHONE_TERMINAL_QUERY);
const showScrollbar = !phone || settings.showTerminalScrollbar;
const availableModifiers = settings.keyBarItems.reduce((mask, item) => item.type === "modifier"
? mask | { ctrl: 4, alt: 2, shift: 1 }[item.modifier] : mask, 0);
useLayoutEffect(() => {
Expand Down Expand Up @@ -2347,18 +2350,18 @@ export function PaneTerminal({
)}
</div>
{presented && findOpen && !chatView && paneId !== null && (
<FindBar key={paneId} paneId={paneId} viewportIntent={viewportIntent} onResult={setSearchState} activePane={active} focusRequest={findRequest} disabled={!connected || held || ended || released || !outputReady}
<FindBar key={`find-${paneId}`} paneId={paneId} viewportIntent={viewportIntent} onResult={setSearchState} activePane={active} focusRequest={findRequest} disabled={!connected || held || ended || released || !outputReady}
onClose={() => { setFindOpen(false); if (activeRef.current && !coarse) termRef.current?.focus(); }} />
)}
<div className="terminal-surface">
{/* xterm hides its rendered rows from assistive technology, so the region around the
visible grid carries the pane's name: a screen reader announces which pane
this is. No tabIndex: xterm's helper textarea takes the keyboard here on attach, so a
stop on the wrapper would only be an empty one ahead of it. */}
<div className={`pane-terminal${paneId === null ? " is-idle" : ""}${!chatView && paneId !== null ? " has-viewport-tools" : ""}`} ref={hostRef} id={viewportId} data-mouse-reporting={mouseReporting && !observing && !held && !secretActive && connected && !chatView ? "" : undefined} role={chatView ? undefined : "region"} aria-roledescription={chatView ? undefined : t("Terminal")} aria-label={chatView ? undefined : terminalName} />
<div className={`pane-terminal${paneId === null ? " is-idle" : ""}${!chatView && paneId !== null && showScrollbar ? " has-history-scrollbar" : ""}`} ref={hostRef} id={viewportId} data-mouse-reporting={mouseReporting && !observing && !held && !secretActive && connected && !chatView ? "" : undefined} role={chatView ? undefined : "region"} aria-roledescription={chatView ? undefined : t("Terminal")} aria-label={chatView ? undefined : terminalName} />
<span className="terminal-font-size" ref={pinchBadgeRef} hidden aria-hidden="true" />
{paneId !== null && <TerminalViewportTools key={paneId} viewportIntent={viewportIntent} terminal={terminal} paneId={paneId} viewportId={viewportId}
pageScroll={chatView ? undefined : pageScroll}
showScrollbar={showScrollbar} pageScroll={chatView ? undefined : pageScroll}
onApplicationPage={sendApplicationPage}
enabled={presented && connected && outputReady && !chatView && !held && !ended && !released && !unsupported}
interactive={!observing && !secretActive && !viewportSearching} search={searchState} onError={setInputError} />}
Expand Down
6 changes: 6 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { previewAlertSound, unlockAlertSound } from "../lib/alertSound.ts";
import { HerdrUpdateControls, UpdateControls } from "./UpdateControls.tsx";
import { TelemetryControls } from "./TelemetryControls.tsx";
import { useSheetSwipe } from "../lib/sheets.ts";
import { useMediaQuery } from "../lib/useMediaQuery.ts";
import { PHONE_TERMINAL_QUERY } from "../lib/terminalViewport.ts";

export interface SettingsDialogProps {
open: boolean;
Expand Down Expand Up @@ -146,6 +148,7 @@ function shownBy(entry: SettingsLevel | null): { page: SettingsPage | null; keyB
function AppearancePage() {
const { settings, update } = useSettings();
const t = useT();
const phone = useMediaQuery(PHONE_TERMINAL_QUERY);
return (
<SettingsGroup>
<SettingsRow label={t("Theme")} wide>
Expand All @@ -164,6 +167,9 @@ function AppearancePage() {
<SettingsRow label={t("Density")}>
<Segmented label={t("Density")} value={settings.density} onChange={(density) => update({ density })} options={[{ value: "comfortable", label: t("Comfortable") }, { value: "compact", label: t("Compact") }]} />
</SettingsRow>
{phone && <SettingsRow label={t("Show terminal scrollbar")} description={t("On this phone, show the terminal history bar. Off, the terminal uses its space; swipe still scrolls.")}>
<Toggle label={t("Show terminal scrollbar")} checked={settings.showTerminalScrollbar} onChange={(showTerminalScrollbar) => update({ showTerminalScrollbar })} />
</SettingsRow>}
<SettingsRow label={t("Language")} description={t("Follows the browser unless you choose one")} htmlFor="settings-language">
<select id="settings-language" className="select settings-select" value={settings.language} onChange={(event) => update({ language: event.target.value as typeof settings.language })}>
{LANGUAGE_SETTINGS.map((language) => <option key={language} value={language}>{language === "system" ? t("System") : LANGUAGE_NAMES[language]}</option>)}
Expand Down
6 changes: 4 additions & 2 deletions src/components/TerminalViewportTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface Props {
onApplicationPage?: (direction: -1 | 1) => void;
viewportId: string;
enabled: boolean;
/** Only the visible bar; history metrics, paging and search remain enabled when hidden. */
showScrollbar: boolean;
interactive: boolean;
search: TerminalSearchState | null;
onError(message: string): void;
Expand All @@ -29,7 +31,7 @@ interface Props {
/** how long a page asked for on stale metrics waits for the read that tells history from a program's screen */
const HELD_PAGE_MS = 1000;

export function TerminalViewportTools({ viewportIntent, terminal, paneId, pageScroll, onApplicationPage, viewportId, enabled, interactive, search, onError }: Props) {
export function TerminalViewportTools({ viewportIntent, terminal, paneId, pageScroll, onApplicationPage, viewportId, enabled, showScrollbar, interactive, search, onError }: Props) {
const t = useT();
const api = useMachineApi();
const { settings } = useSettings();
Expand Down Expand Up @@ -227,7 +229,7 @@ export function TerminalViewportTools({ viewportIntent, terminal, paneId, pageSc
return <div ref={layer} className="terminal-viewport-tools" aria-hidden={!enabled || undefined}>
{enabled && search && box && hits.map((hit, index) => <span key={index} className={`terminal-find-hit${hit.current ? " is-current" : ""}`} aria-hidden="true"
style={{ left: box.left + hit.col * box.width / box.cols, top: box.top + hit.row * box.height / box.rows, width: hit.width * box.width / box.cols, height: box.height / box.rows }} />)}
{enabled && box && box.trackHeight > 0 && metrics && metrics.max_offset_from_bottom > 0 && thumb && <div ref={track} className="terminal-history-scrollbar"
{enabled && showScrollbar && box && box.trackHeight > 0 && metrics && metrics.max_offset_from_bottom > 0 && thumb && <div ref={track} className="terminal-history-scrollbar"
role="scrollbar" aria-label={t("Terminal scrollback")} aria-controls={viewportId} aria-orientation="vertical"
aria-valuemin={0} aria-valuemax={metrics.max_offset_from_bottom} aria-valuenow={metrics.max_offset_from_bottom - metrics.offset_from_bottom}
aria-disabled={!interactive || undefined} tabIndex={interactive ? 0 : -1} style={{ top: box.trackTop, height: box.trackHeight }}
Expand Down
Loading