feat(app): auto-focus terminal on create/switch, stabilize sidebar ordering for new items
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
createAgent,
|
||||
createAgentInRepo,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
setWorkingDirectory,
|
||||
} from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "./helpers/workspace-tabs";
|
||||
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
@@ -264,6 +266,23 @@ async function readCurrentTerminalBuffer(page: Page): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTerminalFocused(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
const surface = document.querySelector<HTMLElement>(
|
||||
'[data-testid="terminal-surface"]'
|
||||
);
|
||||
if (!surface) {
|
||||
return false;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLElement && surface.contains(active);
|
||||
});
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 })
|
||||
@@ -376,11 +395,7 @@ test("new terminal does not inherit output from the previously selected terminal
|
||||
directory: repo.path,
|
||||
prompt: "hello",
|
||||
});
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: repo.path,
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
@@ -403,6 +418,74 @@ test("new terminal does not inherit output from the previously selected terminal
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace terminal tabs auto-focus on create and switch on desktop web", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-focus-");
|
||||
|
||||
try {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
await createAgentInRepo(page, {
|
||||
directory: repo.path,
|
||||
prompt: "hello",
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
|
||||
const firstMarker = `terminal-focus-one-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||
|
||||
const terminalTabIdsBeforeSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
|
||||
id.startsWith("workspace-tab-terminal_")
|
||||
);
|
||||
|
||||
await page.getByTestId("workspace-new-terminal-tab").first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
|
||||
const terminalTabIdsAfterSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
|
||||
id.startsWith("workspace-tab-terminal_")
|
||||
);
|
||||
const secondTerminalTabId = terminalTabIdsAfterSecondCreate.find(
|
||||
(id) => !terminalTabIdsBeforeSecondCreate.includes(id)
|
||||
);
|
||||
const firstTerminalTabId = terminalTabIdsBeforeSecondCreate[0];
|
||||
|
||||
if (!firstTerminalTabId || !secondTerminalTabId) {
|
||||
throw new Error("Expected two distinct terminal tabs to exist.");
|
||||
}
|
||||
|
||||
const secondMarker = `terminal-focus-two-${Date.now()}`;
|
||||
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||
|
||||
await page.getByTestId(firstTerminalTabId).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
await expectCurrentTerminalBufferToContain(page, firstMarker);
|
||||
|
||||
await page.getByTestId(secondTerminalTabId).first().click();
|
||||
await waitForTerminalAttachToSettle(page);
|
||||
await expectTerminalFocused(page);
|
||||
await expectCurrentTerminalBufferToContain(page, secondMarker);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
summarizeTerminalText,
|
||||
terminalDebugLog,
|
||||
@@ -343,7 +344,19 @@ export default function TerminalEmulator({
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
}
|
||||
runtimeRef.current?.focus();
|
||||
return focusWithRetries({
|
||||
focus: () => {
|
||||
runtimeRef.current?.focus();
|
||||
},
|
||||
isFocused: () => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const active = typeof document !== "undefined" ? document.activeElement : null;
|
||||
return active instanceof HTMLElement && root.contains(active);
|
||||
},
|
||||
});
|
||||
}, [focusRequestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -214,6 +214,7 @@ export function TerminalPane({
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateSelectedTerminalId = useCallback(
|
||||
(next: string | null) => {
|
||||
@@ -279,6 +280,21 @@ export function TerminalPane({
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isScreenFocused || !selectedTerminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
|
||||
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromWorkspaces,
|
||||
} from './use-sidebar-workspaces-list'
|
||||
@@ -46,6 +47,28 @@ describe('applyStoredOrdering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMissingOrderKeys', () => {
|
||||
it('appends unseen keys while preserving existing order', () => {
|
||||
const result = appendMissingOrderKeys({
|
||||
currentOrder: ['project-b', 'project-a'],
|
||||
visibleKeys: ['project-a', 'project-b', 'project-c'],
|
||||
})
|
||||
|
||||
expect(result).toEqual(['project-b', 'project-a', 'project-c'])
|
||||
})
|
||||
|
||||
it('returns the same array when there are no unseen keys', () => {
|
||||
const currentOrder = ['project-a', 'project-b']
|
||||
|
||||
const result = appendMissingOrderKeys({
|
||||
currentOrder,
|
||||
visibleKeys: ['project-b', 'project-a'],
|
||||
})
|
||||
|
||||
expect(result).toBe(currentOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
it('uses workspace descriptor name and status directly', () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
@@ -70,4 +93,148 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
|
||||
expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut')
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed')
|
||||
})
|
||||
|
||||
it('preserves stored project order even when activity changes', () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
{
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
]
|
||||
|
||||
const seededOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: initialWorkspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
}).map((project) => project.projectKey),
|
||||
})
|
||||
|
||||
const updatedProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
projectOrder: seededOrder,
|
||||
workspaceOrderByScope: {},
|
||||
})
|
||||
|
||||
expect(updatedProjects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a'])
|
||||
})
|
||||
|
||||
it('appends new projects after the stored project order', () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
id: '/repo/c',
|
||||
projectId: 'project-c',
|
||||
name: 'feat/c',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-04T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/b',
|
||||
projectId: 'project-b',
|
||||
name: 'feat/b',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/a',
|
||||
projectId: 'project-a',
|
||||
name: 'feat/a',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
projectOrder: ['project-b', 'project-a', 'project-c'],
|
||||
workspaceOrderByScope: {},
|
||||
})
|
||||
|
||||
expect(projects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a', 'project-c'])
|
||||
})
|
||||
|
||||
it('preserves stored workspace order when workspace activity changes', () => {
|
||||
const initialProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {},
|
||||
})
|
||||
|
||||
const seededWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
|
||||
})
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: 'srv',
|
||||
workspaces: [
|
||||
{
|
||||
id: '/repo/main',
|
||||
projectId: 'project-1',
|
||||
name: 'main',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: '/repo/feature',
|
||||
projectId: 'project-1',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
projectOrder: ['project-1'],
|
||||
workspaceOrderByScope: {
|
||||
'srv::project-1': seededWorkspaceOrder,
|
||||
},
|
||||
})
|
||||
|
||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||
'/repo/main',
|
||||
'/repo/feature',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
@@ -217,6 +217,23 @@ export function applyStoredOrdering<T>(input: {
|
||||
return ordered
|
||||
}
|
||||
|
||||
export function appendMissingOrderKeys(input: {
|
||||
currentOrder: string[]
|
||||
visibleKeys: string[]
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder)
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key))
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder
|
||||
}
|
||||
|
||||
return [...input.currentOrder, ...missingKeys]
|
||||
}
|
||||
|
||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||
return `${serverId.trim()}::${projectKey.trim()}`
|
||||
}
|
||||
@@ -296,6 +313,35 @@ export function useSidebarWorkspacesList(options?: {
|
||||
})
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedProjectOrder,
|
||||
visibleKeys: projects.map((project) => project.projectKey),
|
||||
})
|
||||
if (nextProjectOrder !== persistedProjectOrder) {
|
||||
useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder)
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey)
|
||||
const persistedWorkspaceOrder =
|
||||
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER
|
||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
})
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
useSidebarOrderStore
|
||||
.getState()
|
||||
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder)
|
||||
}
|
||||
}
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId])
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user