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()
+ }
+ })
+})