feat(desktop): manual window dragging and agent tab pruning

This commit is contained in:
Mohamed Boudra
2026-03-21 14:40:30 +07:00
parent 5ae7a0c9c2
commit 0bc693f157
16 changed files with 471 additions and 62 deletions
@@ -1,11 +1,14 @@
import { useGlobalSearchParams, useLocalSearchParams } from 'expo-router'
import { useCallback } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
} from '@/utils/host-routes'
export default function HostWorkspaceLayout() {
const router = useRouter()
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
@@ -22,12 +25,20 @@ export default function HostWorkspaceLayout() {
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
const openIntent = parseWorkspaceOpenIntent(openValue)
const handleOpenIntentConsumed = useCallback(
function handleOpenIntentConsumed() {
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any)
},
[router, serverId, workspaceId]
)
return (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
onOpenIntentConsumed={handleOpenIntentConsumed}
/>
)
}
@@ -41,7 +41,7 @@ export function ScreenHeader({
? trafficLightPadding.left
: 0;
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
const dragHandlers = useDesktopDragHandlers();
return (
<View style={styles.header}>
@@ -50,7 +50,6 @@ export function ScreenHeader({
style={[
styles.row,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
dragRegionStyle,
]}
{...dragHandlers}
>
+3 -3
View File
@@ -602,7 +602,7 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const dragHandlers = useDesktopDragHandlers()
const trafficLightPadding = useTrafficLightPadding()
const hostStatusDotStyle = useMemo(
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
@@ -616,9 +616,9 @@ function DesktopSidebar({
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{trafficLightPadding.top > 0 ? (
<View style={[{ height: trafficLightPadding.top }, dragRegionStyle]} {...dragHandlers} />
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={[styles.sidebarHeader, dragRegionStyle]} {...dragHandlers}>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -12,14 +12,6 @@ export function getDesktopWindow(): DesktopWindowBridge | null {
}
}
export async function startDesktopDragging(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.startDragging !== "function") {
return;
}
await win.startDragging();
}
export async function toggleDesktopMaximize(): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.toggleMaximize !== "function") {
+3 -1
View File
@@ -41,7 +41,9 @@ export interface DesktopOpenerBridge {
export interface DesktopWindowBridge {
label?: string;
startDragging?: () => Promise<void>;
startMove?: (screenX: number, screenY: number) => void;
moving?: (screenX: number, screenY: number) => void;
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
onResized?: <TEvent = unknown>(
@@ -238,7 +238,7 @@ function DraftAgentScreenContent({
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout
)
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers()
const dragHandlers = useDesktopDragHandlers()
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
const draftIdRef = useRef(generateDraftId())
const draftAgentIdRef = useRef(generateDraftId())
@@ -973,7 +973,7 @@ function DraftAgentScreenContent({
const explorerServerId = draftExplorerCheckout?.serverId ?? null
const explorerIsGit = draftExplorerCheckout?.isGit ?? false
const mainContent = (
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.container} {...dragHandlers}>
<View style={styles.outerContainer}>
<View style={styles.agentPanel}>
<View
@@ -21,7 +21,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isMobile) {
@@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
@@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({
}));
export function StartupSplashScreen() {
const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers();
const dragHandlers = useDesktopDragHandlers();
return (
<View style={[styles.container, dragRegionStyle]} {...dragHandlers}>
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text>
</View>
@@ -101,14 +101,30 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("does not prune archived agent tabs when knownAgentIds contains the agent", () => {
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
const knownAgentIds = new Set(["archived-agent"]);
const activeAgentIds = new Set<string>();
expect(
shouldPruneWorkspaceAgentTab({
agentId: "archived-agent",
agentsHydrated: true,
knownAgentIds,
activeAgentIds,
})
).toBe(true);
});
it("does not prune active agent tabs", () => {
const knownAgentIds = new Set(["active-agent"]);
const activeAgentIds = new Set(["active-agent"]);
expect(
shouldPruneWorkspaceAgentTab({
agentId: "active-agent",
agentsHydrated: true,
knownAgentIds,
activeAgentIds,
})
).toBe(false);
});
@@ -119,6 +135,7 @@ describe("workspace agent visibility", () => {
agentId: "missing-agent",
agentsHydrated: true,
knownAgentIds: new Set<string>(),
activeAgentIds: new Set<string>(),
})
).toBe(true);
});
@@ -71,10 +71,15 @@ export function canOpenAgentTabFromRoute(input: {
return input.knownAgentIds.has(input.agentId);
}
// Prune agent tabs that are unknown (deleted) or archived.
// Archived agents get pruned so that archiving on one client closes the tab on all clients.
// Users can still explicitly open archived agents via navigation (openIntent),
// which re-creates the tab in a separate effect.
export function shouldPruneWorkspaceAgentTab(input: {
agentId: string;
agentsHydrated: boolean;
knownAgentIds: Set<string>;
activeAgentIds: Set<string>;
}): boolean {
if (!input.agentId.trim()) {
return false;
@@ -82,5 +87,5 @@ export function shouldPruneWorkspaceAgentTab(input: {
if (!input.agentsHydrated) {
return false;
}
return !input.knownAgentIds.has(input.agentId);
return !input.activeAgentIds.has(input.agentId);
}
+85 -29
View File
@@ -1,6 +1,5 @@
import type { CSSProperties } from "react";
import { useState, useEffect } from "react";
import { Platform, type ViewStyle } from "react-native";
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
@@ -9,25 +8,6 @@ import {
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
type DesktopDragHandlers = {
style?: CSSProperties & ViewStyle;
};
const DESKTOP_DRAG_REGION_STYLE = {
WebkitAppRegion: "drag",
} as CSSProperties & ViewStyle;
export async function startDragging() {
const win = getDesktopWindow();
if (win && typeof win.startDragging === "function") {
try {
await win.startDragging();
} catch (error) {
console.warn("[DesktopWindow] startDragging failed", error);
}
}
}
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
@@ -39,14 +19,90 @@ export async function toggleMaximize() {
}
}
export function useDesktopDragHandlers(): DesktopDragHandlers {
if (Platform.OS !== "web" || !isDesktop()) {
return {};
}
// ---------------------------------------------------------------------------
// Manual window dragging via pointer events.
// Mirrors the Tauri implementation: single pointerdown handler with
// double-click-to-maximize via timing, closest() for interactive check,
// and pointer capture for move tracking.
// ---------------------------------------------------------------------------
return {
style: DESKTOP_DRAG_REGION_STYLE,
};
const INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, " +
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
"[role='menuitem'], [contenteditable='true']";
const DOUBLE_CLICK_MS = 300;
type DesktopDragViewProps = Pick<
ViewProps,
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
>;
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isDesktop();
useEffect(() => {
if (!isActive) return;
function handleBlur() {
if (!isDragging.current) return;
isDragging.current = false;
getDesktopWindow()?.endMove?.();
}
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [isActive]);
return useMemo((): DesktopDragViewProps => {
if (!isActive) return {};
function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return;
isDragging.current = false;
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
const el = e.currentTarget as unknown as Element | null;
if (el && "releasePointerCapture" in el) {
el.releasePointerCapture(e.nativeEvent.pointerId);
}
getDesktopWindow()?.endMove?.();
}
return {
onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return;
// On web, e.target is a DOM Element (typed as HostInstance in RN)
const target = e.target as unknown as Element;
if (target.closest?.(INTERACTIVE_SELECTOR)) return;
e.preventDefault();
const now = Date.now();
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0;
void toggleMaximize();
return;
}
lastPointerDownAt.current = now;
const win = getDesktopWindow();
if (!win?.startMove) return;
isDragging.current = true;
const el = e.currentTarget as unknown as Element;
el.setPointerCapture(e.nativeEvent.pointerId);
win.startMove(e.nativeEvent.screenX, e.nativeEvent.screenY);
},
onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return;
getDesktopWindow()?.moving?.(e.nativeEvent.screenX, e.nativeEvent.screenY);
},
onPointerUp: stopDrag,
onPointerCancel: stopDrag,
};
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; top: number } {