mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user