fix: close workflow console target transitions

This commit is contained in:
lda
2026-08-05 00:40:52 +07:00 Verified
parent 3a948c60bd
commit 8a74c178f6
12 changed files with 547 additions and 58 deletions
@@ -143,7 +143,12 @@ describe("useCapabilityDiscovery", () => {
.mockResolvedValueOnce(page([summary("local.documents.read")]))
.mockResolvedValueOnce(page([summary("remote.documents.read", "remote.documents")]));
client.inspect.mockResolvedValue(detail("local.documents.read"));
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
const renders: Array<{ items: ReadonlyArray<CapabilitySummary>; selected: CapabilityDetail | null }> = [];
const { result, rerender } = renderHook(() => {
const current = useCapabilityDiscovery();
renders.push({ items: current.items, selected: current.selected });
return current;
});
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.inspect("local.documents.read"));
await waitFor(() => expect(result.current.selected?.name).toBe("local.documents.read"));
@@ -154,10 +159,35 @@ describe("useCapabilityDiscovery", () => {
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
});
const renderCountBeforeTargetChange = renders.length;
rerender();
expect(renders[renderCountBeforeTargetChange]?.items).toEqual([]);
expect(renders[renderCountBeforeTargetChange]?.selected).toBeNull();
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
expect(result.current.selected).toBeNull();
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
});
it("reconnects with applied filters instead of unsubmitted filter edits", async () => {
client.list
.mockResolvedValueOnce(page([summary("local.documents.read")]))
.mockResolvedValue(page([summary("remote.documents.read", "remote.documents")]));
const { result, rerender } = renderHook(() => useCapabilityDiscovery());
await waitFor(() => expect(result.current.phase).toBe("ready"));
act(() => result.current.setQuery("unsubmitted-query"));
act(() => result.current.setSourceId("unsubmitted-source"));
mockedUseConsoleWorkspace.mockReturnValue({
connection: connectedState,
connectedTarget: "http://reconnected-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
});
rerender();
await waitFor(() => expect(result.current.items[0]?.sourceId).toBe("remote.documents"));
expect(result.current.selected).toBeNull();
expect(client.list).toHaveBeenLastCalledWith({ limit: 50 });
});
@@ -8,6 +8,7 @@ import type {
CapabilityDetail,
CapabilitySummary,
} from "../domain/capability-models.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
const PAGE_LIMIT = 50;
@@ -38,6 +39,11 @@ type DiscoveryStateWithAppliedFilters = DiscoveryState & {
readonly appliedSourceId: string;
};
type ConnectionProvenance = {
readonly readExecutor: ConsoleReadExecutor;
readonly connectedTarget: string;
};
const initialState: DiscoveryStateWithAppliedFilters = {
phase: "disconnected",
query: "",
@@ -78,6 +84,14 @@ const appendUnique = (
return result;
};
const isSameConnection = (
left: ConnectionProvenance | null,
right: ConnectionProvenance | null,
): boolean => {
if (left === null || right === null) return left === right;
return left.readExecutor === right.readExecutor && left.connectedTarget === right.connectedTarget;
};
export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
const { connectedTarget, readExecutor } = useConsoleWorkspace();
const client = useMemo<CapabilityClient | null>(
@@ -87,12 +101,24 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
const [state, setState] = useState<DiscoveryStateWithAppliedFilters>(initialState);
const listGenerationRef = useRef(0);
const inspectGenerationRef = useRef(0);
const committedProvenanceRef = useRef<ConnectionProvenance | null>(null);
const listProvenanceRef = useRef<ConnectionProvenance | null>(null);
const selectedProvenanceRef = useRef<ConnectionProvenance | null>(null);
const currentProvenance = useMemo<ConnectionProvenance | null>(
() =>
readExecutor !== null && connectedTarget !== null
? { readExecutor, connectedTarget }
: null,
[connectedTarget, readExecutor],
);
const runList = useCallback(
(filters: CapabilityFilters, cursor: string | undefined, append: boolean): void => {
if (!client) return;
if (!client || currentProvenance === null) return;
const requestProvenance = currentProvenance;
const generation = ++listGenerationRef.current;
if (append === false) inspectGenerationRef.current++;
listProvenanceRef.current = requestProvenance;
setState((current) => ({
...current,
appliedQuery: filters.query,
@@ -107,7 +133,10 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
void client
.list(requestParams(filters.query, filters.sourceId, cursor))
.then((page) => {
if (generation !== listGenerationRef.current) return;
if (
generation !== listGenerationRef.current ||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
) return;
setState((current) => ({
...current,
phase: "ready",
@@ -117,7 +146,10 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
}));
})
.catch((error: unknown) => {
if (generation !== listGenerationRef.current) return;
if (
generation !== listGenerationRef.current ||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
) return;
setState((current) => ({
...current,
phase: "error",
@@ -125,13 +157,16 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
}));
});
},
[client],
[client, currentProvenance],
);
useEffect(() => {
committedProvenanceRef.current = currentProvenance;
if (!client || !connectedTarget) {
listGenerationRef.current++;
inspectGenerationRef.current++;
listProvenanceRef.current = null;
selectedProvenanceRef.current = null;
setState((current) => ({
...current,
phase: "disconnected",
@@ -143,11 +178,15 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
return;
}
runList({ query: state.query, sourceId: state.sourceId }, undefined, false);
runList(
{ query: state.appliedQuery, sourceId: state.appliedSourceId },
undefined,
false,
);
// The executor identity changes with the connected target. Query and source
// filters are intentionally retained so reconnecting preserves the view.
// filters are intentionally retained so reconnecting preserves the submitted view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, connectedTarget, runList]);
}, [client, connectedTarget, currentProvenance, readExecutor, runList]);
const setQuery = useCallback((query: string) => {
setState((current) => ({ ...current, query }));
@@ -182,24 +221,54 @@ export const useCapabilityDiscovery = (): CapabilityDiscoveryController => {
const inspect = useCallback(
(qualifiedName: string) => {
if (!client) return;
if (currentProvenance === null) return;
const generation = ++inspectGenerationRef.current;
const requestProvenance = currentProvenance;
selectedProvenanceRef.current = null;
setState((current) => ({ ...current, phase: "loading", selected: null, message: null }));
void client
.inspect(qualifiedName)
.then((detail) => {
if (generation !== inspectGenerationRef.current) return;
if (
generation !== inspectGenerationRef.current ||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
) return;
selectedProvenanceRef.current = requestProvenance;
setState((current) => ({ ...current, phase: "ready", selected: detail, message: null }));
})
.catch((error: unknown) => {
if (generation !== inspectGenerationRef.current) return;
if (
generation !== inspectGenerationRef.current ||
!isSameConnection(requestProvenance, committedProvenanceRef.current)
) return;
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
});
},
[client],
[client, currentProvenance],
);
const hasCurrentList = isSameConnection(
listProvenanceRef.current,
currentProvenance,
);
const hasCurrentSelection = isSameConnection(
selectedProvenanceRef.current,
currentProvenance,
);
const visiblePhase =
currentProvenance === null
? "disconnected"
: hasCurrentList
? state.phase
: "loading";
return {
...state,
phase: visiblePhase,
items: hasCurrentList ? state.items : [],
selected: hasCurrentSelection ? state.selected : null,
nextCursor: hasCurrentList ? state.nextCursor : null,
message: hasCurrentList ? state.message : null,
setQuery,
setSourceId,
search,
@@ -130,14 +130,19 @@ describe("useDraftWorkspace", () => {
});
it("does not expose a loaded detail during URL or target transitions", async () => {
client.list.mockResolvedValue(page([]));
client.list.mockResolvedValue(page([workspace("draft-first")]));
client.load
.mockResolvedValueOnce(workspace("draft-first"))
.mockReturnValueOnce(deferred<DraftWorkspace>().promise)
.mockReturnValueOnce(deferred<DraftWorkspace>().promise);
const renders: Array<{ items: ReadonlyArray<DraftWorkspace>; selected: DraftWorkspace | null }> = [];
const { result, rerender } = renderHook(
({ workspaceId }: { workspaceId: string | null }) => useDraftWorkspace(workspaceId),
({ workspaceId }: { workspaceId: string | null }) => {
const current = useDraftWorkspace(workspaceId);
renders.push({ items: current.items, selected: current.selected });
return current;
},
{ initialProps: { workspaceId: "draft-first" } },
);
await waitFor(() => expect(result.current.selected?.workspaceId).toBe("draft-first"));
@@ -148,8 +153,10 @@ describe("useDraftWorkspace", () => {
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
});
const renderCountBeforeTargetChange = renders.length;
rerender({ workspaceId: "draft-first" });
expect(result.current.selected).toBeNull();
expect(renders[renderCountBeforeTargetChange]?.items).toEqual([]);
expect(renders[renderCountBeforeTargetChange]?.selected).toBeNull();
rerender({ workspaceId: "draft-second" });
expect(result.current.selected).toBeNull();
@@ -28,7 +28,12 @@ type StoredDraftSelection = {
readonly workspace: DraftWorkspace;
readonly workspaceId: string;
readonly connectedTarget: string;
readonly connectionGeneration: number;
readonly readExecutor: ConsoleReadExecutor;
};
type DraftReadProvenance = {
readonly readExecutor: ConsoleReadExecutor;
readonly connectedTarget: string;
};
type DraftWorkspaceState = Omit<DraftWorkspaceController, "refresh" | "selected"> & {
@@ -47,6 +52,14 @@ const initialState: DraftWorkspaceState = {
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const isCurrentProvenance = (
current: DraftReadProvenance | null,
request: DraftReadProvenance,
): boolean =>
current !== null &&
request.readExecutor === current.readExecutor &&
request.connectedTarget === current.connectedTarget;
export const useDraftWorkspace = (
workspaceId: string | null,
): DraftWorkspaceController => {
@@ -65,27 +78,27 @@ export const useDraftWorkspace = (
readonly connectedTarget: string | null;
readonly workspaceId: string | null;
} | null>(null);
const connectionSignatureRef = useRef<{
readonly readExecutor: ConsoleReadExecutor | null;
readonly connectedTarget: string | null;
} | null>(null);
const connectionGenerationRef = useRef(0);
const connectionSignature = { readExecutor, connectedTarget };
const previousConnection = connectionSignatureRef.current;
if (
previousConnection === null ||
previousConnection.readExecutor !== readExecutor ||
previousConnection.connectedTarget !== connectedTarget
) {
connectionGenerationRef.current++;
connectionSignatureRef.current = connectionSignature;
}
const listProvenanceRef = useRef<DraftReadProvenance | null>(null);
const committedProvenanceRef = useRef<DraftReadProvenance | null>(null);
const currentProvenance = useMemo<DraftReadProvenance | null>(
() =>
readExecutor !== null && connectedTarget !== null
? {
readExecutor,
connectedTarget,
}
: null,
[connectedTarget, readExecutor],
);
const runList = useCallback((force = false) => {
if (!client || !connectedTarget) return;
if (!client || currentProvenance === null) return;
if (listPendingRef.current && !force) return;
listPendingRef.current = false;
const generation = ++listGenerationRef.current;
const requestProvenance = currentProvenance;
listProvenanceRef.current = requestProvenance;
listPendingRef.current = true;
setState((current) => ({
...current,
@@ -96,7 +109,10 @@ export const useDraftWorkspace = (
void client
.list()
.then((page) => {
if (generation !== listGenerationRef.current) return;
if (
generation !== listGenerationRef.current ||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
) return;
setState((current) => ({
...current,
listPhase: "ready",
@@ -105,7 +121,10 @@ export const useDraftWorkspace = (
}));
})
.catch((error: unknown) => {
if (generation !== listGenerationRef.current) return;
if (
generation !== listGenerationRef.current ||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
) return;
setState((current) => ({
...current,
listPhase: "error",
@@ -115,7 +134,7 @@ export const useDraftWorkspace = (
.finally(() => {
if (generation === listGenerationRef.current) listPendingRef.current = false;
});
}, [client, connectedTarget]);
}, [client, currentProvenance]);
const runDetail = useCallback(
(nextWorkspaceId: string | null, force = false) => {
@@ -144,7 +163,7 @@ export const useDraftWorkspace = (
}
const requestTarget = connectedTarget;
const requestConnectionGeneration = connectionGenerationRef.current;
const requestProvenance = currentProvenance;
detailPendingRef.current = true;
setState((current) => ({
...current,
@@ -158,7 +177,8 @@ export const useDraftWorkspace = (
.then((detail) => {
if (
generation !== detailGenerationRef.current ||
requestConnectionGeneration !== connectionGenerationRef.current
requestProvenance === null ||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
) return;
setState((current) => ({
...current,
@@ -167,13 +187,17 @@ export const useDraftWorkspace = (
workspace: detail,
workspaceId: nextWorkspaceId,
connectedTarget: requestTarget,
connectionGeneration: requestConnectionGeneration,
readExecutor: requestProvenance.readExecutor,
},
detailMessage: null,
}));
})
.catch((error: unknown) => {
if (generation !== detailGenerationRef.current) return;
if (
generation !== detailGenerationRef.current ||
requestProvenance === null ||
!isCurrentProvenance(committedProvenanceRef.current, requestProvenance)
) return;
setState((current) => ({
...current,
detailPhase: "error",
@@ -185,10 +209,11 @@ export const useDraftWorkspace = (
if (generation === detailGenerationRef.current) detailPendingRef.current = false;
});
},
[client, connectedTarget],
[client, connectedTarget, currentProvenance],
);
useEffect(() => {
committedProvenanceRef.current = currentProvenance;
const previousRequest = observedRequestRef.current;
const connectionChanged =
previousRequest === null ||
@@ -203,6 +228,7 @@ export const useDraftWorkspace = (
detailGenerationRef.current++;
listPendingRef.current = false;
detailPendingRef.current = false;
listProvenanceRef.current = null;
setState((current) => ({
...current,
listPhase: "disconnected",
@@ -234,7 +260,7 @@ export const useDraftWorkspace = (
} else if (workspaceChanged) {
runDetail(workspaceId, true);
}
}, [client, connectedTarget, readExecutor, runDetail, runList, workspaceId]);
}, [client, connectedTarget, currentProvenance, readExecutor, runDetail, runList, workspaceId]);
const refresh = useCallback(() => {
if (!client || !connectedTarget) return;
@@ -249,9 +275,34 @@ export const useDraftWorkspace = (
connectedTarget !== null &&
storedSelection.workspaceId === workspaceId &&
storedSelection.connectedTarget === connectedTarget &&
storedSelection.connectionGeneration === connectionGenerationRef.current
storedSelection.readExecutor === readExecutor
? storedSelection.workspace
: null;
return { ...state, selected, refresh };
const hasCurrentList =
currentProvenance !== null &&
listProvenanceRef.current !== null &&
isCurrentProvenance(currentProvenance, listProvenanceRef.current);
const visibleListPhase =
currentProvenance === null
? "disconnected"
: hasCurrentList
? state.listPhase
: "loading";
const visibleDetailPhase =
currentProvenance === null
? "disconnected"
: storedSelection !== null && selected === null
? "loading"
: state.detailPhase;
return {
...state,
listPhase: visibleListPhase,
detailPhase: visibleDetailPhase,
items: hasCurrentList ? state.items : [],
listMessage: hasCurrentList ? state.listMessage : null,
selected,
refresh,
};
};