Import existing Claude/Codex/OpenCode sessions (#766)

* Restrict provider import fan-out to enabled, non-derived allowlist

Move provider eligibility from a session-level post-filter to the agent
manager's fan-out so disabled providers are never spawned (fixes the
Gemini-disabled hang) and derived providers (like a custom Z.AI extending
Claude) don't duplicate their base provider's sessions.

Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based
providers are excluded from import discovery entirely.

* Filter Codex persisted threads by cwd before hydrating turns

Codex's listPersistedAgents previously called thread/read includeTurns on
every thread returned by thread/list, even when the import sheet was
scoped to one cwd. With many recent threads outside the scope, that fan
of reads dominated the import-sheet open latency.

Add an optional cwd hint to ListPersistedAgentsOptions, thread it through
the agent manager, and have Codex pre-filter the cheap thread/list rows by
cwd before scheduling the per-thread hydration. Widen the thread/list
window when filtering since most rows will be from other cwds. Other
providers ignore the hint.

* Fan out import session fetch per enabled provider

Sheet now reads the providers snapshot already warmed by the workspace
screen, filters to enabled importable providers (claude/codex/opencode),
and issues one fetchRecentProviderSessions request per provider via
useQueries with limit 15. Results are merged, deduped by handle, and
sorted by last activity.

Falls back to a single unfiltered request when the daemon does not
expose a provider snapshot (older servers). Per-provider failures show
inline so a single broken provider does not hide the rest.

* Add provider filter badges to import sheet

Show "All" plus one badge per enabled importable provider above the
session list when more than one is available. Single-select narrows the
already-merged list — no refetch. Each row now shows the provider's
icon next to its label so multiple providers stay visually distinct.

Selection resets when the sheet closes.

* Distinguish already-imported empty state in import sheet

Track how many descriptors were dropped because their handle was
already imported, surface the count back to the client as an optional
`filteredAlreadyImportedCount`, and switch the sheet copy to
"All recent sessions are already imported." when the merged list is
empty solely due to that filter.

* Cover already-imported provider session filtering

* List persisted OpenCode sessions for import

* Revalidate import sessions when sheet reopens

* Add agent persistSession plumbing

* Drop redundant visibility-transition refetch and test boilerplate

The import sheet's queries are gated on `enabled: visible`, and react-query
already refetches stale data when `enabled` flips on, so the manual
`wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the
two `selectedProvider` reset effects into one.

Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those
helpers throw on miss, so the assertion was noise.

Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type
(it never returns null) and the dead `Boolean(descriptor)` filter at the
caller.

* Add provider-opaque session import path

Introduces fetchRecentProviderSessions on the daemon client plus an
importAgent overload accepting providerId/providerHandleId, and a
projection helper that renders persisted descriptors as provider-opaque
recent-session payloads with prompt previews.

* Unslop the import-session feature

Centralizes the importable-providers list, normalizes import_agent_request
shape at one boundary, and removes the per-provider fan-out fallback in
the import sheet. Old daemons without features.providersSnapshot now show
"Update host" instead of being served a degraded path.

Replaces internal-helper unit tests on the OpenCode and Codex providers
with boundary tests through listPersistedAgents; makes storageRoot
injectable on the OpenCode provider so the boundary test can target a
temp directory.

Splits the CLAUDE.md back-compat rule into two contracts: protocol stays
backward-compatible (always); features may require new daemons (no
fallback paths). Tags the two new compat shims with COMPAT(name) and
target removal dates.

* Extract importable-session listing into its own module

Move listImportableProviderSessions out of session.ts and drop the
unused generic listPersistedAgents from AgentManager.
This commit is contained in:
Mohamed Boudra
2026-05-09 16:36:06 +08:00
committed by GitHub
parent 8be2692b8a
commit 5b884aee0c
24 changed files with 3373 additions and 45 deletions
@@ -24,6 +24,7 @@ import {
Ellipsis,
EllipsisVertical,
Globe,
Import as ImportIcon,
PanelRight,
RotateCw,
Settings,
@@ -57,6 +58,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -174,6 +176,7 @@ const ThemedX = withUnistyles(X);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings);
const ThemedPanelRight = withUnistyles(PanelRight);
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
@@ -186,6 +189,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
@@ -777,14 +781,17 @@ interface WorkspaceHeaderMenuProps {
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -811,14 +818,17 @@ function WorkspaceHeaderMenu({
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -865,6 +875,14 @@ function WorkspaceHeaderMenu({
New browser tab
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID="workspace-header-import-agent"
leading={menuImportIcon}
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
Import agent...
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={menuCopyIcon}
@@ -912,14 +930,17 @@ interface WorkspaceHeaderTitleBarProps {
showCreateBrowserTab: boolean;
isMobile: boolean;
createTerminalDisabled: boolean;
importAgentDisabled: boolean;
menuNewAgentIcon: ReactElement;
menuNewTerminalIcon: ReactElement;
menuNewBrowserIcon: ReactElement;
menuImportIcon: ReactElement;
menuCopyIcon: ReactElement;
menuSettingsIcon: ReactElement;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateBrowser: () => void;
onOpenImportSheet: () => void;
onCopyWorkspacePath: () => void;
onCopyBranchName: () => void;
onOpenSetupTab: () => void;
@@ -938,14 +959,17 @@ function WorkspaceHeaderTitleBar({
showCreateBrowserTab,
isMobile,
createTerminalDisabled,
importAgentDisabled,
menuNewAgentIcon,
menuNewTerminalIcon,
menuNewBrowserIcon,
menuImportIcon,
menuCopyIcon,
menuSettingsIcon,
onCreateDraftTab,
onCreateTerminal,
onCreateBrowser,
onOpenImportSheet,
onCopyWorkspacePath,
onCopyBranchName,
onOpenSetupTab,
@@ -983,14 +1007,17 @@ function WorkspaceHeaderTitleBar({
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={importAgentDisabled}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={menuNewBrowserIcon}
menuImportIcon={menuImportIcon}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminal={onCreateTerminal}
onCreateBrowser={onCreateBrowser}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
@@ -1388,6 +1415,14 @@ function WorkspaceScreenContent({
);
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
const openImportSheet = useCallback(() => {
setIsImportSheetVisible(true);
}, []);
const closeImportSheet = useCallback(() => {
setIsImportSheetVisible(false);
}, []);
// Warm the global provider snapshot so the model picker is ready when opened.
useProvidersSnapshot(normalizedServerId, {
@@ -1933,6 +1968,18 @@ function WorkspaceScreenContent({
},
[focusWorkspaceTab, persistenceKey],
);
const handleImportedAgent = useCallback(
(agentId: string) => {
if (!persistenceKey) {
return;
}
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId });
if (tabId) {
navigateToTabId(tabId);
}
},
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
@@ -3212,14 +3259,17 @@ function WorkspaceScreenContent({
showCreateBrowserTab={showCreateBrowserTab}
isMobile={isMobile}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
menuNewAgentIcon={menuNewAgentIcon}
menuNewTerminalIcon={menuNewTerminalIcon}
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
menuImportIcon={MENU_IMPORT_ICON}
menuCopyIcon={menuCopyIcon}
menuSettingsIcon={menuSettingsIcon}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
@@ -3301,6 +3351,14 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
<WorkspaceImportSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
workspaceDirectory={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
</View>
</WorkspaceFocusProvider>
)