feat(app): auto-focus terminal on create/switch, stabilize sidebar ordering for new items

This commit is contained in:
Mohamed Boudra
2026-03-06 23:19:12 +07:00
parent 1985bd6669
commit a99edcc0b2
5 changed files with 332 additions and 7 deletions
@@ -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