Fix archived workspace session routing\n\nCloses #128
This commit is contained in:
@@ -608,12 +608,7 @@ export default function RootLayout() {
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) =>
|
||||
`${params?.serverId}:${params?.workspaceId}`
|
||||
}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { useGlobalSearchParams, useLocalSearchParams } from 'expo-router'
|
||||
import { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -9,6 +9,8 @@ export default function HostWorkspaceLayout() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[]
|
||||
workspaceId?: string | string[]
|
||||
}>()
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[]
|
||||
}>()
|
||||
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
||||
@@ -17,7 +19,7 @@ export default function HostWorkspaceLayout() {
|
||||
: params.workspaceId
|
||||
const serverId = serverValue?.trim() ?? ''
|
||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
||||
const openValue = Array.isArray(params.open) ? params.open[0] : params.open
|
||||
const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useCallback, useMemo, useState, type ReactElement } from 'react'
|
||||
import { router, usePathname, type Href } from 'expo-router'
|
||||
import { router, type Href } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import { shortenPath } from '@/utils/shorten-path'
|
||||
@@ -250,7 +250,6 @@ export function AgentList({
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
@@ -270,15 +269,13 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId
|
||||
const agentId = agent.id
|
||||
const shouldReplace = pathname.startsWith('/h/')
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
|
||||
onAgentSelect?.()
|
||||
|
||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
||||
navigate(route)
|
||||
router.navigate(route)
|
||||
},
|
||||
[isActionSheetVisible, pathname, onAgentSelect]
|
||||
[isActionSheetVisible, onAgentSelect]
|
||||
)
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -196,8 +195,6 @@ export function useCommandCenter() {
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
didNavigateRef.current = true;
|
||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||
const navigate = shouldReplace ? router.replace : router.push;
|
||||
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
@@ -207,9 +204,9 @@ export function useCommandCenter() {
|
||||
agent.cwd,
|
||||
agent.id
|
||||
) as Href;
|
||||
navigate(route);
|
||||
router.navigate(route);
|
||||
},
|
||||
[pathname, setOpen]
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import {
|
||||
canOpenAgentTabFromRoute,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
|
||||
function makeAgent(input: {
|
||||
@@ -104,6 +105,33 @@ describe("workspace agent visibility", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prune archived agent tabs when the workspace lookup still contains the agent", () => {
|
||||
const archivedAgent = makeAgent({
|
||||
id: "archived-agent",
|
||||
cwd: "/repo/worktree",
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
const lookup = new Map<string, Agent>([[archivedAgent.id, archivedAgent]]);
|
||||
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: "archived-agent",
|
||||
agentsHydrated: true,
|
||||
workspaceAgentLookup: lookup,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prunes agent tabs once agents are hydrated and the agent is missing from the workspace lookup", () => {
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: "missing-agent",
|
||||
agentsHydrated: true,
|
||||
workspaceAgentLookup: new Map<string, Agent>(),
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts same-createdAt agents by lastActivityAt descending", () => {
|
||||
const workspaceId = "/repo/worktree";
|
||||
const createdAt = new Date("2026-03-04T00:00:00.000Z");
|
||||
|
||||
@@ -72,3 +72,17 @@ export function canOpenAgentTabFromRoute(input: {
|
||||
}
|
||||
return input.workspaceAgentLookup.has(input.agentId);
|
||||
}
|
||||
|
||||
export function shouldPruneWorkspaceAgentTab(input: {
|
||||
agentId: string;
|
||||
agentsHydrated: boolean;
|
||||
workspaceAgentLookup: Map<string, Agent>;
|
||||
}): boolean {
|
||||
if (!input.agentId.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!input.agentsHydrated) {
|
||||
return false;
|
||||
}
|
||||
return !input.workspaceAgentLookup.has(input.agentId);
|
||||
}
|
||||
|
||||
@@ -95,8 +95,12 @@ import {
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import {
|
||||
buildWorkspacePaneContentModel,
|
||||
WorkspacePaneContent,
|
||||
@@ -148,6 +152,34 @@ function buildOpenIntentKey(input: {
|
||||
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";
|
||||
@@ -796,6 +828,7 @@ function WorkspaceScreenContent({
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||
const reopenedArchivedOpenIntentsRef = useRef(new Set<string>());
|
||||
const pendingCloseTabIdsRef = useRef(new Set<string>());
|
||||
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
||||
const currentOpenIntentKey = useMemo(
|
||||
@@ -881,6 +914,43 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || openIntent?.kind !== "agent" || !currentOpenIntentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = openIntent.agentId.trim();
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (reopenedArchivedOpenIntentsRef.current.has(currentOpenIntentKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = agentsById.get(agentId) ?? null;
|
||||
if (!agent?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
reopenedArchivedOpenIntentsRef.current.add(currentOpenIntentKey);
|
||||
void client.refreshAgent(agentId).catch((error) => {
|
||||
console.warn("[WorkspaceScreen] Failed to reopen archived agent", {
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, [
|
||||
agentsById,
|
||||
client,
|
||||
currentOpenIntentKey,
|
||||
isConnected,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openIntent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openIntent || !persistenceKey) {
|
||||
return;
|
||||
@@ -904,9 +974,6 @@ function WorkspaceScreenContent({
|
||||
...(draftId === "new" ? {} : { draftId }),
|
||||
focus: true,
|
||||
});
|
||||
if (tabId) {
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -918,15 +985,15 @@ function WorkspaceScreenContent({
|
||||
: { kind: "file", path: openIntent.path };
|
||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
||||
if (tabId) {
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
focusWorkspaceTab(persistenceKey, tabId);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusWorkspaceTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
resolvedOpenIntentKey,
|
||||
]);
|
||||
|
||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||
@@ -956,7 +1023,6 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
const agentIds = new Set(workspaceAgents.map((agent) => agent.id));
|
||||
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
||||
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
||||
if (tab.target.kind !== "draft") {
|
||||
@@ -989,7 +1055,15 @@ function WorkspaceScreenContent({
|
||||
const canPruneAgentTabs = hasHydratedAgents;
|
||||
const canPruneTerminalTabs = terminalsQuery.isSuccess;
|
||||
for (const tab of uiTabs) {
|
||||
if (canPruneAgentTabs && tab.target.kind === "agent" && !agentIds.has(tab.target.agentId)) {
|
||||
if (
|
||||
canPruneAgentTabs &&
|
||||
tab.target.kind === "agent" &&
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: tab.target.agentId,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
workspaceAgentLookup: agentsById,
|
||||
})
|
||||
) {
|
||||
closeWorkspaceTab(persistenceKey, tab.tabId);
|
||||
}
|
||||
if (
|
||||
@@ -1013,6 +1087,24 @@ function WorkspaceScreenContent({
|
||||
]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !persistenceKey) {
|
||||
@@ -1021,8 +1113,6 @@ function WorkspaceScreenContent({
|
||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||
|
||||
const activeTab = resolvedPaneTabState.activeTab;
|
||||
|
||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||
[resolvedPaneTabState.tabs]
|
||||
|
||||
Reference in New Issue
Block a user