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="index" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
name="h/[serverId]/workspace/[workspaceId]"
|
|
||||||
getId={({ params }) =>
|
|
||||||
`${params?.serverId}:${params?.workspaceId}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="h/[serverId]/agent/[agentId]"
|
name="h/[serverId]/agent/[agentId]"
|
||||||
options={{ gestureEnabled: false }}
|
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 { WorkspaceScreen } from '@/screens/workspace/workspace-screen'
|
||||||
import {
|
import {
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
@@ -9,6 +9,8 @@ export default function HostWorkspaceLayout() {
|
|||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string | string[]
|
serverId?: string | string[]
|
||||||
workspaceId?: string | string[]
|
workspaceId?: string | string[]
|
||||||
|
}>()
|
||||||
|
const globalParams = useGlobalSearchParams<{
|
||||||
open?: string | string[]
|
open?: string | string[]
|
||||||
}>()
|
}>()
|
||||||
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId
|
||||||
@@ -17,7 +19,7 @@ export default function HostWorkspaceLayout() {
|
|||||||
: params.workspaceId
|
: params.workspaceId
|
||||||
const serverId = serverValue?.trim() ?? ''
|
const serverId = serverValue?.trim() ?? ''
|
||||||
const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : ''
|
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)
|
const openIntent = parseWorkspaceOpenIntent(openValue)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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, usePathname, type Href } from 'expo-router'
|
import { router, type Href } 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'
|
||||||
@@ -250,7 +250,6 @@ export function AgentList({
|
|||||||
showAttentionIndicator = true,
|
showAttentionIndicator = true,
|
||||||
}: AgentListProps) {
|
}: AgentListProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const pathname = usePathname()
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null)
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
@@ -270,15 +269,13 @@ export function AgentList({
|
|||||||
|
|
||||||
const serverId = agent.serverId
|
const serverId = agent.serverId
|
||||||
const agentId = agent.id
|
const agentId = agent.id
|
||||||
const shouldReplace = pathname.startsWith('/h/')
|
|
||||||
const navigate = shouldReplace ? router.replace : router.push
|
|
||||||
|
|
||||||
onAgentSelect?.()
|
onAgentSelect?.()
|
||||||
|
|
||||||
const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href
|
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) => {
|
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
buildHostSettingsRoute,
|
buildHostSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
@@ -196,8 +195,6 @@ export function useCommandCenter() {
|
|||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(agent: AggregatedAgent) => {
|
(agent: AggregatedAgent) => {
|
||||||
didNavigateRef.current = true;
|
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.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
@@ -207,9 +204,9 @@ export function useCommandCenter() {
|
|||||||
agent.cwd,
|
agent.cwd,
|
||||||
agent.id
|
agent.id
|
||||||
) as Href;
|
) as Href;
|
||||||
navigate(route);
|
router.navigate(route);
|
||||||
},
|
},
|
||||||
[pathname, setOpen]
|
[setOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
import {
|
import {
|
||||||
canOpenAgentTabFromRoute,
|
canOpenAgentTabFromRoute,
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
|
shouldPruneWorkspaceAgentTab,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
|
|
||||||
function makeAgent(input: {
|
function makeAgent(input: {
|
||||||
@@ -104,6 +105,33 @@ describe("workspace agent visibility", () => {
|
|||||||
).toBe(true);
|
).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", () => {
|
it("sorts same-createdAt agents by lastActivityAt descending", () => {
|
||||||
const workspaceId = "/repo/worktree";
|
const workspaceId = "/repo/worktree";
|
||||||
const createdAt = new Date("2026-03-04T00:00:00.000Z");
|
const createdAt = new Date("2026-03-04T00:00:00.000Z");
|
||||||
|
|||||||
@@ -72,3 +72,17 @@ export function canOpenAgentTabFromRoute(input: {
|
|||||||
}
|
}
|
||||||
return input.workspaceAgentLookup.has(input.agentId);
|
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";
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
import {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
|
shouldPruneWorkspaceAgentTab,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} 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 {
|
import {
|
||||||
buildWorkspacePaneContentModel,
|
buildWorkspacePaneContentModel,
|
||||||
WorkspacePaneContent,
|
WorkspacePaneContent,
|
||||||
@@ -148,6 +152,34 @@ function buildOpenIntentKey(input: {
|
|||||||
return `${input.serverId}:${input.workspaceId}:${openParam}`;
|
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";
|
||||||
@@ -796,6 +828,7 @@ function WorkspaceScreenContent({
|
|||||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const consumedOpenIntentsRef = useRef(new Set<string>());
|
const consumedOpenIntentsRef = useRef(new Set<string>());
|
||||||
|
const reopenedArchivedOpenIntentsRef = useRef(new Set<string>());
|
||||||
const pendingCloseTabIdsRef = useRef(new Set<string>());
|
const pendingCloseTabIdsRef = useRef(new Set<string>());
|
||||||
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null);
|
||||||
const currentOpenIntentKey = useMemo(
|
const currentOpenIntentKey = useMemo(
|
||||||
@@ -881,6 +914,43 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
}, [currentOpenIntentKey, resolvedOpenIntentKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!openIntent || !persistenceKey) {
|
if (!openIntent || !persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -904,9 +974,6 @@ function WorkspaceScreenContent({
|
|||||||
...(draftId === "new" ? {} : { draftId }),
|
...(draftId === "new" ? {} : { draftId }),
|
||||||
focus: true,
|
focus: true,
|
||||||
});
|
});
|
||||||
if (tabId) {
|
|
||||||
setResolvedOpenIntentKey(intentKey);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,15 +985,15 @@ function WorkspaceScreenContent({
|
|||||||
: { kind: "file", path: openIntent.path };
|
: { kind: "file", path: openIntent.path };
|
||||||
const tabId = openWorkspaceTab(persistenceKey, target);
|
const tabId = openWorkspaceTab(persistenceKey, target);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
setResolvedOpenIntentKey(intentKey);
|
focusWorkspaceTab(persistenceKey, tabId);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
currentOpenIntentKey,
|
currentOpenIntentKey,
|
||||||
|
focusWorkspaceTab,
|
||||||
openIntent,
|
openIntent,
|
||||||
openWorkspaceDraftTab,
|
openWorkspaceDraftTab,
|
||||||
openWorkspaceTab,
|
openWorkspaceTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
resolvedOpenIntentKey,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey
|
||||||
@@ -956,7 +1023,6 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentIds = new Set(workspaceAgents.map((agent) => agent.id));
|
|
||||||
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
||||||
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
||||||
if (tab.target.kind !== "draft") {
|
if (tab.target.kind !== "draft") {
|
||||||
@@ -989,7 +1055,15 @@ function WorkspaceScreenContent({
|
|||||||
const canPruneAgentTabs = hasHydratedAgents;
|
const canPruneAgentTabs = hasHydratedAgents;
|
||||||
const canPruneTerminalTabs = terminalsQuery.isSuccess;
|
const canPruneTerminalTabs = terminalsQuery.isSuccess;
|
||||||
for (const tab of uiTabs) {
|
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);
|
closeWorkspaceTab(persistenceKey, tab.tabId);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -1013,6 +1087,24 @@ function WorkspaceScreenContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const activeTabId = resolvedPaneTabState.activeTabId;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeTabId || !persistenceKey) {
|
if (!activeTabId || !persistenceKey) {
|
||||||
@@ -1021,8 +1113,6 @@ function WorkspaceScreenContent({
|
|||||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||||
|
|
||||||
const activeTab = resolvedPaneTabState.activeTab;
|
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||||
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
|
() => resolvedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||||
[resolvedPaneTabState.tabs]
|
[resolvedPaneTabState.tabs]
|
||||||
|
|||||||
Reference in New Issue
Block a user