fix: retain session failures across recovery surfaces (#3669)
This commit is contained in:
@@ -4,24 +4,11 @@ import { resolveCompetingInitialScrollOwner } from "./message-list-native-scroll
|
||||
const noProgrammaticLock = () => false;
|
||||
|
||||
describe("resolveCompetingInitialScrollOwner", () => {
|
||||
it("gives a new recovery failure the initial reveal before unread placement", () => {
|
||||
expect(
|
||||
resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore: false,
|
||||
hasExplicitScrollTarget: false,
|
||||
hasRecoveryReveal: true,
|
||||
hasUnreadDivider: true,
|
||||
isProgrammaticScrollLocked: noProgrammaticLock,
|
||||
}),
|
||||
).toBe("recovery");
|
||||
});
|
||||
|
||||
it("preserves layout and explicit message placement precedence", () => {
|
||||
expect(
|
||||
resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore: true,
|
||||
hasExplicitScrollTarget: true,
|
||||
hasRecoveryReveal: true,
|
||||
hasUnreadDivider: true,
|
||||
isProgrammaticScrollLocked: noProgrammaticLock,
|
||||
}),
|
||||
@@ -30,10 +17,28 @@ describe("resolveCompetingInitialScrollOwner", () => {
|
||||
resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore: false,
|
||||
hasExplicitScrollTarget: true,
|
||||
hasRecoveryReveal: true,
|
||||
hasUnreadDivider: true,
|
||||
isProgrammaticScrollLocked: noProgrammaticLock,
|
||||
}),
|
||||
).toBe("explicit-target");
|
||||
});
|
||||
|
||||
it("uses the unread divider and then the programmatic owner for ordinary placement", () => {
|
||||
expect(
|
||||
resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore: false,
|
||||
hasExplicitScrollTarget: false,
|
||||
hasUnreadDivider: true,
|
||||
isProgrammaticScrollLocked: noProgrammaticLock,
|
||||
}),
|
||||
).toBe("unread-divider");
|
||||
expect(
|
||||
resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore: false,
|
||||
hasExplicitScrollTarget: false,
|
||||
hasUnreadDivider: false,
|
||||
isProgrammaticScrollLocked: () => true,
|
||||
}),
|
||||
).toBe("programmatic-scroll");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,6 @@ const SCROLL_KEYS = new Set([
|
||||
"PageUp",
|
||||
" ",
|
||||
]);
|
||||
const RECOVERY_SCROLL_KEYS = new Set(["ArrowDown", "End", "PageDown", " "]);
|
||||
|
||||
/** Writes a clamped maximum so the browser resolves the native bottom without
|
||||
* forcing a synchronous scrollHeight layout read. */
|
||||
@@ -234,112 +233,31 @@ type PrependScrollState = {
|
||||
anchorTop: number | null;
|
||||
};
|
||||
|
||||
type RecoveryRevealState = {
|
||||
key: string | null;
|
||||
keepAtTop: boolean;
|
||||
};
|
||||
|
||||
/** Transfers recovery scroll ownership only after a real downward gesture. */
|
||||
function useRecoveryPrependScrollOwnership({
|
||||
/** Captures the reader's current position between older-history requests. */
|
||||
function usePrependScrollStateCapture({
|
||||
scrollRef,
|
||||
recoveryRevealStateRef,
|
||||
isLoadingMoreRef,
|
||||
olderLoadPendingRef,
|
||||
newestItemKeyRef,
|
||||
scrollState,
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
recoveryRevealStateRef: React.MutableRefObject<RecoveryRevealState>;
|
||||
isLoadingMoreRef: React.MutableRefObject<boolean>;
|
||||
olderLoadPendingRef: React.MutableRefObject<boolean>;
|
||||
newestItemKeyRef: React.MutableRefObject<string | null>;
|
||||
scrollState: React.MutableRefObject<PrependScrollState>;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
let userScrollIntentUntil = 0;
|
||||
let pointerActive = false;
|
||||
let touchActive = false;
|
||||
let touchStartY: number | null = null;
|
||||
const markUserScrollIntent = () => {
|
||||
userScrollIntentUntil = Date.now() + USER_SCROLL_INTENT_WINDOW_MS;
|
||||
};
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY > 0) markUserScrollIntent();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (RECOVERY_SCROLL_KEYS.has(event.key)) markUserScrollIntent();
|
||||
};
|
||||
const onPointerDown = () => {
|
||||
pointerActive = true;
|
||||
markUserScrollIntent();
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
pointerActive = false;
|
||||
};
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
touchActive = true;
|
||||
touchStartY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
const nextY = event.touches[0]?.clientY;
|
||||
if (touchStartY !== null && nextY !== undefined && nextY < touchStartY) {
|
||||
markUserScrollIntent();
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
touchActive = false;
|
||||
touchStartY = null;
|
||||
};
|
||||
const onScroll = () => {
|
||||
const recoveryScrolledAway =
|
||||
recoveryRevealStateRef.current.keepAtTop &&
|
||||
el.scrollTop > 0 &&
|
||||
(userScrollIntentUntil >= Date.now() || pointerActive || touchActive);
|
||||
if (recoveryScrolledAway) {
|
||||
recoveryRevealStateRef.current.keepAtTop = false;
|
||||
if (olderLoadPendingRef.current || isLoadingMoreRef.current) {
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
}
|
||||
}
|
||||
// Native overflow anchoring can adjust scrollTop while the older page is
|
||||
// being inserted. Keep the pre-request baseline until our layout effect
|
||||
// has restored the visual position explicitly.
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const capture = () => {
|
||||
if (olderLoadPendingRef.current || isLoadingMoreRef.current) return;
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
scrollState.current = capturePrependScrollState(element, newestItemKeyRef.current);
|
||||
};
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
el.addEventListener("wheel", onWheel, { passive: true });
|
||||
el.addEventListener("keydown", onKeyDown);
|
||||
el.addEventListener("pointerdown", onPointerDown, { passive: true });
|
||||
el.addEventListener("pointerup", onPointerUp, { passive: true });
|
||||
el.addEventListener("pointercancel", onPointerUp, { passive: true });
|
||||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", onTouchMove, { passive: true });
|
||||
el.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||
el.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
el.removeEventListener("keydown", onKeyDown);
|
||||
el.removeEventListener("pointerdown", onPointerDown);
|
||||
el.removeEventListener("pointerup", onPointerUp);
|
||||
el.removeEventListener("pointercancel", onPointerUp);
|
||||
el.removeEventListener("touchstart", onTouchStart);
|
||||
el.removeEventListener("touchmove", onTouchMove);
|
||||
el.removeEventListener("touchend", onTouchEnd);
|
||||
el.removeEventListener("touchcancel", onTouchEnd);
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [
|
||||
isLoadingMoreRef,
|
||||
newestItemKeyRef,
|
||||
olderLoadPendingRef,
|
||||
recoveryRevealStateRef,
|
||||
scrollRef,
|
||||
scrollState,
|
||||
]);
|
||||
capture();
|
||||
element.addEventListener("scroll", capture, { passive: true });
|
||||
return () => element.removeEventListener("scroll", capture);
|
||||
}, [isLoadingMoreRef, newestItemKeyRef, olderLoadPendingRef, scrollRef, scrollState]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,7 +277,6 @@ function useScrollPositionOnPrepend(
|
||||
items: RenderItem[],
|
||||
isLoadingMore: boolean,
|
||||
isProgrammaticScrollLocked: () => boolean,
|
||||
recoveryRevealKey: string | null,
|
||||
): () => void {
|
||||
const scrollState = useRef<PrependScrollState>({
|
||||
scrollHeight: 0,
|
||||
@@ -370,22 +287,11 @@ function useScrollPositionOnPrepend(
|
||||
const isLoadingMoreRef = useRef(isLoadingMore);
|
||||
const olderLoadPendingRef = useRef(false);
|
||||
const newestItemKeyRef = useRef<string | null>(getNewestNonSyntheticItemKey(items));
|
||||
const recoveryRevealStateRef = useRef({
|
||||
key: recoveryRevealKey,
|
||||
keepAtTop: recoveryRevealKey !== null,
|
||||
});
|
||||
if (recoveryRevealStateRef.current.key !== recoveryRevealKey) {
|
||||
recoveryRevealStateRef.current = {
|
||||
key: recoveryRevealKey,
|
||||
keepAtTop: recoveryRevealKey !== null,
|
||||
};
|
||||
}
|
||||
isLoadingMoreRef.current = isLoadingMore;
|
||||
if (isLoadingMore) olderLoadPendingRef.current = true;
|
||||
newestItemKeyRef.current = getNewestNonSyntheticItemKey(items);
|
||||
useRecoveryPrependScrollOwnership({
|
||||
usePrependScrollStateCapture({
|
||||
scrollRef,
|
||||
recoveryRevealStateRef,
|
||||
isLoadingMoreRef,
|
||||
olderLoadPendingRef,
|
||||
newestItemKeyRef,
|
||||
@@ -417,11 +323,6 @@ function useScrollPositionOnPrepend(
|
||||
prevFirstKeyRef.current = nextFirstKey;
|
||||
if (olderLoadSettled) olderLoadPendingRef.current = false;
|
||||
if (!el || !prepend || isProgrammaticScrollLocked()) return;
|
||||
if (recoveryRevealStateRef.current.key !== null && recoveryRevealStateRef.current.keepAtTop) {
|
||||
if (el.scrollTop !== 0) el.scrollTop = 0;
|
||||
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
|
||||
return;
|
||||
}
|
||||
const prev = scrollState.current;
|
||||
const anchor = findMessageRow(el, prev.anchorKey);
|
||||
if (anchor && prev.anchorTop !== null) {
|
||||
@@ -1311,8 +1212,6 @@ type InitialScrollApplyParams = {
|
||||
isNearBottomRef: React.RefObject<boolean>;
|
||||
envSwitchPlacementToken: number | null;
|
||||
hasUnreadDivider: boolean;
|
||||
recoveryRevealKey: string | null;
|
||||
recoveryPlacementPendingRef: React.RefObject<boolean>;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
phase: "provisional" | "final";
|
||||
};
|
||||
@@ -1320,11 +1219,9 @@ type InitialScrollApplyParams = {
|
||||
function markInitialScrollConsumed(
|
||||
didInitialScroll: React.RefObject<boolean>,
|
||||
activationPendingRef: React.RefObject<boolean>,
|
||||
recoveryPlacementPendingRef: React.RefObject<boolean>,
|
||||
): void {
|
||||
didInitialScroll.current = true;
|
||||
activationPendingRef.current = false;
|
||||
recoveryPlacementPendingRef.current = false;
|
||||
}
|
||||
|
||||
function completeEnvSwitchPlacement(envSwitchPlacementToken: number | null): void {
|
||||
@@ -1348,30 +1245,23 @@ function isCurrentEnvSwitchPlacement(
|
||||
export type CompetingInitialScrollOwner =
|
||||
| "layout-restore"
|
||||
| "explicit-target"
|
||||
| "recovery"
|
||||
| "unread-divider"
|
||||
| "programmatic-scroll";
|
||||
|
||||
export function resolveCompetingInitialScrollOwner(params: {
|
||||
hasPendingLayoutRestore: boolean;
|
||||
hasExplicitScrollTarget: boolean;
|
||||
hasRecoveryReveal: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
}): CompetingInitialScrollOwner | null {
|
||||
if (params.hasPendingLayoutRestore) return "layout-restore";
|
||||
if (params.hasExplicitScrollTarget) return "explicit-target";
|
||||
if (params.hasRecoveryReveal) return "recovery";
|
||||
if (params.hasUnreadDivider) return "unread-divider";
|
||||
if (params.isProgrammaticScrollLocked()) return "programmatic-scroll";
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInitialPlacementOwner(
|
||||
enabled: boolean,
|
||||
recoveryRevealKey: string | null,
|
||||
): "recovery" | "bottom" | "saved-position" {
|
||||
if (recoveryRevealKey !== null) return "recovery";
|
||||
function resolveInitialPlacementOwner(enabled: boolean): "bottom" | "saved-position" {
|
||||
return enabled ? "bottom" : "saved-position";
|
||||
}
|
||||
|
||||
@@ -1381,13 +1271,12 @@ function reportInitialPlacement(params: {
|
||||
sessionId: string | null;
|
||||
token: number | null;
|
||||
enabled: boolean;
|
||||
recoveryRevealKey: string | null;
|
||||
}): void {
|
||||
if (!isDebug()) return;
|
||||
placementDebug(`${params.phase} placement`, {
|
||||
sessionId: params.sessionId,
|
||||
token: params.token,
|
||||
owner: resolveInitialPlacementOwner(params.enabled, params.recoveryRevealKey),
|
||||
owner: resolveInitialPlacementOwner(params.enabled),
|
||||
scrollTop: params.element.scrollTop,
|
||||
scrollHeight: params.element.scrollHeight,
|
||||
clientHeight: params.element.clientHeight,
|
||||
@@ -1434,8 +1323,6 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
|
||||
isNearBottomRef,
|
||||
envSwitchPlacementToken,
|
||||
hasUnreadDivider,
|
||||
recoveryRevealKey,
|
||||
recoveryPlacementPendingRef,
|
||||
isProgrammaticScrollLocked,
|
||||
phase,
|
||||
} = params;
|
||||
@@ -1455,18 +1342,17 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
|
||||
const competingOwner = resolveCompetingInitialScrollOwner({
|
||||
hasPendingLayoutRestore,
|
||||
hasExplicitScrollTarget,
|
||||
hasRecoveryReveal: recoveryRevealKey !== null,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked,
|
||||
});
|
||||
if (competingOwner && competingOwner !== "recovery") {
|
||||
if (competingOwner) {
|
||||
if (phase === "provisional") return;
|
||||
placementDebug(`${phase} placement delegated`, {
|
||||
sessionId,
|
||||
owner: competingOwner,
|
||||
token: envSwitchPlacementToken,
|
||||
});
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
|
||||
completeEnvSwitchPlacement(envSwitchPlacementToken);
|
||||
return;
|
||||
}
|
||||
@@ -1475,17 +1361,14 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
|
||||
getStoredAutoScrollTop(sessionId) ??
|
||||
undefined)
|
||||
: undefined;
|
||||
const scrollTop =
|
||||
competingOwner === "recovery"
|
||||
? 0
|
||||
: resolveNativeInitialScrollTop({
|
||||
enabled,
|
||||
hasPendingLayoutRestore,
|
||||
savedScrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
});
|
||||
const scrollTop = resolveNativeInitialScrollTop({
|
||||
enabled,
|
||||
hasPendingLayoutRestore,
|
||||
savedScrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
});
|
||||
if (phase === "final") {
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
|
||||
}
|
||||
if (scrollTop === null) {
|
||||
completeFinalEnvSwitchPlacement(phase, envSwitchPlacementToken);
|
||||
@@ -1507,7 +1390,6 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
|
||||
sessionId,
|
||||
token: envSwitchPlacementToken,
|
||||
enabled,
|
||||
recoveryRevealKey,
|
||||
});
|
||||
finishAppliedInitialPlacement({
|
||||
phase,
|
||||
@@ -1528,31 +1410,16 @@ function shouldSkipProvisionalPlacement(
|
||||
return phase === "provisional" && (hasUnreadDivider || appliedToken === currentToken);
|
||||
}
|
||||
|
||||
function useInitialPlacementLatches(
|
||||
envSwitchPlacementToken: number | null,
|
||||
recoveryRevealKey: string | null,
|
||||
) {
|
||||
function useInitialPlacementLatches(envSwitchPlacementToken: number | null) {
|
||||
const didInitialScroll = useRef(false);
|
||||
const lastTokenRef = useRef<number | null>(null);
|
||||
const provisionalTokenRef = useRef<number | null>(null);
|
||||
const lastRecoveryRevealKeyRef = useRef<string | null>(null);
|
||||
const recoveryPlacementPendingRef = useRef(recoveryRevealKey !== null);
|
||||
if (envSwitchPlacementToken !== null && envSwitchPlacementToken !== lastTokenRef.current) {
|
||||
lastTokenRef.current = envSwitchPlacementToken;
|
||||
provisionalTokenRef.current = null;
|
||||
didInitialScroll.current = false;
|
||||
}
|
||||
if (recoveryRevealKey !== null && recoveryRevealKey !== lastRecoveryRevealKeyRef.current) {
|
||||
lastRecoveryRevealKeyRef.current = recoveryRevealKey;
|
||||
provisionalTokenRef.current = null;
|
||||
didInitialScroll.current = false;
|
||||
recoveryPlacementPendingRef.current = true;
|
||||
}
|
||||
if (recoveryRevealKey === null) {
|
||||
lastRecoveryRevealKeyRef.current = null;
|
||||
recoveryPlacementPendingRef.current = false;
|
||||
}
|
||||
return { didInitialScroll, provisionalTokenRef, recoveryPlacementPendingRef };
|
||||
return { didInitialScroll, provisionalTokenRef };
|
||||
}
|
||||
|
||||
type InitialPlacementLatches = ReturnType<typeof useInitialPlacementLatches>;
|
||||
@@ -1569,7 +1436,6 @@ type InitialScrollPositionParams = {
|
||||
envSwitchPlacementToken: number | null;
|
||||
isRestoringLayout: boolean;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
recoveryRevealKey: string | null;
|
||||
placementLatches: InitialPlacementLatches;
|
||||
};
|
||||
|
||||
@@ -1579,7 +1445,6 @@ type InitialScrollPositionEffectParams = Omit<InitialScrollPositionParams, "plac
|
||||
provisionalTokenRef: React.RefObject<number | null>;
|
||||
isVisibleRef: React.RefObject<boolean>;
|
||||
activationPendingRef: React.RefObject<boolean>;
|
||||
recoveryPlacementPendingRef: React.RefObject<boolean>;
|
||||
};
|
||||
|
||||
function runInitialScrollPositionEffect(
|
||||
@@ -1597,19 +1462,17 @@ function runInitialScrollPositionEffect(
|
||||
envSwitchPlacementToken,
|
||||
isRestoringLayout,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
storeApi,
|
||||
didInitialScroll,
|
||||
provisionalTokenRef,
|
||||
isVisibleRef,
|
||||
activationPendingRef,
|
||||
recoveryPlacementPendingRef,
|
||||
} = params;
|
||||
if (!isVisible || (envSwitchPlacementToken !== null && isRestoringLayout)) return;
|
||||
const hasEmptyTranscript = itemCount === 0 && recoveryRevealKey === null;
|
||||
const hasEmptyTranscript = itemCount === 0;
|
||||
if (envSwitchPlacementToken !== null && hasEmptyTranscript) {
|
||||
if (historyRefreshPending) return;
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
|
||||
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
|
||||
completeEnvSwitchPlacement(envSwitchPlacementToken);
|
||||
return;
|
||||
}
|
||||
@@ -1640,8 +1503,6 @@ function runInitialScrollPositionEffect(
|
||||
isNearBottomRef,
|
||||
envSwitchPlacementToken,
|
||||
hasUnreadDivider,
|
||||
recoveryRevealKey,
|
||||
recoveryPlacementPendingRef,
|
||||
isProgrammaticScrollLocked,
|
||||
phase,
|
||||
});
|
||||
@@ -1664,11 +1525,10 @@ function useInitialScrollPosition({
|
||||
envSwitchPlacementToken,
|
||||
isRestoringLayout,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
placementLatches,
|
||||
}: InitialScrollPositionParams) {
|
||||
const storeApi = useAppStoreApi();
|
||||
const { didInitialScroll, provisionalTokenRef, recoveryPlacementPendingRef } = placementLatches;
|
||||
const { didInitialScroll, provisionalTokenRef } = placementLatches;
|
||||
const { isVisibleRef, activationPendingRef } = useActivationPending(
|
||||
isVisible,
|
||||
envSwitchPlacementToken,
|
||||
@@ -1687,13 +1547,11 @@ function useInitialScrollPosition({
|
||||
envSwitchPlacementToken,
|
||||
isRestoringLayout,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
storeApi,
|
||||
didInitialScroll,
|
||||
provisionalTokenRef,
|
||||
isVisibleRef,
|
||||
activationPendingRef,
|
||||
recoveryPlacementPendingRef,
|
||||
}),
|
||||
[
|
||||
itemCount,
|
||||
@@ -1707,7 +1565,6 @@ function useInitialScrollPosition({
|
||||
envSwitchPlacementToken,
|
||||
isRestoringLayout,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
scrollRef,
|
||||
],
|
||||
);
|
||||
@@ -1728,7 +1585,6 @@ type NativeScrollManagementParams = {
|
||||
isLoadingMore: boolean;
|
||||
loadMore: () => Promise<number>;
|
||||
isVisible: boolean;
|
||||
recoveryRevealKey?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1755,7 +1611,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
isVisible,
|
||||
recoveryRevealKey = null,
|
||||
} = params;
|
||||
const pendingChatInitialPlacement = useDockviewStore(
|
||||
(state) => state.pendingChatInitialPlacement,
|
||||
@@ -1763,9 +1618,8 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
|
||||
const isRestoringLayout = useDockviewStore((state) => state.isRestoringLayout);
|
||||
const envSwitchPlacementToken =
|
||||
pendingChatInitialPlacement?.sessionId === sessionId ? pendingChatInitialPlacement.token : null;
|
||||
const placementLatches = useInitialPlacementLatches(envSwitchPlacementToken, recoveryRevealKey);
|
||||
const initialPlacementPending =
|
||||
envSwitchPlacementToken !== null || placementLatches.recoveryPlacementPendingRef.current;
|
||||
const placementLatches = useInitialPlacementLatches(envSwitchPlacementToken);
|
||||
const initialPlacementPending = envSwitchPlacementToken !== null;
|
||||
const programmaticScrollLockRef = useRef(false);
|
||||
const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []);
|
||||
const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({
|
||||
@@ -1790,7 +1644,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
|
||||
items,
|
||||
isLoadingMore,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
);
|
||||
const loadMoreWithPrependBaseline = useCallback(() => {
|
||||
beginOlderLoad();
|
||||
@@ -1825,7 +1678,6 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
|
||||
envSwitchPlacementToken,
|
||||
isRestoringLayout,
|
||||
isProgrammaticScrollLocked,
|
||||
recoveryRevealKey,
|
||||
placementLatches,
|
||||
});
|
||||
|
||||
|
||||
@@ -86,11 +86,6 @@ const HARNESS_RENDER_ERROR = "harness did not render";
|
||||
const NATIVE_SCROLL_MANAGEMENT_TEST_ID = "native-scroll-management-container";
|
||||
const AUTO_SCROLL_CONTAINER_TEST_ID = "auto-scroll-container";
|
||||
const CACHED_MESSAGE_ID = "cached-message";
|
||||
const RECOVERY_SESSION_ID = "session-recovery";
|
||||
const RECOVERY_MESSAGE_ID = "recovery-message";
|
||||
const OLDEST_ACTIVITY_ID = "activity-old";
|
||||
const NEWEST_MESSAGE_ID = "newest";
|
||||
const OLDER_MESSAGE_ID = "older";
|
||||
const TEST_MESSAGES = [{} as Message];
|
||||
/** Always returns false: the harness never locks programmatic scrolling. */
|
||||
const NEVER_LOCKED = () => false;
|
||||
@@ -114,7 +109,6 @@ function Harness({
|
||||
enabled = true,
|
||||
sessionId = null,
|
||||
isProgrammaticScrollLocked = NEVER_LOCKED,
|
||||
recoveryRevealKey = null,
|
||||
}: {
|
||||
itemCount: number;
|
||||
anchoredBarOffsetPx: number;
|
||||
@@ -127,7 +121,6 @@ function Harness({
|
||||
enabled?: boolean;
|
||||
sessionId?: string | null;
|
||||
isProgrammaticScrollLocked?: () => boolean;
|
||||
recoveryRevealKey?: string | null;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
@@ -155,7 +148,6 @@ function Harness({
|
||||
sessionId,
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
recoveryRevealKey,
|
||||
} as Parameters<typeof useScrollToDividerOrBottom>[4];
|
||||
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx, scrollOptions);
|
||||
return (
|
||||
@@ -290,7 +282,6 @@ function NativeScrollManagementHarness({
|
||||
historyRefreshPending = false,
|
||||
hasUnreadDivider = false,
|
||||
hasMore = true,
|
||||
recoveryRevealKey = null,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
messages?: Message[];
|
||||
@@ -304,7 +295,6 @@ function NativeScrollManagementHarness({
|
||||
historyRefreshPending?: boolean;
|
||||
hasUnreadDivider?: boolean;
|
||||
hasMore?: boolean;
|
||||
recoveryRevealKey?: string | null;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useNativeScrollMetrics(scrollRef, metrics);
|
||||
@@ -322,7 +312,6 @@ function NativeScrollManagementHarness({
|
||||
loadMore,
|
||||
isVisible,
|
||||
historyRefreshPending,
|
||||
recoveryRevealKey,
|
||||
});
|
||||
if (recoveryRef) recoveryRef.current = showRecovery;
|
||||
return (
|
||||
@@ -371,301 +360,6 @@ describe("isElementInPreloadRegion", () => {
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- pagination invariants share one fixture and lifecycle.
|
||||
describe("useNativeScrollManagement transcript pagination", () => {
|
||||
it("reveals recovery at the top once per error stamp and preserves same-stamp scrolling", () => {
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 240, clientHeight: 400 };
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(RECOVERY_MESSAGE_ID)]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
recoveryRevealKey="session-recovery:bootstrap-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
metrics.scrollTop = 240;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(RECOVERY_MESSAGE_ID)]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
recoveryRevealKey="session-recovery:bootstrap-1"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(240);
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(RECOVERY_MESSAGE_ID)]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(240);
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(RECOVERY_MESSAGE_ID)]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
recoveryRevealKey="session-recovery:bootstrap-1"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
metrics.scrollTop = 240;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(RECOVERY_MESSAGE_ID)]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
recoveryRevealKey="session-recovery:bootstrap-2"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- recovery ownership and the subsequent prepend handoff share one controlled lifecycle.
|
||||
it("keeps recovery visible while an older page settles, then resumes normal anchoring after user scroll", async () => {
|
||||
const page = Promise.withResolvers<number>();
|
||||
const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
|
||||
const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
|
||||
const newest = transcriptMessage(NEWEST_MESSAGE_ID);
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
|
||||
const wrappedLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise<number>;
|
||||
|
||||
const pendingLoad = wrappedLoadMore();
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(OLDER_MESSAGE_ID), activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
page.resolve(1);
|
||||
await act(async () => {
|
||||
await pendingLoad;
|
||||
});
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(OLDER_MESSAGE_ID), activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
const nextPage = Promise.withResolvers<number>();
|
||||
const nextLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise<number>;
|
||||
const pendingNextLoad = nextLoadMore();
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(OLDER_MESSAGE_ID), activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => nextPage.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 100 }));
|
||||
metrics.scrollTop = 40;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(metrics.scrollTop).toBe(40);
|
||||
|
||||
metrics.scrollHeight = 300;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[
|
||||
transcriptMessage("older-2"),
|
||||
transcriptMessage(OLDER_MESSAGE_ID),
|
||||
activity,
|
||||
newest,
|
||||
]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => nextPage.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(140);
|
||||
|
||||
nextPage.resolve(1);
|
||||
await act(async () => {
|
||||
await pendingNextLoad;
|
||||
});
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[
|
||||
transcriptMessage("older-2"),
|
||||
transcriptMessage(OLDER_MESSAGE_ID),
|
||||
activity,
|
||||
newest,
|
||||
]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
loadMore={() => nextPage.promise}
|
||||
recoveryRevealKey="session-recovery:failure-1"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(140);
|
||||
});
|
||||
|
||||
it("keeps an already-loading older page below a newly revealed recovery card", async () => {
|
||||
const page = Promise.withResolvers<number>();
|
||||
const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
|
||||
const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
|
||||
const newest = transcriptMessage(NEWEST_MESSAGE_ID);
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
loadMore={() => page.promise}
|
||||
/>,
|
||||
);
|
||||
const wrappedLoadMore = sharedSentinelCalls.at(-1)?.[4] as () => Promise<number>;
|
||||
const pendingLoad = wrappedLoadMore();
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => page.promise}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-2"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
metrics.scrollHeight = 200;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(OLDER_MESSAGE_ID), activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
isLoadingMore
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-2"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
|
||||
page.resolve(1);
|
||||
await act(async () => {
|
||||
await pendingLoad;
|
||||
});
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[transcriptMessage(OLDER_MESSAGE_ID), activity, newest]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
loadMore={() => page.promise}
|
||||
recoveryRevealKey="session-recovery:failure-2"
|
||||
/>,
|
||||
);
|
||||
expect(metrics.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("resumes normal auto-scroll after the initial recovery placement is consumed", () => {
|
||||
const metrics = { scrollHeight: 100, scrollTop: 0, clientHeight: 50 };
|
||||
const firstMessage = { id: "first-message" } as Message;
|
||||
const scrollerMessages = [firstMessage];
|
||||
const activity = transcriptActivity(OLDEST_ACTIVITY_ID, "turn-1");
|
||||
const newest = transcriptMessage(NEWEST_MESSAGE_ID);
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
messages={scrollerMessages}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
hasMore={false}
|
||||
recoveryRevealKey="session-recovery:placement-1"
|
||||
/>,
|
||||
);
|
||||
const scroller = screen.getByTestId(NATIVE_SCROLL_MANAGEMENT_TEST_ID);
|
||||
|
||||
metrics.scrollTop = 50;
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: 100 }));
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
metrics.scrollHeight = 150;
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[activity, newest]}
|
||||
messages={[...scrollerMessages, { id: "second-message" } as Message]}
|
||||
metrics={metrics}
|
||||
sessionId={RECOVERY_SESSION_ID}
|
||||
enabled
|
||||
hasMore={false}
|
||||
recoveryRevealKey="session-recovery:placement-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(metrics.scrollTop).toBe(2_147_483_647);
|
||||
});
|
||||
|
||||
// @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.11
|
||||
// @covers AC-UI-TRANSCRIPT-AUTO-SCROLL-001.14
|
||||
it("places cached enabled history before refresh and reconciles after it settles", () => {
|
||||
@@ -1049,48 +743,6 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
expect(sharedSentinelRecheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not recheck a restored transcript while explicit recovery is active", () => {
|
||||
const requestAnimationFrame = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
const recoveryRef = { current: false };
|
||||
const { rerender } = render(
|
||||
<NativeScrollManagementHarness
|
||||
items={[]}
|
||||
sessionId="session-1"
|
||||
recoveryRef={recoveryRef}
|
||||
isVisible={false}
|
||||
/>,
|
||||
);
|
||||
const options = sharedSentinelCalls.at(-1)?.[5] as {
|
||||
onLoadSettled: (result: {
|
||||
count: number;
|
||||
rejected: boolean;
|
||||
continuation: "no-progress";
|
||||
}) => void;
|
||||
};
|
||||
act(() => {
|
||||
options.onLoadSettled({ count: 0, rejected: false, continuation: "no-progress" });
|
||||
});
|
||||
sharedSentinelRecheck.mockClear();
|
||||
|
||||
rerender(
|
||||
<NativeScrollManagementHarness
|
||||
items={[]}
|
||||
sessionId="session-1"
|
||||
recoveryRef={recoveryRef}
|
||||
isVisible
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(recoveryRef.current).toBe(true);
|
||||
expect(sharedSentinelRecheck).not.toHaveBeenCalled();
|
||||
requestAnimationFrame.mockRestore();
|
||||
});
|
||||
|
||||
it("retries a disarmed short page on the next upward scroll", () => {
|
||||
const metrics = { scrollHeight: 1000, scrollTop: 100, clientHeight: 400 };
|
||||
render(<NativeScrollManagementHarness items={[]} metrics={metrics} />);
|
||||
@@ -1351,18 +1003,10 @@ describe("useNativeScrollManagement transcript pagination", () => {
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- this suite keeps the related scroll invariants together.
|
||||
describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
it("leaves an active recovery transcript at the top for its first reveal", () => {
|
||||
render(
|
||||
<Harness
|
||||
itemCount={2}
|
||||
anchoredBarOffsetPx={0}
|
||||
dividerKey={null}
|
||||
scrollHeight={1000}
|
||||
recoveryRevealKey="session-1:bootstrap-1"
|
||||
/>,
|
||||
);
|
||||
it("places an enabled transcript at the bottom on its first render", () => {
|
||||
render(<Harness itemCount={2} anchoredBarOffsetPx={0} dividerKey={null} scrollHeight={1000} />);
|
||||
|
||||
expect(screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID).scrollTop).toBe(0);
|
||||
expect(screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID).scrollTop).toBe(1000);
|
||||
});
|
||||
|
||||
it("waits for an inactive transcript to become visible before placing the initial view", () => {
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
/* eslint-disable max-lines -- native transcript composition owns scrolling. */
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } from "react";
|
||||
import { SessionPanelContent } from "@kandev/ui/pannel-session";
|
||||
import type { Message, TaskSessionState } from "@/lib/types/http";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
@@ -110,7 +102,6 @@ type NativeMessageListScrollParams = {
|
||||
/** Changes when transcript status rows can add/remove space above messages. */
|
||||
scrollLayoutKey: string;
|
||||
isVisible: boolean;
|
||||
recoveryRevealKey?: string | null;
|
||||
};
|
||||
|
||||
type ScrollToDividerOptions = {
|
||||
@@ -121,22 +112,8 @@ type ScrollToDividerOptions = {
|
||||
isProgrammaticScrollLocked?: () => boolean;
|
||||
isVisible?: boolean;
|
||||
historyRefreshPending?: boolean;
|
||||
recoveryRevealKey?: string | null;
|
||||
};
|
||||
|
||||
function consumeRecoveryRevealForDivider(
|
||||
recoveryRevealKey: string | null,
|
||||
didInitialScroll: React.RefObject<boolean>,
|
||||
activationPendingRef: React.RefObject<boolean>,
|
||||
): boolean {
|
||||
if (recoveryRevealKey === null) return false;
|
||||
// The native initial-position hook owns the recovery reveal. Mark this
|
||||
// hook consumed so its normal bottom/divider placement cannot override it.
|
||||
didInitialScroll.current = true;
|
||||
activationPendingRef.current = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
const {
|
||||
scrollRef,
|
||||
@@ -159,7 +136,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
onFirstMessageHiddenChange,
|
||||
scrollLayoutKey,
|
||||
isVisible,
|
||||
recoveryRevealKey,
|
||||
} = params;
|
||||
const {
|
||||
handleScrollToMessage,
|
||||
@@ -182,7 +158,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
isVisible,
|
||||
recoveryRevealKey,
|
||||
});
|
||||
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
|
||||
useEffect(() => {
|
||||
@@ -196,7 +171,6 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
historyRefreshPending,
|
||||
recoveryRevealKey,
|
||||
});
|
||||
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
||||
handleScrollToMessage,
|
||||
@@ -304,7 +278,6 @@ type NativeMessageListBodyProps = {
|
||||
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
|
||||
autoScrollEnabled: boolean;
|
||||
dividerBeforeItemKey?: string | null;
|
||||
prependContent?: ReactNode;
|
||||
launchErrorOwned: boolean;
|
||||
launchErrorStamp?: string;
|
||||
launchErrorOccurredAt?: string;
|
||||
@@ -346,7 +319,7 @@ type NativeMessageListBodyProps = {
|
||||
* keep their existing visible-host behavior without participating in read
|
||||
* tracking.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- divider and recovery placement share one scroll lifecycle.
|
||||
// eslint-disable-next-line max-lines-per-function -- divider and initial placement share one scroll lifecycle.
|
||||
export function useScrollToDividerOrBottom(
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>,
|
||||
itemCount: number,
|
||||
@@ -362,7 +335,6 @@ export function useScrollToDividerOrBottom(
|
||||
isProgrammaticScrollLocked = () => false,
|
||||
isVisible = true,
|
||||
historyRefreshPending = false,
|
||||
recoveryRevealKey = null,
|
||||
} = options;
|
||||
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
|
||||
const isUserScrollingRef = useDividerUserScrolling(scrollRef);
|
||||
@@ -393,8 +365,6 @@ export function useScrollToDividerOrBottom(
|
||||
}
|
||||
const el = scrollRef.current;
|
||||
if (!el || historyRefreshPending) return;
|
||||
if (consumeRecoveryRevealForDivider(recoveryRevealKey, didInitialScroll, activationPendingRef))
|
||||
return;
|
||||
if (itemCount === 0) return;
|
||||
|
||||
const placeInitialPosition = () => {
|
||||
@@ -464,7 +434,6 @@ export function useScrollToDividerOrBottom(
|
||||
isProgrammaticScrollLocked,
|
||||
isVisible,
|
||||
historyRefreshPending,
|
||||
recoveryRevealKey,
|
||||
scrollRef,
|
||||
]);
|
||||
}
|
||||
@@ -520,14 +489,12 @@ function NativeMessageListBody({
|
||||
onScrollToMessage,
|
||||
autoScrollEnabled,
|
||||
dividerBeforeItemKey,
|
||||
prependContent,
|
||||
launchErrorOwned,
|
||||
launchErrorStamp,
|
||||
launchErrorOccurredAt,
|
||||
}: NativeMessageListBodyProps) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
{prependContent ? <div className="mb-4">{prependContent}</div> : null}
|
||||
{/* Sentinel for lazy loading older messages */}
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
|
||||
@@ -623,8 +590,6 @@ export const NativeMessageList = memo(
|
||||
launchErrorOwned = false,
|
||||
launchErrorStamp,
|
||||
launchErrorOccurredAt,
|
||||
prependContent,
|
||||
recoveryRevealKey,
|
||||
}: MessageListProps,
|
||||
ref,
|
||||
) {
|
||||
@@ -699,7 +664,6 @@ export const NativeMessageList = memo(
|
||||
isWorking,
|
||||
].join(":"),
|
||||
isVisible,
|
||||
recoveryRevealKey,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -739,7 +703,6 @@ export const NativeMessageList = memo(
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
autoScrollEnabled={autoScrollEnabled}
|
||||
dividerBeforeItemKey={dividerBeforeItemKey}
|
||||
prependContent={prependContent}
|
||||
launchErrorOwned={launchErrorOwned}
|
||||
launchErrorStamp={launchErrorStamp}
|
||||
launchErrorOccurredAt={launchErrorOccurredAt}
|
||||
|
||||
@@ -64,10 +64,6 @@ export type MessageListProps = {
|
||||
* top — the desktop-only, opt-in anchored last-prompt bar. `null`/`undefined`
|
||||
* when the setting is off or on mobile. */
|
||||
stickyPromptBar?: ReactNode;
|
||||
/** Rendered above transcript status and messages inside the native viewport. */
|
||||
prependContent?: ReactNode;
|
||||
/** Identity of the active recovery content for one-time initial reveal. */
|
||||
recoveryRevealKey?: string | null;
|
||||
/** Current rendered height (px) of the anchored last-prompt bar's pinned
|
||||
* overlay, or 0/undefined when it isn't showing. Lets a target scrolled
|
||||
* to the top of the transcript (e.g. the unread "New" divider) reserve
|
||||
|
||||
@@ -11,6 +11,9 @@ export type ActionMeta = {
|
||||
action_visibility?: "running";
|
||||
variant?: string;
|
||||
recovery_actions?: boolean;
|
||||
scope?: "session" | "task";
|
||||
error_stamp?: string;
|
||||
recovery_stamp?: string;
|
||||
is_auth_error?: boolean;
|
||||
auth_methods?: RecoveryAuthMethod[];
|
||||
error_output?: string;
|
||||
|
||||
@@ -123,23 +123,25 @@ function renderWithTranscript(
|
||||
});
|
||||
}
|
||||
|
||||
describe("ActionMessage — recovery card retires once the agent is back", () => {
|
||||
it("hides the card when a resume re-established the agent after the failure", () => {
|
||||
describe("ActionMessage — recovery history remains after the agent is back", () => {
|
||||
it("retains the entry without controls when a resume re-established the agent", () => {
|
||||
// A resumed agent settles at WAITING_FOR_INPUT, not RUNNING, so the session
|
||||
// state alone never retires the card.
|
||||
renderWithTranscript("WAITING_FOR_INPUT", [bootMessage(BOOTED_AFTER_FAILURE)]);
|
||||
|
||||
expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
|
||||
expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(FRESH_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the card when a fresh start re-established the agent after the failure", () => {
|
||||
it("retains the entry without controls when a fresh start re-established the agent", () => {
|
||||
renderWithTranscript("WAITING_FOR_INPUT", [
|
||||
bootMessage(BOOTED_AFTER_FAILURE, { is_resuming: false }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(FRESH_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the card visible when the resume attempt failed", () => {
|
||||
@@ -314,7 +316,7 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
|
||||
|
||||
// The launch was accepted, so the card hid; the agent then failed to come up.
|
||||
live.setSessionState("STARTING");
|
||||
@@ -329,7 +331,7 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
|
||||
const live = renderWithLiveStore([]);
|
||||
|
||||
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
|
||||
await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
|
||||
|
||||
live.setSessionState("FAILED");
|
||||
|
||||
@@ -341,13 +343,13 @@ describe("ActionMessage — a recovery that failed keeps its controls", () => {
|
||||
const live = renderWithLiveStore([]);
|
||||
|
||||
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
|
||||
await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull());
|
||||
|
||||
live.setSessionState("STARTING");
|
||||
live.setMessages([bootMessage(BOOTED_AFTER_FAILURE)]);
|
||||
live.setSessionState("WAITING_FOR_INPUT");
|
||||
|
||||
expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
|
||||
expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- recovery action variants share one rendering harness. */
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
@@ -142,12 +143,17 @@ function renderAction(
|
||||
sessionState?: TaskSessionState,
|
||||
sessionError?: string,
|
||||
activeTurnId?: string,
|
||||
sessionMetadata?: Record<string, unknown> | null,
|
||||
) {
|
||||
const initialState: Partial<AppState> = sessionState
|
||||
? {
|
||||
taskSessions: {
|
||||
items: {
|
||||
[TEST_SESSION_ID]: { state: sessionState, error_message: sessionError } as TaskSession,
|
||||
[TEST_SESSION_ID]: {
|
||||
state: sessionState,
|
||||
error_message: sessionError,
|
||||
metadata: sessionMetadata,
|
||||
} as TaskSession,
|
||||
},
|
||||
},
|
||||
turns: {
|
||||
@@ -288,21 +294,68 @@ describe("ActionMessage — transient retry (warning variant)", () => {
|
||||
renderAction(errorMsg, "WAITING_FOR_INPUT", "");
|
||||
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides a recovery card after its Resume request succeeds", async () => {
|
||||
describe("ActionMessage — session recovery history", () => {
|
||||
it("keeps the recovery entry after its Resume request succeeds and removes controls", async () => {
|
||||
const errorMsg = recoveryMessage(true);
|
||||
|
||||
renderAction(errorMsg, "WAITING_FOR_INPUT", "");
|
||||
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
|
||||
await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
|
||||
await waitFor(() => expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy());
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the recovery card hidden after a successful resume settles back to waiting", async () => {
|
||||
it("shows recovery controls only for the current stamped failure", () => {
|
||||
const historical = recoveryMessage(true);
|
||||
historical.metadata = {
|
||||
...(historical.metadata as Record<string, unknown>),
|
||||
error_stamp: "failure-old",
|
||||
};
|
||||
const current = recoveryMessage(true);
|
||||
current.metadata = {
|
||||
...(current.metadata as Record<string, unknown>),
|
||||
error_stamp: "failure-current",
|
||||
};
|
||||
const { rerender } = renderAction(historical, "WAITING_FOR_INPUT", "", undefined, {
|
||||
last_agent_error: {
|
||||
message: "The newer session failure.",
|
||||
stamp: "failure-current",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
|
||||
rerender(<ActionMessage comment={current} />);
|
||||
|
||||
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps controls for an unstamped legacy row matching the current failure", () => {
|
||||
const legacy = recoveryMessage(true);
|
||||
legacy.content = "Agent encountered an error: The agent could not start.";
|
||||
legacy.metadata = {
|
||||
...(legacy.metadata as Record<string, unknown>),
|
||||
error_stamp: undefined,
|
||||
};
|
||||
|
||||
renderAction(legacy, "WAITING_FOR_INPUT", "", undefined, {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: legacy.created_at,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(RESUME_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the historical recovery entry after a successful resume settles back to waiting", async () => {
|
||||
const errorMsg = recoveryMessage(true);
|
||||
|
||||
const { setSessionState } = renderActionWithStore(errorMsg, "WAITING_FOR_INPUT", "");
|
||||
fireEvent.click(screen.getByTestId(RESUME_TEST_ID));
|
||||
await waitFor(() => expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull());
|
||||
await waitFor(() => expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy());
|
||||
|
||||
// A successful resume drives the session through an active state (which
|
||||
// hides the card via isSessionActive) and then back to WAITING_FOR_INPUT
|
||||
@@ -312,7 +365,7 @@ describe("ActionMessage — transient retry (warning variant)", () => {
|
||||
setSessionState("STARTING");
|
||||
setSessionState("WAITING_FOR_INPUT");
|
||||
|
||||
expect(screen.queryByText(RECOVERY_MESSAGE)).toBeNull();
|
||||
expect(screen.getByText(RECOVERY_MESSAGE)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RESUME_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,11 +13,62 @@ import { parseRetryAt, retryCountdownLabel } from "./transient-retry";
|
||||
import { hasSessionRecoveryResolutionAfter } from "@/hooks/processed-message-filtering";
|
||||
import { ActionButtons } from "./action-message-actions";
|
||||
import { SessionRecoveryActionButtons, sessionRecoveryAction } from "./action-message-recovery";
|
||||
import {
|
||||
lastAgentErrorStamp,
|
||||
readLastAgentError,
|
||||
type LastAgentError,
|
||||
} from "@/lib/session-last-agent-error";
|
||||
import { legacyRecoveryMessageMatchesError } from "@/lib/session-recovery-presentation";
|
||||
|
||||
function isSessionActive(state?: TaskSessionState) {
|
||||
return state === "RUNNING" || state === "STARTING" || state === "COMPLETED";
|
||||
}
|
||||
|
||||
function currentSessionRecoveryError(sessionMetadata: Record<string, unknown> | null) {
|
||||
if (!sessionMetadata) return null;
|
||||
return readLastAgentError(sessionMetadata);
|
||||
}
|
||||
|
||||
function isCurrentRecoveryMessage(
|
||||
isRecoveryMessage: boolean,
|
||||
messageRecoveryStamp: string | undefined,
|
||||
currentRecoveryError: LastAgentError | null,
|
||||
comment: Message,
|
||||
) {
|
||||
if (!isRecoveryMessage) return true;
|
||||
if (!currentRecoveryError) return true;
|
||||
const currentRecoveryStamp = lastAgentErrorStamp(currentRecoveryError);
|
||||
if (messageRecoveryStamp) return currentRecoveryStamp === messageRecoveryStamp;
|
||||
return legacyRecoveryMessageMatchesError(
|
||||
comment.content,
|
||||
comment.created_at,
|
||||
currentRecoveryError,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowRecoveryActions({
|
||||
isRecoveryMessage,
|
||||
isCurrentRecovery,
|
||||
recoveryResolvedDurably,
|
||||
agentRebooted,
|
||||
recoveryRequested,
|
||||
recoveryFailedAgain,
|
||||
sessionState,
|
||||
}: {
|
||||
isRecoveryMessage: boolean;
|
||||
isCurrentRecovery: boolean;
|
||||
recoveryResolvedDurably: boolean;
|
||||
agentRebooted: boolean;
|
||||
recoveryRequested: boolean;
|
||||
recoveryFailedAgain: boolean;
|
||||
sessionState?: TaskSessionState;
|
||||
}) {
|
||||
if (!isRecoveryMessage) return true;
|
||||
if (!isCurrentRecovery || recoveryResolvedDurably || agentRebooted) return false;
|
||||
if (recoveryRequested && !recoveryFailedAgain) return false;
|
||||
return !isSessionActive(sessionState);
|
||||
}
|
||||
|
||||
export const ActionMessage = memo(function ActionMessage({ comment }: { comment: Message }) {
|
||||
// Read session state from the store instead of receiving it as a prop, so a
|
||||
// state transition doesn't re-render every message in the list (only the
|
||||
@@ -51,6 +102,23 @@ export const ActionMessage = memo(function ActionMessage({ comment }: { comment:
|
||||
// that came back failed — a failed boot row, or a session driven to FAILED —
|
||||
// must surface its card again, buttons included, or the retry is unreachable.
|
||||
const recoveryFailedAgain = agentBootFailed || sessionState === "FAILED";
|
||||
const currentRecoveryError = currentSessionRecoveryError(sessionMetadata ?? null);
|
||||
const messageRecoveryStamp = metadata?.recovery_stamp ?? metadata?.error_stamp;
|
||||
const isCurrentRecovery = isCurrentRecoveryMessage(
|
||||
isRecoveryMessage,
|
||||
messageRecoveryStamp,
|
||||
currentRecoveryError,
|
||||
comment,
|
||||
);
|
||||
const recoveryActionsVisible = shouldShowRecoveryActions({
|
||||
isRecoveryMessage,
|
||||
isCurrentRecovery,
|
||||
recoveryResolvedDurably,
|
||||
agentRebooted,
|
||||
recoveryRequested,
|
||||
recoveryFailedAgain,
|
||||
sessionState,
|
||||
});
|
||||
|
||||
if (metadata?.action_visibility === "running") {
|
||||
if (sessionState === "RUNNING" && comment.turn_id && activeTurnId === comment.turn_id) {
|
||||
@@ -77,9 +145,7 @@ export const ActionMessage = memo(function ActionMessage({ comment }: { comment:
|
||||
sessionState={sessionState}
|
||||
taskId={comment.task_id}
|
||||
sessionId={comment.session_id}
|
||||
recoveryResolved={
|
||||
recoveryResolvedDurably || agentRebooted || (recoveryRequested && !recoveryFailedAgain)
|
||||
}
|
||||
recoveryActionsVisible={recoveryActionsVisible}
|
||||
onRecoveryRequested={() => setRecoveryRequested(true)}
|
||||
/>
|
||||
);
|
||||
@@ -92,7 +158,7 @@ function SettledActionMessage({
|
||||
sessionState,
|
||||
taskId,
|
||||
sessionId,
|
||||
recoveryResolved,
|
||||
recoveryActionsVisible,
|
||||
onRecoveryRequested,
|
||||
}: {
|
||||
metadata: ActionMeta | undefined;
|
||||
@@ -101,12 +167,12 @@ function SettledActionMessage({
|
||||
sessionState?: TaskSessionState;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
recoveryResolved: boolean;
|
||||
recoveryActionsVisible: boolean;
|
||||
onRecoveryRequested: () => void;
|
||||
}) {
|
||||
// A retry card is persisted against the failed turn, so hide it while the
|
||||
// replacement turn is starting or running to avoid showing stale progress.
|
||||
if (isSessionActive(sessionState)) return null;
|
||||
if (isSessionActive(sessionState) && metadata?.recovery_actions !== true) return null;
|
||||
|
||||
if (metadata?.retrying) {
|
||||
return <TransientRetryNotice metadata={metadata} taskId={taskId} />;
|
||||
@@ -117,10 +183,9 @@ function SettledActionMessage({
|
||||
metadata={metadata}
|
||||
message={message}
|
||||
sessionError={sessionError}
|
||||
sessionState={sessionState}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
recoveryResolved={recoveryResolved}
|
||||
recoveryActionsVisible={recoveryActionsVisible}
|
||||
onRecoveryRequested={onRecoveryRequested}
|
||||
/>
|
||||
);
|
||||
@@ -130,32 +195,23 @@ function SettledFailureMessage({
|
||||
metadata,
|
||||
message,
|
||||
sessionError,
|
||||
sessionState,
|
||||
taskId,
|
||||
sessionId,
|
||||
recoveryResolved,
|
||||
recoveryActionsVisible,
|
||||
onRecoveryRequested,
|
||||
}: {
|
||||
metadata: ActionMeta | undefined;
|
||||
message: string;
|
||||
sessionError?: string;
|
||||
sessionState?: TaskSessionState;
|
||||
taskId?: string;
|
||||
sessionId?: string;
|
||||
recoveryResolved: boolean;
|
||||
recoveryActionsVisible: boolean;
|
||||
onRecoveryRequested: () => void;
|
||||
}) {
|
||||
// A waiting session can still need recovery, so only hide this persisted card
|
||||
// once its own recovery resolved: either the Resume click was acknowledged or
|
||||
// the transcript shows the agent booted again after this failure. A resumed
|
||||
// agent settles at WAITING_FOR_INPUT, which isSessionActive deliberately
|
||||
// excludes, so without that second signal the card outlives the failure it
|
||||
// describes.
|
||||
if (isSessionActive(sessionState) || (metadata?.recovery_actions && recoveryResolved))
|
||||
return null;
|
||||
const renderedMetadata = recoveryActionsVisible ? metadata : withoutRecoveryActions(metadata);
|
||||
|
||||
const specialRecovery = renderSpecialRecovery({
|
||||
metadata,
|
||||
metadata: renderedMetadata,
|
||||
message,
|
||||
sessionError,
|
||||
taskId,
|
||||
@@ -169,16 +225,16 @@ function SettledFailureMessage({
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full" data-testid="session-recovery-action-message">
|
||||
<div className="flex items-start gap-3 w-full rounded px-2 py-1 -mx-2">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<IconAlertTriangle className={cn("h-4 w-4", iconClass)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className={cn("text-xs break-words", textClass)}>{message}</div>
|
||||
<ActionMessageDetails metadata={metadata} />
|
||||
<ActionMessageDetails metadata={renderedMetadata} />
|
||||
{renderSettledActionButtons({
|
||||
actions: metadata?.actions,
|
||||
actions: renderedMetadata?.actions,
|
||||
taskId,
|
||||
sessionId,
|
||||
isRecoveryMessage: metadata?.recovery_actions === true,
|
||||
@@ -190,6 +246,11 @@ function SettledFailureMessage({
|
||||
);
|
||||
}
|
||||
|
||||
function withoutRecoveryActions(metadata: ActionMeta | undefined): ActionMeta | undefined {
|
||||
if (!metadata) return undefined;
|
||||
return { ...metadata, actions: undefined };
|
||||
}
|
||||
|
||||
function renderSettledActionButtons({
|
||||
actions,
|
||||
taskId,
|
||||
|
||||
@@ -74,6 +74,7 @@ vi.mock("../prompt-history-panel-content", () => ({
|
||||
|
||||
import {
|
||||
MobilePanelArea,
|
||||
mobilePanelTopNavHeight,
|
||||
resolveMobilePluginPanel,
|
||||
resolveMobileReviewSource,
|
||||
terminalPaddingBottom,
|
||||
@@ -327,6 +328,11 @@ describe("resolveMobilePluginPanel", () => {
|
||||
});
|
||||
|
||||
describe("MobilePanelArea PR identity", () => {
|
||||
it("removes nested top-bar padding when the outer task error reserves it", () => {
|
||||
expect(mobilePanelTopNavHeight(false)).toBe("3.5rem");
|
||||
expect(mobilePanelTopNavHeight(true)).toBe("0px");
|
||||
});
|
||||
|
||||
it("remounts detail feedback when the user chooses another mixed-provider review", () => {
|
||||
function MobileReviewHarness() {
|
||||
const reviews: ReviewItemSummary[] = [
|
||||
|
||||
@@ -74,6 +74,10 @@ export function resolveMobileReviewSource(
|
||||
const TOP_NAV_HEIGHT = "3.5rem";
|
||||
const BOTTOM_NAV_HEIGHT = "3.25rem";
|
||||
|
||||
export function mobilePanelTopNavHeight(hasSharedTaskError: boolean): string {
|
||||
return hasSharedTaskError ? "0px" : TOP_NAV_HEIGHT;
|
||||
}
|
||||
|
||||
type SessionMobileLayoutProps = {
|
||||
workspaceId: string | null;
|
||||
workflowId: string | null;
|
||||
@@ -94,6 +98,7 @@ type SessionMobileLayoutProps = {
|
||||
onTaskUnarchived?: (taskId: string) => void;
|
||||
taskCanvases?: Canvas[];
|
||||
onOpenCanvas?: (canvasId: string) => void;
|
||||
hasSharedTaskError?: boolean;
|
||||
};
|
||||
|
||||
function MobileChatPanelContent({
|
||||
@@ -213,7 +218,7 @@ export function MobilePanelArea({
|
||||
style={{
|
||||
paddingTop: `calc(${topNavHeight} + env(safe-area-inset-top, 0px))`,
|
||||
paddingBottom: `calc(${bottomNavHeight} + env(safe-area-inset-bottom, 0px))`,
|
||||
height: "100dvh",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{currentMobilePanel === "chat" && (
|
||||
@@ -662,7 +667,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
|
||||
}
|
||||
}, [currentMobilePanel, effectiveMobilePanel]);
|
||||
return (
|
||||
<div className="h-dvh relative bg-background" data-testid="mobile-task-layout">
|
||||
<div className="relative h-full min-h-0 bg-background" data-testid="mobile-task-layout">
|
||||
<MobileTopBarSticky
|
||||
{...props}
|
||||
activeTaskId={activeTaskId}
|
||||
@@ -686,7 +691,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
|
||||
onNavigateToPrompt={handleNavigateToPrompt}
|
||||
onScrollTargetConsumed={handleMobileScrollTargetConsumed}
|
||||
mobileScrollTarget={mobileScrollTarget}
|
||||
topNavHeight={TOP_NAV_HEIGHT}
|
||||
topNavHeight={mobilePanelTopNavHeight(Boolean(props.hasSharedTaskError))}
|
||||
bottomNavHeight={BOTTOM_NAV_HEIGHT}
|
||||
reviews={reviews}
|
||||
selectedReview={selectedReview}
|
||||
|
||||
@@ -78,6 +78,7 @@ vi.mock("./task-launch-error-context", () => ({
|
||||
mocks.taskLaunchErrorProviderValue = value;
|
||||
return <>{children}</>;
|
||||
},
|
||||
useTaskLaunchErrorContext: () => null,
|
||||
}));
|
||||
vi.mock("@/hooks/use-task-sessions", () => ({
|
||||
useTaskSessions: mocks.useTaskSessions,
|
||||
@@ -371,7 +372,7 @@ describe("PreviewSessionBody delivery", () => {
|
||||
expect(mocks.taskLaunchErrorProviderValue?.automaticRecovery).toBe(recovery);
|
||||
});
|
||||
|
||||
it("keeps the bootstrap recovery card and automatic outcome in passthrough preview", () => {
|
||||
it("keeps passthrough recovery feedback outside the terminal preview", () => {
|
||||
mocks.sessions = [
|
||||
makeSession("session-1", {
|
||||
is_passthrough: true,
|
||||
@@ -410,9 +411,7 @@ describe("PreviewSessionBody delivery", () => {
|
||||
|
||||
render(<PreviewSessionTabs taskId={TASK_ID} sessionId="session-1" />);
|
||||
|
||||
expect(screen.getByTestId("preview-bootstrap-recovery-card").textContent).toContain(
|
||||
"recovery_failed",
|
||||
);
|
||||
expect(screen.queryByTestId("preview-bootstrap-recovery-card")).toBeNull();
|
||||
expect(screen.getByTestId("preview-passthrough-toolbar")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,11 +25,8 @@ import { TabRenameInput } from "./tab-rename-input";
|
||||
import { PreviewPlanPanel, usePreviewPlanSummary } from "./preview-plan-panel";
|
||||
import { TaskChatPanel } from "./task-chat-panel";
|
||||
import { TaskLaunchErrorProvider } from "./task-launch-error-context";
|
||||
import {
|
||||
selectSessionRecoveryError,
|
||||
type SessionRecoveryOwner,
|
||||
} from "@/lib/session-recovery-presentation";
|
||||
import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
|
||||
import { PreviewTaskErrorShell } from "./preview-task-error-shell";
|
||||
import { type SessionRecoveryOwner } from "@/lib/session-recovery-presentation";
|
||||
import type { HandoffPreset } from "./new-session-dialog";
|
||||
import { MAX_SESSION_NAME_LENGTH, useSessionRenameCommitter } from "./use-session-rename";
|
||||
import {
|
||||
@@ -253,30 +250,12 @@ function PreviewSessionTabDialogHost({
|
||||
}
|
||||
|
||||
function PreviewSessionRecoverySurface({
|
||||
taskId,
|
||||
activeSession,
|
||||
workspaceId,
|
||||
bootstrapRecoveryError,
|
||||
resumption,
|
||||
}: {
|
||||
taskId: string;
|
||||
activeSession: TaskSession | null;
|
||||
workspaceId?: string | null;
|
||||
bootstrapRecoveryError: ReturnType<typeof selectSessionRecoveryError>;
|
||||
resumption: ReturnType<typeof useSessionResumption>;
|
||||
}) {
|
||||
if (activeSession?.is_passthrough && bootstrapRecoveryError) {
|
||||
return (
|
||||
<SessionBootstrapRecoveryCard
|
||||
taskId={taskId}
|
||||
sessionId={activeSession.id}
|
||||
workspaceId={workspaceId}
|
||||
error={bootstrapRecoveryError}
|
||||
automaticRecovery={resumption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (bootstrapRecoveryError) return null;
|
||||
return (
|
||||
<SessionRecoveryFeedback
|
||||
error={resumption.error}
|
||||
@@ -351,12 +330,6 @@ export function PreviewSessionTabs({
|
||||
// active session is ready (resumes / restores workspace after a kandev
|
||||
// restart where the session row is persisted but agentctl isn't alive).
|
||||
const resumption = useSessionResumption(taskId, activeSessionId, isArchived ?? null);
|
||||
const bootstrapRecoveryError = selectSessionRecoveryError(
|
||||
taskStatusSummary?.active_error,
|
||||
activeSessionId,
|
||||
activeSession?.metadata,
|
||||
);
|
||||
|
||||
const dialogs = usePreviewSessionTabDialogs(taskId, sortedSessions);
|
||||
// `handleSessionRemoved` is captured once by `useSessionActions`'s `remove`
|
||||
// closure at the moment delete is confirmed, and `session.delete` can take
|
||||
@@ -397,16 +370,32 @@ export function PreviewSessionTabs({
|
||||
const tabs = useMemo<SessionTab[]>(() => [...sessionTabs, planTab], [sessionTabs, planTab]);
|
||||
|
||||
if (!isLoaded && sortedSessions.length === 0) {
|
||||
return <PreviewLoadingState label={t("task:loadingAgents")} />;
|
||||
return (
|
||||
<PreviewTaskErrorShell
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={taskStatusSummary}
|
||||
resumption={resumption}
|
||||
>
|
||||
<PreviewLoadingState label={t("task:loadingAgents")} />
|
||||
</PreviewTaskErrorShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSessions && !hasPlan) {
|
||||
return (
|
||||
<PreviewNoSessionsState
|
||||
ensureSession={ensureSession}
|
||||
resumption={resumption}
|
||||
<PreviewTaskErrorShell
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
statusSummary={taskStatusSummary}
|
||||
resumption={resumption}
|
||||
>
|
||||
<PreviewNoSessionsState
|
||||
ensureSession={ensureSession}
|
||||
resumption={resumption}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</PreviewTaskErrorShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,42 +403,43 @@ export function PreviewSessionTabs({
|
||||
// still have a plan worth showing — fall back to the Plan view rather than
|
||||
// an empty session body when there's nothing else to select.
|
||||
return (
|
||||
<div className="flex h-full flex-col min-h-0" data-testid="preview-session-tabs">
|
||||
<PreviewSessionRecoverySurface
|
||||
taskId={taskId}
|
||||
activeSession={activeSession}
|
||||
workspaceId={workspaceId}
|
||||
bootstrapRecoveryError={bootstrapRecoveryError}
|
||||
resumption={resumption}
|
||||
/>
|
||||
<div className="border-b px-2 py-1">
|
||||
<SessionTabs
|
||||
tabs={tabs}
|
||||
activeTab={viewMode === "plan" ? PLAN_TAB_ID : (activeSessionId ?? "")}
|
||||
onTabChange={planTabState.handleTabChange}
|
||||
listClassName="bg-transparent p-0 !h-7 gap-1 overflow-x-auto overflow-y-hidden min-w-0 shrink [@media(pointer:coarse)]:!h-11 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<PreviewTabBody
|
||||
viewMode={viewMode}
|
||||
planState={planTabState.planState}
|
||||
activeSession={activeSession}
|
||||
<PreviewTaskErrorShell
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={taskStatusSummary}
|
||||
resumption={resumption}
|
||||
>
|
||||
<div className="flex h-full flex-col min-h-0" data-testid="preview-session-tabs">
|
||||
<PreviewSessionRecoverySurface workspaceId={workspaceId} resumption={resumption} />
|
||||
<div className="border-b px-2 py-1">
|
||||
<SessionTabs
|
||||
tabs={tabs}
|
||||
activeTab={viewMode === "plan" ? PLAN_TAB_ID : (activeSessionId ?? "")}
|
||||
onTabChange={planTabState.handleTabChange}
|
||||
listClassName="bg-transparent p-0 !h-7 gap-1 overflow-x-auto overflow-y-hidden min-w-0 shrink [@media(pointer:coarse)]:!h-11 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<PreviewTabBody
|
||||
viewMode={viewMode}
|
||||
planState={planTabState.planState}
|
||||
activeSession={activeSession}
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={taskStatusSummary}
|
||||
resumption={resumption}
|
||||
/>
|
||||
</div>
|
||||
<PreviewSessionTabDialogHost
|
||||
dialogs={dialogs}
|
||||
sortedSessions={sortedSessions}
|
||||
profilesById={profilesById}
|
||||
agentLabelsById={agentLabelsById}
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={taskStatusSummary}
|
||||
resumption={resumption}
|
||||
isPrimarySession={isPrimarySession}
|
||||
/>
|
||||
</div>
|
||||
<PreviewSessionTabDialogHost
|
||||
dialogs={dialogs}
|
||||
sortedSessions={sortedSessions}
|
||||
profilesById={profilesById}
|
||||
agentLabelsById={agentLabelsById}
|
||||
taskId={taskId}
|
||||
isPrimarySession={isPrimarySession}
|
||||
/>
|
||||
</div>
|
||||
</PreviewTaskErrorShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
|
||||
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
||||
import { TaskLaunchErrorProvider } from "./task-launch-error-context";
|
||||
import { TaskSharedError } from "./task-shared-error";
|
||||
|
||||
export function PreviewTaskErrorShell({
|
||||
taskId,
|
||||
workspaceId,
|
||||
statusSummary,
|
||||
resumption,
|
||||
children,
|
||||
}: {
|
||||
taskId: string;
|
||||
workspaceId?: string | null;
|
||||
statusSummary?: ReturnType<typeof useTaskStatusSummary>;
|
||||
resumption: ReturnType<typeof useSessionResumption>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TaskLaunchErrorProvider
|
||||
value={{
|
||||
taskId,
|
||||
workspaceId: workspaceId ?? "",
|
||||
statusSummary,
|
||||
automaticRecovery: resumption,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<TaskSharedError />
|
||||
{children}
|
||||
</div>
|
||||
</TaskLaunchErrorProvider>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
TimelineEvent,
|
||||
} from "@/app/office/tasks/[id]/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
|
||||
import { TaskSharedError } from "@/components/task/task-shared-error";
|
||||
|
||||
// TAB_TRIGGER_BASE adds a 1px ring to the active tab on top of
|
||||
// shadcn's default bg/text active styling, so the selected tab is
|
||||
@@ -137,44 +139,54 @@ export function ChatActivityTabs({
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="chat" className="mt-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chat" className={TAB_TRIGGER_BASE}>
|
||||
{t("task:chat")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className={TAB_TRIGGER_BASE}>
|
||||
{t("task:activity")}
|
||||
</TabsTrigger>
|
||||
<TaskLaunchErrorProvider
|
||||
value={{
|
||||
taskId: task.id,
|
||||
workspaceId: task.workspaceId,
|
||||
statusSummary: task.statusSummary,
|
||||
repositories: task.repositories,
|
||||
}}
|
||||
>
|
||||
<TaskSharedError />
|
||||
<Tabs defaultValue="chat" className="mt-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chat" className={TAB_TRIGGER_BASE}>
|
||||
{t("task:chat")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className={TAB_TRIGGER_BASE}>
|
||||
{t("task:activity")}
|
||||
</TabsTrigger>
|
||||
{officeGroups.map((g) => (
|
||||
<AgentTabTrigger key={g.id} group={g} />
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="chat">
|
||||
<ApprovalActionBar task={task} />
|
||||
<TaskChat
|
||||
taskId={task.id}
|
||||
workspaceId={task.workspaceId}
|
||||
statusSummary={task.statusSummary}
|
||||
repositories={task.repositories}
|
||||
comments={comments}
|
||||
timeline={timeline}
|
||||
sessions={sessions}
|
||||
decisions={task.decisions}
|
||||
reviewers={task.reviewers}
|
||||
approvers={task.approvers}
|
||||
scrollParent={scrollParent}
|
||||
readOnly={readOnly}
|
||||
onCommentsChanged={onCommentsChanged}
|
||||
taskTitle={task.title}
|
||||
taskDescription={task.description}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<TaskActivity taskId={task.id} entries={activity} />
|
||||
</TabsContent>
|
||||
{officeGroups.map((g) => (
|
||||
<AgentTabTrigger key={g.id} group={g} />
|
||||
<AgentTabContent key={g.id} taskId={task.id} group={g} />
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="chat">
|
||||
<ApprovalActionBar task={task} />
|
||||
<TaskChat
|
||||
taskId={task.id}
|
||||
workspaceId={task.workspaceId}
|
||||
statusSummary={task.statusSummary}
|
||||
repositories={task.repositories}
|
||||
comments={comments}
|
||||
timeline={timeline}
|
||||
sessions={sessions}
|
||||
decisions={task.decisions}
|
||||
reviewers={task.reviewers}
|
||||
approvers={task.approvers}
|
||||
scrollParent={scrollParent}
|
||||
readOnly={readOnly}
|
||||
onCommentsChanged={onCommentsChanged}
|
||||
taskTitle={task.title}
|
||||
taskDescription={task.description}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<TaskActivity taskId={task.id} entries={activity} />
|
||||
</TabsContent>
|
||||
{officeGroups.map((g) => (
|
||||
<AgentTabContent key={g.id} taskId={task.id} group={g} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Tabs>
|
||||
</TaskLaunchErrorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRunErrorsFromSessions,
|
||||
filterVisibleRunErrors,
|
||||
hasMatchingSessionLaunchError,
|
||||
liveSessionMetadataFromStore,
|
||||
mergeLiveSessionMetadata,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
import type { TaskSession } from "@/app/office/tasks/[id]/types";
|
||||
|
||||
const URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
|
||||
const SESSION_STARTED_AT = "2026-08-02T15:00:00Z";
|
||||
|
||||
function session(overrides: Partial<TaskSession>): TaskSession {
|
||||
return {
|
||||
@@ -17,7 +17,7 @@ function session(overrides: Partial<TaskSession>): TaskSession {
|
||||
agentRole: "agent",
|
||||
state: "FAILED",
|
||||
isPrimary: false,
|
||||
startedAt: "2026-08-02T15:00:00Z",
|
||||
startedAt: SESSION_STARTED_AT,
|
||||
errorMessage: "usage limit reached",
|
||||
...overrides,
|
||||
};
|
||||
@@ -64,12 +64,16 @@ describe("buildRunErrorsFromSessions", () => {
|
||||
});
|
||||
|
||||
it("omits the URL when metadata or the field is absent", () => {
|
||||
const legacyError = buildRunErrorsFromSessions([session({ errorMessage: "boom" })]);
|
||||
expect(legacyError).toHaveLength(1);
|
||||
expect(legacyError[0].remediationUrl).toBeUndefined();
|
||||
expect(legacyError[0].isActive).toBe(true);
|
||||
for (const s of [
|
||||
session({ errorMessage: "boom" }),
|
||||
session({ metadata: { last_agent_error: { message: "boom" } } }),
|
||||
session({ metadata: { last_agent_error: { message: "boom", remediation_url: "" } } }),
|
||||
]) {
|
||||
const errors = buildRunErrorsFromSessions([s]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].remediationUrl).toBeUndefined();
|
||||
}
|
||||
});
|
||||
@@ -88,11 +92,38 @@ describe("buildRunErrorsFromSessions", () => {
|
||||
expect(errors[0].remediationUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips non-FAILED sessions", () => {
|
||||
it("preserves an active error on a non-FAILED session", () => {
|
||||
const errors = buildRunErrorsFromSessions([
|
||||
session({ state: "RUNNING", metadata: { last_agent_error: { remediation_url: URL } } }),
|
||||
session({
|
||||
state: "RUNNING",
|
||||
metadata: { last_agent_error: { message: "still starting", stamp: "failure-1" } },
|
||||
}),
|
||||
]);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves a dismissed error as history after the session resumes", () => {
|
||||
const errors = buildRunErrorsFromSessions([
|
||||
session({
|
||||
state: "IDLE",
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: SESSION_STARTED_AT,
|
||||
dismissed_at: "2026-08-02T15:05:00Z",
|
||||
stamp: "failure-1",
|
||||
recovery_actions: ["retry_default"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].failedAt).toBe(SESSION_STARTED_AT);
|
||||
expect(errors[0].errorStamp).toBe("failure-1");
|
||||
expect(errors[0].isActive).toBe(false);
|
||||
expect(errors[0].recoveryActions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +149,7 @@ describe("hasMatchingSessionLaunchError", () => {
|
||||
it("does not match an unstamped run error to a stamped summary", () => {
|
||||
expect(
|
||||
hasMatchingSessionLaunchError("s1", "new-stamp", [
|
||||
{ id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: "2026-08-02T15:00:00Z" },
|
||||
{ id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: SESSION_STARTED_AT },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -130,7 +161,7 @@ describe("hasMatchingSessionLaunchError", () => {
|
||||
id: "e1",
|
||||
sessionId: "s1",
|
||||
rawPayload: "boom",
|
||||
failedAt: "2026-08-02T15:00:00Z",
|
||||
failedAt: SESSION_STARTED_AT,
|
||||
errorStamp: "new-stamp",
|
||||
},
|
||||
]),
|
||||
@@ -138,32 +169,6 @@ describe("hasMatchingSessionLaunchError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterVisibleRunErrors", () => {
|
||||
it("keeps ordinary provider errors when the active summary is not a launch error", () => {
|
||||
const errors = buildRunErrorsFromSessions([
|
||||
session({
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The provider rejected the request.",
|
||||
code: "provider_auth_required",
|
||||
stamp: "provider-stamp-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
filterVisibleRunErrors(errors, {
|
||||
session_id: "s1",
|
||||
stamp: "provider-stamp-1",
|
||||
occurred_at: "2026-08-02T15:00:01Z",
|
||||
preview: "The provider rejected the request.",
|
||||
category: "provider_auth_required",
|
||||
}),
|
||||
).toEqual(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveSessionMetadataFromStore", () => {
|
||||
const metadata = { last_agent_error: { message: "boom", remediation_url: URL } };
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { CommentTurnContext } from "./turn-context";
|
||||
import { groupSortKey, type SessionGroup } from "./session-groups";
|
||||
import { normalizeRemediationUrl } from "@/lib/remediation-url";
|
||||
import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error";
|
||||
import { isTypedTaskLaunchError } from "./components/task-launch-error-entry";
|
||||
import {
|
||||
lastAgentErrorStamp,
|
||||
readLastAgentError,
|
||||
readLastAgentErrorIncludingDismissed,
|
||||
} from "@/lib/session-last-agent-error";
|
||||
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
|
||||
import type {
|
||||
RunError,
|
||||
@@ -11,7 +14,6 @@ import type {
|
||||
TaskSession,
|
||||
TimelineEvent,
|
||||
} from "@/app/office/tasks/[id]/types";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
|
||||
/**
|
||||
* Discriminated union of every row that can appear in the task chat
|
||||
@@ -50,38 +52,51 @@ export function buildLaterAgentReplyMap(comments: TaskComment[]): Map<string, bo
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull RunError entries out of failed sessions. One entry per office
|
||||
* session in FAILED state — the session row's error_message becomes
|
||||
* the rawPayload for the chat entry's Show details. The remediation URL is
|
||||
* copied from the persisted last_agent_error metadata and passed through the
|
||||
* browser-edge allowlist, so RunError never carries an unvalidated string.
|
||||
* Pull retained session failures into the Office chat timeline. The session
|
||||
* metadata remains the source of the chronological breadcrumb after recovery;
|
||||
* only an undismissed record carries recovery actions. The remediation URL is
|
||||
* copied from the persisted metadata and passed through the browser-edge
|
||||
* allowlist, so RunError never carries an unvalidated string.
|
||||
*/
|
||||
export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] {
|
||||
const errors: RunError[] = [];
|
||||
for (const s of sessions) {
|
||||
if (s.state !== "FAILED") continue;
|
||||
const failedAt = s.completedAt ?? s.updatedAt ?? s.startedAt ?? "";
|
||||
const lastError = lastAgentErrorFromSessionMetadata(s.metadata);
|
||||
const parsedLastError = readLastAgentError(s.metadata);
|
||||
errors.push({
|
||||
id: `re-${s.id}`,
|
||||
sessionId: s.id,
|
||||
agentProfileId: s.agentProfileId,
|
||||
rawPayload: s.errorMessage ?? "",
|
||||
failedAt,
|
||||
failureCode: lastError?.code,
|
||||
failureDetails: lastError?.details,
|
||||
message: parsedLastError?.message,
|
||||
recoveryActions: parsedLastError?.recoveryActions,
|
||||
taskRepositoryId: parsedLastError?.taskRepositoryId,
|
||||
errorStamp: parsedLastError ? lastAgentErrorStamp(parsedLastError) : undefined,
|
||||
remediationUrl:
|
||||
normalizeRemediationUrl(remediationUrlFromSessionMetadata(s.metadata)) ?? undefined,
|
||||
});
|
||||
const error = buildRunErrorFromSession(s);
|
||||
if (error) errors.push(error);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function buildRunErrorFromSession(s: TaskSession): RunError | null {
|
||||
const parsedLastError = readLastAgentErrorIncludingDismissed(s.metadata);
|
||||
const isLegacy = parsedLastError === null;
|
||||
// Older Office sessions only stored error_message on the session row. Keep
|
||||
// those FAILED rows in the chronological timeline while using metadata as
|
||||
// the richer identity for resumed and dismissed failures.
|
||||
if (isLegacy && s.state !== "FAILED") return null;
|
||||
|
||||
const activeLastError = readLastAgentError(s.metadata);
|
||||
const error: RunError = {
|
||||
id: `re-${s.id}`,
|
||||
sessionId: s.id,
|
||||
agentProfileId: s.agentProfileId,
|
||||
rawPayload: s.errorMessage ?? "",
|
||||
failedAt: parsedLastError?.occurredAt ?? s.completedAt ?? s.updatedAt ?? s.startedAt ?? "",
|
||||
recoveryActions: activeLastError?.recoveryActions,
|
||||
isActive: activeLastError !== null || isLegacy,
|
||||
};
|
||||
if (isLegacy) return error;
|
||||
|
||||
error.failureCode = parsedLastError.code;
|
||||
error.failureDetails = parsedLastError.details;
|
||||
error.message = parsedLastError.message;
|
||||
error.taskRepositoryId = parsedLastError.taskRepositoryId;
|
||||
error.errorStamp = lastAgentErrorStamp(parsedLastError);
|
||||
const remediationUrl = normalizeRemediationUrl(parsedLastError.remediationUrl);
|
||||
if (remediationUrl) error.remediationUrl = remediationUrl;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function hasMatchingSessionLaunchError(
|
||||
summarySessionId: string | undefined,
|
||||
summaryStamp: string | undefined,
|
||||
@@ -96,20 +111,6 @@ export function hasMatchingSessionLaunchError(
|
||||
);
|
||||
}
|
||||
|
||||
export function filterVisibleRunErrors(
|
||||
runErrors: RunError[],
|
||||
activeError: TaskStatusSummary["active_error"],
|
||||
): RunError[] {
|
||||
if (!isTypedTaskLaunchError(activeError)) return runErrors;
|
||||
return runErrors.filter(
|
||||
(error) =>
|
||||
!isMatchingTaskLaunchError(activeError, {
|
||||
sessionId: error.sessionId,
|
||||
errorStamp: error.errorStamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeLiveSessionMetadata(
|
||||
base: Record<string, unknown> | null | undefined,
|
||||
live: Record<string, unknown> | null | undefined,
|
||||
@@ -138,29 +139,6 @@ export function liveSessionMetadataFromStore(
|
||||
return row.metadata;
|
||||
}
|
||||
|
||||
function remediationUrlFromSessionMetadata(metadata: Record<string, unknown> | null | undefined) {
|
||||
const lastError = metadata?.last_agent_error;
|
||||
if (!lastError || typeof lastError !== "object") return undefined;
|
||||
const record = lastError as Record<string, unknown>;
|
||||
const raw = record.remediation_url ?? record.remediationUrl;
|
||||
return typeof raw === "string" && raw !== "" ? raw : undefined;
|
||||
}
|
||||
|
||||
function lastAgentErrorFromSessionMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): { code?: string; details?: string } | undefined {
|
||||
const lastError = metadata?.last_agent_error;
|
||||
if (!lastError || typeof lastError !== "object") return undefined;
|
||||
const record = lastError as Record<string, unknown>;
|
||||
const code = record.code ?? record.failure_code ?? record.failureCode;
|
||||
const details =
|
||||
record.details ?? record.failure_details ?? record.failureDetails ?? record.error_output;
|
||||
return {
|
||||
code: typeof code === "string" && code !== "" ? code : undefined,
|
||||
details: typeof details === "string" && details !== "" ? details : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type MergeChatEntriesArgs = {
|
||||
comments: TaskComment[];
|
||||
timeline: TimelineEvent[];
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ManagedRuntimeNpmRunError({
|
||||
}: {
|
||||
error: RunError;
|
||||
agentName: string;
|
||||
onRetry: () => void;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
@@ -56,18 +56,20 @@ export function ManagedRuntimeNpmRunError({
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={onRetry}
|
||||
data-testid="run-error-managed-runtime-retry-button"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
{t("chat:managedRuntimeRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={onRetry}
|
||||
data-testid="run-error-managed-runtime-retry-button"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
{t("chat:managedRuntimeRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,4 +72,17 @@ describe("RunErrorEntry", () => {
|
||||
expect(screen.getByTestId("run-error-continue-new-branch-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("run-error-restore-workspace-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders recovered session history without stale recovery controls", () => {
|
||||
render(
|
||||
<RunErrorEntry
|
||||
taskId="task-1"
|
||||
workspaceId="workspace-1"
|
||||
error={{ ...runError("provider_auth_required"), isActive: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("run-error-resume-button")).toBeNull();
|
||||
expect(screen.queryByTestId("run-error-fresh-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ function TypedRunLaunchErrorEntry({
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId ?? ""}
|
||||
repositories={repositories}
|
||||
isActive={error.isActive !== false}
|
||||
error={{
|
||||
session_id: error.sessionId,
|
||||
task_repository_id: error.taskRepositoryId,
|
||||
@@ -125,9 +126,55 @@ function RunErrorRecoveryFeedback({
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyRunErrorActions({
|
||||
error,
|
||||
isActive,
|
||||
busyAction,
|
||||
onRecover,
|
||||
}: {
|
||||
error: RunError;
|
||||
isActive: boolean;
|
||||
busyAction: SessionRecoveryAction | "restore" | null;
|
||||
onRecover: (action: SessionRecoveryAction) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<RemediationLink url={error.remediationUrl} />
|
||||
{isActive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={() => onRecover("resume")}
|
||||
disabled={busyAction !== null}
|
||||
data-testid="run-error-resume-button"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
{t("task:resumeSession")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={() => onRecover("fresh_start")}
|
||||
disabled={busyAction !== null}
|
||||
data-testid="run-error-fresh-button"
|
||||
>
|
||||
<IconPlayerPlay className="h-3 w-3" />
|
||||
{t("task:startFreshSession")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyRunErrorEntry({
|
||||
agentName,
|
||||
error,
|
||||
isActive,
|
||||
onRecover,
|
||||
onRetry,
|
||||
onRestore,
|
||||
@@ -140,6 +187,7 @@ function LegacyRunErrorEntry({
|
||||
}: {
|
||||
agentName: string;
|
||||
error: RunError;
|
||||
isActive: boolean;
|
||||
onRecover: (action: SessionRecoveryAction) => Promise<boolean>;
|
||||
onRetry: () => void;
|
||||
onRestore: () => void;
|
||||
@@ -168,16 +216,18 @@ function LegacyRunErrorEntry({
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("task:theAgentStoppedWithAnError")}</p>
|
||||
<RunErrorRecoveryFeedback
|
||||
workspaceId={workspaceId}
|
||||
recoveryError={recoveryError}
|
||||
recoveryNotice={recoveryNotice}
|
||||
branchDetails={branchDetails}
|
||||
busyAction={busyAction}
|
||||
onRetry={onRetry}
|
||||
onRestore={onRestore}
|
||||
onNewBranch={onNewBranch}
|
||||
/>
|
||||
{isActive && (
|
||||
<RunErrorRecoveryFeedback
|
||||
workspaceId={workspaceId}
|
||||
recoveryError={recoveryError}
|
||||
recoveryNotice={recoveryNotice}
|
||||
branchDetails={branchDetails}
|
||||
busyAction={busyAction}
|
||||
onRetry={onRetry}
|
||||
onRestore={onRestore}
|
||||
onNewBranch={onNewBranch}
|
||||
/>
|
||||
)}
|
||||
{error.rawPayload && (
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
|
||||
@@ -196,31 +246,12 @@ function LegacyRunErrorEntry({
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<RemediationLink url={error.remediationUrl} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={() => onRecover("resume")}
|
||||
disabled={busyAction !== null}
|
||||
data-testid="run-error-resume-button"
|
||||
>
|
||||
<IconRefresh className="h-3 w-3" />
|
||||
{t("task:resumeSession")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
|
||||
onClick={() => onRecover("fresh_start")}
|
||||
disabled={busyAction !== null}
|
||||
data-testid="run-error-fresh-button"
|
||||
>
|
||||
<IconPlayerPlay className="h-3 w-3" />
|
||||
{t("task:startFreshSession")}
|
||||
</Button>
|
||||
</div>
|
||||
<LegacyRunErrorActions
|
||||
error={error}
|
||||
isActive={isActive}
|
||||
busyAction={busyAction}
|
||||
onRecover={onRecover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -274,7 +305,7 @@ export function RunErrorEntry({
|
||||
<ManagedRuntimeNpmRunError
|
||||
error={error}
|
||||
agentName={agentName}
|
||||
onRetry={() => void handleRecover("runtime_retry")}
|
||||
onRetry={error.isActive === false ? undefined : () => void handleRecover("runtime_retry")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +314,7 @@ export function RunErrorEntry({
|
||||
<LegacyRunErrorEntry
|
||||
agentName={agentName}
|
||||
error={error}
|
||||
isActive={error.isActive !== false}
|
||||
onRecover={handleRecover}
|
||||
onRetry={handleRetry}
|
||||
onRestore={() => void handleRestore()}
|
||||
|
||||
@@ -62,11 +62,14 @@ vi.mock("@/components/task/task-launch-error-context", () => ({
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
const TASK_REPOSITORY_ID = "task-repo-1";
|
||||
const SESSION_ID = "session-1";
|
||||
const ERROR_STAMP = "launch-stamp-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const OCCURRED_AT = "2026-08-19T10:00:00Z";
|
||||
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
|
||||
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
|
||||
const RETRY_DEFAULT_BUTTON_TEST_ID = "task-launch-retry_default-button";
|
||||
const TASK_LAUNCH_RECOVER_METHOD = "task.launch.recover";
|
||||
|
||||
const error: TaskStatusSummaryActiveError = {
|
||||
task_repository_id: TASK_REPOSITORY_ID,
|
||||
@@ -118,7 +121,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
|
||||
it("renders checkout details and retries the launch without branch settings", async () => {
|
||||
const checkoutError: TaskStatusSummaryActiveError = {
|
||||
session_id: "session-1",
|
||||
session_id: SESSION_ID,
|
||||
stamp: "checkout-stamp-1",
|
||||
occurred_at: OCCURRED_AT,
|
||||
preview: "The workspace could not be prepared for this launch.",
|
||||
@@ -138,9 +141,9 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
|
||||
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
|
||||
task_id: TASK_ID,
|
||||
session_id: "session-1",
|
||||
session_id: SESSION_ID,
|
||||
action: "retry_launch",
|
||||
error_stamp: "checkout-stamp-1",
|
||||
});
|
||||
@@ -205,7 +208,21 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText(error.preview)).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
|
||||
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("hides recovery actions for a retained session history entry", () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
isActive={false}
|
||||
error={{ ...error, recovery_actions: ["retry_default"] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("routes a session-owned bootstrap error to the recovery card", () => {
|
||||
@@ -213,13 +230,13 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
<TaskChatLaunchError
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
sessionId="session-1"
|
||||
sessionId={SESSION_ID}
|
||||
statusSummary={{
|
||||
revision: 2,
|
||||
updated_at: OCCURRED_AT,
|
||||
active_error: {
|
||||
...error,
|
||||
session_id: "session-1",
|
||||
session_id: SESSION_ID,
|
||||
phase: "bootstrap",
|
||||
category: "generic_launch_failure",
|
||||
recovery_actions: ["retry_launch"],
|
||||
@@ -249,13 +266,13 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
<TaskChatLaunchError
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
sessionId="session-1"
|
||||
sessionId={SESSION_ID}
|
||||
statusSummary={{
|
||||
revision: 3,
|
||||
updated_at: OCCURRED_AT,
|
||||
active_error: {
|
||||
...error,
|
||||
session_id: "session-1",
|
||||
session_id: SESSION_ID,
|
||||
phase: "bootstrap",
|
||||
category: "generic_launch_failure",
|
||||
recovery_actions: ["retry_launch"],
|
||||
@@ -309,10 +326,10 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
|
||||
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByText(error.preview)).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
|
||||
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
|
||||
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
|
||||
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
|
||||
task_id: TASK_ID,
|
||||
task_repository_id: TASK_REPOSITORY_ID,
|
||||
action: "retry_default",
|
||||
@@ -332,7 +349,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("mock-branch-option"));
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
|
||||
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
|
||||
task_id: TASK_ID,
|
||||
task_repository_id: TASK_REPOSITORY_ID,
|
||||
action: "pick_base_branch",
|
||||
@@ -341,6 +358,26 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps task-scoped recovery on the task source when it has a session correlation", async () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={TASK_ID}
|
||||
workspaceId={WORKSPACE_ID}
|
||||
repositories={[]}
|
||||
error={{ ...error, scope: "task", session_id: "retired-session-1" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
|
||||
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
|
||||
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
|
||||
task_id: TASK_ID,
|
||||
task_repository_id: TASK_REPOSITORY_ID,
|
||||
action: "retry_default",
|
||||
error_stamp: ERROR_STAMP,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for a branch selection before sending the picker action", async () => {
|
||||
render(
|
||||
<TaskLaunchErrorEntry
|
||||
@@ -380,7 +417,7 @@ describe("TaskLaunchErrorEntry", () => {
|
||||
</>,
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByTestId("task-launch-retry_default-button");
|
||||
const buttons = screen.getAllByTestId(RETRY_DEFAULT_BUTTON_TEST_ID);
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ type TaskLaunchErrorEntryProps = {
|
||||
workspaceId: string;
|
||||
error: TaskStatusSummaryActiveError;
|
||||
repositories?: TaskRepository[];
|
||||
/** Retained session history has no live recovery controls. */
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
|
||||
@@ -58,7 +60,7 @@ function useTaskLaunchRecovery({
|
||||
const request = Promise.resolve().then(() =>
|
||||
client.request("task.launch.recover", {
|
||||
task_id: taskId,
|
||||
...(error.session_id ? { session_id: error.session_id } : {}),
|
||||
...(error.scope !== "task" && error.session_id ? { session_id: error.session_id } : {}),
|
||||
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
|
||||
action,
|
||||
...(baseBranch ? { base_branch: baseBranch } : {}),
|
||||
@@ -196,6 +198,7 @@ export function TaskLaunchErrorEntry({
|
||||
workspaceId,
|
||||
error,
|
||||
repositories,
|
||||
isActive = true,
|
||||
}: TaskLaunchErrorEntryProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
|
||||
@@ -243,16 +246,18 @@ export function TaskLaunchErrorEntry({
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
<TaskLaunchRecoveryActions
|
||||
actions={error.recovery_actions ?? []}
|
||||
workspaceId={workspaceId}
|
||||
repositories={repositories}
|
||||
taskRepositoryId={error.task_repository_id}
|
||||
currentBase={currentBase}
|
||||
pendingAction={pendingAction}
|
||||
onRecover={sendRecovery}
|
||||
/>
|
||||
{recoveryError && (
|
||||
{isActive && (
|
||||
<TaskLaunchRecoveryActions
|
||||
actions={error.recovery_actions ?? []}
|
||||
workspaceId={workspaceId}
|
||||
repositories={repositories}
|
||||
taskRepositoryId={error.task_repository_id}
|
||||
currentBase={currentBase}
|
||||
pendingAction={pendingAction}
|
||||
onRecover={sendRecovery}
|
||||
/>
|
||||
)}
|
||||
{isActive && recoveryError && (
|
||||
<p
|
||||
className="mt-2 text-xs text-destructive"
|
||||
data-testid="task-launch-recovery-error"
|
||||
|
||||
@@ -635,7 +635,7 @@ describe("TaskChat run error entries", () => {
|
||||
expect(screen.queryByTestId("run-error-raw-payload")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a task-owned launch error before the empty state", () => {
|
||||
it("leaves task-owned launch errors to the task shell", () => {
|
||||
const statusSummary: TaskStatusSummary = {
|
||||
revision: 3,
|
||||
updated_at: T_11,
|
||||
@@ -658,8 +658,8 @@ describe("TaskChat run error entries", () => {
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
|
||||
expect(screen.getByText(/choose a base branch/i)).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-launch-error-entry")).toBeNull();
|
||||
expect(screen.queryByText(/choose a base branch/i)).toBeNull();
|
||||
expect(screen.getByText(/no comments yet/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { formatRelativeTime } from "@/lib/utils";
|
||||
import { MarkdownComment } from "./markdown-comment";
|
||||
import { AgentTurnPanel } from "./components/agent-turn-panel";
|
||||
import { RunErrorEntry } from "./components/run-error-entry";
|
||||
import { TaskChatLaunchError } from "./components/task-chat-launch-error";
|
||||
import { UserCommentRunBadge } from "./components/user-comment-run-badge";
|
||||
import { buildCommentTurnContext, type CommentTurnContext } from "./turn-context";
|
||||
import { groupSessionsForTimeline, groupSortKey, type SessionGroup } from "./session-groups";
|
||||
@@ -35,7 +34,6 @@ import type {
|
||||
import {
|
||||
buildLaterAgentReplyMap,
|
||||
buildRunErrorsFromSessions,
|
||||
filterVisibleRunErrors,
|
||||
mergeChatEntries,
|
||||
type ChatEntry,
|
||||
} from "./chat-entries";
|
||||
@@ -591,7 +589,6 @@ export function TaskChat({
|
||||
onCommentsChanged,
|
||||
taskTitle,
|
||||
taskDescription,
|
||||
statusSummary,
|
||||
repositories,
|
||||
}: TaskChatProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -616,10 +613,6 @@ export function TaskChat({
|
||||
);
|
||||
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
|
||||
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
|
||||
const visibleRunErrors = useMemo(
|
||||
() => filterVisibleRunErrors(runErrors, statusSummary?.active_error),
|
||||
[runErrors, statusSummary?.active_error],
|
||||
);
|
||||
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
@@ -629,10 +622,10 @@ export function TaskChat({
|
||||
groups: renderedGroups,
|
||||
decisions,
|
||||
turnCtx,
|
||||
runErrors: visibleRunErrors,
|
||||
runErrors,
|
||||
laterAgentReplyMap,
|
||||
}),
|
||||
[comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
|
||||
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
|
||||
);
|
||||
|
||||
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
|
||||
@@ -653,12 +646,6 @@ export function TaskChat({
|
||||
{t("task:showOlderSessions", { count: olderGroups.length })}
|
||||
</button>
|
||||
)}
|
||||
<TaskChatLaunchError
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
statusSummary={statusSummary}
|
||||
repositories={repositories}
|
||||
/>
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground py-4">{t("task:noCommentsYet")}</p>
|
||||
) : (
|
||||
|
||||
@@ -2,13 +2,20 @@ import type { ReactNode } from "react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import {
|
||||
buildGroupedRenderItems,
|
||||
insertLastAgentErrorItem,
|
||||
type RenderItem,
|
||||
} from "@/hooks/use-processed-messages";
|
||||
|
||||
const launchError = {
|
||||
stamp: "task-wide-launch-error",
|
||||
scope: "task" as const,
|
||||
occurred_at: "2026-08-20T10:00:00Z",
|
||||
preview: "The workspace could not be prepared.",
|
||||
category: "workspace_checkout_failed",
|
||||
};
|
||||
const SESSION_ID = "prior-session";
|
||||
|
||||
const messageListRecoveryRevealKeys = vi.hoisted(() => [] as Array<string | null | undefined>);
|
||||
|
||||
@@ -28,15 +35,15 @@ const appStoreState = {
|
||||
},
|
||||
taskSessions: {
|
||||
items: {
|
||||
"prior-session": { name: null, agent_profile_id: null, last_read_message_id: null },
|
||||
[SESSION_ID]: { name: null, agent_profile_id: null, last_read_message_id: null },
|
||||
},
|
||||
},
|
||||
agentProfiles: { items: [] },
|
||||
messages: { bySession: { "prior-session": [priorTranscriptMessage] } },
|
||||
messages: { bySession: { [SESSION_ID]: [priorTranscriptMessage] } },
|
||||
};
|
||||
|
||||
const panelState = {
|
||||
resolvedSessionId: "prior-session",
|
||||
resolvedSessionId: SESSION_ID,
|
||||
session: {
|
||||
state: "FAILED",
|
||||
error_message: "The prior session stopped.",
|
||||
@@ -47,7 +54,7 @@ const panelState = {
|
||||
messagesLoading: false,
|
||||
historyRefreshPending: false,
|
||||
isInitialMessagesLoading: false,
|
||||
groupedItems: [],
|
||||
groupedItems: [] as RenderItem[],
|
||||
allMessages: [priorTranscriptMessage],
|
||||
footerActionMessages: [],
|
||||
permissionsByToolCallId: new Map(),
|
||||
@@ -118,22 +125,37 @@ vi.mock("./chat/chat-input-area", () => ({
|
||||
|
||||
vi.mock("@/components/task/chat/message-list", () => ({
|
||||
MessageList: ({
|
||||
items,
|
||||
messages,
|
||||
launchErrorOwned,
|
||||
prependContent,
|
||||
recoveryRevealKey,
|
||||
}: {
|
||||
items: RenderItem[];
|
||||
messages: Message[];
|
||||
launchErrorOwned?: boolean;
|
||||
prependContent?: ReactNode;
|
||||
recoveryRevealKey?: string | null;
|
||||
}) => {
|
||||
messageListRecoveryRevealKeys.push(recoveryRevealKey);
|
||||
return (
|
||||
<div data-testid="message-list">
|
||||
{prependContent}
|
||||
{!launchErrorOwned &&
|
||||
messages.map((message) => <div key={message.id}>{message.content}</div>)}
|
||||
{messages.map((message) => {
|
||||
const metadata = message.metadata as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
data-testid={
|
||||
metadata?.recovery_actions === true ? "persisted-recovery-message" : undefined
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.map((item) =>
|
||||
item.type === "agent_error_notice" ? (
|
||||
<div key={item.id} data-testid="agent-error-notice">
|
||||
provisional recovery notice
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -218,18 +240,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("TaskChatPanel launch-error ownership", () => {
|
||||
it("keeps a task-wide card and prior failed-session surfaces together", () => {
|
||||
render(<TaskChatPanel sessionId="prior-session" taskId="task-1" />);
|
||||
it("leaves the task-wide card to the task shell and preserves session history", () => {
|
||||
render(<TaskChatPanel sessionId={SESSION_ID} taskId="task-1" />);
|
||||
|
||||
expect(screen.getByTestId("task-launch-error-entry").textContent).toContain(
|
||||
launchError.preview,
|
||||
);
|
||||
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-launch-error-entry")).toBeNull();
|
||||
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
|
||||
expect(screen.getByText(priorTranscriptMessage.content)).toBeTruthy();
|
||||
expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:task-wide-launch-error");
|
||||
expect(messageListRecoveryRevealKeys.at(-1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reveals a persisted bootstrap fallback for the selected session", () => {
|
||||
it("does not reveal a persisted session failure through scroll coupling", () => {
|
||||
const previousMetadata = panelState.session.metadata;
|
||||
panelState.session.metadata = {
|
||||
last_agent_error: {
|
||||
@@ -241,11 +261,49 @@ describe("TaskChatPanel launch-error ownership", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
render(<TaskChatPanel sessionId="prior-session" taskId="task-1" />);
|
||||
render(<TaskChatPanel sessionId={SESSION_ID} taskId="task-1" />);
|
||||
|
||||
expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:persisted-bootstrap-error");
|
||||
expect(messageListRecoveryRevealKeys.at(-1)).toBeUndefined();
|
||||
} finally {
|
||||
panelState.session.metadata = previousMetadata;
|
||||
}
|
||||
});
|
||||
|
||||
it("counts one recovery representation across persisted messages and provisional notices", () => {
|
||||
const persistedRecoveryMessage = {
|
||||
...priorTranscriptMessage,
|
||||
id: "persisted-recovery",
|
||||
session_id: SESSION_ID as Message["session_id"],
|
||||
content: "Agent encountered an error: saved session failed.",
|
||||
metadata: { recovery_actions: true, error_stamp: "session-failure-1" },
|
||||
created_at: "2026-08-20T11:00:00Z",
|
||||
} as Message;
|
||||
const previousMessages = panelState.allMessages;
|
||||
const previousItems = panelState.groupedItems;
|
||||
panelState.allMessages = [priorTranscriptMessage, persistedRecoveryMessage];
|
||||
panelState.groupedItems = insertLastAgentErrorItem(
|
||||
buildGroupedRenderItems(panelState.allMessages, SESSION_ID, {
|
||||
canAnchorPrepareProgress: false,
|
||||
}),
|
||||
SESSION_ID,
|
||||
{
|
||||
message: "saved session failed",
|
||||
occurredAt: "2026-08-20T11:00:00Z",
|
||||
stamp: "session-failure-1",
|
||||
},
|
||||
panelState.allMessages,
|
||||
);
|
||||
|
||||
try {
|
||||
render(<TaskChatPanel sessionId={SESSION_ID} taskId="task-1" />);
|
||||
|
||||
const persisted = screen.queryAllByTestId("persisted-recovery-message");
|
||||
const provisional = screen.queryAllByTestId("agent-error-notice");
|
||||
expect([...persisted, ...provisional]).toHaveLength(1);
|
||||
expect(provisional).toHaveLength(0);
|
||||
} finally {
|
||||
panelState.allMessages = previousMessages;
|
||||
panelState.groupedItems = previousItems;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,16 +52,10 @@ import { routePanelMouseDown } from "./chat/route-panel-mouse-down";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
|
||||
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
|
||||
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
|
||||
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
|
||||
import {
|
||||
isTaskLaunchErrorOwnedBySession,
|
||||
isTaskLaunchErrorVisibleForSession,
|
||||
} from "@/components/task/chat/types";
|
||||
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
|
||||
import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
|
||||
import { statusSummaryTaskError } from "@/lib/task-status-summary";
|
||||
|
||||
/** Returns a `clarificationKey` that increments each time a pending
|
||||
* clarification is resolved, letting the composer reset its input state for
|
||||
@@ -1034,22 +1028,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
pendingClarification,
|
||||
pendingClarificationGroup,
|
||||
} = panelState;
|
||||
const activeLaunchError = launchStatusSummary?.active_error;
|
||||
const selectedSessionRecoveryError = selectSessionRecoveryError(
|
||||
activeLaunchError,
|
||||
resolvedSessionId,
|
||||
session?.metadata,
|
||||
);
|
||||
const visibleRecoveryError =
|
||||
selectedSessionRecoveryError ??
|
||||
(isTypedTaskLaunchError(activeLaunchError) &&
|
||||
isTaskLaunchErrorVisibleForSession(activeLaunchError, resolvedSessionId)
|
||||
? activeLaunchError
|
||||
: null);
|
||||
const launchErrorOwned = Boolean(
|
||||
isTypedTaskLaunchError(activeLaunchError) &&
|
||||
isTaskLaunchErrorOwnedBySession(activeLaunchError, resolvedSessionId),
|
||||
);
|
||||
const taskLaunchError = statusSummaryTaskError(launchStatusSummary);
|
||||
const launchErrorOwned = Boolean(taskLaunchError);
|
||||
const showAgentStartHint = useComposerAgentStartHint(
|
||||
resolvedSessionId,
|
||||
session?.state,
|
||||
@@ -1174,20 +1154,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
(e: React.MouseEvent<HTMLDivElement>) => routePanelMouseDown(e, panelRef),
|
||||
[],
|
||||
);
|
||||
const launchErrorContent = launchErrorContext ? (
|
||||
<TaskChatLaunchError
|
||||
taskId={launchErrorContext.taskId}
|
||||
workspaceId={launchErrorContext.workspaceId}
|
||||
statusSummary={launchStatusSummary}
|
||||
sessionId={resolvedSessionId}
|
||||
sessionMetadata={session?.metadata}
|
||||
repositories={launchErrorContext.repositories}
|
||||
/>
|
||||
) : null;
|
||||
const recoveryRevealKey = visibleRecoveryError
|
||||
? `${resolvedSessionId ?? ""}:${visibleRecoveryError.stamp || visibleRecoveryError.occurred_at}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PanelRoot
|
||||
ref={panelRef}
|
||||
@@ -1231,10 +1197,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
|
||||
isVisible={transcriptIsVisible}
|
||||
launchErrorOwned={launchErrorOwned}
|
||||
launchErrorStamp={launchErrorOwned ? activeLaunchError?.stamp : undefined}
|
||||
launchErrorOccurredAt={launchErrorOwned ? activeLaunchError?.occurred_at : undefined}
|
||||
prependContent={launchErrorContent}
|
||||
recoveryRevealKey={recoveryRevealKey}
|
||||
launchErrorStamp={launchErrorOwned ? taskLaunchError?.stamp : undefined}
|
||||
launchErrorOccurredAt={launchErrorOwned ? taskLaunchError?.occurred_at : undefined}
|
||||
stickyPromptBar={
|
||||
showAnchoredBar && lastPromptMessage ? (
|
||||
<AnchoredLastPromptBar
|
||||
|
||||
@@ -6,11 +6,17 @@ import { TaskLaunchErrorProvider, useTaskLaunchErrorContext } from "./task-launc
|
||||
const { toastMock } = vi.hoisted(() => ({
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
const { statusSummaryMock } = vi.hoisted(() => ({
|
||||
statusSummaryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
|
||||
vi.mock("@/hooks/domains/task/use-task-status-summary", () => ({
|
||||
useTaskStatusSummary: statusSummaryMock,
|
||||
}));
|
||||
|
||||
const initialSummary: TaskStatusSummary = {
|
||||
revision: 1,
|
||||
@@ -46,6 +52,10 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
toastMock.mockReset();
|
||||
statusSummaryMock.mockReset();
|
||||
statusSummaryMock.mockImplementation(
|
||||
(_taskId: string, detail: TaskStatusSummary | null | undefined) => detail,
|
||||
);
|
||||
});
|
||||
|
||||
describe("TaskLaunchErrorProvider", () => {
|
||||
@@ -72,4 +82,20 @@ describe("TaskLaunchErrorProvider", () => {
|
||||
|
||||
expect(screen.getByTestId("summary-stamp").textContent).toBe("initial");
|
||||
});
|
||||
|
||||
it("replaces the hydrated summary with a live task projection", () => {
|
||||
const liveSummary: TaskStatusSummary = {
|
||||
...initialSummary,
|
||||
active_error: {
|
||||
...initialSummary.active_error!,
|
||||
stamp: "live",
|
||||
},
|
||||
};
|
||||
statusSummaryMock.mockReturnValue(liveSummary);
|
||||
|
||||
renderProvider(initialSummary);
|
||||
|
||||
expect(screen.getByTestId("summary-stamp").textContent).toBe("live");
|
||||
expect(statusSummaryMock).toHaveBeenCalledWith("task-1", initialSummary);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
import type { TaskRepository } from "@/lib/types/http";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
import type { SessionRecoveryOwner } from "@/lib/session-recovery-presentation";
|
||||
@@ -12,6 +12,8 @@ export type TaskLaunchErrorContextValue = {
|
||||
statusSummary?: TaskStatusSummary | null;
|
||||
repositories?: TaskRepository[];
|
||||
automaticRecovery?: SessionRecoveryOwner | null;
|
||||
/** Claims one assertive announcement for a task error stamp. */
|
||||
claimTaskErrorAnnouncement?: (stamp: string) => boolean;
|
||||
};
|
||||
|
||||
const TaskLaunchErrorContext = createContext<TaskLaunchErrorContextValue | null>(null);
|
||||
@@ -24,8 +26,21 @@ export function TaskLaunchErrorProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const statusSummary = useTaskStatusSummary(value.taskId, value.statusSummary);
|
||||
const announcedTaskErrorRef = useRef<string | null>(null);
|
||||
const claimTaskErrorAnnouncement = useCallback(
|
||||
(stamp: string) => {
|
||||
const key = `${value.taskId}:${stamp}`;
|
||||
if (!stamp || announcedTaskErrorRef.current === key) return false;
|
||||
announcedTaskErrorRef.current = key;
|
||||
return true;
|
||||
},
|
||||
[value.taskId],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(() => ({ ...value, statusSummary }), [statusSummary, value]);
|
||||
const contextValue = useMemo(
|
||||
() => ({ ...value, statusSummary, claimTaskErrorAnnouncement }),
|
||||
[claimTaskErrorAnnouncement, statusSummary, value],
|
||||
);
|
||||
return (
|
||||
<TaskLaunchErrorContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
@@ -9,10 +9,9 @@ import { SessionMobileLayout, SessionTabletLayout } from "./mobile";
|
||||
import type { Repository, RepositoryScript } from "@/lib/types/http";
|
||||
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
|
||||
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
|
||||
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||
import { useTaskCanvasLifecycleActivation } from "./dockview-canvas-activation";
|
||||
import { statusSummaryTaskError } from "@/lib/task-status-summary";
|
||||
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { SelectedDiff } from "@/hooks/use-session-layout-state";
|
||||
@@ -76,31 +75,14 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
taskCanvases = [],
|
||||
}: TaskLayoutProps) {
|
||||
const { isMobile, usesDesktopWorkbench, isFullDesktop } = useResponsiveBreakpoint();
|
||||
const launchErrorContext = useTaskLaunchErrorContext();
|
||||
const hasSharedTaskError = Boolean(statusSummaryTaskError(launchErrorContext?.statusSummary));
|
||||
useTaskCanvasLifecycleActivation({ taskId, workspaceId, isMobile });
|
||||
const router = useRouter();
|
||||
const onOpenCanvas = useCallback(
|
||||
(canvasId: string) => router.push(canvasHref(canvasId)),
|
||||
[router],
|
||||
);
|
||||
const launchErrorContext = useTaskLaunchErrorContext();
|
||||
const activeLaunchError = launchErrorContext?.statusSummary?.active_error;
|
||||
|
||||
if (launchErrorContext && !sessionId && isTypedTaskLaunchError(activeLaunchError)) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-auto px-4"
|
||||
data-testid="session-chat"
|
||||
>
|
||||
<TaskChatLaunchError
|
||||
taskId={launchErrorContext.taskId}
|
||||
workspaceId={launchErrorContext.workspaceId}
|
||||
statusSummary={launchErrorContext.statusSummary}
|
||||
repositories={launchErrorContext.repositories}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -123,6 +105,7 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
onTaskUnarchived={onTaskUnarchived}
|
||||
taskCanvases={taskCanvases}
|
||||
onOpenCanvas={onOpenCanvas}
|
||||
hasSharedTaskError={hasSharedTaskError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/components/task/task-launch-error-context";
|
||||
import { SessionBootstrapRecoveryCard } from "@/components/task/chat/session-bootstrap-recovery-card";
|
||||
import { selectSessionRecoveryError } from "@/lib/session-recovery-presentation";
|
||||
import { TaskSharedError } from "@/components/task/task-shared-error";
|
||||
import {
|
||||
buildDebugEntries,
|
||||
buildArchivedValue,
|
||||
@@ -430,7 +431,10 @@ export function TaskPageInner(props: TaskPageInnerProps) {
|
||||
workspaceId={task?.workspace_id ?? null}
|
||||
isPassthrough={sessionPanel.isSessionPassthrough}
|
||||
/>
|
||||
<TaskLayout {...layoutProps} />
|
||||
<TaskSharedError reserveMobileTopBar={isMobile} />
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<TaskLayout {...layoutProps} />
|
||||
</div>
|
||||
</TaskLaunchErrorProvider>
|
||||
</TaskArchivedProvider>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { TaskSharedError } from "./task-shared-error";
|
||||
|
||||
const { context, state, taskPreview } = vi.hoisted(() => {
|
||||
const taskPreview = "The task could not be prepared.";
|
||||
const state = { isMobile: false };
|
||||
const context = {
|
||||
taskId: "task-1",
|
||||
workspaceId: "workspace-1",
|
||||
statusSummary: {
|
||||
revision: 4,
|
||||
updated_at: "2026-09-14T10:00:00Z",
|
||||
task_error: {
|
||||
scope: "task" as const,
|
||||
stamp: "task-error-1",
|
||||
occurred_at: "2026-09-14T09:59:00Z",
|
||||
preview: taskPreview,
|
||||
recovery_actions: ["retry_launch" as const],
|
||||
},
|
||||
},
|
||||
repositories: [],
|
||||
claimTaskErrorAnnouncement: vi.fn((_stamp: string) => true),
|
||||
};
|
||||
return { context, state, taskPreview };
|
||||
});
|
||||
|
||||
vi.mock("@/components/task/task-launch-error-context", () => ({
|
||||
useTaskLaunchErrorContext: () => context,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => ({ isMobile: state.isMobile }),
|
||||
}));
|
||||
|
||||
vi.mock("./simple/components/task-launch-error-entry", () => ({
|
||||
TaskLaunchErrorEntry: () => <div data-testid="task-shared-error-details-content" />,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/dialog", () => ({
|
||||
Dialog: ({ children, open }: { children: ReactNode; open?: boolean }) => (
|
||||
<div>{open ? children : null}</div>
|
||||
),
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/drawer", () => ({
|
||||
Drawer: ({ children, open }: { children: ReactNode; open?: boolean }) => (
|
||||
<div>{open ? children : null}</div>
|
||||
),
|
||||
DrawerContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DrawerDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DrawerHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DrawerTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe("TaskSharedError", () => {
|
||||
beforeEach(() => {
|
||||
context.claimTaskErrorAnnouncement.mockReset();
|
||||
context.claimTaskErrorAnnouncement.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("keeps a task-owned error above task content and opens desktop details", () => {
|
||||
state.isMobile = false;
|
||||
render(<TaskSharedError />);
|
||||
|
||||
expect(screen.getByTestId("task-shared-error")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByTestId("task-shared-error-announcement").textContent).toContain(taskPreview);
|
||||
expect(screen.getByText(taskPreview)).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-shared-error-details-content")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-shared-error-details"));
|
||||
expect(screen.getByTestId("task-shared-error-details-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the mobile drawer for the same task error", () => {
|
||||
state.isMobile = true;
|
||||
render(<TaskSharedError reserveMobileTopBar />);
|
||||
|
||||
expect(screen.getByTestId("task-shared-error").className).toContain(
|
||||
"mt-[calc(3.5rem+env(safe-area-inset-top,0px))]",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-shared-error-details"));
|
||||
expect(screen.getByTestId("task-shared-error-details-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("announces a new task error stamp once across component remounts", () => {
|
||||
let announcedStamp: string | null = null;
|
||||
context.claimTaskErrorAnnouncement.mockImplementation((stamp: string) => {
|
||||
if (announcedStamp === stamp) return false;
|
||||
announcedStamp = stamp;
|
||||
return true;
|
||||
});
|
||||
|
||||
const first = render(<TaskSharedError />);
|
||||
expect(screen.getByTestId("task-shared-error-announcement").textContent).toContain(taskPreview);
|
||||
first.unmount();
|
||||
|
||||
render(<TaskSharedError />);
|
||||
expect(screen.getByTestId("task-shared-error-announcement").textContent).toBe("");
|
||||
expect(context.claimTaskErrorAnnouncement).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@kandev/ui/drawer";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { statusSummaryTaskError } from "@/lib/task-status-summary";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
|
||||
import { TaskLaunchErrorEntry } from "./simple/components/task-launch-error-entry";
|
||||
|
||||
/** Persistent task-owned failure surface. It is mounted above the task tabs so
|
||||
* session changes and Dockview panel changes cannot hide the recovery affordance. */
|
||||
export function TaskSharedError({
|
||||
reserveMobileTopBar = false,
|
||||
}: {
|
||||
reserveMobileTopBar?: boolean;
|
||||
} = {}) {
|
||||
const context = useTaskLaunchErrorContext();
|
||||
const error = statusSummaryTaskError(context?.statusSummary);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const localAnnouncementStampRef = useRef<string | null>(null);
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const { t } = useTranslation();
|
||||
const launchNeedsAttention = t("task:launchNeedsAttention");
|
||||
const errorStamp = error?.stamp ?? "";
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!context || !error || !errorStamp) return;
|
||||
const shouldAnnounce = context.claimTaskErrorAnnouncement
|
||||
? context.claimTaskErrorAnnouncement(errorStamp)
|
||||
: localAnnouncementStampRef.current !== errorStamp;
|
||||
if (!shouldAnnounce) return;
|
||||
localAnnouncementStampRef.current = errorStamp;
|
||||
setAnnouncement(`${error.preview}. ${launchNeedsAttention}`);
|
||||
}, [context, error, errorStamp, launchNeedsAttention]);
|
||||
|
||||
if (!context || !error) return null;
|
||||
|
||||
const details = (
|
||||
<TaskLaunchErrorEntry
|
||||
taskId={context.taskId}
|
||||
workspaceId={context.workspaceId}
|
||||
error={error}
|
||||
repositories={context.repositories}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-w-0 shrink-0 items-start gap-3 border-b border-destructive/25 bg-destructive/5 px-4 py-2.5",
|
||||
isMobile && reserveMobileTopBar && "mt-[calc(3.5rem+env(safe-area-inset-top,0px))]",
|
||||
)}
|
||||
data-testid="task-shared-error"
|
||||
>
|
||||
<IconAlertTriangle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{error.preview}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{launchNeedsAttention}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 cursor-pointer sm:min-h-8"
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
data-testid="task-shared-error-details"
|
||||
>
|
||||
{t("task:showDetails")}
|
||||
</Button>
|
||||
</section>
|
||||
<p
|
||||
aria-atomic="true"
|
||||
aria-live="assertive"
|
||||
className="sr-only"
|
||||
data-testid="task-shared-error-announcement"
|
||||
>
|
||||
{announcement}
|
||||
</p>
|
||||
|
||||
{isMobile ? (
|
||||
<Drawer open={detailsOpen} onOpenChange={setDetailsOpen} direction="bottom">
|
||||
<DrawerContent
|
||||
className="overflow-hidden pb-[max(1rem,env(safe-area-inset-bottom))] data-[vaul-drawer-direction=bottom]:mb-2 data-[vaul-drawer-direction=bottom]:max-h-[calc(100dvh-1rem)]"
|
||||
data-testid="task-shared-error-drawer"
|
||||
>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{error.preview}</DrawerTitle>
|
||||
<DrawerDescription>{launchNeedsAttention}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 pb-4" data-vaul-no-drag>
|
||||
{details}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{error.preview}</DialogTitle>
|
||||
<DialogDescription>{launchNeedsAttention}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{details}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user