diff --git a/packages/app/package.json b/packages/app/package.json index dc1a2e941..565c8a2a4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,12 +26,12 @@ "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main" }, "dependencies": { - "@getpaseo/expo-two-way-audio": "0.1.30", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", + "@getpaseo/expo-two-way-audio": "0.1.30", "@getpaseo/server": "0.1.30", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", @@ -58,7 +58,7 @@ "@react-navigation/native": "^7.1.8", "@tanstack/react-query": "^5.90.11", "@tanstack/react-virtual": "^3.13.21", -"@xterm/addon-fit": "^0.11.0", + "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index af864cd85..a9b66fb62 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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 ( ) } diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index 1d7cad66f..3f98fdf05 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -41,7 +41,7 @@ export function ScreenHeader({ ? trafficLightPadding.left : 0; - const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers(); + const dragHandlers = useDesktopDragHandlers(); return ( @@ -50,7 +50,6 @@ export function ScreenHeader({ style={[ styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }, - dragRegionStyle, ]} {...dragHandlers} > diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index cbd95b852..c6070ae8e 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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 ( {trafficLightPadding.top > 0 ? ( - + ) : null} - + { - const win = getDesktopWindow(); - if (!win || typeof win.startDragging !== "function") { - return; - } - await win.startDragging(); -} - export async function toggleDesktopMaximize(): Promise { const win = getDesktopWindow(); if (!win || typeof win.toggleMaximize !== "function") { diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 8711fd176..5479cf6fc 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -41,7 +41,9 @@ export interface DesktopOpenerBridge { export interface DesktopWindowBridge { label?: string; - startDragging?: () => Promise; + startMove?: (screenX: number, screenY: number) => void; + moving?: (screenX: number, screenY: number) => void; + endMove?: () => void; toggleMaximize?: () => Promise; isFullscreen?: () => Promise; onResized?: ( diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 5d7aeaa7f..11c4843ad 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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 = ( - + { if (!isMobile) { @@ -30,7 +30,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { }, [isMobile, openAgentList]); return ( - + diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 41a40987f..507630af7 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -18,10 +18,10 @@ const styles = StyleSheet.create((theme) => ({ })); export function StartupSplashScreen() { - const { style: dragRegionStyle, ...dragHandlers } = useDesktopDragHandlers(); + const dragHandlers = useDesktopDragHandlers(); return ( - + Starting up… diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 063916191..deb06b4b6 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -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(); 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(), + activeAgentIds: new Set(), }) ).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 5d90bf7f9..bc4ab9bf2 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -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; + activeAgentIds: Set; }): 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); } diff --git a/packages/app/src/utils/desktop-window.ts b/packages/app/src/utils/desktop-window.ts index 94c53f233..e27898b0f 100644 --- a/packages/app/src/utils/desktop-window.ts +++ b/packages/app/src/utils/desktop-window.ts @@ -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 } { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 80d57a545..897c74296 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -71,7 +71,7 @@ async function createMainWindow(): Promise { show: false, ...(iconPath ? { icon: iconPath } : {}), titleBarStyle: isMac ? "hidden" : "default", - trafficLightPosition: isMac ? { x: 16, y: 20 } : undefined, + trafficLightPosition: isMac ? { x: 16, y: 17 } : undefined, webPreferences: { preload: getPreloadPath(), contextIsolation: true, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 36b9bcad1..456783772 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -19,7 +19,11 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }, window: { getCurrentWindow: () => ({ - startDragging: () => ipcRenderer.invoke("paseo:window:startDragging"), + startMove: (screenX: number, screenY: number) => + ipcRenderer.send("paseo:window:startMove", { screenX, screenY }), + moving: (screenX: number, screenY: number) => + ipcRenderer.send("paseo:window:moving", { screenX, screenY }), + endMove: () => ipcRenderer.send("paseo:window:endMove"), toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), onResized: (handler: EventHandler): (() => void) => { diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index ceaf52d2c..511ac0f3b 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -1,13 +1,45 @@ import { app, BrowserWindow, ipcMain } from "electron"; export function registerWindowManager(): void { - ipcMain.handle("paseo:window:startDragging", (event) => { - const win = BrowserWindow.fromWebContents(event.sender); - // Desktop dragging is handled via CSS `-webkit-app-region: drag`. - // This handler exists only to satisfy the renderer bridge contract. - if (win) { - // No-op. + // --------------------------------------------------------------------------- + // Manual window dragging (replaces flaky CSS -webkit-app-region: drag). + // State is keyed per window id to support multiple windows. + // --------------------------------------------------------------------------- + const moveStates = new Map(); + + ipcMain.on( + "paseo:window:startMove", + (event, payload: { screenX: number; screenY: number }) => { + if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return; + const [winX, winY] = win.getPosition(); + moveStates.set(win.id, { offsetX: payload.screenX - winX, offsetY: payload.screenY - winY }); } + ); + + ipcMain.on( + "paseo:window:moving", + (event, payload: { screenX: number; screenY: number }) => { + if (typeof payload?.screenX !== "number" || typeof payload?.screenY !== "number") return; + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return; + const state = moveStates.get(win.id); + if (!state) return; + win.setPosition( + Math.round(payload.screenX - state.offsetX), + Math.round(payload.screenY - state.offsetY) + ); + } + ); + + ipcMain.on("paseo:window:endMove", (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win) moveStates.delete(win.id); + }); + + app.on("browser-window-created", (_event, win) => { + win.on("closed", () => moveStates.delete(win.id)); }); ipcMain.handle("paseo:window:toggleMaximize", (event) => { diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts new file mode 100644 index 000000000..d4c11df56 --- /dev/null +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -0,0 +1,291 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const { watchCalls, watchMock } = vi.hoisted(() => { + const hoistedWatchCalls: Array<{ + path: string + listener: () => void + close: ReturnType + }> = [] + + const hoistedWatchMock = vi.fn( + (watchPath: string, _options: { recursive: boolean }, listener: () => void) => { + const close = vi.fn() + const watcher = { + close, + on: vi.fn().mockReturnThis(), + } + hoistedWatchCalls.push({ + path: watchPath, + listener, + close, + }) + return watcher as any + } + ) + + return { + watchCalls: hoistedWatchCalls, + watchMock: hoistedWatchMock, + } +}) + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + watch: watchMock, + } +}) + +import { Session } from './session.js' + +function createSessionForWorkspaceGitWatchTests(): { + session: Session + emitted: Array<{ type: string; payload: unknown }> +} { + const emitted: Array<{ type: string; payload: unknown }> = [] + const projects = new Map() + const workspaces = new Map() + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + const session = new Session({ + clientId: 'test-client', + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: '/tmp/paseo-test', + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (projectId: string) => projects.get(projectId) ?? null, + upsert: async (record: any) => { + projects.set(record.projectId, record) + }, + archive: async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId) + if (!existing) { + return + } + projects.set(projectId, { + ...existing, + archivedAt, + updatedAt: archivedAt, + }) + }, + remove: async (projectId: string) => { + projects.delete(projectId) + }, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + upsert: async (record: any) => { + workspaces.set(record.workspaceId, record) + }, + archive: async (workspaceId: string, archivedAt: string) => { + const existing = workspaces.get(workspaceId) + if (!existing) { + return + } + workspaces.set(workspaceId, { + ...existing, + archivedAt, + updatedAt: archivedAt, + }) + }, + remove: async (workspaceId: string) => { + workspaces.delete(workspaceId) + }, + } as any, + createAgentMcpTransport: async () => { + throw new Error('not used') + }, + stt: null, + tts: null, + terminalManager: null, + }) as any + + session.listAgentPayloads = async () => [] + + return { + session, + emitted, + } +} + +describe('workspace git watch targets', () => { + beforeEach(() => { + watchCalls.length = 0 + watchMock.mockClear() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('debounces watcher events and skips unchanged branch/diff snapshots', async () => { + const { session, emitted } = createSessionForWorkspaceGitWatchTests() + const sessionAny = session as any + + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: 'repo', + checkout: { + cwd, + isGit: true, + currentBranch: 'main', + remoteUrl: 'https://github.com/acme/repo.git', + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + sessionAny.resolveCheckoutGitDir = async () => '/tmp/repo/.git' + sessionAny.workspaceUpdatesSubscription = { + subscriptionId: 'sub-1', + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + } + sessionAny.reconcileActiveWorkspaceRecords = async () => new Set() + + let descriptor = { + id: '/tmp/repo', + projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'git', + workspaceKind: 'local_checkout', + name: 'main', + status: 'done', + activityAt: null, + diffStat: { additions: 1, deletions: 0 }, + } + + sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor] + + await sessionAny.ensureWorkspaceRegistered('/tmp/repo') + sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]) + + expect(watchCalls.map((entry) => entry.path).sort()).toEqual([ + '/tmp/repo/.git/HEAD', + '/tmp/repo/.git/refs/heads', + ]) + + watchCalls[0]!.listener() + watchCalls[1]!.listener() + await vi.advanceTimersByTimeAsync(500) + + expect(emitted.filter((message) => message.type === 'workspace_update')).toHaveLength(0) + + descriptor = { + ...descriptor, + name: 'renamed-branch', + } + watchCalls[0]!.listener() + await vi.advanceTimersByTimeAsync(500) + + const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[] + expect(workspaceUpdates).toHaveLength(1) + expect(workspaceUpdates[0]?.payload).toMatchObject({ + kind: 'upsert', + workspace: { + id: '/tmp/repo', + name: 'renamed-branch', + diffStat: { additions: 1, deletions: 0 }, + }, + }) + + descriptor = { + ...descriptor, + diffStat: { additions: 3, deletions: 1 }, + } + watchCalls[1]!.listener() + await vi.advanceTimersByTimeAsync(500) + + expect(emitted.filter((message) => message.type === 'workspace_update')).toHaveLength(2) + + await session.cleanup() + }) + + test('closes watchers when a workspace is archived and when the session closes', async () => { + const { session } = createSessionForWorkspaceGitWatchTests() + const sessionAny = session as any + + sessionAny.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: path.basename(cwd), + checkout: { + cwd, + isGit: true, + currentBranch: 'main', + remoteUrl: 'https://github.com/acme/repo.git', + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + + sessionAny.resolveCheckoutGitDir = async (cwd: string) => path.join(cwd, '.git') + + await sessionAny.ensureWorkspaceRegistered('/tmp/repo-one') + expect(sessionAny.workspaceGitWatchTargets.size).toBe(1) + expect(watchCalls).toHaveLength(2) + + await sessionAny.archiveWorkspaceRecord('/tmp/repo-one', '2026-03-21T00:00:00.000Z') + + expect(sessionAny.workspaceGitWatchTargets.size).toBe(0) + expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true) + + watchCalls.length = 0 + watchMock.mockClear() + + await sessionAny.ensureWorkspaceRegistered('/tmp/repo-two') + expect(sessionAny.workspaceGitWatchTargets.size).toBe(1) + expect(watchCalls).toHaveLength(2) + + await session.cleanup() + + expect(sessionAny.workspaceGitWatchTargets.size).toBe(0) + expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true) + }) + + test('resolves refs from the shared git dir for linked worktrees', async () => { + const { session } = createSessionForWorkspaceGitWatchTests() + const sessionAny = session as any + const tempDir = mkdtempSync(path.join(tmpdir(), 'session-workspace-git-watch-')) + const gitDir = path.join(tempDir, 'repo', '.git', 'worktrees', 'feature') + + mkdirSync(gitDir, { recursive: true }) + writeFileSync(path.join(gitDir, 'commondir'), '../..\n') + + try { + expect(await sessionAny.resolveWorkspaceGitRefsRoot(gitDir)).toBe(path.join(tempDir, 'repo', '.git')) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + await session.cleanup() + } + }) +})