fix: retain session failures across recovery surfaces (#3669)

This commit is contained in:
Carlos Florêncio
2026-09-15 09:36:47 +01:00
committed by GitHub
parent 54e2e21f32
commit eeeb6f5586
101 changed files with 4057 additions and 1704 deletions
@@ -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 } };
+42 -64
View File
@@ -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();
});
+2 -15
View File
@@ -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;
}
});
});
+5 -41
View File
@@ -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}
+5 -22
View File
@@ -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}
/>
);
}
+5 -1
View File
@@ -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>
)}
</>
);
}