diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index 034dc3eb0..0578dbe3f 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -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 { } } +async function expectTerminalFocused(page: Page): Promise { + await expect + .poll(async () => { + return await page.evaluate(() => { + const surface = document.querySelector( + '[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 { 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-"); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index fd56f094b..183745007 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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(() => { diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index e26bfd1cd..ddec4fa75 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -214,6 +214,7 @@ export function TerminalPane({ const selectedTerminalIdRef = useRef(selectedTerminalId); const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); + const lastAutoFocusKeyRef = useRef(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; diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index ea587763d..56f3e1ac4 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -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', + ]) + }) }) diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 1a9fbedaf..f782bf7ce 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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(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