Restore archived agents from History (#2316)
* fix(app): restore archived agents from History History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration. * fix(app): preserve archived agent recovery invariants * fix(server): preserve timeline hydration call shape * fix(app): retain agent tabs through lookup * fix(server): upgrade in-flight timeline broadcast
This commit is contained in:
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeck({
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: {
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -394,6 +394,7 @@ export function AgentList({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: true,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ArchivedAgentCalloutProps {
|
||||
serverId: string;
|
||||
@@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||
|
||||
@@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
if (!client || !isConnected || isUnarchiving) return;
|
||||
setIsUnarchiving(true);
|
||||
setUnarchiveError(null);
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
||||
setUnarchiveError(toErrorMessage(error));
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
<View style={styles.calloutStack}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
{unarchiveError ? (
|
||||
<Text style={styles.errorText} testID="agent-unarchive-error">
|
||||
{unarchiveError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
},
|
||||
calloutStack: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
calloutText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
@@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat
|
||||
function createBaseInput(): AgentScreenMachineInput {
|
||||
return {
|
||||
agent: null,
|
||||
isArchived: false,
|
||||
continuity: { kind: "none" },
|
||||
missingAgentState: { kind: "idle" },
|
||||
isConnected: true,
|
||||
@@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(result.memory.lastReadyAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an archived agent before provider history is initialized", () => {
|
||||
const result = deriveAgentScreenViewState({
|
||||
input: {
|
||||
...createBaseInput(),
|
||||
agent: createAgent("agent-1"),
|
||||
isArchived: true,
|
||||
needsAuthoritativeSync: true,
|
||||
},
|
||||
memory: createBaseMemory(),
|
||||
});
|
||||
|
||||
const ready = expectReadyState(result.state);
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
expect(ready.sync).toEqual({ status: "idle" });
|
||||
});
|
||||
|
||||
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
|
||||
@@ -41,6 +41,7 @@ export type AgentScreenMissingState =
|
||||
|
||||
export interface AgentScreenMachineInput {
|
||||
agent: AgentScreenAgent | null;
|
||||
isArchived: boolean;
|
||||
missingAgentState: AgentScreenMissingState;
|
||||
isConnected: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
@@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean
|
||||
|
||||
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
|
||||
return (
|
||||
!input.isArchived &&
|
||||
!hasOptimisticCreateContinuity(input) &&
|
||||
!input.hasHydratedHistoryBefore &&
|
||||
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
||||
@@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: {
|
||||
hadInitialSyncFailure: boolean;
|
||||
}): AgentScreenReadySyncState {
|
||||
const { input, hadInitialSyncFailure } = args;
|
||||
if (input.isArchived) {
|
||||
return { status: "idle" };
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
return { status: "reconnecting" };
|
||||
}
|
||||
|
||||
@@ -341,13 +341,14 @@ function useAgentPanelDescriptor(
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
|
||||
return (
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
@@ -481,11 +482,13 @@ type AgentLookupState =
|
||||
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
@@ -535,6 +538,7 @@ function AgentPanelContent({
|
||||
return (
|
||||
<AgentPanelBody
|
||||
serverId={resolvedServerId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={resolvedAgentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={runtimeClient}
|
||||
@@ -547,6 +551,7 @@ function AgentPanelContent({
|
||||
|
||||
function AgentPanelBody({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
@@ -555,6 +560,7 @@ function AgentPanelBody({
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
@@ -585,6 +591,8 @@ function AgentPanelBody({
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
|
||||
|
||||
useEffect(() => {
|
||||
lookupAttemptTokenRef.current += 1;
|
||||
@@ -596,6 +604,9 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
if (agentState.id) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
if (lookupState.tag !== "idle") {
|
||||
setLookupState({ tag: "idle" });
|
||||
}
|
||||
@@ -618,6 +629,9 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({
|
||||
tag: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
@@ -626,6 +640,9 @@ function AgentPanelBody({
|
||||
}
|
||||
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({ tag: "idle" });
|
||||
return;
|
||||
})
|
||||
@@ -635,12 +652,25 @@ function AgentPanelBody({
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({ tag: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
resolvePendingAgent,
|
||||
serverId,
|
||||
workspaceKey,
|
||||
]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
@@ -894,6 +924,7 @@ function ChatAgentContent({
|
||||
routeKey: `${serverId}:${agentId ?? ""}`,
|
||||
input: {
|
||||
agent: agent ?? null,
|
||||
isArchived: agentState.archivedAt !== null,
|
||||
missingAgentState,
|
||||
isConnected,
|
||||
isArchivingCurrentAgent,
|
||||
@@ -947,6 +978,9 @@ function ChatAgentContent({
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.archivedAt) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id && hasAppliedAuthoritativeHistory) {
|
||||
if (
|
||||
missingAgentState.kind === "resolving" ||
|
||||
@@ -1012,6 +1046,7 @@ function ChatAgentContent({
|
||||
});
|
||||
}, [
|
||||
agentState.id,
|
||||
agentState.archivedAt,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
agentId,
|
||||
client,
|
||||
|
||||
@@ -291,11 +291,13 @@ interface WorkspaceScreenProps {
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
recoveryAgentId?: string | null;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -912,6 +914,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
return (
|
||||
@@ -920,6 +923,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
recoveryAgentId={recoveryAgentId ?? null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1715,6 +1719,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1791,6 +1796,7 @@ function WorkspaceScreenContent({
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
agentId: recoveryAgentId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
|
||||
@@ -194,6 +194,7 @@ interface ReorderPaneTabsInLayoutInput {
|
||||
export interface WorkspaceTabReconcileState {
|
||||
layout: WorkspaceLayout;
|
||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||
pendingAgentIds?: ReadonlySet<string> | null;
|
||||
hiddenAgentIds?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
@@ -1653,13 +1654,14 @@ interface EntityTabGroup {
|
||||
function applyPinnedAndHidden(input: {
|
||||
baseAgentIds: Set<string>;
|
||||
pinnedAgentIds: Set<string>;
|
||||
pendingAgentIds: Set<string>;
|
||||
hiddenAgentIds: Set<string>;
|
||||
knownAgentIds: Set<string>;
|
||||
}): Set<string> {
|
||||
const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input;
|
||||
const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input;
|
||||
const result = new Set(baseAgentIds);
|
||||
for (const agentId of pinnedAgentIds) {
|
||||
if (knownAgentIds.has(agentId)) {
|
||||
if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) {
|
||||
result.add(agentId);
|
||||
}
|
||||
}
|
||||
@@ -1784,6 +1786,7 @@ export function reconcileWorkspaceTabs(
|
||||
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
|
||||
let reconciledFocusedTabId = originalFocusedTabId;
|
||||
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
|
||||
const pendingAgentIds = new Set(state.pendingAgentIds ?? []);
|
||||
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
|
||||
@@ -1795,12 +1798,14 @@ export function reconcileWorkspaceTabs(
|
||||
const visibleAgentIds = applyPinnedAndHidden({
|
||||
baseAgentIds: activeAgentIds,
|
||||
pinnedAgentIds,
|
||||
pendingAgentIds,
|
||||
hiddenAgentIds,
|
||||
knownAgentIds,
|
||||
});
|
||||
const autoOpenSet = applyPinnedAndHidden({
|
||||
baseAgentIds: autoOpenAgentIds,
|
||||
pinnedAgentIds,
|
||||
pendingAgentIds,
|
||||
hiddenAgentIds,
|
||||
knownAgentIds,
|
||||
});
|
||||
|
||||
@@ -1591,6 +1591,66 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
it("keeps an explicitly pinned archived agent before its detail is hydrated", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" });
|
||||
store.pinAgent(workspaceKey, "archived-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
autoOpenAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_archived-agent",
|
||||
]);
|
||||
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
autoOpenAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_archived-agent",
|
||||
]);
|
||||
|
||||
store.resolvePendingAgent(workspaceKey, "archived-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
autoOpenAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]);
|
||||
|
||||
store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" });
|
||||
store.pinAgent(workspaceKey, "archived-agent");
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
autoOpenAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
|
||||
expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([
|
||||
"agent_archived-agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargeting a tab to an agent clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
@@ -75,6 +75,7 @@ interface WorkspaceLayoutStore {
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
|
||||
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
@@ -89,6 +90,7 @@ interface WorkspaceLayoutStore {
|
||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
||||
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
||||
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
||||
resolvePendingAgent: (workspaceKey: string, agentId: string) => void;
|
||||
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
||||
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
||||
splitPane: (
|
||||
@@ -229,6 +231,7 @@ export function createWorkspaceLayoutStore(
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
pendingAgentIdsByWorkspace: {},
|
||||
hiddenAgentIdsByWorkspace: {},
|
||||
focusRestorationByWorkspace: {},
|
||||
openTabFocused: (workspaceKey, target) => {
|
||||
@@ -467,6 +470,7 @@ export function createWorkspaceLayoutStore(
|
||||
{
|
||||
layout: currentLayout,
|
||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
},
|
||||
snapshot,
|
||||
@@ -483,6 +487,25 @@ export function createWorkspaceLayoutStore(
|
||||
};
|
||||
});
|
||||
},
|
||||
resolvePendingAgent: (workspaceKey, agentId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedWorkspaceKey || !normalizedAgentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
);
|
||||
if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) {
|
||||
return state;
|
||||
}
|
||||
return { pendingAgentIdsByWorkspace };
|
||||
});
|
||||
},
|
||||
reorderTabs: (workspaceKey, tabIds) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
@@ -762,7 +785,12 @@ export function createWorkspaceLayoutStore(
|
||||
set((state) => {
|
||||
const currentPinnedAgentIds =
|
||||
state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
if (currentPinnedAgentIds?.has(normalizedAgentId)) {
|
||||
const currentPendingAgentIds =
|
||||
state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null;
|
||||
if (
|
||||
currentPinnedAgentIds?.has(normalizedAgentId) &&
|
||||
currentPendingAgentIds?.has(normalizedAgentId)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -779,6 +807,11 @@ export function createWorkspaceLayoutStore(
|
||||
...state.pinnedAgentIdsByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextPinnedAgentIds,
|
||||
},
|
||||
pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -803,6 +836,11 @@ export function createWorkspaceLayoutStore(
|
||||
delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey];
|
||||
return {
|
||||
pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace,
|
||||
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -814,6 +852,11 @@ export function createWorkspaceLayoutStore(
|
||||
...state.pinnedAgentIdsByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextPinnedAgentIds,
|
||||
},
|
||||
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
|
||||
state.pendingAgentIdsByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
normalizedAgentId,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -872,6 +915,7 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace;
|
||||
if (!hasAny) {
|
||||
@@ -883,6 +927,8 @@ export function createWorkspaceLayoutStore(
|
||||
state.splitSizesByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
|
||||
state.pinnedAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } =
|
||||
state.pendingAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
|
||||
state.hiddenAgentIdsByWorkspace;
|
||||
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
|
||||
@@ -891,6 +937,7 @@ export function createWorkspaceLayoutStore(
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
pinnedAgentIdsByWorkspace,
|
||||
pendingAgentIdsByWorkspace,
|
||||
hiddenAgentIdsByWorkspace,
|
||||
focusRestorationByWorkspace,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWorkspaceRecoveryModel } from "./model";
|
||||
import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model";
|
||||
|
||||
describe("resolveWorkspaceRecoveryModel", () => {
|
||||
it("keeps newer recovery actions visible but non-actionable", () => {
|
||||
@@ -29,3 +29,24 @@ describe("resolveWorkspaceRecoveryModel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverWorkspaceSelection", () => {
|
||||
it("restores the workspace and selected archived agent as one recovery action", async () => {
|
||||
const operations: string[] = [];
|
||||
|
||||
await recoverWorkspaceSelection({
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
client: {
|
||||
restoreWorkspace: async (workspaceId) => {
|
||||
operations.push(`workspace:${workspaceId}`);
|
||||
},
|
||||
refreshAgent: async (agentId) => {
|
||||
operations.push(`agent:${agentId}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,22 @@ export interface WorkspaceRecoveryController {
|
||||
retryInspection: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceSelectionRecoveryClient {
|
||||
restoreWorkspace: (workspaceId: string) => Promise<unknown>;
|
||||
refreshAgent: (agentId: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function recoverWorkspaceSelection(input: {
|
||||
client: WorkspaceSelectionRecoveryClient;
|
||||
workspaceId: string;
|
||||
agentId?: string | null;
|
||||
}): Promise<void> {
|
||||
await input.client.restoreWorkspace(input.workspaceId);
|
||||
if (input.agentId) {
|
||||
await input.client.refreshAgent(input.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecoveryPhase(input: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
|
||||
import {
|
||||
recoverWorkspaceSelection,
|
||||
resolveWorkspaceRecoveryModel,
|
||||
type WorkspaceRecoveryController,
|
||||
} from "./model";
|
||||
|
||||
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
|
||||
|
||||
@@ -28,6 +32,7 @@ function waitForMinimumRecoveryLoadingTime(): Promise<void> {
|
||||
export function useWorkspaceRecovery(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string | null;
|
||||
enabled: boolean;
|
||||
}): WorkspaceRecoveryController {
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
@@ -56,7 +61,11 @@ export function useWorkspaceRecovery(input: {
|
||||
}
|
||||
await waitForRecoveryLoadingPresentation();
|
||||
await waitForMinimumRecoveryLoadingTime();
|
||||
await client.restoreWorkspace(input.workspaceId);
|
||||
await recoverWorkspaceSelection({
|
||||
client,
|
||||
workspaceId: input.workspaceId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user