refactor: migrate workspace tab actions to keyboard action dispatcher

This commit is contained in:
Mohamed Boudra
2026-03-17 20:19:41 +07:00
parent 4897627943
commit abc8ad3fd4
18 changed files with 322 additions and 649 deletions
+8 -1
View File
@@ -35,6 +35,7 @@ import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { DownloadToast } from "@/components/download-toast";
import { UpdateBanner } from "@/desktop/updates/update-banner";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
@@ -379,6 +380,7 @@ function AppContainer({
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<KeyboardShortcutsDialog />
@@ -578,7 +580,12 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]"
getId={({ params }) =>
`${params?.serverId}:${params?.workspaceId}`
}
/>
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
@@ -21,6 +21,7 @@ import {
type MutableRefObject,
} from 'react'
import { router, usePathname } from 'expo-router'
import { navigateToWorkspace } from '@/hooks/use-workspace-navigation'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler'
import * as Clipboard from 'expo-clipboard'
@@ -1093,7 +1094,7 @@ function ProjectBlock({
return
}
onWorkspacePress?.()
router.replace(workspaceRoute as any)
navigateToWorkspace(serverId, item.workspaceId)
}}
drag={input?.drag ?? (() => {})}
isDragging={input?.isDragging ?? false}
@@ -1147,7 +1148,7 @@ function ProjectBlock({
return
}
onWorkspacePress?.()
router.replace(buildHostWorkspaceRoute(serverId, flattenedWorkspace.workspaceId) as any)
navigateToWorkspace(serverId, flattenedWorkspace.workspaceId)
}}
drag={drag}
isDragging={isDragging}
@@ -7,10 +7,6 @@ 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,
} from "../terminal/runtime/terminal-debug";
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
@@ -287,14 +283,6 @@ export default function TerminalEmulator({
}
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:skip-duplicate",
details: {
sequence: outputChunkSequence,
lastAppliedSequence: appliedChunkSequenceRef.current,
},
});
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
@@ -307,13 +295,6 @@ export default function TerminalEmulator({
appliedChunkSequenceRef.current = outputChunkSequence;
if (outputChunkText.length === 0) {
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:clear",
details: {
sequence: outputChunkSequence,
},
});
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
@@ -321,16 +302,6 @@ export default function TerminalEmulator({
});
return;
}
terminalDebugLog({
scope: "emulator-component",
event: "output:chunk:write",
details: {
sequence: outputChunkSequence,
replay: outputChunkReplay,
length: outputChunkText.length,
preview: summarizeTerminalText({ text: outputChunkText, maxChars: 80 }),
},
});
runtime.write({
text: outputChunkText,
suppressInput: outputChunkReplay,
@@ -383,10 +354,6 @@ export default function TerminalEmulator({
touchAction: "pan-y",
}}
onPointerDown={() => {
terminalDebugLog({
scope: "emulator-component",
event: "surface:pointer-down-focus",
});
runtimeRef.current?.focus();
}}
>
+5 -53
View File
@@ -35,10 +35,6 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import {
summarizeTerminalText,
terminalDebugLog,
} from "@/terminal/runtime/terminal-debug";
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator from "./terminal-emulator";
@@ -366,7 +362,7 @@ export function TerminalPane({
const terminals = terminalsQuery.data?.terminals ?? [];
useEffect(() => {
if (!client || !isConnected) {
if (!client || !isConnected || !isScreenFocused) {
return;
}
@@ -716,17 +712,6 @@ export function TerminalPane({
meta: input.meta,
},
};
terminalDebugLog({
scope: "terminal-pane",
event: "input:key:send",
details: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
activeStreamId: getCurrentActiveStreamId(),
},
});
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
}
@@ -745,16 +730,6 @@ export function TerminalPane({
if (data.length === 0) {
return;
}
const currentStreamId = getCurrentActiveStreamId();
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:received",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId: currentStreamId,
},
});
if (hasPendingTerminalModifiers(modifiers)) {
const pendingResolution = resolvePendingModifierDataInput({
@@ -788,15 +763,6 @@ export function TerminalPane({
});
return;
}
terminalDebugLog({
scope: "terminal-pane",
event: "input:data:send",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 80 }),
activeStreamId: currentStreamId,
},
});
const pendingEntry: PendingTerminalInput = {
type: "data",
data,
@@ -835,15 +801,6 @@ export function TerminalPane({
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
terminalDebugLog({
scope: "terminal-pane",
event: "display:resize:send",
details: {
terminalId: selectedTerminalId,
rows: normalizedRows,
cols: normalizedCols,
},
});
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
@@ -871,13 +828,6 @@ export function TerminalPane({
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback((sequence: number) => {
terminalDebugLog({
scope: "terminal-pane",
event: "output:chunk:consumed",
details: {
sequence,
},
});
outputSession.consume({ sequence });
}, [outputSession]);
@@ -1040,7 +990,7 @@ export function TerminalPane({
) : null}
<View style={styles.outputContainer}>
{selectedTerminal ? (
{selectedTerminal && isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
dom={{
@@ -1084,13 +1034,15 @@ export function TerminalPane({
resizeRequestToken={resizeRequestToken}
/>
</View>
) : selectedTerminal ? (
<View style={styles.terminalGestureContainer} />
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching ? (
{isAttaching && isScreenFocused ? (
<View
style={styles.attachOverlay}
pointerEvents="none"
@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktop) return;
void checkForUpdates({ silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true });
}, CHECK_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isDesktop, checkForUpdates]);
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<Pressable
onPress={() => setDismissed(true)}
hitSlop={8}
style={styles.closeButton}
>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>
{isInstalled ? "Update installed" : "Update available"}
</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
<Pressable
onPress={() => void openExternalUrl(CHANGELOG_URL)}
style={({ pressed }) => [
styles.outlineButton,
pressed && styles.buttonPressed,
]}
>
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
isInstalling && styles.buttonDisabled,
]}
>
<Text style={styles.primaryButtonText}>
{isInstalling ? "Installing..." : "Install & restart"}
</Text>
</Pressable>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
bottom: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
banner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[4],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.xl,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 12,
elevation: 8,
maxWidth: 480,
},
closeButton: {
position: "absolute",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
alignItems: "center",
},
outlineButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
outlineButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
primaryButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.foreground,
},
primaryButtonText: {
color: theme.colors.surface0,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
buttonPressed: {
opacity: 0.8,
},
buttonDisabled: {
opacity: 0.5,
},
}));
@@ -1,14 +1,14 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { usePathname } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostWorkspaceRoute,
parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
@@ -32,7 +32,6 @@ export function useKeyboardShortcuts({
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
@@ -57,11 +56,7 @@ export function useKeyboardShortcuts({
return false;
}
const shouldReplace =
Boolean(parseHostWorkspaceRouteFromPathname(pathname)) ||
Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
navigateToWorkspace(target.serverId, target.workspaceId);
return true;
};
const navigateRelativeWorkspace = (delta: 1 | -1): boolean => {
@@ -77,7 +72,7 @@ export function useKeyboardShortcuts({
if (!fallback) {
return false;
}
router.push(buildHostWorkspaceRoute(fallback.serverId, fallback.workspaceId) as any);
navigateToWorkspace(fallback.serverId, fallback.workspaceId);
return true;
}
@@ -92,7 +87,7 @@ export function useKeyboardShortcuts({
if (!target) {
return false;
}
router.replace(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
navigateToWorkspace(target.serverId, target.workspaceId);
return true;
};
@@ -134,22 +129,6 @@ export function useKeyboardShortcuts({
return false;
}
};
const requestWorkspaceTabAction = (input:
| { kind: "new" | "close-current" }
| { kind: "navigate-index"; index: number }
| { kind: "navigate-relative"; delta: 1 | -1 }): boolean => {
const route = parseHostWorkspaceRouteFromPathname(pathname);
if (!route) {
return false;
}
useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({
serverId: route.serverId,
workspaceId: route.workspaceId,
...input,
});
return true;
};
const handleAction = (input: {
action: string;
payload: KeyboardShortcutPayload;
@@ -159,23 +138,31 @@ export function useKeyboardShortcuts({
case "agent.new":
return openProjectPicker();
case "workspace.tab.new":
return requestWorkspaceTabAction({ kind: "new" });
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.new",
scope: "workspace",
});
case "workspace.tab.close.current":
return requestWorkspaceTabAction({ kind: "close-current" });
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.close-current",
scope: "workspace",
});
case "workspace.tab.navigate.index":
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
return false;
}
return requestWorkspaceTabAction({
kind: "navigate-index",
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.navigate-index",
scope: "workspace",
index: input.payload.index,
});
case "workspace.tab.navigate.relative":
if (!input.payload || typeof input.payload !== "object" || !("delta" in input.payload)) {
return false;
}
return requestWorkspaceTabAction({
kind: "navigate-relative",
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.navigate-relative",
scope: "workspace",
delta: input.payload.delta,
});
case "workspace.navigate.index":
@@ -326,7 +313,6 @@ export function useKeyboardShortcuts({
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
/**
* Navigate to a workspace screen. Uses router.navigate() which,
* combined with getId on the workspace Stack.Screen, ensures:
* - Only one instance of each workspace exists in the stack
* - History is preserved (back button works)
* - No duplicate workspace screens
*/
export function navigateToWorkspace(serverId: string, workspaceId: string) {
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href as any);
}
export function useWorkspaceNavigation() {
return {
navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string) => {
navigateToWorkspace(serverId, workspaceId);
},
[]
),
};
}
@@ -9,12 +9,22 @@ export type KeyboardActionId =
| "message-input.dictation-toggle"
| "message-input.dictation-cancel"
| "message-input.voice-toggle"
| "message-input.voice-mute-toggle";
| "message-input.voice-mute-toggle"
| "workspace.tab.new"
| "workspace.tab.close-current"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative";
export type KeyboardActionDefinition = {
id: KeyboardActionId;
scope: KeyboardActionScope;
};
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 };
export type KeyboardActionHandler = {
handlerId: string;
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useIsFocused } from "@react-navigation/native";
import {
ActivityIndicator,
BackHandler,
@@ -54,7 +55,8 @@ import {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
} from "@/stores/workspace-tabs-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import {
buildWorkspaceOpenIntentParam,
@@ -363,6 +365,7 @@ function WorkspaceScreenContent({
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isScreenFocused = useIsFocused();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -651,12 +654,6 @@ function WorkspaceScreenContent({
const retargetWorkspaceTab = useWorkspaceTabsStore((state) => state.retargetTab);
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const workspaceTabActionRequest = useKeyboardShortcutsStore(
(state) => state.workspaceTabActionRequest
);
const clearWorkspaceTabActionRequest = useKeyboardShortcutsStore(
(state) => state.clearWorkspaceTabActionRequest
);
const consumedOpenIntentsRef = useRef(new Set<string>());
const pendingCloseTabIdsRef = useRef(new Set<string>());
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
@@ -1386,59 +1383,56 @@ function WorkspaceScreenContent({
[handleBulkCloseTabs, tabs]
);
useEffect(() => {
if (!workspaceTabActionRequest) {
return;
}
if (
workspaceTabActionRequest.serverId !== normalizedServerId ||
workspaceTabActionRequest.workspaceId !== normalizedWorkspaceId
) {
return;
}
const request = workspaceTabActionRequest;
clearWorkspaceTabActionRequest(request.id);
if (request.kind === "new") {
handleCreateDraftTab();
return;
}
if (request.kind === "close-current") {
if (activeTabId) {
void handleCloseTabById(activeTabId);
}
return;
}
if (request.kind === "navigate-index") {
const next = tabs[request.index - 1] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
return;
}
if (request.kind === "navigate-relative") {
if (tabs.length > 0) {
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
const fromIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = (fromIndex + request.delta + tabs.length) % tabs.length;
const next = tabs[nextIndex] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
const handleWorkspaceTabAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
switch (action.id) {
case "workspace.tab.new":
handleCreateDraftTab();
return true;
case "workspace.tab.close-current":
if (activeTabId) {
void handleCloseTabById(activeTabId);
}
return true;
case "workspace.tab.navigate-index": {
const next = tabs[action.index - 1] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
return true;
}
case "workspace.tab.navigate-relative": {
if (tabs.length > 0) {
const currentIndex = tabs.findIndex((tab) => tab.tabId === activeTabId);
const fromIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex = (fromIndex + action.delta + tabs.length) % tabs.length;
const next = tabs[nextIndex] ?? null;
if (next?.tabId) {
navigateToTabId(next.tabId);
}
}
return true;
}
default:
return false;
}
}
}, [
activeTabId,
clearWorkspaceTabActionRequest,
handleCloseTabById,
handleCreateDraftTab,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
tabs,
workspaceTabActionRequest,
]);
},
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs]
);
useKeyboardActionHandler({
handlerId: `workspace-tab-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: [
"workspace.tab.new",
"workspace.tab.close-current",
"workspace.tab.navigate-index",
"workspace.tab.navigate-relative",
] as const,
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
priority: 100,
isActive: () => isScreenFocused,
handle: handleWorkspaceTabAction,
});
const renderContent = () => {
if (
@@ -1523,7 +1517,7 @@ function WorkspaceScreenContent({
);
}
return (
return isScreenFocused ? (
<TerminalPane
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
@@ -1544,6 +1538,8 @@ function WorkspaceScreenContent({
hideHeader
manageTerminalDirectorySubscription={false}
/>
) : (
<View style={styles.contentPlaceholder} />
);
};
@@ -2069,6 +2065,11 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
contentPlaceholder: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
emptyState: {
flex: 1,
alignItems: "center",
@@ -9,33 +9,13 @@ beforeEach(() => {
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
});
});
describe("keyboard-shortcuts-store", () => {
it("queues and clears workspace tab action requests", () => {
useKeyboardShortcutsStore.getState().requestWorkspaceTabAction({
serverId: "srv-1",
workspaceId: "/repo/main",
kind: "navigate-index",
index: 3,
});
const request = useKeyboardShortcutsStore.getState().workspaceTabActionRequest;
expect(request).toEqual({
id: 1,
serverId: "srv-1",
workspaceId: "/repo/main",
kind: "navigate-index",
index: 3,
});
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(999);
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).not.toBeNull();
useKeyboardShortcutsStore.getState().clearWorkspaceTabActionRequest(1);
expect(useKeyboardShortcutsStore.getState().workspaceTabActionRequest).toBeNull();
it("toggles command center open state", () => {
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(false);
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
});
});
@@ -1,28 +1,6 @@
import { create } from "zustand";
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
export type WorkspaceTabActionRequest =
| {
id: number;
serverId: string;
workspaceId: string;
kind: "new" | "close-current";
}
| {
id: number;
serverId: string;
workspaceId: string;
kind: "navigate-index";
index: number;
}
| {
id: number;
serverId: string;
workspaceId: string;
kind: "navigate-relative";
delta: 1 | -1;
};
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
@@ -33,8 +11,6 @@ interface KeyboardShortcutsState {
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
/** All visible workspace targets in top-to-bottom visual order. */
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
nextWorkspaceTabActionRequestId: number;
setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void;
@@ -44,30 +20,10 @@ interface KeyboardShortcutsState {
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void;
requestWorkspaceTabAction: (input:
| {
serverId: string;
workspaceId: string;
kind: "new" | "close-current";
}
| {
serverId: string;
workspaceId: string;
kind: "navigate-index";
index: number;
}
| {
serverId: string;
workspaceId: string;
kind: "navigate-relative";
delta: 1 | -1;
}) => void;
clearWorkspaceTabActionRequest: (id: number) => void;
}
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
(set, get) => ({
(set) => ({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
@@ -75,8 +31,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
@@ -87,23 +41,5 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
set({ sidebarShortcutWorkspaceTargets: targets }),
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestWorkspaceTabAction: (input) => {
const id = get().nextWorkspaceTabActionRequestId;
set({
workspaceTabActionRequest: {
...input,
id,
} as WorkspaceTabActionRequest,
nextWorkspaceTabActionRequestId: id + 1,
});
},
clearWorkspaceTabActionRequest: (id) => {
const current = get().workspaceTabActionRequest;
if (!current || current.id !== id) {
return;
}
set({ workspaceTabActionRequest: null });
},
})
);
@@ -1,77 +0,0 @@
type TerminalDebugGlobal = {
__PASEO_TERMINAL_DEBUG?: boolean;
};
type TerminalDebugLogInput = {
scope: string;
event: string;
details?: Record<string, unknown>;
};
function resolveGlobalDebugFlag(): boolean | null {
if (typeof globalThis === "undefined") {
return null;
}
const value = (globalThis as TerminalDebugGlobal).__PASEO_TERMINAL_DEBUG;
if (typeof value === "boolean") {
return value;
}
return null;
}
export function isTerminalDebugEnabled(): boolean {
const globalFlag = resolveGlobalDebugFlag();
if (globalFlag !== null) {
return globalFlag;
}
return process.env.NODE_ENV === "development";
}
export function terminalDebugLog(input: TerminalDebugLogInput): void {
if (!isTerminalDebugEnabled()) {
return;
}
const payload = input.details
? { ...input.details, ts: Date.now() }
: { ts: Date.now() };
console.log(`[terminal][${input.scope}] ${input.event}`, payload);
}
function escapeControlBytes(input: { text: string }): string {
let output = "";
for (const char of input.text) {
const code = char.charCodeAt(0);
if (code === 10) {
output += "\\n";
continue;
}
if (code === 13) {
output += "\\r";
continue;
}
if (code === 9) {
output += "\\t";
continue;
}
if (code < 32 || code === 127) {
output += `\\x${code.toString(16).padStart(2, "0")}`;
continue;
}
output += char;
}
return output;
}
export function summarizeTerminalText(input: {
text: string;
maxChars?: number;
}): string {
const maxChars = input.maxChars ?? 80;
const escaped = escapeControlBytes({ text: input.text });
if (escaped.length <= maxChars) {
return escaped;
}
return `${escaped.slice(0, maxChars)}…`;
}
@@ -10,7 +10,6 @@ import {
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
@@ -126,13 +125,6 @@ export class TerminalEmulatorRuntime {
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:start",
details: {
initialOutputLength: input.initialOutputText.length,
},
});
this.unmount();
input.host.innerHTML = "";
@@ -219,15 +211,6 @@ export class TerminalEmulatorRuntime {
}
this.lastSize = { rows: nextRows, cols: nextCols };
terminalDebugLog({
scope: "emulator-runtime",
event: "resize:emit",
details: {
rows: nextRows,
cols: nextCols,
force,
},
});
this.callbacks.onResize?.({
rows: nextRows,
cols: nextCols,
@@ -239,24 +222,8 @@ export class TerminalEmulatorRuntime {
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:suppressed",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
return;
}
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
this.callbacks.onInput?.(data);
});
@@ -292,17 +259,6 @@ export class TerminalEmulatorRuntime {
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
terminalDebugLog({
scope: "emulator-runtime",
event: "key:intercepted",
details: {
key: normalizedKey,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
meta: modifiers.meta,
},
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
@@ -368,17 +324,6 @@ export class TerminalEmulatorRuntime {
if (input.initialOutputText.length > 0) {
this.write({ text: input.initialOutputText, suppressInput: true });
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
details: {
length: input.initialOutputText.length,
preview: summarizeTerminalText({
text: input.initialOutputText,
maxChars: 96,
}),
},
});
}
this.processOutputQueue();
@@ -466,15 +411,6 @@ export class TerminalEmulatorRuntime {
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:enqueue",
details: {
chunkLength: input.text.length,
queueLength: this.outputOperations.length,
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
},
});
this.processOutputQueue();
}
@@ -485,10 +421,6 @@ export class TerminalEmulatorRuntime {
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:clear",
});
this.processOutputQueue();
}
@@ -504,10 +436,6 @@ export class TerminalEmulatorRuntime {
try {
terminal.options.theme = input.theme;
terminalDebugLog({
scope: "emulator-runtime",
event: "theme:update",
});
} catch {
// ignore
return;
@@ -525,13 +453,6 @@ export class TerminalEmulatorRuntime {
}
unmount(): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:unmount",
details: {
pendingWriteOperations: this.outputOperations.length,
},
});
this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation;
this.inFlightOutputOperation = null;
@@ -593,14 +514,6 @@ export class TerminalEmulatorRuntime {
}
const text = operation.text;
terminalDebugLog({
scope: "emulator-runtime",
event: "output:flush",
details: {
length: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);
@@ -1,5 +1,3 @@
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
@@ -37,13 +35,6 @@ export class TerminalOutputDeliveryQueue {
if (chunk.text.length === 0) {
this.pendingChunks.length = 0;
this.pendingChunks.push(chunk);
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:clear",
details: {
sequence: chunk.sequence,
},
});
this.tryDeliver();
return;
}
@@ -59,39 +50,14 @@ export class TerminalOutputDeliveryQueue {
} else {
this.pendingChunks.push(chunk);
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:text",
details: {
sequence: chunk.sequence,
pendingCount: this.pendingChunks.length,
textLength: chunk.text.length,
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
},
});
this.tryDeliver();
}
consume(input: { sequence: number }): void {
if (this.inFlightChunk?.sequence !== input.sequence) {
terminalDebugLog({
scope: "output-delivery-queue",
event: "consume:ignored",
details: {
sequence: input.sequence,
inFlightSequence: this.inFlightChunk?.sequence ?? null,
},
});
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "consume",
details: {
sequence: input.sequence,
},
});
this.clearInFlightTimeout();
this.inFlightChunk = null;
this.tryDeliver();
@@ -114,14 +80,6 @@ export class TerminalOutputDeliveryQueue {
}
this.inFlightChunk = nextChunk;
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:start",
details: {
sequence: nextChunk.sequence,
pendingCount: this.pendingChunks.length,
},
});
this.deliverInFlightChunk();
}
@@ -136,15 +94,6 @@ export class TerminalOutputDeliveryQueue {
if (!this.inFlightChunk) {
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:timeout-retry",
details: {
sequence: this.inFlightChunk.sequence,
pendingCount: this.pendingChunks.length,
timeoutMs: this.deliveryTimeoutMs,
},
});
this.deliverInFlightChunk();
}, this.deliveryTimeoutMs);
@@ -4,7 +4,6 @@ import {
readTerminalOutputBuffer,
type TerminalOutputBuffer,
} from "@/utils/terminal-output-buffer";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputChunk = {
sequence: number;
@@ -54,14 +53,6 @@ export class TerminalOutputPump {
return;
}
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
@@ -102,17 +93,6 @@ export class TerminalOutputPump {
}
this.selectedChunkAccumulator += input.text;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:accumulate",
details: {
terminalId: input.terminalId,
appendedLength: input.text.length,
accumulatorLength: this.selectedChunkAccumulator.length,
replay: input.replay,
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
},
});
this.scheduleSelectedChunkFlush();
}
@@ -173,16 +153,6 @@ export class TerminalOutputPump {
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:flush",
details: {
terminalId: this.selectedTerminalId,
textLength: text.length,
replay,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.emitSelectedChunk({ text, replay });
}
@@ -6,7 +6,6 @@ import {
TerminalOutputDeliveryQueue,
type TerminalOutputDeliveryChunk,
} from "./terminal-output-delivery-queue";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
type TerminalOutputState = {
selectedTerminalId: string | null;
@@ -75,18 +74,6 @@ export class TerminalOutputSession {
chunkSequence: chunk.sequence,
chunkReplay: chunk.replay,
};
terminalDebugLog({
scope: "output-session",
event: "deliver",
details: {
selectedTerminalId: this.state.selectedTerminalId,
sequence: chunk.sequence,
replay: chunk.replay,
chunkLength: chunk.text.length,
snapshotLength: snapshotText.length,
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
},
});
this.emit();
},
});
@@ -121,14 +108,6 @@ export class TerminalOutputSession {
chunkSequence: 0,
chunkReplay: false,
};
terminalDebugLog({
scope: "output-session",
event: "selected-terminal:set",
details: {
selectedTerminalId: input.terminalId,
snapshotLength: snapshotText.length,
},
});
this.emit();
}
@@ -154,13 +133,6 @@ export class TerminalOutputSession {
chunkSequence: 0,
chunkReplay: false,
};
terminalDebugLog({
scope: "output-session",
event: "selected-terminal:clear",
details: {
terminalId: input.terminalId,
},
});
this.emit();
}
@@ -208,14 +180,6 @@ export function releaseTerminalOutputSession(input: { scopeKey: string }): void
if (current <= 1) {
sessionRefCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
terminalDebugLog({
scope: "output-session",
event: "scope:release",
details: {
scopeKey: input.scopeKey,
released: true,
},
});
return;
}
@@ -7,7 +7,6 @@ import {
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalStreamControllerAttachPayload = {
streamId: number | null;
@@ -114,14 +113,6 @@ export class TerminalStreamController {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
const nextTerminalId = input.terminalId;
const previousTerminalId = this.selectedTerminalId;
@@ -166,14 +157,6 @@ export class TerminalStreamController {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:exit",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
},
});
if (this.selectedTerminalId !== input.terminalId) {
return;
@@ -256,16 +239,6 @@ export class TerminalStreamController {
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:attempt",
details: {
terminalId: input.terminalId,
generation: input.generation,
attempt,
maxAttachAttempts,
},
});
try {
const preferredSize = this.options.getPreferredSize();
@@ -299,15 +272,6 @@ export class TerminalStreamController {
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:response-error",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -385,19 +349,6 @@ export class TerminalStreamController {
} else {
unsubscribe();
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:success",
details: {
terminalId: input.terminalId,
streamId,
replayedFrom: attachPayload.replayedFrom ?? null,
requestedResumeOffset: resumeOffset ?? null,
currentOffset: attachPayload.currentOffset,
reset: attachPayload.reset,
bootstrapReset: shouldResetForReplayBootstrap,
},
});
this.updateStatus({
terminalId: input.terminalId,
streamId,
@@ -408,15 +359,6 @@ export class TerminalStreamController {
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:exception",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
@@ -510,21 +452,6 @@ export class TerminalStreamController {
if (text.length === 0) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:chunk",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
offset: chunkOffset,
endOffset: chunkEndOffset,
replay: Boolean(input.chunk.replay),
byteLength: input.chunk.data.byteLength,
textLength: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.options.onChunk({
terminalId: input.terminalId,
text,
@@ -577,15 +504,6 @@ export class TerminalStreamController {
if (!activeStream) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:detach",
details: {
terminalId: activeStream.terminalId,
streamId: activeStream.streamId,
shouldDetach: input.shouldDetach,
},
});
this.activeStream = null;
try {
@@ -633,16 +551,6 @@ export class TerminalStreamController {
private updateStatus(status: TerminalStreamControllerStatus): void {
this.status = status;
terminalDebugLog({
scope: "stream-controller",
event: "status:update",
details: {
terminalId: status.terminalId,
streamId: status.streamId,
isAttaching: status.isAttaching,
error: status.error,
},
});
this.options.onStatusChange?.(status);
}
+1 -1
View File
@@ -2629,7 +2629,7 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.27"
version = "0.1.28"
dependencies = [
"base64 0.22.1",
"dirs",