Compare commits

...

8 Commits

Author SHA1 Message Date
Mohamed Boudra
60e833245d fix(desktop): add cleanup-assets job to delete stale release assets before rebuild 2026-03-21 15:56:01 +07:00
Mohamed Boudra
f424503234 fix(app): skip agent list refresh when screen is unfocused 2026-03-21 15:47:43 +07:00
Mohamed Boudra
1d42542514 refactor: extract isInteractiveDesktopDragTarget and add debug logging 2026-03-21 15:44:49 +07:00
Mohamed Boudra
d1314a4d5d Merge branch 'feat/unit-1-workspace-tab-store-navigation'
# Conflicts:
#	packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx
#	packages/app/src/screens/workspace/workspace-agent-visibility.test.ts
#	packages/app/src/screens/workspace/workspace-agent-visibility.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
2026-03-21 15:27:35 +07:00
Mohamed Boudra
48c3308c31 refactor: move workspace tab navigation logic to prepareWorkspaceTab 2026-03-21 15:21:02 +07:00
Mohamed Boudra
ba149330b1 fix(desktop): fix release workflow publish target, signing, and Windows build
- Change electron-builder publish owner from anthropics to getpaseo
- Remove CSC_NAME (auto-discovered from cert, secret had rejected prefix)
- Remove CSC_IDENTITY_AUTO_DISCOVERY=false from build script (breaks Windows cmd.exe)
2026-03-21 15:02:40 +07:00
Mohamed Boudra
6731879931 fix(desktop): guard manual window drag coordinates 2026-03-21 14:58:57 +07:00
Mohamed Boudra
b15159caeb chore(release): cut 0.1.32 2026-03-21 14:47:53 +07:00
39 changed files with 769 additions and 585 deletions

View File

@@ -35,7 +35,41 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
cleanup-assets:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Delete all existing release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
assets=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)
if [[ -z "$assets" ]]; then
echo "No existing assets to clean up"
exit 0
fi
for asset in $assets; do
echo "Deleting $asset"
gh release delete-asset "$RELEASE_TAG" "$asset" --repo "${{ github.repository }}" --yes || true
done
publish-macos:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
strategy:
fail-fast: false
@@ -134,7 +168,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_NAME: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
@@ -150,6 +183,7 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
publish-linux:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:
contents: write
@@ -250,6 +284,7 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
needs: cleanup-assets
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
permissions:
contents: write

32
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.31",
"version": "0.1.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.31",
"version": "0.1.32",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34268,15 +34268,15 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"@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.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -34399,11 +34399,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -34440,10 +34440,10 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"@getpaseo/cli": "0.1.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/cli": "0.1.32",
"@getpaseo/server": "0.1.32",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -34476,7 +34476,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.31",
"version": "0.1.32",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -34513,7 +34513,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -34529,12 +34529,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.31",
"@getpaseo/relay": "0.1.32",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
@@ -34898,7 +34898,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.31",
"version": "0.1.32",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "../../src/utils/host-routes";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
@@ -162,7 +159,7 @@ export async function seedBottomAnchorAgent(input: {
id: created.id,
title,
expectedTailText,
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
};
}

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,8 +31,8 @@
"@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.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/expo-two-way-audio": "0.1.32",
"@getpaseo/server": "0.1.32",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -4,8 +4,8 @@ import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
const router = useRouter();
@@ -39,7 +39,11 @@ export default function HostAgentReadyRoute() {
if (normalizedCwd) {
redirectedRef.current = true;
router.replace(
buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
target: { kind: "agent", agentId },
}) as any
);
}
}, [agentCwd, agentId, router, serverId]);
@@ -78,7 +82,13 @@ export default function HostAgentReadyRoute() {
const cwd = result?.agent?.cwd?.trim();
redirectedRef.current = true;
if (cwd) {
router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any);
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
target: { kind: "agent", agentId },
}) as any
);
return;
}
router.replace(buildHostRootRoute(serverId) as any);

View File

