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,
@@ -0,0 +1,139 @@
---
created: 2026-09-13
status: completed
requirements:
- REQ-TASKS-REMOTE-CONTRIBUTION-TASKS-002
- REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
system_design:
- ../../specs/tasks/system-design/remote-contribution-tasks.md
- ../../specs/agents/system-design/session-recovery-failures.md
legacy_specs: []
---
# Implementation Plan: Startup Recovery Scrolling and Timeout
## Overview
Allow more time for contribution checks on a busy machine. Keep the recovery
card and transcript in one scroll area. Implement two sequential work orders.
## Evidence and scope
Task `539ebf53-b1a8-476c-829a-f8b466287d34` failed during resume on September 13.
The preflight HTTP request exceeded its 30-second deadline at 17:09:06 Lisbon time.
A later preflight completed in 18.749 seconds. Resume succeeded at 17:09:52.
The user reported 100% CPU usage on the host. Logs do not independently prove
which resource delayed Git. The two-minute budget is a proposed tolerance,
not a measured guarantee under arbitrary host starvation.
The screenshot shows two scrollbars. Source confirms that `PanelBody` uses
`overflow-auto`, while its full-height transcript has another scroll viewport.
The recovery card adds height above that full-height child.
In scope: bounded preflight tolerance, transport deadline alignment, shared
chat scroll ownership, recovery reveal, and desktop/phone regression coverage.
Out of scope: global timeout increases, adaptive CPU policy, automatic retries,
Git publication, recovery authorization changes, and provider session replacement.
## Requirement conformance
Existing recovery criterion 006.6 and its responsive design require shared
scroll ownership. Draft criterion 006.7 defines reveal and user-scroll behavior.
Draft contribution criterion 002.6 defines the longer bounded check.
Existing contribution criteria 002.3 and 002.4 retain rejection and all-repository gates.
The owning requirements and designs contain both amendments.
## Technical approach
Task 01 gives the full contribution preflight loop two minutes. A dedicated
bounded request path avoids the ordinary HTTP client's 60-second cutoff.
Preserve earlier deadlines, cancellation, response decoding, and shared transport.
The existing overall readiness default is 15 minutes and needs no increase.
Task 02 moves the recovery card inside the transcript viewport and disables
outer scrolling in the chat panel. Coordinate one-time reveal with the existing
scroll controller. Key bootstrap reveals by session and failure stamp, and
fallback recovery reveals by session, attempt, and outcome. Keep an active
recovery reveal at the transcript top while older pages settle, then return
prepend ownership to the reader after a downward gesture.
No new recovery state store or duplicate action owner is necessary.
## ASCII UI preview
Before: a scrollable body contains a recovery card plus a full-height scrolling
transcript. Their combined height exceeds the available body height.
```text
UI-01: Task Chat, active startup failure (desktop and phone)
+---------------------------------------+
| Session navigation | fixed
+---------------------------------------+
| Startup failure summary ^ |
| Resume / Restore / Start fresh | |
| Recovery details (expand inline) | | one scroll area
| | |
| Conversation history v |
+---------------------------------------+
| Composer | fixed
+---------------------------------------+
```
Desktop uses the existing compact action row. Phone stacks the same actions
with 44-pixel touch targets. Expanded details wrap in the same scroll area.
A new failure reveals the card once. The user can then scroll freely.
The composer retains safe-area clearance. Labels and spacing are illustrative.
Scroll ownership, action reachability, and one-time reveal are requirements.
## Mobile design contract
Entry: the dedicated task Chat surface in `mobile/session-mobile-layout.tsx`.
Exemplar: the existing inline recovery card and task mobile layout.
Hierarchy: cause, primary Resume action, secondary recovery actions, then details.
The card stays inline because it explains the current conversation failure.
Desktop and phone share recovery state and handlers. Phone uses stacked actions.
One transcript viewport owns vertical scrolling, including long expanded details.
No horizontal document overflow or new fixed-height phone wrapper is permitted.
## Tests
Task 01 covers contribution criteria 002.3, 002.4, and 002.6 with lifecycle and
agentctl client tests. Use controlled transport responses and short test budgets.
Prove extended-budget success, timeout, cancellation, earlier caller deadline,
and a mixed multi-repository result. No sustained CPU load is required.
## E2E tests
Task 02 extends `launch-failure-recovery.spec.ts` and
`mobile-launch-failure-recovery.spec.ts` for recovery criteria 006.1, 006.6, and 006.7.
Seed history taller than the viewport. Assert one scroll owner, initial/new-error
reveal, expanded details, user-controlled scrolling, reload, and composer access.
Check desktop, the configured phone project, and a short viewport.
Existing completed recovery packages retain their historical results.
## Work orders
- [x] [Task 01: Extend the bounded preflight budget](task-01-preflight-budget.md)
- [x] [Task 02: Use one recovery and transcript scroll area](task-02-chat-scroll.md)
## Verification results
Targeted product tests and review regressions: passed.
- `python3 scripts/list-docs.py validate`: passed (267 decisions, 868 specifications).
- `python3 scripts/lint-spec-files.py --all`: passed.
- `(cd apps/backend && go test -race ./internal/agent/runtime/agentctl ./internal/agent/runtime/lifecycle)`: passed.
- `(cd apps/web && pnpm exec vitest run components/task/chat/message-list-native.test.tsx components/task/chat/message-list-native-scroll.test.ts components/quick-chat/quick-chat-session-view.test.tsx hooks/domains/session/use-session-resumption.test.ts)`: passed (110 tests), including controlled older-page settling, an in-flight page when a new failure arrives, and same-session fallback recovery attempts.
- PR fixup regression suite: passed (119 tests), including visible task-wide and persisted-metadata recovery reveal keys and resumption of normal auto-scroll after the one-time recovery placement.
- `(cd apps/web && pnpm run lint && pnpm run typecheck && pnpm run i18n:check)`: passed.
- `(cd apps/backend && make build)`: passed.
- `(cd apps/backend && make test)`: reached unrelated environment-sensitive failures in process probes, config-home discovery, launcher, and Office SQLite migration tests; the changed `agentctl` and lifecycle packages passed.
- `(cd apps/web && pnpm run build)`: passed.
- Desktop and phone bootstrap recovery E2E scenarios: passed.
- `git diff --check`: passed.
- Plan status inventory: both work orders completed.
## Risks
A hung preflight can take longer to report failure. Cancellation must still work.
A new card reveal can conflict with bottom-follow or hidden-panel restoration.
Short viewports require the full card to share the transcript scroll area.
@@ -0,0 +1,92 @@
---
id: "01-preflight-budget"
title: "Extend the bounded preflight budget"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-REMOTE-CONTRIBUTION-TASKS-002
acceptance_criteria:
- AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.3
- AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.4
- AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.6
system_design:
- ../../specs/tasks/system-design/remote-contribution-tasks.md
---
# Task 01: Extend the Bounded Preflight Budget
## Summary
Give contribution startup checks one two-minute budget across all repositories.
Make the preflight HTTP path honor that budget without changing unrelated requests.
## In scope
- Extend the lifecycle default and bound direct preflight callers.
- Reuse shared transport and response handling without mutating the shared client.
- Preserve earlier deadlines, cancellation, rejection, and history-only admission rules.
## Out of scope
Global HTTP, ACP load, workspace refresh, and readiness timeout changes.
No new operator setting, retry loop, or credential policy.
## Acceptance
- A preflight can complete within two minutes despite the ordinary client's shorter timeout.
- Expiry or cancellation blocks startup, with no late agent launch or permission bypass.
- All repositories share one budget. One rejection blocks the complete operation.
## Verification
First add `TestGitPushPreflightHonorsOperationBudget` with a short ordinary-client
timeout and a controlled response. Its expected RED is premature transport expiry.
Extend `TestStartAgentProcessBoundsContributionPreflight` for the default budget,
short injected expiry, and caller cancellation. Preserve existing admission tests.
Use barriers or a controlled transport. Do not run two-minute sleeps or CPU stress.
```bash
(cd apps/backend && go test -race ./internal/agent/runtime/agentctl ./internal/agent/runtime/lifecycle)
git diff --check
```
## Files likely touched
- `apps/backend/internal/agent/runtime/lifecycle/manager_startup.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_startup_test.go`
- `apps/backend/internal/agent/runtime/agentctl/git.go`
- `apps/backend/internal/agent/runtime/agentctl/git_test.go`
- `apps/backend/internal/agent/runtime/agentctl/client.go` (only if the scoped request path needs it)
## Dependencies
None.
## Risks
A transport-specific timeout or shared client mutation can defeat the intended bound.
Tests must cover ordinary request isolation and earlier caller deadlines.
## Parallelism
`sequential`
## Inputs
- [Plan](plan.md), evidence and technical approach.
- [Contribution requirements](../../specs/tasks/requirements/remote-contribution-tasks.md), requirement 002.
- [Contribution design](../../specs/tasks/system-design/remote-contribution-tasks.md), proposed timing amendment.
- Existing startup preflight and client Git request tests.
## Results
Implemented a two-minute default for the complete contribution preflight loop.
`GitPushPreflight` uses a per-operation client copy with the same transport and
headers, while the ordinary Git client keeps its 60-second timeout. Direct
preflight callers receive the same bounded context, and earlier cancellation or
deadlines still win. Rejections continue to block startup before agent launch.
Validation passed with the agentctl and lifecycle race suite, targeted preflight
tests, and `git diff --check`.
@@ -0,0 +1,164 @@
---
id: "02-chat-scroll"
title: "Use one recovery and transcript scroll area"
status: done
wave: 2
depends_on:
- "01-preflight-budget"
plan: "plan.md"
requirements:
- REQ-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006
acceptance_criteria:
- AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.1
- AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.6
- AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7
system_design:
- ../../specs/agents/system-design/session-recovery-failures.md
---
# Task 02: Use One Recovery and Transcript Scroll Area
## Summary
Put the recovery card inside the transcript viewport. Reveal a new failure once,
then preserve user scrolling and the existing composer layout.
## In scope
- Remove outer chat scrolling without changing the shared PanelBody default.
- Pass the recovery content into the real MessageList scroll viewport.
- Coordinate failure reveal, bottom-follow, session switching, and panel visibility.
- Cover desktop and phone, including long history and expanded details.
- Audit preview and Quick Chat callers for the same composition defect.
## Out of scope
New recovery actions, duplicate banners, translation changes, and transcript virtualization.
No permanent fixed recovery panel or nested details scroller.
## Acceptance
- Exactly one chat content viewport scrolls when history exceeds its height.
- Initial and new failures reveal the card once. Repeated state events preserve user position.
- Expanded details, recovery actions, and composer remain reachable on desktop and phone.
## ASCII UI preview
[Full preview and mobile contract](plan.md#ascii-ui-preview).
Applies to recovery criteria 006.1, 006.6, and 006.7.
```text
UI-01: Task Chat, active startup failure (desktop and phone)
+---------------------------------------+
| Session navigation | fixed
+---------------------------------------+
| Startup failure summary ^ |
| Resume / Restore / Start fresh | |
| Recovery details (expand inline) | | one scroll area
| | |
| Conversation history v |
+---------------------------------------+
| Composer | fixed
+---------------------------------------+
```
Desktop uses the existing compact action row. Phone stacks the same actions
with 44-pixel touch targets. Expanded details wrap in the same scroll area.
A new failure reveals the card once. The user can then scroll freely.
The composer retains safe-area clearance. Labels and spacing are illustrative.
Scroll ownership, action reachability, and one-time reveal are requirements.
## Verification
Extend the existing bootstrap-card scenarios with history taller than the viewport.
The expected RED is two independently scrollable ancestors or a card outside the
active viewport. Do not use a missing selector as the behavioral failure.
Assert computed overflow and actual scroll movement. Scroll history manually and
send the same error stamp again. Assert that the viewport does not jump.
Send a new stamp and assert that the card enters the viewport.
Expand long details and reach the final recovery action at a short height.
Keep the normal no-error bottom-follow case. Capture desktop and phone screenshots.
Use the existing isolated fixtures. Never navigate a test browser to the live task.
If dependencies are absent, run the install command once before package commands.
```bash
(cd apps && pnpm install --frozen-lockfile)
(cd apps/web && pnpm e2e:run --project chromium tests/task/launch-failure-recovery.spec.ts)
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-launch-failure-recovery.spec.ts)
(cd apps/web && pnpm run typecheck)
git diff --check
```
If scroll-controller logic changes, add focused controller tests in the new
`message-list-native-scroll.test.ts` file. Run the exact command from the root:
```bash
(cd apps/web && pnpm exec vitest run components/task/chat/message-list-native-scroll.test.ts)
```
## Files likely touched
- `apps/web/components/task/task-chat-panel.tsx`
- `apps/web/components/task/chat/message-list-shared.tsx`
- `apps/web/components/task/chat/message-list-native.tsx`
- `apps/web/components/task/chat/message-list-native-scroll.ts`
- `apps/web/components/task/chat/message-list-native-scroll.test.ts`
- `apps/web/components/quick-chat/quick-chat-session-view.tsx` (if the same defect applies)
- `apps/web/e2e/tests/task/launch-failure-recovery.spec.ts`
- `apps/web/e2e/tests/task/mobile-launch-failure-recovery.spec.ts`
- A shared E2E helper beside these specs, if both need the same geometry checks.
## Dependencies
Task 01 precedes this task for sequential delivery. There is no schema dependency.
## Risks
Reveal can conflict with initial bottom positioning or hidden-panel restoration.
Use session and error identity, not translated error text, for reveal ownership.
## Parallelism
`sequential`
## Inputs
- [Recovery requirements](../../specs/agents/requirements/session-recovery-failures.md), requirement 006.
- [Recovery design](../../specs/agents/system-design/session-recovery-failures.md), responsive and scrolling sections.
- The current native transcript controller and bootstrap recovery E2E scenarios.
## Results
Moved task and Quick Chat recovery content into the native transcript viewport,
disabled the outer chat body scroll, and coordinated one-time recovery reveals
with the existing placement and auto-scroll controllers. The reveal is keyed by
session and failure identity, preserves later user scrolling, and keeps the
existing phone action layout and composer allocation.
Added focused scroll-controller coverage plus long-history desktop and phone
recovery scenarios. Both browser scenarios passed with expanded details, one
scroll owner, same-stamp position preservation, new-stamp reveal, composer
visibility, and phone touch-target checks.
Review fixes:
- Recovery-owned prepend pages keep the newly revealed card at the transcript
top while a page settles, including a page already in flight when a new
failure arrives. A real downward wheel, key, pointer, or touch gesture gives
scroll ownership back to the reader, after which normal message anchoring
resumes.
- The reveal key now follows the recovery surface that is actually rendered,
including task-wide errors while another session is selected and persisted
session metadata fallbacks.
- The one-time recovery placement latch clears after the initial reveal even
while feedback remains visible, so normal bottom-follow, activation catch-up,
and persisted-offset restoration resume afterward.
- Quick Chat fallback recovery keys now use the session, monotonic recovery
attempt, and outcome identity. Clearing feedback re-arms the reveal latch.
Duplicate same-attempt state, including changed translated error text,
keeps the existing key.
- Added controlled pagination, same-session recovery, visible-error-key, and
post-reveal auto-scroll regression coverage. The review cleanup also names
the raw resumption setter clearly and removes the obsolete test harness cast.
@@ -45,8 +45,13 @@ results, including the shared recovery owner and phone touch-target checks.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.4:** Retry shall update the existing card and disable equivalent actions while pending. Successful resume shall clear the active failure; workspace-only success shall show a nonblocking notice that the agent remains stopped.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.5:** Reload, reconnect, and reversed event order shall converge on the current failure. A stale attempt or unrelated historical error shall neither replace nor be hidden by that failure.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.6:** Desktop and phone shall expose the same recovery choices and details. Phone actions shall have at least 44-pixel touch targets, with no horizontal page overflow or extra details scroller.
- **AC-AGENTS-AGENT-RESUME-RUNTIME-RECOVERY-006.7:** Chat shall use one vertical scroll area for the recovery card, expanded details, and transcript. Opening a session with an active failure, or receiving a new failure, shall reveal the card. Later user scrolling shall remain under user control. Recovery actions and the composer shall remain reachable on desktop and phone.
## Proposed recovery scrolling amendment
This amendment is draft. The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) owns implementation of criterion 006.7.
## Proposed recovery attempt amendment
The following requirement is draft. The existing requirements remain active.
@@ -97,6 +97,25 @@ Use 28-pixel fine-pointer buttons and at least 44-pixel phone/coarse-pointer
targets. The semantic disclosure supports Enter/Space and expanded state.
### Proposed recovery scroll ownership
The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) implements criterion 006.7.
Place the recovery card inside the existing transcript viewport, before the message content.
Disable outer `PanelBody` scrolling for the mounted chat.
Preserve the fixed composer and existing dynamic viewport and safe-area layout.
Expanded recovery details remain in the transcript scroll area.
Do not add a card scroller, duplicate banner, or permanent fixed recovery panel.
Reveal the active card once per session and failure stamp when the chat becomes visible.
Use the existing transcript scroll controller to coordinate this reveal with initial bottom positioning.
A repeated event with the same stamp must not pull the user away from older messages.
Scope reveal state to session identity and defer hidden-panel scrolling until visibility returns.
Preserve normal bottom-follow behavior when no active recovery card exists.
Task detail and preview share the chat panel. Audit Quick Chat for the same nested-scroll condition.
The dedicated phone Chat surface retains stacked touch actions and one-dimensional navigation.
The recovery card remains the sole explanation and action owner.
## Persistence and compatibility
Use the existing error records and optional fields specified by the
@@ -41,6 +41,11 @@ user intent for destructive replacement, and evidence-based version comparison.
- **AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.3:** Authentication, permission, invalid destination, missing source branch, network, timeout, and unclassified preflight failures shall remain distinct from confirmed history rejection. History rejection shall not be represented as proof of write permission.
- **AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.4:** A task with multiple repositories shall start only when every required preflight passes or qualifies for the history-only resume exception. One qualifying repository shall not hide a blocking failure in another.
- **AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.5:** After resume, the existing Changes surface shall retain its provider/local version choices and their authorization conditions. A history-only rejection shall not create an agent failure or a recovery banner.
- **AC-TASKS-REMOTE-CONTRIBUTION-TASKS-002.6:** Startup contribution checks shall allow up to two minutes across all required repositories, unless an earlier caller deadline or cancellation applies. A check that finishes within this budget shall not fail because of a shorter internal transport timeout. Budget expiry shall block startup and retain recovery actions.
## Proposed preflight timing amendment
This amendment is draft. The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) owns implementation of criterion 002.6.
## Delivery
@@ -64,6 +64,23 @@ that a future push will succeed. Actual pushes keep all existing checks.
Blocking startup failures use the
[task launch failure projection](task-launch-failure-recovery.md).
## Proposed preflight timing amendment
The [startup recovery fix package](../../../plans/startup-recovery-scroll-timeout/plan.md) implements criterion 002.6.
Use one two-minute context for the complete `preflightRemoteContributionPushes` loop.
An earlier caller deadline or cancellation still ends the operation.
`GitPushPreflight` currently uses the ordinary client with a 60-second timeout.
Give this operation a bounded transport path that honors the complete preflight budget.
Reuse the existing transport, authentication, response decoding, and error handling.
Do not mutate the shared HTTP client or increase unrelated Git request timeouts.
Direct preflight callers without a deadline also receive the two-minute bound.
The normal readiness budget already exceeds this preflight budget.
Keep readiness, ACP load, workspace refresh, and Git publication deadlines unchanged.
A delayed permission rejection remains a rejection. Expiry never grants admission.
Use short test budgets and controlled responses instead of CPU saturation or long sleeps.
## Migrated design source
## Why