mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb |
37
.github/workflows/desktop-release.yml
vendored
37
.github/workflows/desktop-release.yml
vendored
@@ -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
32
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.32",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
27
packages/app/src/utils/desktop-window-drag-coordinates.ts
Normal file
27
packages/app/src/utils/desktop-window-drag-coordinates.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
packages/app/src/utils/desktop-window.test.ts
Normal file
40
packages/app/src/utils/desktop-window.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
42
packages/app/src/utils/workspace-navigation.ts
Normal file
42
packages/app/src/utils/workspace-navigation.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,7 @@ extraResources:
|
||||
to: app-dist
|
||||
publish:
|
||||
provider: github
|
||||
owner: anthropics
|
||||
owner: getpaseo
|
||||
repo: paseo
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
71
packages/desktop/src/window/window-drag-coordinates.test.ts
Normal file
71
packages/desktop/src/window/window-drag-coordinates.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
64
packages/desktop/src/window/window-drag-coordinates.ts
Normal file
64
packages/desktop/src/window/window-drag-coordinates.ts
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.32",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user