fix: harden startup recovery timeout and scrolling (#3654)

This commit is contained in:
Carlos Florêncio
2026-09-13 21:57:10 +01:00
committed by GitHub
parent bc38ea1ec7
commit 7d4bd1104a
26 changed files with 1903 additions and 165 deletions
@@ -7,10 +7,13 @@ import (
"fmt"
"net/http"
"net/url"
"time"
)
const GitPushPreflightHistoryUpdateRequired = "history_update_required"
const gitPushPreflightTimeout = 2 * time.Minute
// GitOperationResult represents the result of a git operation.
// This matches the server-side process.GitOperationResult.
type GitOperationResult struct {
@@ -105,7 +108,13 @@ func (c *Client) GitPushPreflight(ctx context.Context, repo string, opts PushOpt
Remote: opts.Remote,
ExpectedBranch: opts.ExpectedBranch,
}
return c.gitOperation(ctx, "/api/v1/git/push-preflight", payload)
return c.gitOperationWithClient(ctx, "/api/v1/git/push-preflight", payload, c.gitPushPreflightClient())
}
func (c *Client) gitPushPreflightClient() *http.Client {
client := *c.httpClient
client.Timeout = gitPushPreflightTimeout
return &client
}
// GitReplaceRemoteContribution replaces the bound contribution branch when
@@ -335,6 +344,15 @@ func (c *Client) GitCreatePR(ctx context.Context, title, body, baseBranch string
// gitOperation is a helper that performs a git operation via HTTP POST.
func (c *Client) gitOperation(ctx context.Context, path string, payload interface{}) (*GitOperationResult, error) {
return c.gitOperationWithClient(ctx, path, payload, c.httpClient)
}
func (c *Client) gitOperationWithClient(
ctx context.Context,
path string,
payload interface{},
httpClient *http.Client,
) (*GitOperationResult, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -346,7 +364,7 @@ func (c *Client) gitOperation(ctx context.Context, path string, payload interfac
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
@@ -4,13 +4,39 @@ import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// gitOperationOK is the canonical success body every /api/v1/git/* POST returns.
const gitOperationOK = `{"success":true,"operation":"pull","output":"Already up to date."}`
func TestGitPushPreflightHonorsOperationBudget(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
timer := time.NewTimer(25 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"operation":"push-preflight"}`))
case <-r.Context().Done():
}
}))
t.Cleanup(server.Close)
client := newHTTPOnlyClient(server.URL)
client.httpClient.Timeout = 10 * time.Millisecond
result, err := client.GitPushPreflight(context.Background(), "", PushOptions{})
if err != nil || result == nil || !result.Success {
t.Fatalf("GitPushPreflight() = result %#v, error %v; want delayed response to succeed", result, err)
}
if got := client.httpClient.Timeout; got != 10*time.Millisecond {
t.Fatalf("ordinary client timeout = %s, want unchanged 10ms", got)
}
}
func TestGitPushSendsPushOptions(t *testing.T) {
srv, got := captureServer(t, jsonResponder(http.StatusOK, gitOperationOK))
_, err := newHTTPOnlyClient(srv.URL).GitPush(context.Background(), "svc", PushOptions{
@@ -19,7 +19,7 @@ import (
"github.com/kandev/kandev/internal/task/models"
)
const defaultRemoteContributionPreflightTimeout = 30 * time.Second
const defaultRemoteContributionPreflightTimeout = 2 * time.Minute
func (m *Manager) contributionPreflightTimeout() time.Duration {
if m.remoteContributionPreflightTimeout > 0 {
@@ -171,9 +171,7 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re
m.updateExecutionError(executionID, "agentctl not ready: "+err.Error())
return fmt.Errorf("agentctl not ready: %w", err)
}
preflightCtx, cancelPreflight := context.WithTimeout(operationCtx, m.contributionPreflightTimeout())
err = m.preflightRemoteContributionPushes(preflightCtx, execution)
cancelPreflight()
err = m.preflightRemoteContributionPushes(operationCtx, execution)
if err != nil {
m.updateExecutionError(executionID, "contribution push preflight failed: "+err.Error())
return err
@@ -221,6 +219,9 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re
}
func (m *Manager) preflightRemoteContributionPushes(ctx context.Context, execution *AgentExecution) error {
preflightCtx, cancel := context.WithTimeout(ctx, m.contributionPreflightTimeout())
defer cancel()
if execution == nil {
return nil
}
@@ -242,7 +243,7 @@ func (m *Manager) preflightRemoteContributionPushes(ctx context.Context, executi
}
sort.Strings(keys)
for _, key := range keys {
result, err := client.GitPushPreflight(ctx, key, agentctl.PushOptions{})
result, err := client.GitPushPreflight(preflightCtx, key, agentctl.PushOptions{})
if err != nil {
return &BootstrapFailure{
Operation: bootstrapOperation(execution),
@@ -178,6 +178,10 @@ func TestStartAgentProcess_RunsContributionPreflightBeforeAgentStart(t *testing.
}
func TestStartAgentProcessBoundsContributionPreflight(t *testing.T) {
if got := (&Manager{}).contributionPreflightTimeout(); got != 2*time.Minute {
t.Fatalf("default contribution preflight timeout = %s, want 2m", got)
}
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -250,6 +254,233 @@ func TestStartAgentProcessBoundsContributionPreflight(t *testing.T) {
}
}
func newContributionPreflightExecution(
t *testing.T,
serverURL, id, sessionID, agentCommand string,
contributions map[string]models.RemoteContribution,
) *AgentExecution {
t.Helper()
host, portText, err := net.SplitHostPort(strings.TrimPrefix(serverURL, "http://"))
if err != nil {
t.Fatalf("parse agentctl test URL: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse agentctl test port: %v", err)
}
return &AgentExecution{
ID: id, SessionID: sessionID, AgentCommand: agentCommand,
metadata: map[string]interface{}{MetadataKeyRemoteContributions: contributions},
agentctl: agentctl.NewClient(host, port, newTestLogger()),
}
}
func TestPreflightRemoteContributionPushesBoundsDirectCaller(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/git/push-preflight":
close(started)
<-release
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
mgr := newTestManager(t)
mgr.remoteContributionPreflightTimeout = 10 * time.Millisecond
execution := newContributionPreflightExecution(t, server.URL, "exec-direct-preflight-timeout", "", "",
map[string]models.RemoteContribution{"": contributionPreflightTestBinding()})
errCh := make(chan error, 1)
go func() { errCh <- mgr.preflightRemoteContributionPushes(context.Background(), execution) }()
select {
case <-started:
case <-time.After(time.Second):
close(release)
t.Fatal("direct contribution preflight did not start")
}
select {
case err := <-errCh:
close(release)
if err == nil {
t.Fatal("preflight unexpectedly succeeded after its direct budget expired")
}
case <-time.After(time.Second):
close(release)
t.Fatal("direct preflight caller was not bounded")
}
}
func TestPreflightRemoteContributionPushesUsesOneBudgetForAllRepositories(t *testing.T) {
firstStarted := make(chan struct{})
secondStarted := make(chan struct{})
firstRelease := make(chan struct{})
firstReleased := false
releaseFirst := func() {
if !firstReleased {
firstReleased = true
close(firstRelease)
}
}
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/git/push-preflight" {
http.NotFound(w, r)
return
}
requestCount++
switch requestCount {
case 1:
close(firstStarted)
<-firstRelease
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
case 2:
close(secondStarted)
timer := time.NewTimer(70 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
case <-r.Context().Done():
}
}
}))
t.Cleanup(func() {
releaseFirst()
server.Close()
})
mgr := newTestManager(t)
mgr.remoteContributionPreflightTimeout = 80 * time.Millisecond
execution := newContributionPreflightExecution(t, server.URL, "exec-multi-preflight-timeout", "", "",
map[string]models.RemoteContribution{
"a": contributionPreflightTestBinding(),
"b": contributionPreflightTestBinding(),
})
errCh := make(chan error, 1)
go func() { errCh <- mgr.preflightRemoteContributionPushes(context.Background(), execution) }()
select {
case <-firstStarted:
case <-time.After(time.Second):
t.Fatal("first contribution preflight did not start")
}
timer := time.NewTimer(10 * time.Millisecond)
<-timer.C
timer.Stop()
releaseFirst()
select {
case <-secondStarted:
case <-time.After(time.Second):
t.Fatal("second contribution preflight did not start")
}
select {
case err := <-errCh:
if err == nil {
t.Fatal("preflight succeeded after the shared budget expired")
}
case <-time.After(time.Second):
t.Fatal("preflight did not finish at the shared budget")
}
if requestCount != 2 {
t.Fatalf("preflight request count = %d, want both repositories checked", requestCount)
}
}
func TestPreflightRemoteContributionPushesRejectsWhenAnyRepositoryFails(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/git/push-preflight" {
http.NotFound(w, r)
return
}
requestCount++
if requestCount == 1 {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "error": "source branch is read-only"})
}))
t.Cleanup(server.Close)
mgr := newTestManager(t)
execution := newContributionPreflightExecution(t, server.URL, "exec-multi-preflight-rejected", "", "",
map[string]models.RemoteContribution{
"a": contributionPreflightTestBinding(),
"b": contributionPreflightTestBinding(),
})
err := mgr.preflightRemoteContributionPushes(context.Background(), execution)
if err == nil || !strings.Contains(err.Error(), "source branch is read-only") {
t.Fatalf("preflight error = %v, want the failing repository rejection", err)
}
if requestCount != 2 {
t.Fatalf("preflight request count = %d, want both repositories checked", requestCount)
}
}
func TestStartAgentProcessPreservesCallerCancellationDuringContributionPreflight(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
requests := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- r.URL.Path
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/api/v1/git/push-preflight":
close(started)
<-release
default:
t.Errorf("unexpected agentctl request %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
}
}))
t.Cleanup(func() {
close(release)
server.Close()
})
mgr := newTestManager(t)
execution := newContributionPreflightExecution(t, server.URL, "exec-preflight-cancelled", "session-preflight-cancelled", "agent",
map[string]models.RemoteContribution{
"": contributionPreflightTestBinding(),
})
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("seed execution: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() { errCh <- mgr.StartAgentProcess(ctx, execution.ID) }()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("contribution preflight did not start")
}
cancel()
var startErr error
select {
case startErr = <-errCh:
case <-time.After(time.Second):
t.Fatal("StartAgentProcess did not return after caller cancellation")
}
if !errors.Is(startErr, context.Canceled) {
t.Fatalf("StartAgentProcess() error = %v, want context cancellation", startErr)
}
gotRequests := []string{<-requests, <-requests}
if gotRequests[0] != "/health" || gotRequests[1] != "/api/v1/git/push-preflight" {
t.Fatalf("agentctl requests = %v, want readiness and preflight only", gotRequests)
}
}
func TestFailedConcurrentStartDoesNotReleaseLiveStartActivity(t *testing.T) {
mgr := newTestManager(t)
coordinator := activity.NewCoordinator(activity.Options{})
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container";
import { MessageList } from "@/components/task/chat/message-list";
@@ -21,6 +21,8 @@ type QuickChatContentProps = {
placeholderOverride?: string;
initialPrompt?: string;
onInitialPromptAttempted?: () => void;
recoveryContent?: ReactNode;
recoveryRevealKey?: string | null;
};
function useQuickChatState(sessionId: string) {
@@ -50,6 +52,8 @@ export const QuickChatContent = memo(function QuickChatContent({
placeholderOverride,
initialPrompt,
onInitialPromptAttempted,
recoveryContent,
recoveryRevealKey,
}: QuickChatContentProps) {
const [clarificationKey, setClarificationKey] = useState(0);
const shortcutScopeRef = useRef<HTMLDivElement>(null);
@@ -108,6 +112,8 @@ export const QuickChatContent = memo(function QuickChatContent({
sessionState={panelState.session?.state}
worktreePath={getSessionWorkspacePath(panelState.session)}
onOpenFile={undefined}
prependContent={recoveryContent}
recoveryRevealKey={recoveryRevealKey}
/>
</div>
<ClarificationPanelSection
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -16,6 +17,7 @@ const sessionRows = vi.hoisted(
const quickChatSessions = vi.hoisted(
() => [] as Array<{ sessionId: string; taskId?: string; agentProfileId?: string }>,
);
const quickChatContentProps = vi.hoisted(() => [] as Array<{ recoveryRevealKey?: string | null }>);
const useEnsureTaskSession = vi.hoisted(() => vi.fn());
const useTask = vi.hoisted(() => vi.fn());
const useSessionResumption = vi.hoisted(() =>
@@ -25,6 +27,7 @@ const useSessionResumption = vi.hoisted(() =>
error: null,
notice: null,
recoveryFailure: null,
recoveryAttemptId: 0,
taskSessionState: null,
worktreePath: null,
worktreeBranch: null,
@@ -50,7 +53,16 @@ vi.mock("@/components/task/passthrough-terminal", () => ({
PassthroughTerminal: () => <div data-testid="passthrough-terminal" />,
}));
vi.mock("./quick-chat-content", () => ({
QuickChatContent: () => <div data-testid="quick-chat-content" />,
QuickChatContent: ({
recoveryContent,
recoveryRevealKey,
}: {
recoveryContent?: ReactNode;
recoveryRevealKey?: string | null;
}) => {
quickChatContentProps.push({ recoveryRevealKey });
return <div data-testid="quick-chat-content">{recoveryContent}</div>;
},
}));
vi.mock("@/components/task/chat/session-bootstrap-recovery-card", () => ({
SessionBootstrapRecoveryCard: ({
@@ -95,6 +107,7 @@ afterEach(() => {
cleanup();
delete sessionRows[session.sessionId];
quickChatSessions.length = 0;
quickChatContentProps.length = 0;
vi.clearAllMocks();
useTask.mockReset();
useTask.mockReturnValue(null);
@@ -261,6 +274,70 @@ describe("QuickChatSessionView session resumption", () => {
expect(screen.getByTestId(RECOVERY_CARD_TEST_ID).textContent).toContain("recovery_failed");
});
it("keys unstamped recovery feedback by attempt and outcome while preserving duplicate state", () => {
const firstFailure = {
outcome: "recovery_failed" as const,
resumeError: "first resume failure",
restoreError: "first restore failure",
};
const firstAttempt = {
resumptionState: "error" as const,
sessionStatus: null,
error: "first translated failure",
notice: null,
recoveryFailure: firstFailure,
recoveryAttemptId: 4,
taskSessionState: "FAILED" as const,
worktreePath: null,
worktreeBranch: null,
resumeSession: vi.fn(),
};
useSessionResumption.mockReturnValue(firstAttempt as never);
const view = render(<QuickChatSessionView session={session} />);
const firstKey = quickChatContentProps.at(-1)?.recoveryRevealKey;
expect(firstKey).toBe("session-1:recovery:4:recovery_failed");
useSessionResumption.mockReturnValue({
...firstAttempt,
error: "same attempt, different translated failure",
} as never);
view.rerender(<QuickChatSessionView session={session} />);
expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(firstKey);
useSessionResumption.mockReturnValue({
...firstAttempt,
recoveryFailure: {
outcome: "status_unavailable" as const,
kind: "timeout" as const,
statusError: "status request failed",
},
} as never);
view.rerender(<QuickChatSessionView session={session} />);
expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(
"session-1:recovery:4:status_unavailable",
);
useSessionResumption.mockReturnValue({
...firstAttempt,
error: null,
recoveryFailure: null,
recoveryAttemptId: 4,
} as never);
view.rerender(<QuickChatSessionView session={session} />);
expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBeNull();
useSessionResumption.mockReturnValue({
...firstAttempt,
error: "second translated failure",
recoveryAttemptId: 5,
} as never);
view.rerender(<QuickChatSessionView session={session} />);
expect(quickChatContentProps.at(-1)?.recoveryRevealKey).toBe(
"session-1:recovery:5:recovery_failed",
);
});
it("keeps the bootstrap recovery card and automatic outcome in passthrough Quick Chat", () => {
sessionRows[session.sessionId] = { task_id: HYDRATED_TASK_ID, is_passthrough: true };
useTask.mockReturnValue({ isArchived: false, workspaceId: WORKSPACE_ID });
@@ -13,6 +13,9 @@ import type { QuickChatSession } from "@/lib/state/slices/ui/types";
import { QuickChatContent } from "./quick-chat-content";
import { useTranslation } from "react-i18next";
// i18n-exempt: internal recovery identity token, not user-facing copy.
const GENERIC_RECOVERY_OUTCOME = "feedback";
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore((state) => {
const session = state.taskSessions.items[sessionId];
@@ -42,6 +45,33 @@ function resolveTaskArchiveState(
return quickChatTaskId === taskId ? false : null;
}
function resolveRecoveryRevealKey(
sessionId: string,
bootstrapRecoveryError: { stamp: string; occurred_at: string } | null,
hasRecoveryFeedback: boolean,
recoveryAttemptId: number | undefined,
recoveryOutcome: string | null,
): string | null {
if (bootstrapRecoveryError) {
return `${sessionId}:${bootstrapRecoveryError.stamp || bootstrapRecoveryError.occurred_at}`;
}
if (!hasRecoveryFeedback) return null;
return `${sessionId}:recovery:${recoveryAttemptId ?? 0}:${recoveryOutcome ?? GENERIC_RECOVERY_OUTCOME}`;
}
function hasRecoveryFeedback(state: {
error: string | null;
notice: string | null;
recoveryFailure: unknown;
}): boolean {
return Boolean(state.error) || Boolean(state.notice) || state.recoveryFailure !== null;
}
function recoveryOutcome(failure: { outcome: string } | null): string | null {
if (!failure) return null;
return failure.outcome;
}
export function QuickChatSessionView({
session,
onInitialPromptAttempted,
@@ -90,6 +120,13 @@ export function QuickChatSessionView({
) : (
recoveryFeedback
);
const recoveryRevealKey = resolveRecoveryRevealKey(
session.sessionId,
bootstrapRecoveryError,
hasRecoveryFeedback(resumption),
resumption.recoveryAttemptId,
recoveryOutcome(resumption.recoveryFailure),
);
if (isPassthrough) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -103,7 +140,6 @@ export function QuickChatSessionView({
const isConfig = session.kind === "config";
return (
<div className="flex min-h-0 flex-1 flex-col">
{recoverySurface}
<div className="flex min-h-0 flex-1 flex-col">
<QuickChatContent
sessionId={session.sessionId}
@@ -111,6 +147,8 @@ export function QuickChatSessionView({
placeholderOverride={isConfig ? t("chat:configChatPlaceholder") : undefined}
initialPrompt={session.initialPrompt}
onInitialPromptAttempted={onInitialPromptAttempted}
recoveryContent={recoverySurface}
recoveryRevealKey={recoveryRevealKey}
/>
</div>
</div>
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
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,
}),
).toBe("layout-restore");
expect(
resolveCompetingInitialScrollOwner({
hasPendingLayoutRestore: false,
hasExplicitScrollTarget: true,
hasRecoveryReveal: true,
hasUnreadDivider: true,
isProgrammaticScrollLocked: noProgrammaticLock,
}),
).toBe("explicit-target");
});
});
@@ -45,6 +45,7 @@ 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. */
@@ -226,6 +227,121 @@ function reportPaginationSettleDebug(params: {
});
}
type PrependScrollState = {
scrollHeight: number;
scrollTop: number;
anchorKey: string | null;
anchorTop: number | null;
};
type RecoveryRevealState = {
key: string | null;
keepAtTop: boolean;
};
/** Transfers recovery scroll ownership only after a real downward gesture. */
function useRecoveryPrependScrollOwnership({
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.
if (olderLoadPendingRef.current || isLoadingMoreRef.current) return;
scrollState.current = capturePrependScrollState(el, 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,
]);
}
/**
* Continuously captures scroll state via scroll listener.
* On a genuine prepend (older messages loaded above the current view, so the
@@ -243,19 +359,38 @@ function useScrollPositionOnPrepend(
items: RenderItem[],
isLoadingMore: boolean,
isProgrammaticScrollLocked: () => boolean,
recoveryRevealKey: string | null,
): () => void {
const scrollState = useRef<{
scrollHeight: number;
scrollTop: number;
anchorKey: string | null;
anchorTop: number | null;
}>({ scrollHeight: 0, scrollTop: 0, anchorKey: null, anchorTop: null });
const scrollState = useRef<PrependScrollState>({
scrollHeight: 0,
scrollTop: 0,
anchorKey: null,
anchorTop: null,
});
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({
scrollRef,
recoveryRevealStateRef,
isLoadingMoreRef,
olderLoadPendingRef,
newestItemKeyRef,
scrollState,
});
const prevItemCountRef = useRef(items.length);
const prevFirstKeyRef = useRef<string | null>(getOldestNonSyntheticItemKey(items));
const beginOlderLoad = useCallback(() => {
@@ -265,23 +400,6 @@ function useScrollPositionOnPrepend(
olderLoadPendingRef.current = true;
}, [scrollRef]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
/** Captures the container's current scrollHeight/scrollTop so a later
* prepend can restore the visual position. */
const onScroll = () => {
// 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.
if (olderLoadPendingRef.current || isLoadingMoreRef.current) return;
scrollState.current = capturePrependScrollState(el, newestItemKeyRef.current);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [scrollRef]);
useLayoutEffect(() => {
const el = scrollRef.current;
const nextFirstKey = getOldestNonSyntheticItemKey(items);
@@ -299,6 +417,11 @@ 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) {
@@ -672,6 +795,7 @@ export function useAutoScroll(params: {
hasUnreadDividerRef,
sessionIdRef,
isProgrammaticScrollLockedRef,
initialPlacementPending,
});
return { isNearBottomRef, resyncIsNearBottom, markNotNearBottom };
@@ -910,6 +1034,7 @@ function useCatchUpOnVisible({
hasUnreadDividerRef,
sessionIdRef,
isProgrammaticScrollLockedRef,
initialPlacementPending,
}: {
scrollRef: React.RefObject<HTMLDivElement | null>;
isVisible: boolean;
@@ -918,10 +1043,15 @@ function useCatchUpOnVisible({
hasUnreadDividerRef: React.RefObject<boolean>;
sessionIdRef: React.RefObject<string | null>;
isProgrammaticScrollLockedRef: React.RefObject<() => boolean>;
initialPlacementPending: boolean;
}) {
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
useEffect(() => {
if (!isVisible || !activationPendingRef.current) return;
if (initialPlacementPending) {
activationPendingRef.current = false;
return;
}
return scheduleAfterPanelRestore(() => {
if (!isVisibleRef.current) return;
activationPendingRef.current = false;
@@ -944,7 +1074,7 @@ function useCatchUpOnVisible({
scrollNativeToBottom(el);
isNearBottomRef.current = true;
});
}, [isVisible, scrollRef]);
}, [initialPlacementPending, isVisible, scrollRef]);
}
/**
@@ -1181,6 +1311,8 @@ type InitialScrollApplyParams = {
isNearBottomRef: React.RefObject<boolean>;
envSwitchPlacementToken: number | null;
hasUnreadDivider: boolean;
recoveryRevealKey: string | null;
recoveryPlacementPendingRef: React.RefObject<boolean>;
isProgrammaticScrollLocked: () => boolean;
phase: "provisional" | "final";
};
@@ -1188,9 +1320,11 @@ 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 {
@@ -1211,40 +1345,52 @@ function isCurrentEnvSwitchPlacement(
return pending?.token === token && pending.sessionId === sessionId;
}
type CompetingInitialScrollOwner =
export type CompetingInitialScrollOwner =
| "layout-restore"
| "explicit-target"
| "recovery"
| "unread-divider"
| "programmatic-scroll";
function resolveCompetingInitialScrollOwner(params: {
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 reportInitialPlacement(
phase: InitialScrollApplyParams["phase"],
element: HTMLDivElement,
sessionId: string | null,
token: number | null,
function resolveInitialPlacementOwner(
enabled: boolean,
): void {
recoveryRevealKey: string | null,
): "recovery" | "bottom" | "saved-position" {
if (recoveryRevealKey !== null) return "recovery";
return enabled ? "bottom" : "saved-position";
}
function reportInitialPlacement(params: {
phase: InitialScrollApplyParams["phase"];
element: HTMLDivElement;
sessionId: string | null;
token: number | null;
enabled: boolean;
recoveryRevealKey: string | null;
}): void {
if (!isDebug()) return;
placementDebug(`${phase} placement`, {
sessionId,
token,
owner: enabled ? "bottom" : "saved-position",
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
placementDebug(`${params.phase} placement`, {
sessionId: params.sessionId,
token: params.token,
owner: resolveInitialPlacementOwner(params.enabled, params.recoveryRevealKey),
scrollTop: params.element.scrollTop,
scrollHeight: params.element.scrollHeight,
clientHeight: params.element.clientHeight,
});
}
@@ -1288,6 +1434,8 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
isNearBottomRef,
envSwitchPlacementToken,
hasUnreadDivider,
recoveryRevealKey,
recoveryPlacementPendingRef,
isProgrammaticScrollLocked,
phase,
} = params;
@@ -1307,17 +1455,18 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
const competingOwner = resolveCompetingInitialScrollOwner({
hasPendingLayoutRestore,
hasExplicitScrollTarget,
hasRecoveryReveal: recoveryRevealKey !== null,
hasUnreadDivider,
isProgrammaticScrollLocked,
});
if (competingOwner) {
if (competingOwner && competingOwner !== "recovery") {
if (phase === "provisional") return;
placementDebug(`${phase} placement delegated`, {
sessionId,
owner: competingOwner,
token: envSwitchPlacementToken,
});
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
completeEnvSwitchPlacement(envSwitchPlacementToken);
return;
}
@@ -1326,14 +1475,17 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
getStoredAutoScrollTop(sessionId) ??
undefined)
: undefined;
const scrollTop = resolveNativeInitialScrollTop({
enabled,
hasPendingLayoutRestore,
savedScrollTop,
scrollHeight: element.scrollHeight,
});
const scrollTop =
competingOwner === "recovery"
? 0
: resolveNativeInitialScrollTop({
enabled,
hasPendingLayoutRestore,
savedScrollTop,
scrollHeight: element.scrollHeight,
});
if (phase === "final") {
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
}
if (scrollTop === null) {
completeFinalEnvSwitchPlacement(phase, envSwitchPlacementToken);
@@ -1349,7 +1501,14 @@ function applyInitialScrollPosition(params: InitialScrollApplyParams): void {
};
element.scrollTop = scrollTop;
syncNearBottom();
reportInitialPlacement(phase, element, sessionId, envSwitchPlacementToken, enabled);
reportInitialPlacement({
phase,
element,
sessionId,
token: envSwitchPlacementToken,
enabled,
recoveryRevealKey,
});
finishAppliedInitialPlacement({
phase,
enabled,
@@ -1369,16 +1528,128 @@ function shouldSkipProvisionalPlacement(
return phase === "provisional" && (hasUnreadDivider || appliedToken === currentToken);
}
function useInitialPlacementLatches(envSwitchPlacementToken: number | null) {
function useInitialPlacementLatches(
envSwitchPlacementToken: number | null,
recoveryRevealKey: string | 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;
}
return { didInitialScroll, provisionalTokenRef };
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 };
}
type InitialPlacementLatches = ReturnType<typeof useInitialPlacementLatches>;
type InitialScrollPositionParams = {
scrollRef: React.RefObject<HTMLDivElement | null>;
itemCount: number;
sessionId: string | null;
enabled: boolean;
hasUnreadDivider: boolean;
isNearBottomRef: React.RefObject<boolean>;
isVisible: boolean;
historyRefreshPending: boolean;
envSwitchPlacementToken: number | null;
isRestoringLayout: boolean;
isProgrammaticScrollLocked: () => boolean;
recoveryRevealKey: string | null;
placementLatches: InitialPlacementLatches;
};
type InitialScrollPositionEffectParams = Omit<InitialScrollPositionParams, "placementLatches"> & {
storeApi: ReturnType<typeof useAppStoreApi>;
didInitialScroll: React.RefObject<boolean>;
provisionalTokenRef: React.RefObject<number | null>;
isVisibleRef: React.RefObject<boolean>;
activationPendingRef: React.RefObject<boolean>;
recoveryPlacementPendingRef: React.RefObject<boolean>;
};
function runInitialScrollPositionEffect(
params: InitialScrollPositionEffectParams,
): void | (() => void) {
const {
scrollRef,
itemCount,
sessionId,
enabled,
hasUnreadDivider,
isNearBottomRef,
isVisible,
historyRefreshPending,
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
recoveryRevealKey,
storeApi,
didInitialScroll,
provisionalTokenRef,
isVisibleRef,
activationPendingRef,
recoveryPlacementPendingRef,
} = params;
if (!isVisible || (envSwitchPlacementToken !== null && isRestoringLayout)) return;
const hasEmptyTranscript = itemCount === 0 && recoveryRevealKey === null;
if (envSwitchPlacementToken !== null && hasEmptyTranscript) {
if (historyRefreshPending) return;
markInitialScrollConsumed(didInitialScroll, activationPendingRef, recoveryPlacementPendingRef);
completeEnvSwitchPlacement(envSwitchPlacementToken);
return;
}
if (didInitialScroll.current || hasEmptyTranscript) return;
const element = scrollRef.current;
if (!element) return;
const phase = envSwitchPlacementToken !== null && historyRefreshPending ? "provisional" : "final";
if (
shouldSkipProvisionalPlacement(
phase,
hasUnreadDivider,
provisionalTokenRef.current,
envSwitchPlacementToken,
)
) {
return;
}
const applyInitialScroll = () => {
applyInitialScrollPosition({
element,
sessionId,
enabled,
storeApi,
didInitialScroll,
activationPendingRef,
isVisibleRef,
isNearBottomRef,
envSwitchPlacementToken,
hasUnreadDivider,
recoveryRevealKey,
recoveryPlacementPendingRef,
isProgrammaticScrollLocked,
phase,
});
if (phase === "provisional") provisionalTokenRef.current = envSwitchPlacementToken;
};
if (activationPendingRef.current) return scheduleAfterPanelRestore(applyInitialScroll);
applyInitialScroll();
}
function useInitialScrollPosition({
@@ -1393,91 +1664,53 @@ function useInitialScrollPosition({
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
}: {
scrollRef: React.RefObject<HTMLDivElement | null>;
itemCount: number;
sessionId: string | null;
enabled: boolean;
hasUnreadDivider: boolean;
isNearBottomRef: React.RefObject<boolean>;
isVisible: boolean;
historyRefreshPending: boolean;
envSwitchPlacementToken: number | null;
isRestoringLayout: boolean;
isProgrammaticScrollLocked: () => boolean;
}) {
recoveryRevealKey,
placementLatches,
}: InitialScrollPositionParams) {
const storeApi = useAppStoreApi();
const { didInitialScroll, provisionalTokenRef } =
useInitialPlacementLatches(envSwitchPlacementToken);
const { didInitialScroll, provisionalTokenRef, recoveryPlacementPendingRef } = placementLatches;
const { isVisibleRef, activationPendingRef } = useActivationPending(
isVisible,
envSwitchPlacementToken,
);
useEffect(() => {
if (!isVisible) {
return;
}
if (envSwitchPlacementToken !== null && isRestoringLayout) return;
if (envSwitchPlacementToken !== null && itemCount === 0) {
if (historyRefreshPending) return;
markInitialScrollConsumed(didInitialScroll, activationPendingRef);
completeEnvSwitchPlacement(envSwitchPlacementToken);
return;
}
if (didInitialScroll.current || itemCount === 0) return;
const el = scrollRef.current;
if (!el) return;
const phase =
envSwitchPlacementToken !== null && historyRefreshPending ? "provisional" : "final";
if (
shouldSkipProvisionalPlacement(
phase,
hasUnreadDivider,
provisionalTokenRef.current,
envSwitchPlacementToken,
)
) {
return;
}
const applyInitialScroll = () => {
applyInitialScrollPosition({
element: el,
useEffect(
() =>
runInitialScrollPositionEffect({
scrollRef,
itemCount,
sessionId,
enabled,
hasUnreadDivider,
isNearBottomRef,
isVisible,
historyRefreshPending,
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
recoveryRevealKey,
storeApi,
didInitialScroll,
activationPendingRef,
provisionalTokenRef,
isVisibleRef,
isNearBottomRef,
envSwitchPlacementToken,
hasUnreadDivider,
isProgrammaticScrollLocked,
phase,
});
if (phase === "provisional") {
provisionalTokenRef.current = envSwitchPlacementToken;
}
};
if (activationPendingRef.current) {
return scheduleAfterPanelRestore(applyInitialScroll);
}
applyInitialScroll();
}, [
itemCount,
sessionId,
enabled,
hasUnreadDivider,
isNearBottomRef,
storeApi,
isVisible,
historyRefreshPending,
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
scrollRef,
]);
activationPendingRef,
recoveryPlacementPendingRef,
}),
[
itemCount,
sessionId,
enabled,
hasUnreadDivider,
isNearBottomRef,
storeApi,
isVisible,
historyRefreshPending,
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
recoveryRevealKey,
scrollRef,
],
);
}
type NativeScrollManagementParams = {
@@ -1495,6 +1728,7 @@ type NativeScrollManagementParams = {
isLoadingMore: boolean;
loadMore: () => Promise<number>;
isVisible: boolean;
recoveryRevealKey?: string | null;
};
/**
@@ -1521,6 +1755,7 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
isLoadingMore,
loadMore,
isVisible,
recoveryRevealKey = null,
} = params;
const pendingChatInitialPlacement = useDockviewStore(
(state) => state.pendingChatInitialPlacement,
@@ -1528,6 +1763,9 @@ 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 programmaticScrollLockRef = useRef(false);
const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []);
const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({
@@ -1539,7 +1777,7 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
hasUnreadDivider,
isProgrammaticScrollLocked,
isVisible,
initialPlacementPending: envSwitchPlacementToken !== null,
initialPlacementPending,
});
const runGuardedScroll = useProgrammaticScrollGuard(
scrollRef,
@@ -1552,6 +1790,7 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
items,
isLoadingMore,
isProgrammaticScrollLocked,
recoveryRevealKey,
);
const loadMoreWithPrependBaseline = useCallback(() => {
beginOlderLoad();
@@ -1586,6 +1825,8 @@ export function useNativeScrollManagement(params: NativeScrollManagementParams)
envSwitchPlacementToken,
isRestoringLayout,
isProgrammaticScrollLocked,
recoveryRevealKey,
placementLatches,
});
return {
@@ -86,6 +86,11 @@ 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;
@@ -109,6 +114,7 @@ function Harness({
enabled = true,
sessionId = null,
isProgrammaticScrollLocked = NEVER_LOCKED,
recoveryRevealKey = null,
}: {
itemCount: number;
anchoredBarOffsetPx: number;
@@ -121,6 +127,7 @@ function Harness({
enabled?: boolean;
sessionId?: string | null;
isProgrammaticScrollLocked?: () => boolean;
recoveryRevealKey?: string | null;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
@@ -148,6 +155,7 @@ function Harness({
sessionId,
isProgrammaticScrollLocked,
isVisible,
recoveryRevealKey,
} as Parameters<typeof useScrollToDividerOrBottom>[4];
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx, scrollOptions);
return (
@@ -271,6 +279,7 @@ function useNativeScrollMetrics(
function NativeScrollManagementHarness({
items,
messages = [],
metrics,
loadMore = async () => 0,
sessionId = null,
@@ -280,8 +289,11 @@ function NativeScrollManagementHarness({
enabled = false,
historyRefreshPending = false,
hasUnreadDivider = false,
hasMore = true,
recoveryRevealKey = null,
}: {
items: RenderItem[];
messages?: Message[];
metrics?: NativeScrollMetrics;
loadMore?: () => Promise<number>;
sessionId?: string | null;
@@ -291,23 +303,26 @@ function NativeScrollManagementHarness({
enabled?: boolean;
historyRefreshPending?: boolean;
hasUnreadDivider?: boolean;
hasMore?: boolean;
recoveryRevealKey?: string | null;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
useNativeScrollMetrics(scrollRef, metrics);
const { sentinelRef, showRecovery } = useNativeScrollManagement({
scrollRef,
items,
messages: [],
messages,
isWorking: false,
sessionId,
enabled,
hasUnreadDivider,
messagesLoading: false,
hasMore: true,
hasMore,
isLoadingMore,
loadMore,
isVisible,
historyRefreshPending,
recoveryRevealKey,
});
if (recoveryRef) recoveryRef.current = showRecovery;
return (
@@ -356,6 +371,301 @@ 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", () => {
@@ -1041,6 +1351,20 @@ 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"
/>,
);
expect(screen.getByTestId(DIVIDER_SCROLL_CONTAINER_TEST_ID).scrollTop).toBe(0);
});
it("waits for an inactive transcript to become visible before placing the initial view", () => {
const frames: Array<FrameRequestCallback> = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
@@ -2,7 +2,15 @@
/* eslint-disable max-lines -- native transcript composition owns scrolling. */
import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } from "react";
import {
useEffect,
useMemo,
useRef,
memo,
forwardRef,
useImperativeHandle,
type ReactNode,
} 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";
@@ -102,6 +110,7 @@ type NativeMessageListScrollParams = {
/** Changes when transcript status rows can add/remove space above messages. */
scrollLayoutKey: string;
isVisible: boolean;
recoveryRevealKey?: string | null;
};
type ScrollToDividerOptions = {
@@ -112,8 +121,22 @@ 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,
@@ -136,6 +159,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
onFirstMessageHiddenChange,
scrollLayoutKey,
isVisible,
recoveryRevealKey,
} = params;
const {
handleScrollToMessage,
@@ -158,6 +182,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isLoadingMore,
loadMore,
isVisible,
recoveryRevealKey,
});
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
useEffect(() => {
@@ -171,6 +196,7 @@ function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
recoveryRevealKey,
});
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
handleScrollToMessage,
@@ -278,6 +304,7 @@ type NativeMessageListBodyProps = {
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
autoScrollEnabled: boolean;
dividerBeforeItemKey?: string | null;
prependContent?: ReactNode;
launchErrorOwned: boolean;
launchErrorStamp?: string;
launchErrorOccurredAt?: string;
@@ -319,6 +346,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.
export function useScrollToDividerOrBottom(
scrollRef: React.RefObject<HTMLDivElement | null>,
itemCount: number,
@@ -334,6 +362,7 @@ export function useScrollToDividerOrBottom(
isProgrammaticScrollLocked = () => false,
isVisible = true,
historyRefreshPending = false,
recoveryRevealKey = null,
} = options;
const { isVisibleRef, activationPendingRef } = useActivationPending(isVisible);
const isUserScrollingRef = useDividerUserScrolling(scrollRef);
@@ -363,7 +392,10 @@ export function useScrollToDividerOrBottom(
settlingDeadlineRef.current = Date.now() + DIVIDER_SETTLING_WINDOW_MS;
}
const el = scrollRef.current;
if (!el || itemCount === 0 || historyRefreshPending) return;
if (!el || historyRefreshPending) return;
if (consumeRecoveryRevealForDivider(recoveryRevealKey, didInitialScroll, activationPendingRef))
return;
if (itemCount === 0) return;
const placeInitialPosition = () => {
if (!isVisibleRef.current) return;
@@ -432,6 +464,7 @@ export function useScrollToDividerOrBottom(
isProgrammaticScrollLocked,
isVisible,
historyRefreshPending,
recoveryRevealKey,
scrollRef,
]);
}
@@ -487,12 +520,14 @@ 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" />}
@@ -588,6 +623,8 @@ export const NativeMessageList = memo(
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
prependContent,
recoveryRevealKey,
}: MessageListProps,
ref,
) {
@@ -662,6 +699,7 @@ export const NativeMessageList = memo(
isWorking,
].join(":"),
isVisible,
recoveryRevealKey,
});
return (
@@ -701,6 +739,7 @@ export const NativeMessageList = memo(
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
prependContent={prependContent}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
@@ -64,6 +64,10 @@ 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
@@ -10,6 +10,8 @@ const launchError = {
category: "workspace_checkout_failed",
};
const messageListRecoveryRevealKeys = vi.hoisted(() => [] as Array<string | null | undefined>);
const priorTranscriptMessage = {
id: "prior-transcript",
author_type: "agent",
@@ -35,7 +37,11 @@ const appStoreState = {
const panelState = {
resolvedSessionId: "prior-session",
session: { state: "FAILED", error_message: "The prior session stopped." },
session: {
state: "FAILED",
error_message: "The prior session stopped.",
metadata: null as Record<string, unknown> | null,
},
taskId: "task-1",
isWorking: false,
messagesLoading: false,
@@ -114,15 +120,23 @@ vi.mock("@/components/task/chat/message-list", () => ({
MessageList: ({
messages,
launchErrorOwned,
prependContent,
recoveryRevealKey,
}: {
messages: Message[];
launchErrorOwned?: boolean;
}) => (
<div data-testid="message-list">
{!launchErrorOwned &&
messages.map((message) => <div key={message.id}>{message.content}</div>)}
</div>
),
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>)}
</div>
);
},
}));
vi.mock("./simple/components/task-chat-launch-error", () => ({
@@ -198,7 +212,10 @@ vi.mock("react-i18next", () => ({
import { TaskChatPanel } from "./task-chat-panel";
afterEach(() => cleanup());
afterEach(() => {
cleanup();
messageListRecoveryRevealKeys.length = 0;
});
describe("TaskChatPanel launch-error ownership", () => {
it("keeps a task-wide card and prior failed-session surfaces together", () => {
@@ -209,5 +226,26 @@ describe("TaskChatPanel launch-error ownership", () => {
);
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
expect(screen.getByText(priorTranscriptMessage.content)).toBeTruthy();
expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:task-wide-launch-error");
});
it("reveals a persisted bootstrap fallback for the selected session", () => {
const previousMetadata = panelState.session.metadata;
panelState.session.metadata = {
last_agent_error: {
message: "The selected session could not start.",
phase: "bootstrap",
occurred_at: "2026-08-20T11:00:00Z",
stamp: "persisted-bootstrap-error",
},
};
try {
render(<TaskChatPanel sessionId="prior-session" taskId="task-1" />);
expect(messageListRecoveryRevealKeys.at(-1)).toBe("prior-session:persisted-bootstrap-error");
} finally {
panelState.session.metadata = previousMetadata;
}
});
});
+32 -12
View File
@@ -56,8 +56,12 @@ 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 } from "@/components/task/chat/types";
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";
/** Returns a `clarificationKey` that increments each time a pending
* clarification is resolved, letting the composer reset its input state for
@@ -1031,6 +1035,17 @@ export const TaskChatPanel = memo(function TaskChatPanel({
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),
@@ -1159,6 +1174,19 @@ 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
@@ -1170,17 +1198,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onMouseDown={handlePanelMouseDown}
className="outline-none"
>
<PanelBody padding={false} className="relative">
{launchErrorContext && (
<TaskChatLaunchError
taskId={launchErrorContext.taskId}
workspaceId={launchErrorContext.workspaceId}
statusSummary={launchStatusSummary}
sessionId={resolvedSessionId}
sessionMetadata={session?.metadata}
repositories={launchErrorContext.repositories}
/>
)}
<PanelBody padding={false} scroll={false} className="relative overflow-hidden">
<TaskMarkdownFileLinkProvider
taskId={taskId}
sessionId={resolvedSessionId}
@@ -1215,6 +1233,8 @@ export const TaskChatPanel = memo(function TaskChatPanel({
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorOwned ? activeLaunchError?.stamp : undefined}
launchErrorOccurredAt={launchErrorOwned ? activeLaunchError?.occurred_at : undefined}
prependContent={launchErrorContent}
recoveryRevealKey={recoveryRevealKey}
stickyPromptBar={
showAnchoredBar && lastPromptMessage ? (
<AnchoredLastPromptBar
+44
View File
@@ -0,0 +1,44 @@
import type { Locator } from "@playwright/test";
export type TranscriptScrollState = {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
scrollOwnerCount: number;
};
/** Reads the native transcript metrics and counts scrollable ancestors. */
export async function readTranscriptScrollState(
transcript: Locator,
): Promise<TranscriptScrollState> {
return transcript.evaluate((node) => {
const element = node as HTMLElement;
let current: HTMLElement | null = element;
let scrollOwnerCount = 0;
while (current) {
const overflowY = getComputedStyle(current).overflowY;
if (overflowY === "auto" || overflowY === "scroll") scrollOwnerCount += 1;
if (current.dataset.testid === "session-chat") break;
current = current.parentElement;
}
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
scrollOwnerCount,
};
});
}
/** Applies a user-like transcript scroll and returns the resulting offset. */
export async function setTranscriptScrollTop(
transcript: Locator,
scrollTop: number,
): Promise<number> {
return transcript.evaluate((node, nextScrollTop) => {
const element = node as HTMLElement;
element.scrollTop = nextScrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
return element.scrollTop;
}, scrollTop);
}
@@ -8,6 +8,7 @@ import {
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import type { ApiClient } from "../../helpers/api-client";
import { waitForSessionDone } from "../../helpers/session";
import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers/transcript-scroll";
import { SessionPage } from "../../pages/session-page";
type LaunchError = {
@@ -378,8 +379,13 @@ test.describe("task launch failure recovery", () => {
task.session_id,
"Waiting for bootstrap recovery fixture to settle",
);
await apiClient.seedAgentMessages(task.session_id, 40, "bootstrap history");
const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const longDetails = Array.from(
{ length: 12 },
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
).join("\n");
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
@@ -393,7 +399,7 @@ test.describe("task launch failure recovery", () => {
phase: "bootstrap",
attempt_id: "bootstrap-execution-e2e",
code: "generic_launch_failure",
details: "agent_bootstrap; cause=permission_denied",
details: longDetails,
stamp: "bootstrap-presentation-e2e",
causes: [
{
@@ -416,12 +422,72 @@ test.describe("task launch failure recovery", () => {
await expect(card).toContainText("Session startup needs attention");
await expect(card).toContainText("Required contribution access was denied.");
await expect(card).not.toContainText("remote-secret");
const transcript = session.activeChat().locator(".chat-message-list").first();
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
.toBe(1);
const initialScroll = await readTranscriptScrollState(transcript);
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
expect(initialScroll.scrollTop).toBe(0);
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
const details = card.getByTestId("session-bootstrap-recovery-details");
await expect(details).not.toHaveAttribute("open");
await details.getByText("Recovery details").click();
await expect(details).toHaveAttribute("open", "");
await expect(card).toContainText("The required contribution access was denied.");
const expandedScroll = await readTranscriptScrollState(transcript);
expect(expandedScroll.scrollOwnerCount).toBe(1);
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
const middleScrollTop = await setTranscriptScrollTop(
transcript,
Math.floor((expandedScroll.scrollHeight - expandedScroll.clientHeight) / 2),
);
expect(middleScrollTop).toBeGreaterThan(0);
const sameStampMetadata = {
last_agent_error: {
message: "The agent could not start.",
occurred_at: occurredAt,
agent_execution_id: "bootstrap-execution-e2e",
execution_id: "bootstrap-execution-e2e",
phase: "bootstrap",
attempt_id: "bootstrap-execution-e2e",
code: "generic_launch_failure",
details: longDetails,
stamp: "bootstrap-presentation-e2e",
causes: [
{
operation: "resume",
code: "permission_denied",
detail: "The required contribution access was denied.",
},
],
},
};
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: sameStampMetadata,
});
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: {
...sameStampMetadata,
last_agent_error: {
...sameStampMetadata.last_agent_error,
stamp: "bootstrap-presentation-e2e-new",
},
},
});
await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
for (const testId of [
"recovery-resume-button",
"recovery-restore-workspace-button",
@@ -10,6 +10,7 @@ import {
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import type { ApiClient } from "../../helpers/api-client";
import { waitForSessionDone } from "../../helpers/session";
import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers/transcript-scroll";
import { SessionPage } from "../../pages/session-page";
async function waitForLaunchError(apiClient: ApiClient, workspaceId: string, taskId: string) {
@@ -261,7 +262,13 @@ test.describe("mobile task launch failure recovery", () => {
task.session_id,
"Waiting for mobile bootstrap recovery fixture to settle",
);
await apiClient.seedAgentMessages(task.session_id, 40, "mobile bootstrap history");
const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const longDetails = Array.from(
{ length: 12 },
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
).join("\n");
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
@@ -269,13 +276,13 @@ test.describe("mobile task launch failure recovery", () => {
metadata: {
last_agent_error: {
message: "The agent could not start.",
occurred_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
occurred_at: occurredAt,
agent_execution_id: "mobile-bootstrap-execution-e2e",
execution_id: "mobile-bootstrap-execution-e2e",
phase: "bootstrap",
attempt_id: "mobile-bootstrap-execution-e2e",
code: "generic_launch_failure",
details: "agent_bootstrap; cause=permission_denied",
details: longDetails,
stamp: "mobile-bootstrap-presentation-e2e",
causes: [
{
@@ -295,6 +302,14 @@ test.describe("mobile task launch failure recovery", () => {
await expect(card).toHaveCount(1, { timeout: 30_000 });
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
const transcript = session.activeChat().locator(".chat-message-list").first();
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
.toBe(1);
const initialScroll = await readTranscriptScrollState(transcript);
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
expect(initialScroll.scrollTop).toBe(0);
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
for (const testId of [
"recovery-resume-button",
@@ -314,6 +329,58 @@ test.describe("mobile task launch failure recovery", () => {
await details.getByText("Recovery details").tap();
await expect(details).toHaveAttribute("open", "");
await expect(card).toContainText("The required contribution access was denied.");
const expandedScroll = await readTranscriptScrollState(transcript);
expect(expandedScroll.scrollOwnerCount).toBe(1);
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
const middleScrollTop = await setTranscriptScrollTop(
transcript,
Math.floor((expandedScroll.scrollHeight - expandedScroll.clientHeight) / 2),
);
expect(middleScrollTop).toBeGreaterThan(0);
const sameStampMetadata = {
last_agent_error: {
message: "The agent could not start.",
occurred_at: occurredAt,
agent_execution_id: "mobile-bootstrap-execution-e2e",
execution_id: "mobile-bootstrap-execution-e2e",
phase: "bootstrap",
attempt_id: "mobile-bootstrap-execution-e2e",
code: "generic_launch_failure",
details: longDetails,
stamp: "mobile-bootstrap-presentation-e2e",
causes: [
{
operation: "resume",
code: "permission_denied",
detail: "The required contribution access was denied.",
},
],
},
};
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: sameStampMetadata,
});
await expect
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
await apiClient.seedTaskSession(task.id, {
state: "WAITING_FOR_INPUT",
sessionId: task.session_id,
agentProfileId: seedData.agentProfileId,
metadata: {
...sameStampMetadata,
last_agent_error: {
...sameStampMetadata.last_agent_error,
stamp: "mobile-bootstrap-presentation-e2e-new",
},
},
});
await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
await assertNoDocumentHorizontalOverflow(testPage, "mobile bootstrap recovery presentation");
await testPage.screenshot({
@@ -565,6 +565,7 @@ describe("useSessionResumption", () => {
kind: "timeout",
statusError: "WebSocket request timed out: task.session.status",
});
expect(result.current.recoveryAttemptId).toBe(1);
expect(mockRequest).toHaveBeenCalledTimes(2);
mockRequest.mockResolvedValueOnce({
@@ -587,6 +588,7 @@ describe("useSessionResumption", () => {
);
expect(result.current.recoveryFailure).toBeNull();
expect(result.current.resumptionState).toBe("running");
expect(result.current.recoveryAttemptId).toBe(2);
});
it("keeps a workspace restore failure as launch feedback with a launch retry", async () => {
@@ -422,6 +422,7 @@ interface UseSessionResumptionReturn {
error: string | null;
notice: string | null;
recoveryFailure: SessionRecoveryFailure | null;
recoveryAttemptId: number;
taskSessionState: TaskSessionState | null;
worktreePath: string | null;
worktreeBranch: string | null;
@@ -724,7 +725,21 @@ export function useSessionResumption(
taskArchiveState: TaskArchiveState = false,
options: SessionResumptionOptions = {},
): UseSessionResumptionReturn {
const [resumptionState, setResumptionState] = useState<ResumptionState>("idle");
const [resumptionState, setResumptionStateRaw] = useState<ResumptionState>("idle");
const recoveryAttemptIdRef = useRef(0);
const recoveryAttemptActiveRef = useRef(false);
const setResumptionState = useCallback((nextState: ResumptionState) => {
const startsRecoveryAttempt = nextState === "checking" || nextState === "resuming";
if (startsRecoveryAttempt) {
if (!recoveryAttemptActiveRef.current) {
recoveryAttemptIdRef.current += 1;
recoveryAttemptActiveRef.current = true;
}
} else {
recoveryAttemptActiveRef.current = false;
}
setResumptionStateRaw(nextState);
}, []);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [recoveryFailure, setRecoveryFailure] = useState<SessionRecoveryFailure | null>(null);
@@ -787,6 +802,7 @@ export function useSessionResumption(
error,
notice,
recoveryFailure,
recoveryAttemptId: recoveryAttemptIdRef.current,
taskSessionState: session?.state ?? null,
worktreePath,
worktreeBranch,