fix: harden startup recovery timeout and scrolling (#3654)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user