Skip to content
Merged
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
3 changes: 3 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ Drafts stay in this browser and re-anchor to changed files on refresh.
(keys go straight to the terminal). Its tap raises the keyboard in either
mode; afterwards, sends, scrolling, and shortcut actions leave a dismissed
keyboard closed.
- The phone's Back gesture or button closes the tab sheet, then the input dock
or a mobile view such as Workspaces, Files, Changes or Ranger, one per
press, before leaving the page.
- The `>_` button in Composer offers slash commands for Claude Code, Codex, Pi,
Kimi Code, Grok Build, and Antigravity CLI.
- Install as a PWA for an app-style window; see
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-web-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath, URL } from "node:url";
const publicRoot = fileURLToPath(new URL("../server/public/", import.meta.url));
const maxFileCount = 160;
const maxTotalBytes = 14 * 1024 * 1024;
const maxInitialJsBytes = 803 * 1024;
const maxInitialJsBytes = 804 * 1024;
const maxInitialJsGzipBytes = 256 * 1024;
const maxInitialCssBytes = 196 * 1024;

Expand Down
16 changes: 16 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "./mobileControlsPlacement";
import { useMobileControlsDrag } from "./components/useMobileControlsDrag";
import { LAYOUT_CHANGE_EVENT, useLayoutPreferences } from "./layoutPreferences";
import { useSystemBack } from "./systemBack";
import {
shortcutMatches,
shortcutTitle,
Expand Down Expand Up @@ -280,6 +281,7 @@ function PopupOverlay({

type TerminalViewProps = {
paneId?: string;
visible?: boolean;
terminalTheme: ITheme;
terminalFontFamily: string;
terminalFontScale: number;
Expand Down Expand Up @@ -1003,6 +1005,7 @@ function resizeTargetForSplit(
// Render the active tab's Herdr pane layout; single-pane and zoomed tabs keep
// the old full terminal view.
function TerminalPaneLayout({
visible,
terminalTheme,
terminalFontFamily,
terminalFontScale,
Expand All @@ -1016,6 +1019,7 @@ function TerminalPaneLayout({
zenMode,
onExitZenMode,
}: {
visible: boolean;
terminalTheme: ITheme;
terminalFontFamily: string;
terminalFontScale: number;
Expand Down Expand Up @@ -1074,6 +1078,7 @@ function TerminalPaneLayout({
terminalFontScale={terminalFontScale}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
visible={visible}
composerOpen={composerOpen}
onComposerOpenChange={onComposerOpenChange}
agentHistoryOpen={agentHistoryOpen}
Expand Down Expand Up @@ -1133,6 +1138,7 @@ function TerminalPaneLayout({
terminalFontScale={terminalFontScale}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
visible={visible}
composerOpen={composerOpen}
onComposerOpenChange={onComposerOpenChange}
agentHistoryOpen={agentHistoryOpen}
Expand Down Expand Up @@ -1243,6 +1249,7 @@ function TerminalPaneLayout({
showMobileKeys={isActive}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
visible={visible}
composerOpen={isActive ? composerOpen : false}
onComposerOpenChange={isActive ? onComposerOpenChange : undefined}
agentHistoryOpen={isActive ? agentHistoryOpen : false}
Expand Down Expand Up @@ -1675,6 +1682,14 @@ export default function App() {
});
}
}, [mobile, updateInspectorState]);
// The phone's Back closes the tab sheet, then returns from Workspaces,
// Files, Changes, Assistant or Annotations to the session.
useSystemBack(
mobileTabSheetOpen,
() => setMobileTabSheetOpen(false),
"popup",
);
useSystemBack(mobileView !== "session", activateTerminalSurface, "panel");
const toggleSidebar = useCallback(() => {
setMobileView("session");
setSidebarHidden((value) => !value);
Expand Down Expand Up @@ -4264,6 +4279,7 @@ export default function App() {
>
<div className="workspace-terminal-surface">
<TerminalPaneLayout
visible={!mobile || mobileView === "session"}
terminalTheme={terminalTheme}
terminalFontFamily={terminalFontFamily}
terminalFontScale={terminalFontScale}
Expand Down
201 changes: 199 additions & 2 deletions web/src/components/TerminalView.inputMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,54 @@ async function registerDomTests() {
const { act, createElement, StrictMode } = await import("react");
const { createRoot } = await import("react-dom/client");
const { flushSync } = await import("react-dom");
// Event-driven session history: happy-dom does not model traversal reliably.
const entries: { state: object | null; url: string }[] = [
{ state: null, url: "/" },
];
let historyIndex = 0;
const traversals: (() => void)[] = [];
const replaceLocation = browser.history.replaceState.bind(browser.history);
Object.defineProperty(browser, "history", {
value: {
get state() {
return entries[historyIndex]!.state;
},
pushState(state: object | null, _unused: string, url?: string) {
entries.splice(historyIndex + 1);
entries.push({ state, url: url ?? entries[historyIndex]!.url });
historyIndex++;
replaceLocation(state, "", entries[historyIndex]!.url);
},
replaceState(state: object | null, _unused: string, url?: string) {
entries[historyIndex] = {
state,
url: url ?? entries[historyIndex]!.url,
};
replaceLocation(state, "", entries[historyIndex]!.url);
},
back() {
traversals.push(() => {
if (historyIndex === 0) return;
const entry = entries[--historyIndex]!;
replaceLocation(entry.state, "", entry.url);
browser.dispatchEvent(
new browser.PopStateEvent("popstate", { state: entry.state }),
);
});
},
},
});
async function settleHistory() {
for (let turn = 0; turn < 8; turn++) {
await act(async () => {
await Promise.resolve();
for (const complete of traversals.splice(0)) complete();
});
if (!traversals.length) return;
}
throw new Error("history traversal loop");
}
let mobileLayout = true;
const noop = () => {};
const disposable = () => ({ dispose: noop });
type Pane = {
Expand Down Expand Up @@ -160,7 +208,8 @@ async function registerDomTests() {
},
}));
mock.module("../layoutPreferences", () => ({
isMobileLayout: () => true,
isMobileLayout: () => mobileLayout,
useLayoutPreferences: () => ({ mobile: mobileLayout }),
LAYOUT_CHANGE_EVENT: "test-layout-change",
}));
mock.module("./CreateWorkspaceDialog", () => ({
Expand Down Expand Up @@ -258,6 +307,7 @@ async function registerDomTests() {
}
mock.module("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
const { TerminalView } = await import("./TerminalView");
const { useSystemBack } = await import("../systemBack");
const { TERMINAL_INPUT_MODE_STORAGE_KEY } = await import(
"../terminalInputMode"
);
Expand All @@ -277,6 +327,10 @@ async function registerDomTests() {
let root: Root | null = null;
let container: HTMLDivElement;
let open = false;
let visible = true;
let filesOpen = false;
let tabsOpen = false;
let viewKey = "initial";
let strict = false;
let explicitPane: string | undefined;
let terminalFontScale = 100;
Expand All @@ -292,14 +346,20 @@ async function registerDomTests() {
localStorage.clear();
sessionStorage.clear();
open = false;
visible = true;
mobileLayout = true;
filesOpen = false;
tabsOpen = false;
strict = false;
viewKey = "initial";
explicitPane = undefined;
terminalFontScale = 100;
mobileShortcuts = [[], []];
});
afterEach(async () => {
document.documentElement.classList.remove("keyboard-open");
if (root) await act(async () => root?.unmount());
await settleHistory();
root = null;
clearTerminalComposerDraft(draftKey);
document.documentElement.classList.remove("keyboard-open");
Expand All @@ -314,22 +374,69 @@ async function registerDomTests() {
else Reflect.deleteProperty(globalThis, key);
}
});
// Model App's mobile panels around the real TerminalView, which stays
// mounted when CSS hides the terminal surface behind Files.
function MobilePanels({
children,
}: {
children?: import("react").ReactNode;
}) {
useSystemBack(
tabsOpen,
() => {
tabsOpen = false;
render();
},
"popup",
);
useSystemBack(
filesOpen,
() => {
filesOpen = false;
visible = true;
render();
},
"panel",
);
return children;
}
function render() {
const view = createElement(TerminalView, {
key: viewKey,
paneId: explicitPane,
terminalTheme: {},
terminalFontFamily: "monospace",
terminalFontScale,
showMobileKeys: false,
mobileShortcuts,
mobileSideShortcuts: [],
visible,
composerOpen: open,
onComposerOpenChange(next: boolean) {
open = next;
render();
},
});
root!.render(strict ? createElement(StrictMode, null, view) : view);
// App replaces its mobile single-pane branch with a desktop split tree.
const layout = mobileLayout
? view
: createElement(
"div",
{ key: "split" },
view,
createElement(TerminalView, {
paneId: "pane-b",
terminalTheme: {},
terminalFontFamily: "monospace",
terminalFontScale,
showMobileKeys: false,
mobileShortcuts,
mobileSideShortcuts: [],
composerOpen: false,
}),
);
const panels = createElement(MobilePanels, null, layout);
root!.render(strict ? createElement(StrictMode, null, panels) : panels);
}
async function mount(initiallyOpen = false, strictMode = false) {
open = initiallyOpen;
Expand Down Expand Up @@ -406,6 +513,96 @@ async function registerDomTests() {
});
}

async function back() {
browser.history.back();
await settleHistory();
}

test.each([false, true])(
"only a visible mounted Composer consumes Back (visible %s)",
async (surfaceVisible) => {
browser.history.pushState(null, "", "/previous");
browser.history.pushState(null, "", "/terminal");
visible = surfaceVisible;
await mount(true);
const term = currentTerminal();
expect(container.querySelector(".terminal-composer")).not.toBeNull();
await back();
expect(open).toBe(!surfaceVisible);
expect(browser.location.pathname).toBe(
surfaceVisible ? "/terminal" : "/previous",
);
expect(currentTerminal()).toBe(term);
},
);

test("multi-pane mobile to desktop replacement removes the Composer guard", async () => {
browser.history.pushState(null, "", "/previous");
browser.history.pushState(null, "", "/terminal");
await mount(true, true);
const mobileTerminal = currentTerminal();
await act(async () => {
mobileLayout = false;
render();
});
await settleHistory();
expect(currentTerminal()).not.toBe(mobileTerminal);
expect(browser.history.state).not.toHaveProperty("roamgateBack");
expect(open).toBe(true);
await back();
expect(browser.location.pathname).toBe("/previous");
expect(open).toBe(true);
});

test.each([false, true])(
"Type, Files, Tabs, Back, Back restores Composer (hidden pane remount %s)",
async (remount) => {
browser.history.pushState(null, "", "/terminal");
writeTerminalComposerDraft(draftKey, "unsent draft");
await mount();
await typeGesture();
let term = currentTerminal();
await act(async () => {
visible = false;
filesOpen = true;
render();
});
if (remount) {
// A tab/pane replacement while Files covers the terminal must not
// re-register its still-open, but invisible Composer above Files.
await act(async () => {
viewKey = "replacement";
render();
});
expect(currentTerminal()).not.toBe(term);
term = currentTerminal();
}
await act(async () => {
tabsOpen = true;
render();
});
await back();
expect(tabsOpen).toBe(false);
expect(filesOpen).toBe(true);
expect(open).toBe(true);
await back();
expect(filesOpen).toBe(false);
expect(visible).toBe(true);
expect(open).toBe(true);
expect(currentTerminal()).toBe(term);
expect(
container.querySelector<HTMLTextAreaElement>(
'[aria-label="Terminal input draft"]',
)!.value,
).toBe("unsent draft");
expect(readTerminalComposerDraft(draftKey)).toBe("unsent draft");
expect(selectedMode()).toBe("composer");
expectInputBlocked();
await back();
expect(open).toBe(false);
},
);

test.each(["closed", "direct", "composer"] as const)(
"terminal focus activates %s input after touch on a coarse-pointer desktop",
async (mode) => {
Expand Down
Loading
Loading