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:
Mohamed Boudra
2026-03-21 15:27:35 +07:00
17 changed files with 180 additions and 405 deletions

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,42 @@
import { useCallback } from 'react' import { useEffect, useRef } from 'react'
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'
import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store'
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen' import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
import { import {
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from '@/utils/host-routes' } 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() { export default function HostWorkspaceLayout() {
const router = useRouter() const router = useRouter()
const consumedIntentRef = useRef<string | null>(null)
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
serverId?: string | string[] serverId?: string | string[]
workspaceId?: string | string[] workspaceId?: string | string[]
@@ -16,29 +44,44 @@ export default function HostWorkspaceLayout() {
const globalParams = useGlobalSearchParams<{ const globalParams = useGlobalSearchParams<{
open?: string | string[] open?: string | string[]
}>() }>()
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId const serverId = getParamValue(params.serverId)
const workspaceValue = Array.isArray(params.workspaceId) const workspaceValue = getParamValue(params.workspaceId)
? params.workspaceId[0]
: params.workspaceId
const serverId = serverValue?.trim() ?? ''
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : '' const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open const openValue = getParamValue(globalParams.open)
const openIntent = parseWorkspaceOpenIntent(openValue)
const handleOpenIntentConsumed = useCallback( useEffect(() => {
function handleOpenIntentConsumed() { if (!openValue) {
router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any) return
}, }
[router, serverId, workspaceId]
) 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 ( return (
<WorkspaceScreen <WorkspaceScreen
key={`${serverId}:${workspaceId}`} key={`${serverId}:${workspaceId}`}
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
openIntent={openIntent}
onOpenIntentConsumed={handleOpenIntentConsumed}
/> />
) )
} }

View File

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

View File

@@ -67,8 +67,8 @@ import {
import { createMarkdownStyles } from "@/styles/markdown-styles"; import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list"; import { getMarkdownListMarker } from "@/utils/markdown-list";
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { import {
getWorkingIndicatorDotStrength, getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS, WORKING_INDICATOR_CYCLE_MS,
@@ -171,12 +171,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return; return;
} }
const route = buildHostWorkspaceFileRoute( const route = prepareWorkspaceTab({
resolvedServerId, serverId: resolvedServerId,
workspaceId, workspaceId,
normalized.file target: { kind: "file", path: normalized.file },
); });
router.replace(route as any); router.navigate(route as any);
return; return;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { import {
canOpenAgentTabFromRoute,
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab, shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
@@ -89,18 +88,6 @@ describe("workspace agent visibility", () => {
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); 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", () => { it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
const knownAgentIds = new Set(["archived-agent"]); const knownAgentIds = new Set(["archived-agent"]);
const activeAgentIds = new Set<string>(); const activeAgentIds = new Set<string>();

View File

@@ -57,24 +57,8 @@ function setsEqual(a: Set<string>, b: Set<string>): boolean {
return true; 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. // 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. // 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: { export function shouldPruneWorkspaceAgentTab(input: {
agentId: string; agentId: string;
agentsHydrated: boolean; agentsHydrated: boolean;

View File

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

View File

@@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
buildHostRootRoute, buildHostRootRoute,
buildHostWorkspaceAgentRoute,
buildHostWorkspaceFileRoute,
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
buildHostWorkspaceRouteWithOpenIntent,
buildHostWorkspaceTerminalRoute,
decodeFilePathFromPathSegment, decodeFilePathFromPathSegment,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment, encodeFilePathForPathSegment,
@@ -65,24 +61,6 @@ describe("workspace route parsing", () => {
expect(buildHostRootRoute("local")).toBe("/h/local"); 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", () => { it("parses workspace open intent from pathname query", () => {
expect( expect(
parseHostWorkspaceOpenIntentFromPathname( 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( expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
); );

View File

@@ -144,29 +144,6 @@ export function parseWorkspaceOpenIntent(
return null; 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( export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string pathname: string
): WorkspaceOpenIntent | null { ): WorkspaceOpenIntent | null {
@@ -311,55 +288,6 @@ export function buildHostWorkspaceRoute(
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; 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( export function buildHostAgentDetailRoute(
serverId: string, serverId: string,
agentId: string, agentId: string,
@@ -367,11 +295,15 @@ export function buildHostAgentDetailRoute(
): string { ): string {
const normalizedWorkspaceId = trimNonEmpty(workspaceId); const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) { if (normalizedWorkspaceId) {
return buildHostWorkspaceAgentRoute( const normalizedAgentId = trimNonEmpty(agentId);
serverId, if (!normalizedAgentId) {
normalizedWorkspaceId, return "/";
agentId }
); const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
if (base === "/") {
return "/";
}
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
} }
const normalizedServerId = trimNonEmpty(serverId); const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId); const normalizedAgentId = trimNonEmpty(agentId);
@@ -437,19 +369,10 @@ export function mapPathnameToServer(
} }
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) { if (workspaceRoute) {
const workspacePath = buildHostWorkspaceRoute( return buildHostWorkspaceRoute(
normalized, normalized,
workspaceRoute.workspaceId workspaceRoute.workspaceId
); );
const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname);
if (!openIntent) {
return workspacePath;
}
return buildHostWorkspaceRouteWithOpenIntent(
normalized,
workspaceRoute.workspaceId,
openIntent
);
} }
if (suffix.startsWith("agent/")) { if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`; return `${base}/${suffix}`;

View File

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

View File

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