@@ -5,9 +5,9 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
@@ -59,11 +59,11 @@ export default function HostIndexRoute() {
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
buildHostWorkspaceAgentRoute(
prepareWorkspaceTab({
serverId,
primaryAgent.cwd.trim(),
primaryAgent.id
) as any
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any
);
return;
}

View File

@@ -1,14 +1,42 @@
import { useCallback } from 'react'
import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === 'string') {
return value.trim()
}
if (Array.isArray(value)) {
const firstValue = value[0]
return typeof firstValue === 'string' ? firstValue.trim() : ''
}
return ''
}
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
if (openIntent.kind === 'agent') {
return { kind: 'agent', agentId: openIntent.agentId }
}
if (openIntent.kind === 'terminal') {
return { kind: 'terminal', terminalId: openIntent.terminalId }
}
if (openIntent.kind === 'file') {
return { kind: 'file', path: openIntent.path }
}
return { kind: 'draft', draftId: openIntent.draftId }
}
export default function HostWorkspaceLayout() {
const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const params = useLocalSearchParams<{
serverId?: string | string[]
workspaceId?: string | string[]
@@ -16,29 +44,44 @@ export default function HostWorkspaceLayout() {
const globalParams = useGlobalSearchParams<{
open?: string | string[]
}>()
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
const workspaceValue = Array.isArray(params.workspaceId)
? params.workspaceId[0]
: params.workspaceId
const serverId = serverValue?.trim() ?? ''
const serverId = getParamValue(params.serverId)
const workspaceValue = getParamValue(params.workspaceId)
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
const openIntent = parseWorkspaceOpenIntent(openValue)
const openValue = getParamValue(globalParams.open)
const handleOpenIntentConsumed = useCallback(
function handleOpenIntentConsumed() {
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any)
},
[router, serverId, workspaceId]
)
useEffect(() => {
if (!openValue) {
return
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`
if (consumedIntentRef.current === consumptionKey) {
return
}
consumedIntentRef.current = consumptionKey
const openIntent = parseWorkspaceOpenIntent(openValue)
const route = openIntent
? prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === 'agent',
})
: buildHostWorkspaceRoute(serverId, workspaceId)
router.replace(route as any)
}, [openValue, router, serverId, workspaceId])
if (openValue) {
return null
}
return (
<WorkspaceScreen
key={`${serverId}:${workspaceId}`}
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
onOpenIntentConsumed={handleOpenIntentConsumed}
/>
)
}

View File

@@ -9,14 +9,14 @@ import {
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useCallback, useMemo, useState, type ReactElement } from 'react'
import { router, type Href } from 'expo-router'
import { router } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { formatTimeAgo } from '@/utils/time'
import { shortenPath } from '@/utils/shorten-path'
import { type AggregatedAgent } from '@/hooks/use-aggregated-agents'
import { useSessionStore } from '@/stores/session-store'
import { AgentStatusDot } from '@/components/agent-status-dot'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
interface AgentListProps {
agents: AggregatedAgent[]
@@ -272,8 +272,12 @@ export function AgentList({
onAgentSelect?.()
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
router.navigate(route)
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
target: { kind: 'agent', agentId },
})
router.navigate(route as any)
},
[isActionSheetVisible, onAgentSelect]
)

View File

@@ -67,8 +67,8 @@ import {
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
@@ -171,12 +171,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return;
}
const route = buildHostWorkspaceFileRoute(
resolvedServerId,
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
normalized.file
);
router.replace(route as any);
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
return;
}
@@ -261,6 +261,44 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
[looseGap, tightGap]
);
// ---------------------------------------------------------------------------
// DEBUG: track when render callback deps change
// ---------------------------------------------------------------------------
const debugStreamPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugStreamPrevRef.current;
const curr: Record<string, unknown> = {
// handleInlinePathPress deps (line 196-205)
"hip.agent.cwd": agent.cwd,
"hip.openFileExplorer": openFileExplorer,
"hip.requestDirectoryListing": requestDirectoryListing,
"hip.resolvedServerId": resolvedServerId,
"hip.router": router,
"hip.setExplorerTabForCheckout": setExplorerTabForCheckout,
"hip.onOpenWorkspaceFile": onOpenWorkspaceFile,
"hip.workspaceId": workspaceId,
// top-level deps
handleInlinePathPress,
"agent.status": agent.status,
streamRenderStrategy,
getGapBetween,
streamItems,
"streamItems.length": streamItems.length,
streamHead,
baseRenderModel,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentStreamView] deps changed:", changed.join(", "));
}
debugStreamPrevRef.current = curr;
});
const renderStreamItemContent = useCallback(
(
item: StreamItem,

View File

@@ -460,6 +460,9 @@ function MobileSidebar({
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
@@ -623,6 +626,9 @@ function DesktopSidebar({
<Pressable
style={styles.newAgentButton}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={handleViewMore}
>
{({ hovered }) => (

View File

@@ -759,6 +759,13 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
disableOuterSpacing,
}: AssistantMessageProps) {
// DEBUG: log when AssistantMessage actually renders (inside memo boundary)
console.log("[AssistantMessage] render", {
messageLength: message?.length,
timestamp,
hasOnInlinePathPress: !!onInlinePathPress,
});
const { theme, rt } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
@@ -1918,6 +1925,9 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
}: ToolCallProps) {
// DEBUG: log when ToolCall actually renders (inside memo boundary)
console.log("[ToolCall] render", { toolName, status });
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);

View File

@@ -33,9 +33,9 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
import { getIsDesktop } from '@/constants/layout'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import {
buildHostWorkspaceRouteWithOpenIntent,
parseHostWorkspaceRouteFromPathname,
} from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -1649,10 +1649,11 @@ export function SidebarWorkspaceList({
}, 3000)
)
onWorkspacePress?.()
router.replace(
buildHostWorkspaceRouteWithOpenIntent(serverId, workspace.id, {
kind: 'draft',
draftId: 'new',
router.navigate(
prepareWorkspaceTab({
serverId,
workspaceId: workspace.id,
target: { kind: 'draft', draftId: 'new' },
}) as any
)
} catch (error) {

View File

@@ -12,11 +12,11 @@ import {
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import {
buildHostWorkspaceAgentRoute,
buildHostSettingsRoute,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
const EMPTY_AGENTS: AggregatedAgent[] = [];
@@ -199,12 +199,12 @@ export function useCommandCenter() {
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const route: Href = buildHostWorkspaceAgentRoute(
agent.serverId,
agent.cwd,
agent.id
) as Href;
router.navigate(route);
const route = prepareWorkspaceTab({
serverId: agent.serverId,
workspaceId: agent.cwd,
target: { kind: "agent", agentId: agent.id },
});
router.navigate(route as any);
},
[setOpen]
);

View File

@@ -6,7 +6,7 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export function useOpenProject(
serverId: string | null
@@ -37,11 +37,11 @@ export function useOpenProject(
]);
setHasHydratedWorkspaces(normalizedServerId, true);
router.replace(
buildHostWorkspaceRouteWithOpenIntent(
normalizedServerId,
payload.workspace.id,
{ kind: "draft", draftId: "new" }
) as any
prepareWorkspaceTab({
serverId: normalizedServerId,
workspaceId: payload.workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any
);
return true;
} catch (error) {

View File

@@ -107,14 +107,18 @@ function useAgentPanelDescriptor(
function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
const handleOpenWorkspaceFile = useCallback(
(input: { filePath: string }) => {
openFileInWorkspace(input.filePath);
},
[openFileInWorkspace]
);
return (
<AgentPanelContent
serverId={serverId}
agentId={target.agentId}
isPaneFocused={isPaneFocused}
onOpenWorkspaceFile={({ filePath }) => {
openFileInWorkspace(filePath);
}}
onOpenWorkspaceFile={handleOpenWorkspaceFile}
/>
);
}
@@ -336,6 +340,46 @@ function AgentPanelBody({
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]);
// ---------------------------------------------------------------------------
// DEBUG: track which selector values change between renders
// ---------------------------------------------------------------------------
const debugPrevRef = useRef<Record<string, unknown>>({});
useEffect(() => {
const prev = debugPrevRef.current;
const curr: Record<string, unknown> = {
agent,
"agent?.status": agent?.status,
"agent?.cwd": agent?.cwd,
"agent?.updatedAt": agent?.updatedAt,
"agent?.requiresAttention": agent?.requiresAttention,
streamItemsRaw,
"streamItems.length": streamItems.length,
allPendingPermissions,
isInitializingFromMap,
historySyncGeneration,
hasAppliedAuthoritativeHistory,
agentHistorySyncGeneration,
hasSession,
isPaneFocused,
isConnected,
};
const changed: string[] = [];
for (const key of Object.keys(curr)) {
if (!Object.is(prev[key], curr[key])) {
changed.push(key);
}
}
if (changed.length > 0 && Object.keys(prev).length > 0) {
console.log("[AgentPanelBody] values changed:", changed.join(", "), {
changed: Object.fromEntries(
changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])
),
});
}
debugPrevRef.current = curr;
});
// ---------------------------------------------------------------------------
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { createNameId } from 'mnemonic-id'
import type { ImageAttachment } from '@/components/message-input'
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native'
import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -46,7 +46,7 @@ import type {
AgentSessionConfig,
} from '@server/server/agent/agent-sdk-types'
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
import { useDesktopDragHandlers } from '@/utils/desktop-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
@@ -928,12 +928,12 @@ function DraftAgentScreenContent({
}
},
onCreateSuccess: ({ result }) => {
const route: Href = buildHostWorkspaceAgentRoute(
selectedServerId as string,
result.cwd,
result.id
) as Href
router.replace(route)
const route = prepareWorkspaceTab({
serverId: selectedServerId as string,
workspaceId: result.cwd,
target: { kind: 'agent', agentId: result.id },
})
router.replace(route as any)
},
})
useEffect(() => {

View File

@@ -1,5 +1,6 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { AgentList } from "@/components/agent-list";
@@ -8,6 +9,16 @@ import { router } from "expo-router";
import { buildHostRootRoute } from "@/utils/host-routes";
export function AgentsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
if (!isFocused) {
return <View style={styles.container} />;
}
return <AgentsScreenContent serverId={serverId} />;
}
function AgentsScreenContent({ serverId }: { serverId: string }) {
const { agents, isRevalidating, refreshAll } = useAllAgentsList({
serverId,
includeArchived: true,

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import {
canOpenAgentTabFromRoute,
deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
@@ -89,18 +88,6 @@ describe("workspace agent visibility", () => {
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
});
it("allows explicit route open for archived agent once agents are hydrated", () => {
const knownAgentIds = new Set(["archived-agent"]);
expect(
canOpenAgentTabFromRoute({
agentId: "archived-agent",
agentsHydrated: true,
knownAgentIds,
})
).toBe(true);
});
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>();

View File

@@ -57,24 +57,8 @@ function setsEqual(a: Set<string>, b: Set<string>): boolean {
return true;
}
export function canOpenAgentTabFromRoute(input: {
agentId: string;
agentsHydrated: boolean;
knownAgentIds: Set<string>;
}): boolean {
if (!input.agentId.trim()) {
return false;
}
if (!input.agentsHydrated) {
return true;
}
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;

View File

@@ -64,8 +64,6 @@ 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,
type WorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
@@ -103,7 +101,6 @@ import {
} from "@/screens/workspace/workspace-agent-visibility";
import {
deriveWorkspacePaneState,
type WorkspaceDerivedTab,
} from "@/screens/workspace/workspace-pane-state";
import {
buildWorkspacePaneContentModel,
@@ -126,8 +123,6 @@ const EMPTY_SET = new Set<string>();
type WorkspaceScreenProps = {
serverId: string;
workspaceId: string;
openIntent?: WorkspaceOpenIntent | null;
onOpenIntentConsumed?: () => void;
};
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -146,49 +141,6 @@ function decodeSegment(value: string): string {
}
}
function buildOpenIntentKey(input: {
serverId: string;
workspaceId: string;
openIntent?: WorkspaceOpenIntent | null;
}): string | null {
if (!input.openIntent) {
return null;
}
const openParam = buildWorkspaceOpenIntentParam(input.openIntent);
if (!openParam) {
return null;
}
return `${input.serverId}:${input.workspaceId}:${openParam}`;
}
function openIntentMatchesActiveTab(input: {
openIntent?: WorkspaceOpenIntent | null;
activeTab: WorkspaceDerivedTab | null;
}): boolean {
const { openIntent, activeTab } = input;
if (!openIntent || !activeTab) {
return false;
}
const target = activeTab.descriptor.target;
if (openIntent.kind !== target.kind) {
return false;
}
if (openIntent.kind === "agent" && target.kind === "agent") {
return target.agentId === openIntent.agentId;
}
if (openIntent.kind === "terminal" && target.kind === "terminal") {
return target.terminalId === openIntent.terminalId;
}
if (openIntent.kind === "file" && target.kind === "file") {
return target.path === openIntent.path;
}
if (openIntent.kind === "draft" && target.kind === "draft") {
return openIntent.draftId === "new" || target.draftId === openIntent.draftId;
}
return false;
}
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") {
return "New Agent";
@@ -509,8 +461,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
export function WorkspaceScreen({
serverId,
workspaceId,
openIntent,
onOpenIntentConsumed,
}: WorkspaceScreenProps) {
const isFocused = useIsFocused();
@@ -523,8 +473,6 @@ export function WorkspaceScreen({
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
openIntent={openIntent}
onOpenIntentConsumed={onOpenIntentConsumed}
/>
</ExplorerSidebarAnimationProvider>
);
@@ -563,8 +511,6 @@ function useCloseTabs(): UseCloseTabsResult {
function WorkspaceScreenContent({
serverId,
workspaceId,
openIntent,
onOpenIntentConsumed,
}: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -846,7 +792,6 @@ function WorkspaceScreenContent({
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const pinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.pinAgent);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
@@ -861,33 +806,7 @@ function WorkspaceScreenContent({
: EMPTY_PINNED_AGENT_IDS
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const consumedOpenIntentsRef = useRef(new Set<string>());
const { closingTabIds, closeTab } = useCloseTabs();
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
const currentOpenIntentKey = useMemo(
() =>
buildOpenIntentKey({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
openIntent,
}),
[normalizedServerId, normalizedWorkspaceId, openIntent]
);
const currentOpenIntentPinnedAgentId = useMemo(
function getCurrentOpenIntentPinnedAgentId() {
if (!openIntent || openIntent.kind !== "agent" || !hasHydratedAgents) {
return null;
}
if (!workspaceAgentVisibility.knownAgentIds.has(openIntent.agentId)) {
return null;
}
if (workspaceAgentVisibility.activeAgentIds.has(openIntent.agentId)) {
return null;
}
return openIntent.agentId;
},
[hasHydratedAgents, openIntent, workspaceAgentVisibility]
);
const closeWorkspaceTabWithCleanup = useCallback(
function closeWorkspaceTabWithCleanup(input: {
tabId: string;
@@ -984,96 +903,6 @@ function WorkspaceScreenContent({
[ensureWorkspaceTab, focusWorkspaceTab, openWorkspaceTab, persistenceKey]
);
useEffect(() => {
if (!currentOpenIntentKey) {
if (resolvedOpenIntentKey !== null) {
setResolvedOpenIntentKey(null);
}
return;
}
if (resolvedOpenIntentKey === currentOpenIntentKey) {
return;
}
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
useEffect(() => {
if (!openIntent || !persistenceKey) {
return;
}
if (!currentOpenIntentKey) {
return;
}
const intentKey = currentOpenIntentKey;
if (consumedOpenIntentsRef.current.has(intentKey)) {
if (resolvedOpenIntentKey !== intentKey) {
setResolvedOpenIntentKey(intentKey);
}
return;
}
consumedOpenIntentsRef.current.add(intentKey);
if (currentOpenIntentPinnedAgentId) {
pinWorkspaceAgent(persistenceKey, currentOpenIntentPinnedAgentId);
}
if (openIntent.kind === "draft") {
const draftId = openIntent.draftId.trim();
const tabId = openWorkspaceDraftTab({
...(draftId === "new" ? {} : { draftId }),
focus: true,
});
onOpenIntentConsumed?.();
return;
}
const target: WorkspaceTabTarget =
openIntent.kind === "agent"
? { kind: "agent", agentId: openIntent.agentId }
: openIntent.kind === "terminal"
? { kind: "terminal", terminalId: openIntent.terminalId }
: { kind: "file", path: openIntent.path };
const tabId = openWorkspaceTab(persistenceKey, target);
if (tabId) {
focusWorkspaceTab(persistenceKey, tabId);
}
onOpenIntentConsumed?.();
}, [
currentOpenIntentPinnedAgentId,
currentOpenIntentKey,
focusWorkspaceTab,
onOpenIntentConsumed,
openIntent,
openWorkspaceDraftTab,
openWorkspaceTab,
pinWorkspaceAgent,
persistenceKey,
]);
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
? openIntent
: null;
const resolvedPaneTabState = useMemo(
() =>
deriveWorkspacePaneState({
layout: workspaceLayout,
tabs: uiTabs,
preferredTarget:
unresolvedOpenIntent?.kind === "agent"
? { kind: "agent", agentId: unresolvedOpenIntent.agentId }
: unresolvedOpenIntent?.kind === "terminal"
? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId }
: unresolvedOpenIntent?.kind === "draft"
? { kind: "draft", draftId: unresolvedOpenIntent.draftId }
: unresolvedOpenIntent?.kind === "file"
? { kind: "file", path: unresolvedOpenIntent.path }
: null,
}),
[uiTabs, unresolvedOpenIntent, workspaceLayout]
);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) {
return;
@@ -1115,7 +944,6 @@ function WorkspaceScreenContent({
canPruneAgentTabs &&
tab.target.kind === "agent" &&
!pinnedAgentIds.has(tab.target.agentId) &&
tab.target.agentId !== currentOpenIntentPinnedAgentId &&
shouldPruneWorkspaceAgentTab({
agentId: tab.target.agentId,
agentsHydrated: hasHydratedAgents,
@@ -1135,7 +963,6 @@ function WorkspaceScreenContent({
}
}, [
closeWorkspaceTabWithCleanup,
currentOpenIntentPinnedAgentId,
ensureWorkspaceTab,
hasHydratedAgents,
pendingByDraftId,
@@ -1147,25 +974,8 @@ function WorkspaceScreenContent({
workspaceAgentVisibility,
]);
const activeTabId = resolvedPaneTabState.activeTabId;
const activeTab = resolvedPaneTabState.activeTab;
useEffect(() => {
if (!openIntent || !currentOpenIntentKey) {
return;
}
if (resolvedOpenIntentKey === currentOpenIntentKey) {
return;
}
if (
openIntentMatchesActiveTab({
openIntent,
activeTab,
})
) {
setResolvedOpenIntentKey(currentOpenIntentKey);
}
}, [activeTab, currentOpenIntentKey, openIntent, resolvedOpenIntentKey]);
const activeTabId = focusedPaneTabState.activeTabId;
const activeTab = focusedPaneTabState.activeTab;
useEffect(() => {
if (!activeTabId || !persistenceKey) {
@@ -1175,8 +985,8 @@ function WorkspaceScreenContent({
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
const tabs = useMemo<WorkspaceTabDescriptor[]>(
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
[resolvedPaneTabState.tabs]
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
[focusedPaneTabState.tabs]
);
const navigateToTabId = useCallback(
@@ -1194,10 +1004,6 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
if (openIntent) {
emptyWorkspaceSeedRef.current = null;
return;
}
if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) {
emptyWorkspaceSeedRef.current = null;
return;
@@ -1215,7 +1021,6 @@ function WorkspaceScreenContent({
}, [
normalizedServerId,
normalizedWorkspaceId,
openIntent,
openWorkspaceDraftTab,
persistenceKey,
terminals.length,

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
describe('readFiniteScreenPoint', () => {
it('returns finite screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
screenX: 1280,
screenY: 720,
})
})
it('rejects NaN screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull()
})
it('rejects infinite screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull()
})
it('rejects missing screen coordinates', () => {
expect(readFiniteScreenPoint(undefined)).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull()
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull()
})
})

View File

@@ -0,0 +1,27 @@
export type DesktopWindowScreenPoint = {
screenX: number
screenY: number
}
type ScreenPointInput =
| {
screenX?: unknown
screenY?: unknown
}
| null
| undefined
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null
}
return {
screenX: input.screenX,
screenY: input.screenY,
}
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { isInteractiveDesktopDragTarget } from './desktop-window'
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
return {
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
} as unknown as EventTarget
}
describe('isInteractiveDesktopDragTarget', () => {
it('treats focusable pressables as interactive drag exemptions', () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes('[tabindex]'),
})
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
})
it('treats semantic button targets as interactive drag exemptions', () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[role='button']"),
})
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
})
it('returns false for non-interactive targets', () => {
const target = createTarget({
matchesSelector: () => false,
})
expect(isInteractiveDesktopDragTarget(target)).toBe(false)
})
it('returns false when the target does not support closest()', () => {
expect(isInteractiveDesktopDragTarget(null)).toBe(false)
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false)
})
})

View File

@@ -1,20 +1,21 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } 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,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
} from '@/constants/layout'
import { getDesktopWindow } from '@/desktop/electron/window'
import { isDesktop } from '@/desktop/host'
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
export async function toggleMaximize() {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
const win = getDesktopWindow()
if (win && typeof win.toggleMaximize === 'function') {
try {
await win.toggleMaximize();
await win.toggleMaximize()
} catch (error) {
console.warn("[DesktopWindow] toggleMaximize failed", error);
console.warn('[DesktopWindow] toggleMaximize failed', error)
}
}
}
@@ -27,147 +28,159 @@ export async function toggleMaximize() {
// ---------------------------------------------------------------------------
const INTERACTIVE_SELECTOR =
"button, a, input, textarea, select, " +
'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']";
"[role='menuitem'], [tabindex], [contenteditable='true']"
const DOUBLE_CLICK_MS = 300;
const DOUBLE_CLICK_MS = 300
type DesktopDragViewProps = Pick<
ViewProps,
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
>;
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'
>
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null
if (!candidate || typeof candidate.closest !== 'function') {
return false
}
return Boolean(candidate.closest(INTERACTIVE_SELECTOR))
}
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isDesktop();
const isDragging = useRef(false)
const lastPointerDownAt = useRef(0)
const isActive = Platform.OS === 'web' && isDesktop()
useEffect(() => {
if (!isActive) return;
if (!isActive) return
function handleBlur() {
if (!isDragging.current) return;
isDragging.current = false;
getDesktopWindow()?.endMove?.();
if (!isDragging.current) return
isDragging.current = false
getDesktopWindow()?.endMove?.()
}
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [isActive]);
window.addEventListener('blur', handleBlur)
return () => window.removeEventListener('blur', handleBlur)
}, [isActive])
return useMemo((): DesktopDragViewProps => {
if (!isActive) return {};
if (!isActive) return {}
function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return;
isDragging.current = false;
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);
const el = e.currentTarget as unknown as Element | null
if (el && 'releasePointerCapture' in el) {
el.releasePointerCapture(e.nativeEvent.pointerId)
}
getDesktopWindow()?.endMove?.();
getDesktopWindow()?.endMove?.()
}
return {
onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return;
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;
if (isInteractiveDesktopDragTarget(e.target)) return
e.preventDefault();
e.preventDefault()
const now = Date.now();
const now = Date.now()
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0;
void toggleMaximize();
return;
lastPointerDownAt.current = 0
void toggleMaximize()
return
}
lastPointerDownAt.current = now;
lastPointerDownAt.current = now
const win = getDesktopWindow();
if (!win?.startMove) return;
const win = getDesktopWindow()
if (!win?.startMove) return
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!screenPoint) 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);
isDragging.current = true
const el = e.currentTarget as unknown as Element
el.setPointerCapture(e.nativeEvent.pointerId)
win.startMove(screenPoint.screenX, screenPoint.screenY)
},
onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return;
getDesktopWindow()?.moving?.(e.nativeEvent.screenX, e.nativeEvent.screenY);
if (!isDragging.current) return
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!screenPoint) {
stopDrag(e)
return
}
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY)
},
onPointerUp: stopDrag,
onPointerCancel: stopDrag,
};
}, [isActive]);
}
}, [isActive])
}
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false)
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
if (Platform.OS !== 'web' || !getIsDesktopMac()) return
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
let disposed = false
let cleanup: (() => void) | undefined
let didCleanup = false
function runCleanup() {
if (!cleanup || didCleanup) return;
didCleanup = true;
if (!cleanup || didCleanup) return
didCleanup = true
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
console.warn('[DesktopWindow] Failed to remove resize listener', error)
})
} catch (error) {
console.warn("[DesktopWindow] Failed to remove resize listener", error);
console.warn('[DesktopWindow] Failed to remove resize listener', error)
}
}
async function setup() {
const win = getDesktopWindow();
if (!win) return;
const win = getDesktopWindow()
if (!win) return
const fullscreen =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
const fullscreen = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
if (disposed) return
setIsFullscreen(fullscreen)
if (typeof win.onResized !== "function") {
return;
if (typeof win.onResized !== 'function') {
return
}
const unlisten = await win.onResized(async () => {
if (disposed) return;
const fs =
typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
if (disposed) return
const fs = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
if (disposed) return
setIsFullscreen(fs)
})
cleanup = unlisten;
cleanup = unlisten
if (disposed) {
runCleanup();
runCleanup()
}
}
void setup();
void setup()
return () => {
disposed = true;
runCleanup();
};
}, []);
disposed = true
runCleanup()
}
}, [])
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
return { left: 0, top: 0 }
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
}

View File

@@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildHostAgentDetailRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceFileRoute,
buildHostWorkspaceRoute,
buildHostWorkspaceRouteWithOpenIntent,
buildHostWorkspaceTerminalRoute,
decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
@@ -65,24 +61,6 @@ describe("workspace route parsing", () => {
expect(buildHostRootRoute("local")).toBe("/h/local");
});
it("builds workspace routes with open intent query", () => {
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
);
expect(buildHostWorkspaceTerminalRoute("local", "/tmp/repo", "term-1")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=terminal%3Aterm-1"
);
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=file%3Ac3JjL2luZGV4LnRz"
);
expect(
buildHostWorkspaceRouteWithOpenIntent("local", "/tmp/repo", {
kind: "draft",
draftId: "new",
})
).toBe("/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew");
});
it("parses workspace open intent from pathname query", () => {
expect(
parseHostWorkspaceOpenIntentFromPathname(
@@ -106,7 +84,7 @@ describe("workspace route parsing", () => {
});
});
it("keeps agent detail workspace routing on workspace path with open intent", () => {
it("uses the plain workspace route when workspace context is provided", () => {
expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
);

View File

@@ -144,29 +144,6 @@ export function parseWorkspaceOpenIntent(
return null;
}
export function buildWorkspaceOpenIntentParam(
intent: WorkspaceOpenIntent
): string | null {
if (intent.kind === "agent") {
const agentId = trimNonEmpty(intent.agentId);
return agentId ? `agent:${agentId}` : null;
}
if (intent.kind === "terminal") {
const terminalId = trimNonEmpty(intent.terminalId);
return terminalId ? `terminal:${terminalId}` : null;
}
if (intent.kind === "draft") {
const draftId = trimNonEmpty(intent.draftId);
return draftId ? `draft:${draftId}` : null;
}
const normalizedPath = trimNonEmpty(intent.path);
if (!normalizedPath) {
return null;
}
const encodedPath = encodeFilePathForPathSegment(normalizedPath);
return encodedPath ? `file:${encodedPath}` : null;
}
export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string
): WorkspaceOpenIntent | null {
@@ -311,55 +288,6 @@ export function buildHostWorkspaceRoute(
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
}
export function buildHostWorkspaceRouteWithOpenIntent(
serverId: string,
workspaceId: string,
intent: WorkspaceOpenIntent
): string {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
if (base === "/") {
return "/";
}
const open = buildWorkspaceOpenIntentParam(intent);
if (!open) {
return "/";
}
return `${base}?open=${encodeURIComponent(open)}`;
}
export function buildHostWorkspaceAgentRoute(
serverId: string,
workspaceId: string,
agentId: string
): string {
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
kind: "agent",
agentId,
});
}
export function buildHostWorkspaceTerminalRoute(
serverId: string,
workspaceId: string,
terminalId: string
): string {
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
kind: "terminal",
terminalId,
});
}
export function buildHostWorkspaceFileRoute(
serverId: string,
workspaceId: string,
filePath: string
): string {
return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, {
kind: "file",
path: filePath,
});
}
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string,
@@ -367,11 +295,15 @@ export function buildHostAgentDetailRoute(
): string {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
return buildHostWorkspaceAgentRoute(
serverId,
normalizedWorkspaceId,
agentId
);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedAgentId) {
return "/";
}
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
if (base === "/") {
return "/";
}
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
}
const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId);
@@ -437,19 +369,10 @@ export function mapPathnameToServer(
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) {
const workspacePath = buildHostWorkspaceRoute(
return buildHostWorkspaceRoute(
normalized,
workspaceRoute.workspaceId
);
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
if (!openIntent) {
return workspacePath;
}
return buildHostWorkspaceRouteWithOpenIntent(
normalized,
workspaceRoute.workspaceId,
openIntent
);
}
if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`;

View File

@@ -1,7 +1,7 @@
import {
buildHostAgentDetailRoute,
buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
@@ -36,7 +36,8 @@ export function buildNotificationRoute(data: NotificationData): string {
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
if (serverId && agentId) {
if (workspaceId) {
return buildHostWorkspaceAgentRoute(serverId, workspaceId, agentId);
const base = buildHostWorkspaceRoute(serverId, workspaceId);
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`;
}
return buildHostAgentDetailRoute(serverId, agentId);
}

View File

@@ -0,0 +1,42 @@
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface PrepareWorkspaceTabInput {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
pin?: boolean;
}
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
if (target.kind !== "draft" || target.draftId.trim() !== "new") {
return target;
}
return { kind: "draft", draftId: generateDraftId() };
}
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
const target = getPreparedTarget(input.target);
const key =
buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
}) ?? "";
const tabId = useWorkspaceLayoutStore.getState().openTab(key, target);
if (tabId) {
useWorkspaceLayoutStore.getState().focusTab(key, tabId);
}
if (input.pin && target.kind === "agent") {
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
}
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.31",
"version": "0.1.32",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/relay": "0.1.32",
"@getpaseo/server": "0.1.32",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -10,7 +10,7 @@ extraResources:
to: app-dist
publish:
provider: github
owner: anthropics
owner: getpaseo
repo: paseo
mac:
category: public.app-category.developer-tools

View File

@@ -1,18 +1,18 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": {
"build": "npm --prefix ../.. run build:daemon && npm run build:main && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --config electron-builder.yml",
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.31",
"@getpaseo/server": "0.1.31",
"@getpaseo/cli": "0.1.32",
"@getpaseo/server": "0.1.32",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
} from './window-drag-coordinates'
describe('window-drag-coordinates', () => {
describe('readWindowMovePayload', () => {
it('returns finite screen coordinates', () => {
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
screenX: 1500,
screenY: 900,
})
})
it('rejects non-finite screen coordinates', () => {
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull()
expect(readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY })).toBeNull()
})
})
describe('createWindowMoveState', () => {
it('derives finite offsets from the window position', () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: 1200,
windowY: 700,
})
).toEqual({
offsetX: 300,
offsetY: 200,
})
})
it('rejects non-finite offsets', () => {
expect(
createWindowMoveState({
payload: { screenX: 1500, screenY: 900 },
windowX: Number.NaN,
windowY: 700,
})
).toBeNull()
})
})
describe('resolveWindowMovePosition', () => {
it('rounds the next window position', () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1501.4, screenY: 902.6 },
state: { offsetX: 300, offsetY: 200 },
})
).toEqual({
x: 1201,
y: 703,
})
})
it('rejects non-finite next positions', () => {
expect(
resolveWindowMovePosition({
payload: { screenX: 1500, screenY: 900 },
state: { offsetX: Number.NaN, offsetY: 200 },
})
).toBeNull()
})
})
})

View File

@@ -0,0 +1,64 @@
export type WindowMovePayload = {
screenX: number
screenY: number
}
export type WindowMoveState = {
offsetX: number
offsetY: number
}
export type WindowPosition = {
x: number
y: number
}
type WindowMovePayloadInput =
| {
screenX?: unknown
screenY?: unknown
}
| null
| undefined
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null
}
return {
screenX: input.screenX,
screenY: input.screenY,
}
}
export function createWindowMoveState(input: {
payload: WindowMovePayload
windowX: number
windowY: number
}): WindowMoveState | null {
const offsetX = input.payload.screenX - input.windowX
const offsetY = input.payload.screenY - input.windowY
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
return null
}
return { offsetX, offsetY }
}
export function resolveWindowMovePosition(input: {
payload: WindowMovePayload
state: WindowMoveState
}): WindowPosition | null {
const x = Math.round(input.payload.screenX - input.state.offsetX)
const y = Math.round(input.payload.screenY - input.state.offsetY)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null
}
return { x, y }
}

View File

@@ -1,81 +1,103 @@
import { app, BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain } from 'electron'
import {
createWindowMoveState,
readWindowMovePayload,
resolveWindowMovePosition,
type WindowMoveState,
} from './window-drag-coordinates.js'
export function registerWindowManager(): void {
// ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
// State is keyed per window id to support multiple windows.
// ---------------------------------------------------------------------------
const moveStates = new Map<number, { offsetX: number; offsetY: number }>();
const moveStates = new Map<number, WindowMoveState>()
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:startMove', (event, payload: { screenX: number; screenY: number }) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const movePayload = readWindowMovePayload(payload)
if (!movePayload) {
moveStates.delete(win.id)
return
}
);
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)
);
const [winX, winY] = win.getPosition()
const moveState = createWindowMoveState({
payload: movePayload,
windowX: winX,
windowY: winY,
})
if (!moveState) {
moveStates.delete(win.id)
return
}
);
moveStates.set(win.id, moveState)
})
ipcMain.on("paseo:window:endMove", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) moveStates.delete(win.id);
});
ipcMain.on('paseo:window:moving', (event, payload: { screenX: number; screenY: number }) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const state = moveStates.get(win.id)
if (!state) return
const movePayload = readWindowMovePayload(payload)
if (!movePayload) {
moveStates.delete(win.id)
return
}
const nextPosition = resolveWindowMovePosition({
payload: movePayload,
state,
})
if (!nextPosition) {
moveStates.delete(win.id)
return
}
win.setPosition(nextPosition.x, nextPosition.y)
})
app.on("browser-window-created", (_event, win) => {
win.on("closed", () => moveStates.delete(win.id));
});
ipcMain.on('paseo:window:endMove', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) moveStates.delete(win.id)
})
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
app.on('browser-window-created', (_event, win) => {
win.on('closed', () => moveStates.delete(win.id))
})
ipcMain.handle('paseo:window:toggleMaximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) {
win.unmaximize();
win.unmaximize()
} else {
win.maximize();
win.maximize()
}
});
})
ipcMain.handle("paseo:window:isFullscreen", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
return win?.isFullScreen() ?? false;
});
ipcMain.handle('paseo:window:isFullscreen', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => {
if (process.platform === "darwin" || process.platform === "linux") {
app.setBadgeCount(count ?? 0);
ipcMain.handle('paseo:window:setBadgeCount', (_event, count?: number) => {
if (process.platform === 'darwin' || process.platform === 'linux') {
app.setBadgeCount(count ?? 0)
}
});
})
}
export function setupWindowResizeEvents(win: BrowserWindow): void {
win.on("resize", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on('resize', () => {
win.webContents.send('paseo:window:resized', {})
})
win.on("enter-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on('enter-full-screen', () => {
win.webContents.send('paseo:window:resized', {})
})
win.on("leave-full-screen", () => {
win.webContents.send("paseo:window:resized", {});
});
win.on('leave-full-screen', () => {
win.webContents.send('paseo:window:resized', {})
})
}
/**
@@ -83,11 +105,11 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
* The renderer handles drag-drop via standard HTML5 APIs instead.
*/
export function setupDragDropPrevention(win: BrowserWindow): void {
win.webContents.on("will-navigate", (event, url) => {
win.webContents.on('will-navigate', (event, url) => {
// Allow normal navigation (e.g. dev server hot-reload) but block file:// URLs
// that result from dropping files onto the window.
if (url.startsWith("file://")) {
event.preventDefault();
if (url.startsWith('file://')) {
event.preventDefault()
}
});
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.31",
"version": "0.1.32",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.31",
"version": "0.1.32",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.31",
"version": "0.1.32",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,7 +64,7 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.31",
"@getpaseo/relay": "0.1.32",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.31",
"version": "0.1.32",
"private": true,
"type": "module",
"scripts": {