Fix mobile terminal tab routing and remount restoration
This commit is contained in:
@@ -190,6 +190,80 @@ async function runTerminalCommandWithPreEnterEcho(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readCurrentTerminalBuffer(page: Page): Promise<string> {
|
||||||
|
const bufferText = await page.evaluate(() => {
|
||||||
|
try {
|
||||||
|
const terminal = (window as {
|
||||||
|
__paseoTerminal?: {
|
||||||
|
buffer?: {
|
||||||
|
active?: {
|
||||||
|
length?: number;
|
||||||
|
getLine?: (
|
||||||
|
line: number
|
||||||
|
) =>
|
||||||
|
| {
|
||||||
|
translateToString: (trimRight?: boolean) => string;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}).__paseoTerminal;
|
||||||
|
|
||||||
|
const lineCount = terminal?.buffer?.active?.length ?? 0;
|
||||||
|
const getLine = terminal?.buffer?.active?.getLine;
|
||||||
|
if (!getLine || lineCount <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let index = 0; index < lineCount; index += 1) {
|
||||||
|
let line: { translateToString: (trimRight?: boolean) => string } | null = null;
|
||||||
|
try {
|
||||||
|
line = getLine(index);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(line.translateToString(true));
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bufferText.length > 0) {
|
||||||
|
return bufferText;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await visibleTestId(page, "terminal-surface").innerText();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 })
|
||||||
|
.toContain(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCurrentTerminalBufferNotToContain(page: Page, marker: string): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 5000 })
|
||||||
|
.not.toContain(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTerminalAttachToSettle(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
|
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -303,6 +377,70 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mobile terminal tab switch keeps command input routed to the selected tab", async ({ page }) => {
|
||||||
|
const repo = await createTempGitRepo("paseo-e2e-terminal-mobile-routing-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openNewAgentDraft(page);
|
||||||
|
await setWorkingDirectory(page, repo.path);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
await createAgent(page, "Reply with exactly: terminal routing");
|
||||||
|
const createdAgentUrl = page.url();
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(createdAgentUrl);
|
||||||
|
|
||||||
|
await ensureExplorerTabsVisible(page);
|
||||||
|
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||||
|
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||||
|
await terminalsTab.click({ force: true });
|
||||||
|
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await waitForTerminalAttachToSettle(page);
|
||||||
|
|
||||||
|
await visibleTestId(page, "terminals-create-button").click();
|
||||||
|
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
|
||||||
|
await expect
|
||||||
|
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||||
|
.toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const firstTab = tabs.first();
|
||||||
|
const secondTab = tabs.nth(1);
|
||||||
|
const firstTabId = await firstTab.getAttribute("data-testid");
|
||||||
|
const secondTabId = await secondTab.getAttribute("data-testid");
|
||||||
|
if (!firstTabId || !secondTabId) {
|
||||||
|
throw new Error("Expected terminal tab IDs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMarker = `mobile-route-one-${Date.now()}`;
|
||||||
|
await firstTab.click();
|
||||||
|
await visibleTestId(page, "terminal-surface").click({ force: true });
|
||||||
|
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
const secondMarker = `mobile-route-two-${Date.now()}`;
|
||||||
|
await secondTab.click();
|
||||||
|
await visibleTestId(page, "terminal-surface").click({ force: true });
|
||||||
|
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await page.locator(`[data-testid="${firstTabId}"]:visible`).first().click();
|
||||||
|
await waitForTerminalAttachToSettle(page);
|
||||||
|
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||||
|
await expectCurrentTerminalBufferNotToContain(page, secondMarker);
|
||||||
|
|
||||||
|
await page.locator(`[data-testid="${secondTabId}"]:visible`).first().click();
|
||||||
|
await waitForTerminalAttachToSettle(page);
|
||||||
|
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||||
|
await expectCurrentTerminalBufferNotToContain(page, firstMarker);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
|
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
|
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function TerminalEmulator({
|
|||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
|
const appliedInitialOutputRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
@@ -86,15 +87,34 @@ export default function TerminalEmulator({
|
|||||||
cursorColor,
|
cursorColor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
appliedInitialOutputRef.current = initialOutputText;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runtime.unmount();
|
runtime.unmount();
|
||||||
if (runtimeRef.current === runtime) {
|
if (runtimeRef.current === runtime) {
|
||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
}
|
}
|
||||||
|
appliedInitialOutputRef.current = null;
|
||||||
};
|
};
|
||||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
if (!runtime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedInitialOutputRef.current === initialOutputText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedInitialOutputRef.current = initialOutputText;
|
||||||
|
runtime.clear();
|
||||||
|
if (initialOutputText.length > 0) {
|
||||||
|
runtime.write({ text: initialOutputText });
|
||||||
|
}
|
||||||
|
}, [initialOutputText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runtimeRef.current?.setCallbacks({
|
runtimeRef.current?.setCallbacks({
|
||||||
callbacks: {
|
callbacks: {
|
||||||
|
|||||||
@@ -169,6 +169,24 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
|
|
||||||
|
const updateSelectedTerminalId = useCallback(
|
||||||
|
(
|
||||||
|
next:
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| ((current: string | null) => string | null)
|
||||||
|
) => {
|
||||||
|
const current = selectedTerminalIdRef.current;
|
||||||
|
const resolved =
|
||||||
|
typeof next === "function"
|
||||||
|
? (next as (value: string | null) => string | null)(current)
|
||||||
|
: next;
|
||||||
|
selectedTerminalIdRef.current = resolved;
|
||||||
|
setSelectedTerminalId(resolved);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTerminalIdRef.current = selectedTerminalId;
|
selectedTerminalIdRef.current = selectedTerminalId;
|
||||||
}, [selectedTerminalId]);
|
}, [selectedTerminalId]);
|
||||||
@@ -377,7 +395,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
||||||
setSelectedTerminalId(createdTerminal.id);
|
updateSelectedTerminalId(createdTerminal.id);
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -401,7 +419,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
outputPumpRef.current?.clearTerminal({ terminalId });
|
outputPumpRef.current?.clearTerminal({ terminalId });
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
setSelectedTerminalId((current) =>
|
updateSelectedTerminalId((current) =>
|
||||||
current === terminalId ? null : current
|
current === terminalId ? null : current
|
||||||
);
|
);
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
@@ -417,9 +435,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
}, [scopeKey]);
|
}, [scopeKey, updateSelectedTerminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedTerminalId) {
|
if (selectedTerminalId) {
|
||||||
@@ -429,7 +447,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (terminals.length === 0) {
|
if (terminals.length === 0) {
|
||||||
setSelectedTerminalId(null);
|
updateSelectedTerminalId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,16 +460,16 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
|
|
||||||
const stored = selectedTerminalByScopeRef.current.get(scopeKey);
|
const stored = selectedTerminalByScopeRef.current.get(scopeKey);
|
||||||
if (has(stored)) {
|
if (has(stored)) {
|
||||||
setSelectedTerminalId(stored!);
|
updateSelectedTerminalId(stored!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = terminals[0]?.id ?? null;
|
const fallback = terminals[0]?.id ?? null;
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
|
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
|
||||||
setSelectedTerminalId(fallback);
|
updateSelectedTerminalId(fallback);
|
||||||
}
|
}
|
||||||
}, [scopeKey, terminals, selectedTerminalId]);
|
}, [scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||||
@@ -917,7 +935,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={terminal.id}
|
key={terminal.id}
|
||||||
testID={`terminal-tab-${terminal.id}`}
|
testID={`terminal-tab-${terminal.id}`}
|
||||||
onPress={() => setSelectedTerminalId(terminal.id)}
|
onPress={() => updateSelectedTerminalId(terminal.id)}
|
||||||
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
|
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
|
||||||
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
|
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
|
||||||
style={({ pressed, hovered }) => [
|
style={({ pressed, hovered }) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user