refactor: remove unused Virtuoso code (#2224)
This commit is contained in:
@@ -139,6 +139,8 @@ help:
|
||||
@echo " lint-web Run ESLint"
|
||||
@echo " lint-architecture Enforce architecture budgets and compatibility expiry"
|
||||
@echo " lint-format Check formatting with Prettier (web/cli/packages)"
|
||||
@echo " dead-code-workspaces Find unused TypeScript workspace files, exports, and dependencies"
|
||||
@echo " dead-code-go Find unreachable Go functions (host config; verify other targets before deletion)"
|
||||
@echo " fmt Format all code"
|
||||
@echo " fmt-backend Format Go code"
|
||||
@echo " fmt-web Format web/cli/packages with Prettier, then ESLint --fix (web)"
|
||||
@@ -575,6 +577,16 @@ lint-format:
|
||||
@printf "$(CYAN)Checking formatting...$(RESET)\n"
|
||||
@cd $(APPS_DIR) && $(PNPM) run format:check
|
||||
|
||||
.PHONY: dead-code-workspaces
|
||||
dead-code-workspaces:
|
||||
@printf "$(CYAN)Auditing TypeScript workspace dead code...$(RESET)\n"
|
||||
@cd $(APPS_DIR) && $(PNPM) run dead-code
|
||||
|
||||
.PHONY: dead-code-go
|
||||
dead-code-go:
|
||||
@printf "$(CYAN)Auditing Go dead code...$(RESET)\n"
|
||||
@$(MAKE) -C $(BACKEND_DIR) deadcode
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: fmt-backend fmt-web
|
||||
@printf "\n$(GREEN)$(BOLD)✓ Code formatting complete!$(RESET)\n"
|
||||
|
||||
+16
-1
@@ -40,6 +40,7 @@ ifeq ($(OS)$(MSYSTEM),Windows_NT)
|
||||
# "already exists" failure is tolerated; on Unix the prefix is a no-op.
|
||||
MKDIR = cmd /c mkdir
|
||||
CGO_PREFIX = set CGO_ENABLED=1&
|
||||
DEADCODE_ENV = set GOTOOLCHAIN=$(DEADCODE_GO_TOOLCHAIN)&
|
||||
# Cross-compile prefixes for agentctl helpers bundled with the host build.
|
||||
# cmd has no Unix env-var-prefix syntax; `set X=Y&` chains `set` and the next
|
||||
# command in the same shell.
|
||||
@@ -69,6 +70,7 @@ else
|
||||
RMDIR = rm -rf
|
||||
MKDIR = mkdir -p
|
||||
CGO_PREFIX = CGO_ENABLED=1
|
||||
DEADCODE_ENV = GOTOOLCHAIN=$(DEADCODE_GO_TOOLCHAIN)
|
||||
GO_LINUX_AMD64 = CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||
GO_LINUX_ARM64 = CGO_ENABLED=0 GOOS=linux GOARCH=arm64
|
||||
GO_DARWIN_ARM64 = CGO_ENABLED=0 GOOS=darwin GOARCH=arm64
|
||||
@@ -126,7 +128,12 @@ COMMIT := $(shell git rev-parse --short HEAD $(NULL_REDIR) || echo unknown)
|
||||
|
||||
LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME)
|
||||
|
||||
.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint fmt vet check-make-shells help
|
||||
# Force the application module's Go version; go run otherwise selects the
|
||||
# analyzer module's minimum toolchain before go/packages loads this module.
|
||||
DEADCODE_VERSION ?= v0.48.0
|
||||
DEADCODE_GO_TOOLCHAIN ?= go1.26.0
|
||||
|
||||
.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help
|
||||
|
||||
## Default target
|
||||
all: build
|
||||
@@ -341,6 +348,14 @@ lint:
|
||||
@echo "Running linter..."
|
||||
golangci-lint run ./...
|
||||
|
||||
## Report unreachable functions in the host production Go program.
|
||||
# deadcode results are specific to one GOOS/GOARCH/build-tag configuration.
|
||||
# This target intentionally audits the host configuration only; verify every
|
||||
# supported target that could reach a finding before removing reported code.
|
||||
deadcode:
|
||||
@echo "Scanning for unreachable Go functions (host configuration only; verify other targets before deleting findings; toolchain=$(DEADCODE_GO_TOOLCHAIN), override with DEADCODE_GO_TOOLCHAIN=go1.XY.Z)..."
|
||||
$(DEADCODE_ENV) $(GO) run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags fts5 ./...
|
||||
|
||||
## Format code
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
|
||||
@@ -377,10 +377,10 @@ func TestNormalizeShellToolResultStripsLeadingCommandEcho(t *testing.T) {
|
||||
func TestNormalizeShellToolResultStripsLeadingCommandEchoWithWorkDirResolvedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
command := `grep -n "^const STRATEGY\|STRATEGY =" apps/web/components/task/chat/message-list.tsx`
|
||||
command := "grep marker fixtures/example.txt"
|
||||
workDir := "/home/clem/.kandev/tasks/new-message-line_0vc/kdlbs-kandev"
|
||||
absolutePath := workDir + "/apps/web/components/task/chat/message-list.tsx"
|
||||
resolvedCommand := strings.Replace(command, "apps/web/components/task/chat/message-list.tsx", absolutePath, 1)
|
||||
absolutePath := workDir + "/fixtures/example.txt"
|
||||
resolvedCommand := strings.Replace(command, "fixtures/example.txt", absolutePath, 1)
|
||||
|
||||
normalizer := NewNormalizer("")
|
||||
payload := normalizer.NormalizeToolCall("execute", map[string]any{
|
||||
@@ -388,9 +388,9 @@ func TestNormalizeShellToolResultStripsLeadingCommandEchoWithWorkDirResolvedPath
|
||||
"raw_input": map[string]any{"command": command, "cwd": workDir},
|
||||
})
|
||||
|
||||
normalizer.NormalizeToolResult(payload, "$ "+resolvedCommand+"\n24:const STRATEGY = \"native\";\n")
|
||||
normalizer.NormalizeToolResult(payload, "$ "+resolvedCommand+"\n1:marker\n")
|
||||
|
||||
require.Equal(t, "24:const STRATEGY = \"native\";\n", payload.ShellExec().Output.Stdout)
|
||||
require.Equal(t, "1:marker\n", payload.ShellExec().Output.Stdout)
|
||||
}
|
||||
|
||||
// TestNormalizeShellToolUpdateStripsLeadingCommandEchoWithWorkDirResolvedPath
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"workspaces": {
|
||||
"web": {
|
||||
"playwright": {
|
||||
"config": ["e2e/playwright.config.ts"],
|
||||
"entry": ["e2e/**/*.@(spec|test).?(c|m)[jt]s?(x)", "e2e/global-setup.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"build": "pnpm -r run build",
|
||||
"build:web": "pnpm --filter @kandev/web build",
|
||||
"lint": "pnpm -r run lint",
|
||||
"dead-code": "knip --include files,exports,dependencies --no-progress",
|
||||
"format": "prettier --write web cli packages --ignore-unknown",
|
||||
"format:check": "prettier --check web cli packages --ignore-unknown",
|
||||
"clean": "pnpm -r run clean && rm -rf node_modules"
|
||||
@@ -20,6 +21,7 @@
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@types/node": "^20",
|
||||
"knip": "6.31.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5"
|
||||
},
|
||||
|
||||
Generated
+565
-67
File diff suppressed because it is too large
Load Diff
@@ -471,7 +471,7 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) {
|
||||
|
||||
// Track status/expand transitions over the panel's lifetime — the
|
||||
// remote-executor bug suspects the panel staying expanded (tall) while the
|
||||
// env is still "preparing" combined with a Virtuoso initial-scroll race.
|
||||
// env is still "preparing" combined with an initial-scroll race.
|
||||
const prevSnapshotRef = useRef<PrepareSnapshot | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isDebug()) return;
|
||||
|
||||
@@ -22,8 +22,8 @@ type Props = {
|
||||
* arrive. Enabled by default. Disabling freezes the current scroll position
|
||||
* (preserved across navigating away and back); re-enabling resumes
|
||||
* auto-scroll and catches the view up if the transcript progressed while
|
||||
* disabled. See message-list-native.tsx / message-list-virtuoso.tsx for the
|
||||
* renderer-side behavior driven by this preference.
|
||||
* disabled. See message-list-native.tsx for the renderer-side behavior driven
|
||||
* by this preference.
|
||||
*
|
||||
* The icon renders dark green while enabled and reverts to the default
|
||||
* muted color when disabled — the color swap alone carries the on/off
|
||||
|
||||
@@ -154,15 +154,24 @@ const PROGRAMMATIC_SCROLL_GUARD_MS = 1000;
|
||||
* streaming in mid-animation can silently snap the transcript back to the
|
||||
* bottom, cancelling the user's action.
|
||||
*/
|
||||
function useAutoScroll(params: {
|
||||
export function useAutoScroll(params: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messages: Message[];
|
||||
isWorking: boolean;
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
isProgrammaticScrollLocked: () => boolean;
|
||||
}) {
|
||||
const { scrollRef, messages, isWorking, sessionId, enabled, isProgrammaticScrollLocked } = params;
|
||||
const {
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked,
|
||||
} = params;
|
||||
const storeApi = useAppStoreApi();
|
||||
const isNearBottomRef = useRef(true);
|
||||
const prevIsWorkingRef = useRef(isWorking);
|
||||
@@ -172,6 +181,9 @@ function useAutoScroll(params: {
|
||||
if (!el) return;
|
||||
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
||||
}, [scrollRef]);
|
||||
const markNotNearBottom = useCallback(() => {
|
||||
isNearBottomRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -205,7 +217,7 @@ function useAutoScroll(params: {
|
||||
isWorking &&
|
||||
!prevIsWorkingRef.current &&
|
||||
shouldAutoScrollToBottom({
|
||||
isNearBottom: true,
|
||||
isNearBottom: !hasUnreadDivider,
|
||||
isProgrammaticScrollLocked: isProgrammaticScrollLocked(),
|
||||
hasPendingLayoutRestore: useDockviewStore.getState().pendingChatScrollTop !== null,
|
||||
}) &&
|
||||
@@ -218,7 +230,7 @@ function useAutoScroll(params: {
|
||||
}
|
||||
}
|
||||
prevIsWorkingRef.current = isWorking;
|
||||
}, [isWorking, scrollRef, enabled, isProgrammaticScrollLocked]);
|
||||
}, [hasUnreadDivider, isWorking, scrollRef, enabled, isProgrammaticScrollLocked]);
|
||||
|
||||
// Auto-scroll on new messages if near bottom (unless disabled, locked, or a
|
||||
// layout rebuild scroll restore is pending).
|
||||
@@ -239,7 +251,7 @@ function useAutoScroll(params: {
|
||||
|
||||
useCatchUpOnReEnable(scrollRef, messages, enabled, isNearBottomRef);
|
||||
|
||||
return { isNearBottomRef, resyncIsNearBottom };
|
||||
return { isNearBottomRef, resyncIsNearBottom, markNotNearBottom };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,6 +489,7 @@ export function useNativeScrollManagement(params: {
|
||||
isWorking: boolean;
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMore: () => Promise<number>;
|
||||
@@ -488,18 +501,20 @@ export function useNativeScrollManagement(params: {
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
} = params;
|
||||
const programmaticScrollLockRef = useRef(false);
|
||||
const isProgrammaticScrollLocked = useCallback(() => programmaticScrollLockRef.current, []);
|
||||
const { isNearBottomRef, resyncIsNearBottom } = useAutoScroll({
|
||||
const { isNearBottomRef, resyncIsNearBottom, markNotNearBottom } = useAutoScroll({
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked,
|
||||
});
|
||||
const runGuardedScroll = useProgrammaticScrollGuard(
|
||||
@@ -512,5 +527,5 @@ export function useNativeScrollManagement(params: {
|
||||
const sentinelRef = useLazyLoadSentinel(scrollRef, hasMore, isLoadingMore, loadMore);
|
||||
useInitialScrollPosition(scrollRef, items.length, sessionId, enabled, isNearBottomRef);
|
||||
|
||||
return { handleScrollToMessage, sentinelRef };
|
||||
return { handleScrollToMessage, sentinelRef, resyncIsNearBottom, markNotNearBottom };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
vi.mock("@/lib/state/dockview-store", () => ({
|
||||
useDockviewStore: Object.assign(
|
||||
@@ -10,21 +11,38 @@ vi.mock("@/lib/state/dockview-store", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStoreApi: () => ({
|
||||
getState: () => ({ setTranscriptScrollTop: vi.fn() }),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useScrollToDividerOrBottom } from "./message-list-native";
|
||||
import { useAutoScroll } from "./message-list-native-scroll";
|
||||
|
||||
const DIVIDER_KEY = "m2";
|
||||
const TEST_MESSAGES = [{} as Message];
|
||||
const NEVER_LOCKED = () => false;
|
||||
|
||||
function Harness({
|
||||
itemCount,
|
||||
anchoredBarOffsetPx,
|
||||
dividerKey = DIVIDER_KEY,
|
||||
onDividerScroll,
|
||||
}: {
|
||||
itemCount: number;
|
||||
anchoredBarOffsetPx: number;
|
||||
dividerKey?: string | null;
|
||||
onDividerScroll?: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx);
|
||||
useScrollToDividerOrBottom(
|
||||
scrollRef,
|
||||
itemCount,
|
||||
dividerKey,
|
||||
anchoredBarOffsetPx,
|
||||
onDividerScroll,
|
||||
);
|
||||
return (
|
||||
<div ref={scrollRef}>
|
||||
<div id="msg-m1" />
|
||||
@@ -33,6 +51,36 @@ function Harness({
|
||||
);
|
||||
}
|
||||
|
||||
function AutoScrollHarness({
|
||||
isWorking,
|
||||
hasUnreadDivider,
|
||||
messages = TEST_MESSAGES,
|
||||
markRef,
|
||||
}: {
|
||||
isWorking: boolean;
|
||||
hasUnreadDivider: boolean;
|
||||
messages?: Message[];
|
||||
markRef?: { current?: () => void };
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { markNotNearBottom } = useAutoScroll({
|
||||
scrollRef,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId: null,
|
||||
enabled: true,
|
||||
hasUnreadDivider,
|
||||
isProgrammaticScrollLocked: NEVER_LOCKED,
|
||||
});
|
||||
if (markRef) markRef.current = markNotNearBottom;
|
||||
return <div ref={scrollRef} data-testid="auto-scroll-container" />;
|
||||
}
|
||||
|
||||
function setScrollMetrics(element: HTMLElement) {
|
||||
Object.defineProperty(element, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(element, "clientHeight", { configurable: true, value: 400 });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -55,6 +103,54 @@ describe("useScrollToDividerOrBottom — anchored-bar offset", () => {
|
||||
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: "start" });
|
||||
});
|
||||
|
||||
it("resynchronizes auto-scroll state after placing the divider", () => {
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
const onDividerScroll = vi.fn();
|
||||
|
||||
render(<Harness itemCount={2} anchoredBarOffsetPx={0} onDividerScroll={onDividerScroll} />);
|
||||
|
||||
expect(onDividerScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not follow the bottom when work starts with an unread divider", () => {
|
||||
const { rerender } = render(<AutoScrollHarness isWorking={false} hasUnreadDivider={true} />);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="auto-scroll-container"]',
|
||||
);
|
||||
if (!scrollContainer) throw new Error("auto-scroll container did not render");
|
||||
setScrollMetrics(scrollContainer);
|
||||
scrollContainer.scrollTop = 123;
|
||||
|
||||
rerender(<AutoScrollHarness isWorking={true} hasUnreadDivider={true} />);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(123);
|
||||
});
|
||||
|
||||
it("does not follow appended messages after the divider scroll marks the reader away from bottom", () => {
|
||||
const markRef: { current?: () => void } = {};
|
||||
const { rerender } = render(
|
||||
<AutoScrollHarness isWorking={false} hasUnreadDivider={true} markRef={markRef} />,
|
||||
);
|
||||
const scrollContainer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="auto-scroll-container"]',
|
||||
);
|
||||
if (!scrollContainer) throw new Error("auto-scroll container did not render");
|
||||
setScrollMetrics(scrollContainer);
|
||||
scrollContainer.scrollTop = 123;
|
||||
markRef.current?.();
|
||||
|
||||
rerender(
|
||||
<AutoScrollHarness
|
||||
isWorking={false}
|
||||
hasUnreadDivider={true}
|
||||
messages={[...TEST_MESSAGES, {} as Message]}
|
||||
markRef={markRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(123);
|
||||
});
|
||||
|
||||
it("never re-scrolls once the reader has started scrolling, even if the anchored bar's height changes afterward", () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
@@ -72,6 +72,80 @@ function useTranscriptEdgeTracking(
|
||||
]);
|
||||
}
|
||||
|
||||
type NativeMessageListScrollParams = {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
ref: React.ForwardedRef<MessageListHandle>;
|
||||
items: RenderItem[];
|
||||
messages: Message[];
|
||||
isWorking: boolean;
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
dividerBeforeItemKey?: string | null;
|
||||
anchoredBarHeight?: number;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMore: () => Promise<number>;
|
||||
lastPromptMessageId: string | null | undefined;
|
||||
onLastPromptEdgeChange: ((edge: LastPromptEdge) => void) | undefined;
|
||||
firstMessageId: string | null | undefined;
|
||||
onFirstMessageHiddenChange: ((isHidden: boolean) => void) | undefined;
|
||||
};
|
||||
|
||||
function useNativeMessageListScroll(params: NativeMessageListScrollParams) {
|
||||
const {
|
||||
scrollRef,
|
||||
ref,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarHeight,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
} = params;
|
||||
const { handleScrollToMessage, sentinelRef, markNotNearBottom } = useNativeScrollManagement({
|
||||
scrollRef,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled,
|
||||
hasUnreadDivider: Boolean(dividerBeforeItemKey),
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
});
|
||||
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`);
|
||||
}, [anchoredBarOffsetPx]);
|
||||
useScrollToDividerOrBottom(
|
||||
scrollRef,
|
||||
items.length,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarOffsetPx,
|
||||
markNotNearBottom,
|
||||
);
|
||||
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
||||
handleScrollToMessage,
|
||||
]);
|
||||
useTranscriptEdgeTracking(
|
||||
scrollRef,
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
);
|
||||
return { handleScrollToMessage, sentinelRef };
|
||||
}
|
||||
|
||||
type MessageRowProps = {
|
||||
item: RenderItem;
|
||||
sessionId: string | null;
|
||||
@@ -194,6 +268,7 @@ export function useScrollToDividerOrBottom(
|
||||
itemCount: number,
|
||||
dividerBeforeItemKey: string | null | undefined,
|
||||
anchoredBarOffsetPx: number,
|
||||
onDividerScroll?: () => void,
|
||||
) {
|
||||
const isUserScrollingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -239,6 +314,7 @@ export function useScrollToDividerOrBottom(
|
||||
const dividerEl = el.querySelector<HTMLElement>(`[id="msg-${dividerBeforeItemKey}"]`);
|
||||
if (dividerEl) {
|
||||
dividerEl.scrollIntoView({ block: "start" });
|
||||
onDividerScroll?.();
|
||||
didScrollToDivider.current = true;
|
||||
didInitialScroll.current = true;
|
||||
return;
|
||||
@@ -254,7 +330,7 @@ export function useScrollToDividerOrBottom(
|
||||
}
|
||||
el.scrollTop = el.scrollHeight;
|
||||
didInitialScroll.current = true;
|
||||
}, [itemCount, dividerBeforeItemKey, anchoredBarOffsetPx]);
|
||||
}, [itemCount, dividerBeforeItemKey, anchoredBarOffsetPx, onDividerScroll]);
|
||||
}
|
||||
|
||||
/** Sentinel, status/footer, and transcript rows — everything below the
|
||||
@@ -381,32 +457,24 @@ export const NativeMessageList = memo(
|
||||
const streamingMessageId = getStreamingAgentMessageId(messages);
|
||||
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
|
||||
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
|
||||
const { handleScrollToMessage, sentinelRef } = useNativeScrollManagement({
|
||||
const { handleScrollToMessage, sentinelRef } = useNativeMessageListScroll({
|
||||
scrollRef,
|
||||
ref,
|
||||
items,
|
||||
messages,
|
||||
isWorking,
|
||||
sessionId,
|
||||
enabled: autoScrollEnabled,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarHeight,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
});
|
||||
const anchoredBarOffsetPx = anchoredBarScrollOffsetPx(anchoredBarHeight);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.style.setProperty("--anchored-bar-h", `${anchoredBarOffsetPx}px`);
|
||||
}, [anchoredBarOffsetPx]);
|
||||
useScrollToDividerOrBottom(scrollRef, items.length, dividerBeforeItemKey, anchoredBarOffsetPx);
|
||||
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
||||
handleScrollToMessage,
|
||||
]);
|
||||
useTranscriptEdgeTracking(
|
||||
scrollRef,
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<SessionPanelContent
|
||||
|
||||
@@ -60,8 +60,8 @@ export type MessageListProps = {
|
||||
anchoredBarHeight?: number;
|
||||
};
|
||||
|
||||
/** Imperative handle exposed by `MessageList` and both rendering strategies,
|
||||
* letting the chat panel scroll to an arbitrary message (e.g. the last
|
||||
/** Imperative handle exposed by `MessageList`, letting the chat panel scroll
|
||||
* to an arbitrary message (e.g. the last
|
||||
* prompt) from outside the list — from the composer's scroll-up button. */
|
||||
export type MessageListHandle = {
|
||||
scrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
|
||||
@@ -191,8 +191,7 @@ export function isElementFullyVisible(container: HTMLElement, target: HTMLElemen
|
||||
* last-prompt bar's pinned overlay, so a scroll-into-view target (namely
|
||||
* the unread "New" divider) lands below it instead of underneath it.
|
||||
* Clamps an unmeasured/negative height to zero and rounds to whole pixels
|
||||
* — callers feed this straight into a CSS `scroll-margin-top` or a
|
||||
* Virtuoso `scrollToIndex` offset. */
|
||||
* — callers feed this straight into a CSS `scroll-margin-top`. */
|
||||
export function anchoredBarScrollOffsetPx(anchoredBarHeight: number | undefined): number {
|
||||
return Math.max(0, Math.round(anchoredBarHeight ?? 0));
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { VirtuosoHandle, StateSnapshot } from "react-virtuoso";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import {
|
||||
shouldCatchUpOnAutoScrollEnable,
|
||||
hasTranscriptAppendedSinceBaseline,
|
||||
} from "./transcript-auto-scroll";
|
||||
|
||||
/**
|
||||
* Owns the atBottom tracking, snapshot capture, and append-identity baseline
|
||||
* used to decide whether re-enabling auto-scroll should catch the view up to
|
||||
* the bottom — see message-list-native.tsx's `useAutoScroll`/
|
||||
* `useCatchUpOnReEnable` for the equivalent native-renderer logic.
|
||||
* `followOutput` itself is resolved separately by `useGuardedFollowOutput`
|
||||
* (see `useVirtuosoCallbacks`) so it also honors the programmatic-scroll
|
||||
* lock; this hook only feeds it the atBottom signal via
|
||||
* `handleAtBottomStateChange`.
|
||||
*/
|
||||
export function useVirtuosoAutoScrollLifecycle(params: {
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
messages: Message[];
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
|
||||
firstItemIndex: number;
|
||||
itemCount: number;
|
||||
}) {
|
||||
const { sessionId, enabled, messages, virtuosoRef, firstItemIndex, itemCount } = params;
|
||||
const storeApi = useAppStoreApi();
|
||||
|
||||
const isAtBottomRef = useRef(true);
|
||||
const handleAtBottomStateChange = useCallback((isAtBottom: boolean) => {
|
||||
isAtBottomRef.current = isAtBottom;
|
||||
}, []);
|
||||
|
||||
const captureSnapshot = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
virtuosoRef.current?.getState((state) => {
|
||||
storeApi.getState().setTranscriptVirtuosoState(sessionId, state);
|
||||
});
|
||||
}, [sessionId, storeApi, virtuosoRef]);
|
||||
|
||||
// Capture a restorable snapshot on unmount, so a remounted Virtuoso
|
||||
// instance (e.g. navigating away and back while disabled) can pick up
|
||||
// exactly where the user left off.
|
||||
useEffect(() => captureSnapshot, [captureSnapshot]);
|
||||
|
||||
const baselineRef = useRef<{
|
||||
count: number;
|
||||
lastId: string | null;
|
||||
lastUpdatedAt: string | undefined;
|
||||
} | null>(null);
|
||||
const hasInitializedBaselineRef = useRef(false);
|
||||
const prevEnabledRef = useRef(enabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = prevEnabledRef.current;
|
||||
prevEnabledRef.current = enabled;
|
||||
const captureBaseline = () => {
|
||||
const last = messages[messages.length - 1];
|
||||
baselineRef.current = {
|
||||
count: messages.length,
|
||||
lastId: last?.id ?? null,
|
||||
lastUpdatedAt: last?.updated_at,
|
||||
};
|
||||
};
|
||||
|
||||
// First run: if the panel mounted already disabled, there was no
|
||||
// in-process disable transition to capture a baseline from —
|
||||
// establish one now from the transcript at mount time.
|
||||
if (!hasInitializedBaselineRef.current) {
|
||||
hasInitializedBaselineRef.current = true;
|
||||
if (!enabled) captureBaseline();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasEnabled === enabled) return;
|
||||
if (!enabled) {
|
||||
// Disabling: snapshot immediately, in addition to the unmount capture
|
||||
// above, so a remount that happens without an intervening re-render
|
||||
// still restores this exact position. Also capture the append-identity
|
||||
// baseline used to detect real progression once re-enabled.
|
||||
captureSnapshot();
|
||||
captureBaseline();
|
||||
return;
|
||||
}
|
||||
// Re-enabling: catch up to the bottom only if the transcript genuinely
|
||||
// appended content while disabled and it isn't already in view.
|
||||
const baseline = baselineRef.current;
|
||||
baselineRef.current = null;
|
||||
if (!baseline) return;
|
||||
const lastNow = messages[messages.length - 1];
|
||||
const appendedSinceDisable = hasTranscriptAppendedSinceBaseline({
|
||||
baselineCount: baseline.count,
|
||||
currentCount: messages.length,
|
||||
baselineLastId: baseline.lastId,
|
||||
currentLastId: lastNow?.id ?? null,
|
||||
baselineLastUpdatedAt: baseline.lastUpdatedAt,
|
||||
currentLastUpdatedAt: lastNow?.updated_at,
|
||||
});
|
||||
if (
|
||||
shouldCatchUpOnAutoScrollEnable({
|
||||
wasEnabled,
|
||||
nowEnabled: enabled,
|
||||
appendedSinceDisable,
|
||||
isAtBottom: isAtBottomRef.current,
|
||||
})
|
||||
) {
|
||||
virtuosoRef.current?.scrollToIndex({ index: firstItemIndex + itemCount - 1, align: "end" });
|
||||
}
|
||||
}, [enabled, captureSnapshot, firstItemIndex, itemCount, virtuosoRef, messages]);
|
||||
|
||||
// Restore the saved position on first mount when disabled. Lazy-initialized
|
||||
// so it's read once at mount time, not on every render.
|
||||
const [restoreStateFrom] = useState<StateSnapshot | undefined>(() => {
|
||||
if (enabled || !sessionId) return undefined;
|
||||
return storeApi.getState().transcriptAutoScroll.virtuosoStateBySessionId[sessionId];
|
||||
});
|
||||
|
||||
return { handleAtBottomStateChange, restoreStateFrom };
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import {
|
||||
findMessageItemIndex,
|
||||
resolveVirtuosoEdgeState,
|
||||
type RangePosition,
|
||||
} from "./message-list-virtuoso-edges";
|
||||
|
||||
function elementWithRect(top: number, bottom: number): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.getBoundingClientRect = () => ({ top, bottom }) as DOMRect;
|
||||
return el;
|
||||
}
|
||||
|
||||
function message(id: string): Message {
|
||||
return { id } as Message;
|
||||
}
|
||||
|
||||
describe("findMessageItemIndex", () => {
|
||||
it("returns -1 for a null or undefined id", () => {
|
||||
const items: RenderItem[] = [{ type: "message", message: message("m1") }];
|
||||
|
||||
expect(findMessageItemIndex(items, null)).toBe(-1);
|
||||
expect(findMessageItemIndex(items, undefined)).toBe(-1);
|
||||
});
|
||||
|
||||
it("finds a plain message item by id", () => {
|
||||
const items: RenderItem[] = [
|
||||
{ type: "message", message: message("m1") },
|
||||
{ type: "message", message: message("m2") },
|
||||
];
|
||||
|
||||
expect(findMessageItemIndex(items, "m2")).toBe(1);
|
||||
});
|
||||
|
||||
it("finds a message nested inside a turn_group item", () => {
|
||||
const items: RenderItem[] = [
|
||||
{ type: "message", message: message("m1") },
|
||||
{
|
||||
type: "turn_group",
|
||||
id: "group-1",
|
||||
turnId: "turn-1",
|
||||
messages: [message("m2"), message("m3")],
|
||||
},
|
||||
];
|
||||
|
||||
expect(findMessageItemIndex(items, "m3")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when the id doesn't match any item", () => {
|
||||
const items: RenderItem[] = [
|
||||
{ type: "message", message: message("m1") },
|
||||
{ type: "turn_group", id: "group-1", turnId: "turn-1", messages: [message("m2")] },
|
||||
];
|
||||
|
||||
expect(findMessageItemIndex(items, "unknown")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVirtuosoEdgeState", () => {
|
||||
// Codex review #3668343109: the "scroll to start" control stayed hidden
|
||||
// while the first prompt was still mounted but only partially clipped,
|
||||
// because the old logic only compared item indices — which don't move
|
||||
// until the row fully leaves Virtuoso's rendered range. The mounted branch
|
||||
// must read live geometry instead of trusting the index comparison.
|
||||
it("uses live geometry when the row is mounted, ignoring the index comparison", () => {
|
||||
const container = elementWithRect(0, 500);
|
||||
const partiallyClippedRow = elementWithRect(-10, 40);
|
||||
const geometryCheck = vi.fn(() => true);
|
||||
const fromRangePosition = vi.fn(() => false);
|
||||
|
||||
// Index comparison alone would say "not past" (itemIndex === renderedStart),
|
||||
// but the mounted row's geometry says it's already clipped.
|
||||
const result = resolveVirtuosoEdgeState(
|
||||
partiallyClippedRow,
|
||||
container,
|
||||
5,
|
||||
{ start: 5, end: 5 },
|
||||
{ geometryCheck, fromRangePosition },
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(geometryCheck).toHaveBeenCalledWith(container, partiallyClippedRow);
|
||||
expect(fromRangePosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the index/range comparison once the row is unmounted", () => {
|
||||
const container = elementWithRect(0, 500);
|
||||
const geometryCheck = vi.fn(() => false);
|
||||
const fromRangePosition = (position: RangePosition) => position !== "within";
|
||||
const resolvers = { geometryCheck, fromRangePosition };
|
||||
|
||||
expect(resolveVirtuosoEdgeState(null, container, 3, { start: 5, end: 5 }, resolvers)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolveVirtuosoEdgeState(null, container, 5, { start: 5, end: 5 }, resolvers)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveVirtuosoEdgeState(null, container, -1, { start: 5, end: 5 }, resolvers)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(geometryCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a tracked row after the rendered range as out of view", () => {
|
||||
const container = elementWithRect(0, 500);
|
||||
const geometryCheck = vi.fn(() => false);
|
||||
const fromRangePosition = (position: RangePosition) => position !== "within";
|
||||
expect(
|
||||
resolveVirtuosoEdgeState(
|
||||
null,
|
||||
container,
|
||||
8,
|
||||
{ start: 5, end: 7 },
|
||||
{
|
||||
geometryCheck,
|
||||
fromRangePosition,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(geometryCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVirtuosoEdgeState tri-state mapping", () => {
|
||||
function edgeFromPosition(position: RangePosition): "above" | "below" | "visible" {
|
||||
if (position === "before") return "above";
|
||||
if (position === "after") return "below";
|
||||
return "visible";
|
||||
}
|
||||
|
||||
it("maps unmounted range positions to caller-provided values, distinguishing before from after", () => {
|
||||
const container = elementWithRect(0, 500);
|
||||
const geometryCheck = vi.fn(() => "visible" as const);
|
||||
const resolvers = { geometryCheck, fromRangePosition: edgeFromPosition };
|
||||
|
||||
// Before the rendered range: scrolled past it going down -> "above".
|
||||
expect(resolveVirtuosoEdgeState(null, container, 2, { start: 5, end: 7 }, resolvers)).toBe(
|
||||
"above",
|
||||
);
|
||||
// After the rendered range: not yet reached -> "below".
|
||||
expect(resolveVirtuosoEdgeState(null, container, 9, { start: 5, end: 7 }, resolvers)).toBe(
|
||||
"below",
|
||||
);
|
||||
// Within the rendered range but unmounted (e.g. unknown id): "visible".
|
||||
expect(resolveVirtuosoEdgeState(null, container, -1, { start: 5, end: 7 }, resolvers)).toBe(
|
||||
"visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import type { LastPromptEdge } from "./message-list-shared";
|
||||
|
||||
type RenderedRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
/** Index of the item containing `messageId` (a plain message or a turn-group
|
||||
* wrapping it), or -1 when absent/unknown. */
|
||||
export function findMessageItemIndex(
|
||||
items: RenderItem[],
|
||||
messageId: string | null | undefined,
|
||||
): number {
|
||||
if (!messageId) return -1;
|
||||
return items.findIndex((item) => {
|
||||
if (item.type === "turn_group") return item.messages.some((m) => m.id === messageId);
|
||||
if (item.type === "message") return item.message.id === messageId;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an item sits relative to Virtuoso's currently rendered range once
|
||||
* its row is unmounted: `before` it (scrolled past, further down the
|
||||
* transcript), `after` it (not yet reached, e.g. still browsing earlier
|
||||
* history), or `within` it (still rendered, or unknown). */
|
||||
export type RangePosition = "before" | "within" | "after";
|
||||
|
||||
function resolveRangePosition(itemIndex: number, renderedRange: RenderedRange): RangePosition {
|
||||
if (itemIndex < 0) return "within";
|
||||
if (itemIndex < renderedRange.start) return "before";
|
||||
if (itemIndex > renderedRange.end) return "after";
|
||||
return "within";
|
||||
}
|
||||
|
||||
/** Resolves a tracked transcript edge's state. While Virtuoso still has the
|
||||
* marked row mounted, `resolvers.geometryCheck` reads its live DOM position
|
||||
* — the only way to distinguish a partially clipped row from one with no
|
||||
* visible intersection. Once Virtuoso unmounts the row,
|
||||
* `resolvers.fromRangePosition` maps its item-index position relative to
|
||||
* the rendered range to the same result type as `geometryCheck`. */
|
||||
export function resolveVirtuosoEdgeState<T>(
|
||||
row: HTMLElement | null,
|
||||
container: HTMLElement,
|
||||
itemIndex: number,
|
||||
renderedRange: RenderedRange,
|
||||
resolvers: {
|
||||
geometryCheck: (container: HTMLElement, row: HTMLElement) => T;
|
||||
fromRangePosition: (position: RangePosition) => T;
|
||||
},
|
||||
): T {
|
||||
if (row) return resolvers.geometryCheck(container, row);
|
||||
return resolvers.fromRangePosition(resolveRangePosition(itemIndex, renderedRange));
|
||||
}
|
||||
|
||||
/** Maps an unmounted last-prompt row's position relative to Virtuoso's
|
||||
* rendered range to the same edge state native geometry would report. */
|
||||
export function rangePositionToLastPromptEdge(position: RangePosition): LastPromptEdge {
|
||||
if (position === "before") return "above";
|
||||
if (position === "after") return "below";
|
||||
return "visible";
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
import { getItemKey } from "./message-list-shared";
|
||||
|
||||
const FIRST_INDEX_BASE = 100_000;
|
||||
|
||||
const debugFirstIndex = createDebugLogger("chat:virtuoso:firstIndex");
|
||||
|
||||
function computeFirstItemIndex(prevKeys: string[], prevIndex: number, keys: string[]): number {
|
||||
if (prevKeys.length > 0 && keys.length > prevKeys.length) {
|
||||
const oldFirstKey = prevKeys[0];
|
||||
const newPos = keys.indexOf(oldFirstKey);
|
||||
if (newPos > 0) return prevIndex - newPos;
|
||||
if (newPos === -1) {
|
||||
for (let i = 0; i < prevKeys.length; i++) {
|
||||
const idx = keys.indexOf(prevKeys[i]);
|
||||
if (idx >= 0) return prevIndex - (idx - i);
|
||||
}
|
||||
}
|
||||
return prevIndex;
|
||||
}
|
||||
if (prevKeys.length === 0 && keys.length > 0) {
|
||||
return FIRST_INDEX_BASE - keys.length + 1;
|
||||
}
|
||||
return prevIndex;
|
||||
}
|
||||
|
||||
type IndexState = { keys: string[]; firstItemIndex: number };
|
||||
|
||||
/** Virtuoso's `firstItemIndex` must only ever grow smaller (never reset) or
|
||||
* its scroll-anchored windowing jumps. Tracks a stable index across item-key
|
||||
* churn: preserves position when items prepend or reorder rather than
|
||||
* resetting to `FIRST_INDEX_BASE` on every render. */
|
||||
export function useStableFirstItemIndex(items: RenderItem[]) {
|
||||
const keys = useMemo(() => items.map(getItemKey), [items]);
|
||||
|
||||
const [state, setState] = useState<IndexState>(() => {
|
||||
const firstItemIndex = FIRST_INDEX_BASE - keys.length + 1;
|
||||
if (isDebug()) {
|
||||
debugFirstIndex("init", {
|
||||
keyCount: keys.length,
|
||||
firstItemIndex,
|
||||
firstKey: keys[0] ?? "-",
|
||||
lastKey: keys[keys.length - 1] ?? "-",
|
||||
});
|
||||
}
|
||||
return { keys, firstItemIndex };
|
||||
});
|
||||
|
||||
if (keys !== state.keys) {
|
||||
const nextIndex = computeFirstItemIndex(state.keys, state.firstItemIndex, keys);
|
||||
if (isDebug()) {
|
||||
debugFirstIndex("transition", {
|
||||
prevKeyCount: state.keys.length,
|
||||
nextKeyCount: keys.length,
|
||||
prevIndex: state.firstItemIndex,
|
||||
nextIndex,
|
||||
delta: nextIndex - state.firstItemIndex,
|
||||
prevFirstKey: state.keys[0] ?? "-",
|
||||
nextFirstKey: keys[0] ?? "-",
|
||||
prevLastKey: state.keys[state.keys.length - 1] ?? "-",
|
||||
nextLastKey: keys[keys.length - 1] ?? "-",
|
||||
});
|
||||
}
|
||||
setState({ keys, firstItemIndex: nextIndex });
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
return state.firstItemIndex;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useGuardedFollowOutput } from "./message-list-virtuoso-scroll-lock";
|
||||
|
||||
describe("useGuardedFollowOutput", () => {
|
||||
it("follows smoothly at the bottom when enabled and unlocked", () => {
|
||||
const { result } = renderHook(() => useGuardedFollowOutput(() => false, true));
|
||||
expect(result.current(true)).toBe("smooth");
|
||||
});
|
||||
|
||||
it("does not follow when away from the bottom", () => {
|
||||
const { result } = renderHook(() => useGuardedFollowOutput(() => false, true));
|
||||
expect(result.current(false)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow while a programmatic scroll is locked, even at the bottom", () => {
|
||||
const { result } = renderHook(() => useGuardedFollowOutput(() => true, true));
|
||||
expect(result.current(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow while auto-scroll is disabled, even at the bottom and unlocked", () => {
|
||||
const { result } = renderHook(() => useGuardedFollowOutput(() => false, false));
|
||||
expect(result.current(true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow when both disabled and locked", () => {
|
||||
const { result } = renderHook(() => useGuardedFollowOutput(() => true, false));
|
||||
expect(result.current(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { resolveFollowOutput } from "./transcript-auto-scroll";
|
||||
|
||||
export const FOLLOW_SMOOTH = "smooth" as const;
|
||||
|
||||
/** Duration a programmatic scroll's lock stays held if the browser never
|
||||
* reports `scrollend` on the scroll parent — bounded so a missed event can't
|
||||
* wedge follow-bottom off indefinitely. */
|
||||
const PROGRAMMATIC_SCROLL_LOCK_MS = 1000;
|
||||
|
||||
/** Guards a user-initiated `scrollToIndex` (scroll-to-start / scroll-to-last-
|
||||
* prompt) against Virtuoso's own `followOutput` follow-bottom behavior firing
|
||||
* mid-flight — e.g. the agent streams a new message while the scroll is
|
||||
* still animating, which would otherwise snap the transcript back to the
|
||||
* bottom and silently cancel the user's action. Held until the scroll
|
||||
* settles (native `scrollend` on the real scroll-parent element where
|
||||
* supported, a bounded timeout fallback otherwise); Virtuoso re-evaluates
|
||||
* `followOutput` on its own subsequent range/bottom updates once released,
|
||||
* so no manual resync is needed here (unlike the native renderer's
|
||||
* DOM-scroll-listener-driven near-bottom tracking). */
|
||||
export function useProgrammaticScrollLock(scrollParent: HTMLDivElement) {
|
||||
const lockedRef = useRef(false);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const release = useCallback(() => {
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = null;
|
||||
lockedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const runLocked = useCallback(
|
||||
(performScroll: () => void) => {
|
||||
cleanupRef.current?.();
|
||||
lockedRef.current = true;
|
||||
performScroll();
|
||||
|
||||
const timeoutId = window.setTimeout(release, PROGRAMMATIC_SCROLL_LOCK_MS);
|
||||
scrollParent.addEventListener("scrollend", release, { once: true });
|
||||
cleanupRef.current = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
scrollParent.removeEventListener("scrollend", release);
|
||||
};
|
||||
},
|
||||
[scrollParent, release],
|
||||
);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
return { isLocked: useCallback(() => lockedRef.current, []), runLocked };
|
||||
}
|
||||
|
||||
/** Wraps Virtuoso's `followOutput` so it never re-engages while a
|
||||
* programmatic scroll (see `useProgrammaticScrollLock`) is in flight, and
|
||||
* respects the transcript auto-scroll toggle the same way the native
|
||||
* renderer's `shouldAutoScrollToBottom` does. */
|
||||
export function useGuardedFollowOutput(isLocked: () => boolean, enabled: boolean) {
|
||||
return useCallback(
|
||||
(isAtBottom: boolean) => (isLocked() ? false : resolveFollowOutput(enabled, isAtBottom)),
|
||||
[isLocked, enabled],
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
|
||||
const debugScrollParent = createDebugLogger("chat:virtuoso:scrollParent");
|
||||
|
||||
/** Defer providing scroll parent to Virtuoso until the element has non-zero size. */
|
||||
export function useVisibleScrollParent() {
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const setScrollRef = useCallback((node: HTMLDivElement | null) => {
|
||||
nodeRef.current = node;
|
||||
if (node && node.offsetHeight > 0) {
|
||||
if (isDebug()) {
|
||||
debugScrollParent("ref-callback-ready", {
|
||||
offsetHeight: node.offsetHeight,
|
||||
path: "synchronous",
|
||||
});
|
||||
}
|
||||
setScrollParent(node);
|
||||
} else if (isDebug()) {
|
||||
debugScrollParent("ref-callback-defer", {
|
||||
hasNode: Boolean(node),
|
||||
offsetHeight: node?.offsetHeight ?? null,
|
||||
reason: !node ? "no-node" : "zero-height",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
if (!node || scrollParent) return;
|
||||
if (isDebug()) {
|
||||
debugScrollParent("ro-attach", {
|
||||
initialHeight: node.offsetHeight,
|
||||
});
|
||||
}
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.height > 0) {
|
||||
if (isDebug()) {
|
||||
debugScrollParent("ro-ready", {
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
setScrollParent(node);
|
||||
ro.disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(node);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollParent]);
|
||||
return { scrollParent, setScrollRef };
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { VirtuosoHandle } from "react-virtuoso";
|
||||
|
||||
import { useScrollToDividerOnceResolved } from "./message-list-virtuoso";
|
||||
|
||||
const items: RenderItem[] = [
|
||||
{ type: "message", message: { id: "m1" } as Message },
|
||||
{ type: "message", message: { id: "m2" } as Message },
|
||||
];
|
||||
const itemsWithLiveMessage: RenderItem[] = [
|
||||
...items,
|
||||
{ type: "message", message: { id: "m3" } as Message },
|
||||
];
|
||||
const DIVIDER_KEY = "m2";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeHarness(
|
||||
virtuosoRef: { current: VirtuosoHandle },
|
||||
scrollParent: HTMLDivElement,
|
||||
dividerKey: string | null = DIVIDER_KEY,
|
||||
runLocked: (performScroll: () => void) => void = (performScroll) => performScroll(),
|
||||
) {
|
||||
return function DirectHarness({
|
||||
offsetPx,
|
||||
renderItems = items,
|
||||
}: {
|
||||
offsetPx: number;
|
||||
renderItems?: RenderItem[];
|
||||
}) {
|
||||
useScrollToDividerOnceResolved(virtuosoRef, renderItems, 0, dividerKey, {
|
||||
offsetPx,
|
||||
scrollParent,
|
||||
runLocked,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
it("runs the reassert scroll through runLocked so followOutput can't fight a live-update reassertion", () => {
|
||||
let insideLock = false;
|
||||
let scrolledInsideLock: boolean | null = null;
|
||||
const scrollToIndex = vi.fn(() => {
|
||||
scrolledInsideLock = insideLock;
|
||||
});
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const runLocked = vi.fn((performScroll: () => void) => {
|
||||
insideLock = true;
|
||||
performScroll();
|
||||
insideLock = false;
|
||||
});
|
||||
const DirectHarness = makeHarness(
|
||||
virtuosoRef,
|
||||
document.createElement("div"),
|
||||
DIVIDER_KEY,
|
||||
runLocked,
|
||||
);
|
||||
|
||||
render(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(runLocked).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
expect(scrolledInsideLock).toBe(true);
|
||||
});
|
||||
|
||||
describe("useScrollToDividerOnceResolved — anchored-bar offset", () => {
|
||||
it("negatively offsets the divider scroll by the anchored bar's height so the item lands below the pinned bar", () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"));
|
||||
|
||||
render(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledWith({ index: 1, align: "start", offset: -76 });
|
||||
});
|
||||
|
||||
it("re-scrolls once the anchored bar's measured height arrives after the initial placement", () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"));
|
||||
|
||||
const { rerender } = render(<DirectHarness offsetPx={0} />);
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: 0 });
|
||||
|
||||
rerender(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(2);
|
||||
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 });
|
||||
});
|
||||
|
||||
it("never re-scrolls once the reader has started scrolling, even if the anchored bar's height changes afterward", () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const scrollParent = document.createElement("div");
|
||||
document.body.appendChild(scrollParent);
|
||||
const DirectHarness = makeHarness(virtuosoRef, scrollParent);
|
||||
|
||||
const { rerender } = render(<DirectHarness offsetPx={0} />);
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
|
||||
scrollParent.dispatchEvent(new Event("wheel", { bubbles: true }));
|
||||
|
||||
rerender(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
scrollParent.remove();
|
||||
});
|
||||
|
||||
it("never scrolls when there is no unread boundary, regardless of anchored-bar height changes", () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"), null);
|
||||
|
||||
const { rerender } = render(<DirectHarness offsetPx={0} />);
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops re-scrolling once the settling window has elapsed, even without any user interaction", () => {
|
||||
vi.useFakeTimers();
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"));
|
||||
|
||||
const { rerender } = render(<DirectHarness offsetPx={0} />);
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(4001);
|
||||
rerender(<DirectHarness offsetPx={76} />);
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the same offset across a live-update reassertion triggered by a new message, not just an offset change", () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const DirectHarness = makeHarness(virtuosoRef, document.createElement("div"));
|
||||
|
||||
const { rerender } = render(<DirectHarness offsetPx={76} />);
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 });
|
||||
|
||||
// A live message arriving mid-settling-window changes `items` (a new
|
||||
// array reference) while the anchored bar's height is unchanged — the
|
||||
// pre-existing multi-wave reassertion must reapply the *same* offset,
|
||||
// not drop or double it.
|
||||
rerender(<DirectHarness offsetPx={76} renderItems={itemsWithLiveMessage} />);
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledTimes(2);
|
||||
expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 });
|
||||
});
|
||||
});
|
||||
@@ -1,691 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { SessionPanelContent } from "@kandev/ui/pannel-session";
|
||||
import type { RenderItem } from "@/hooks/use-processed-messages";
|
||||
import type { Message, TaskSessionState } from "@/lib/types/http";
|
||||
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
|
||||
import { useSessionTurn } from "@/hooks/domains/session/use-session-turn";
|
||||
import { MessageListFooter } from "./message-list-footer";
|
||||
import { useTranscriptAutoScrollEnabled } from "./use-transcript-auto-scroll-enabled";
|
||||
import { useVirtuosoAutoScrollLifecycle } from "./message-list-virtuoso-auto-scroll";
|
||||
import {
|
||||
findMessageItemIndex,
|
||||
resolveVirtuosoEdgeState,
|
||||
rangePositionToLastPromptEdge,
|
||||
} from "./message-list-virtuoso-edges";
|
||||
import {
|
||||
useGuardedFollowOutput,
|
||||
useProgrammaticScrollLock,
|
||||
} from "./message-list-virtuoso-scroll-lock";
|
||||
import { useStableFirstItemIndex } from "./message-list-virtuoso-index";
|
||||
import { useVisibleScrollParent } from "./message-list-virtuoso-scroll-parent";
|
||||
import {
|
||||
type MessageListProps,
|
||||
type MessageListHandle,
|
||||
type LastPromptEdge,
|
||||
MessageListStatus,
|
||||
MessageItem,
|
||||
UnreadDivider,
|
||||
anchoredBarScrollOffsetPx,
|
||||
canReassertDividerScroll,
|
||||
getItemKey,
|
||||
getConversationLoadingState,
|
||||
getEffectiveActiveTurnId,
|
||||
getStreamingAgentMessageId,
|
||||
getLastTurnGroupId,
|
||||
isElementFullyVisible,
|
||||
resolveLastPromptEdge,
|
||||
} from "./message-list-shared";
|
||||
import { createDebugLogger, isDebug } from "@/lib/debug/log";
|
||||
|
||||
const debugVirtuoso = createDebugLogger("chat:virtuoso");
|
||||
|
||||
type VirtuosoBodyProps = MessageListProps & {
|
||||
scrollParent: HTMLDivElement;
|
||||
activeTurnId: string | null;
|
||||
lastTurnGroupId: string | null;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
loadMore: () => Promise<number>;
|
||||
Header: () => React.ReactNode;
|
||||
Footer: () => React.ReactNode;
|
||||
/** Whether the transcript auto-scroll toggle is enabled for this session. */
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/** Virtuoso windowing can unmount an off-screen prompt. While the prompt's
|
||||
* row remains mounted, use native geometry so the bar opens only when no
|
||||
* prompt content remains visible; once unmounted, fall back to range position. */
|
||||
type TranscriptRangeEdges = {
|
||||
lastPromptMessageId: string | null | undefined;
|
||||
onLastPromptEdgeChange: ((edge: LastPromptEdge) => void) | undefined;
|
||||
firstMessageId: string | null | undefined;
|
||||
onFirstMessageHiddenChange: ((isHidden: boolean) => void) | undefined;
|
||||
};
|
||||
|
||||
function useTranscriptRangeTracking(
|
||||
items: RenderItem[],
|
||||
firstItemIndex: number,
|
||||
edges: TranscriptRangeEdges,
|
||||
scrollParent: HTMLDivElement,
|
||||
) {
|
||||
const {
|
||||
lastPromptMessageId,
|
||||
onLastPromptEdgeChange,
|
||||
firstMessageId,
|
||||
onFirstMessageHiddenChange,
|
||||
} = edges;
|
||||
const lastPromptItemIndex = useMemo(
|
||||
() => findMessageItemIndex(items, lastPromptMessageId),
|
||||
[items, lastPromptMessageId],
|
||||
);
|
||||
const firstMessageItemIndex = useMemo(
|
||||
() => findMessageItemIndex(items, firstMessageId),
|
||||
[items, firstMessageId],
|
||||
);
|
||||
const rangeRef = useRef<{ startIndex: number; endIndex: number } | null>(null);
|
||||
const updateTranscriptEdges = useCallback(
|
||||
(range: { startIndex: number; endIndex: number }) => {
|
||||
const renderedRange = {
|
||||
start: range.startIndex - firstItemIndex,
|
||||
end: range.endIndex - firstItemIndex,
|
||||
};
|
||||
const lastPromptRow = scrollParent.querySelector<HTMLElement>(
|
||||
'[data-last-prompt-row="true"]',
|
||||
);
|
||||
onLastPromptEdgeChange?.(
|
||||
resolveVirtuosoEdgeState(lastPromptRow, scrollParent, lastPromptItemIndex, renderedRange, {
|
||||
geometryCheck: resolveLastPromptEdge,
|
||||
fromRangePosition: rangePositionToLastPromptEdge,
|
||||
}),
|
||||
);
|
||||
const firstMessageRow = scrollParent.querySelector<HTMLElement>(
|
||||
'[data-first-message-row="true"]',
|
||||
);
|
||||
onFirstMessageHiddenChange?.(
|
||||
resolveVirtuosoEdgeState(
|
||||
firstMessageRow,
|
||||
scrollParent,
|
||||
firstMessageItemIndex,
|
||||
renderedRange,
|
||||
{
|
||||
geometryCheck: (container, row) => !isElementFullyVisible(container, row),
|
||||
fromRangePosition: (position) => position !== "within",
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[
|
||||
firstItemIndex,
|
||||
firstMessageItemIndex,
|
||||
lastPromptItemIndex,
|
||||
onFirstMessageHiddenChange,
|
||||
onLastPromptEdgeChange,
|
||||
scrollParent,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (rangeRef.current) updateTranscriptEdges(rangeRef.current);
|
||||
};
|
||||
scrollParent.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollParent.removeEventListener("scroll", handleScroll);
|
||||
}, [scrollParent, updateTranscriptEdges]);
|
||||
|
||||
return useCallback(
|
||||
(range: { startIndex: number; endIndex: number }) => {
|
||||
rangeRef.current = range;
|
||||
requestAnimationFrame(() => updateTranscriptEdges(range));
|
||||
},
|
||||
[updateTranscriptEdges],
|
||||
);
|
||||
}
|
||||
|
||||
type RenderItemArgs = {
|
||||
items: RenderItem[];
|
||||
firstItemIndex: number;
|
||||
sessionId: string | null;
|
||||
permissionsByToolCallId: Map<string, Message>;
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
taskId?: string;
|
||||
worktreePath?: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
lastTurnGroupId: string | null;
|
||||
lastPromptMessageId: string | null | undefined;
|
||||
firstMessageId: string | null | undefined;
|
||||
activeTurnId: string | null;
|
||||
streamingMessageId: string | null;
|
||||
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
|
||||
dividerBeforeItemKey?: string | null;
|
||||
};
|
||||
|
||||
/** Renders one transcript row for a given (rebased) Virtuoso item index. */
|
||||
function useVirtuosoRenderItem(args: RenderItemArgs) {
|
||||
const {
|
||||
items,
|
||||
firstItemIndex,
|
||||
sessionId,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
lastTurnGroupId,
|
||||
activeTurnId,
|
||||
streamingMessageId,
|
||||
lastPromptMessageId,
|
||||
firstMessageId,
|
||||
onScrollToMessage,
|
||||
dividerBeforeItemKey,
|
||||
} = args;
|
||||
return useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index - firstItemIndex];
|
||||
if (!item) return <div />;
|
||||
const isLastPromptRow =
|
||||
item.type === "turn_group"
|
||||
? item.messages.some((message) => message.id === lastPromptMessageId)
|
||||
: item.type === "message" && item.message.id === lastPromptMessageId;
|
||||
const isFirstMessageRow =
|
||||
item.type === "turn_group"
|
||||
? item.messages.some((message) => message.id === firstMessageId)
|
||||
: item.type === "message" && item.message.id === firstMessageId;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`msg-${getItemKey(item)}`}
|
||||
className="pb-2"
|
||||
data-last-prompt-row={isLastPromptRow || undefined}
|
||||
data-first-message-row={isFirstMessageRow || undefined}
|
||||
>
|
||||
{dividerBeforeItemKey === getItemKey(item) && <UnreadDivider />}
|
||||
<MessageItem
|
||||
item={item}
|
||||
sessionId={sessionId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
|
||||
activeTurnId={activeTurnId}
|
||||
streamingMessageId={streamingMessageId}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
items,
|
||||
firstItemIndex,
|
||||
sessionId,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
lastTurnGroupId,
|
||||
activeTurnId,
|
||||
streamingMessageId,
|
||||
lastPromptMessageId,
|
||||
firstMessageId,
|
||||
onScrollToMessage,
|
||||
dividerBeforeItemKey,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects the initial scroll position to this visit's Slack-style unread
|
||||
* divider (see findUnreadDividerItemId) once it resolves, mirroring Slack
|
||||
* drawing the line where you left off instead of always landing on the
|
||||
* newest message.
|
||||
*
|
||||
* Deliberately NOT done via Virtuoso's own `initialTopMostItemIndex` prop
|
||||
* (which only takes effect on Virtuoso's first render): dividerBeforeItemKey
|
||||
* is derived from usePanelActive (Dockview's active-tab signal), which is
|
||||
* backed by useSyncExternalStore and typically only resolves true one
|
||||
* render *after* this component's own mount — so on Virtuoso's actual first
|
||||
* render it's still null even for a session that does have an unread
|
||||
* divider, and a value frozen at that first render could never be
|
||||
* corrected. Scrolling here imperatively, gated on a change to
|
||||
* dividerBeforeItemKey rather than mount, catches it whenever it resolves.
|
||||
*
|
||||
* `anchoredBar.offsetPx` negatively offsets the placement (see Virtuoso's
|
||||
* documented sticky-header pattern) so the divider lands below the
|
||||
* anchored last-prompt bar's pinned overlay instead of underneath it. The
|
||||
* bar's real height typically arrives an async tick after mount, after
|
||||
* the first placement already ran with offset 0 — {@link
|
||||
* canReassertDividerScroll} allows exactly one more correction once it
|
||||
* does, under the same settling-window/no-user-scroll gate
|
||||
* `useScrollToDividerOrBottom` (message-list-native.tsx) uses for its
|
||||
* own multi-wave corrections.
|
||||
*
|
||||
* The actual `scrollToIndex` call runs through `runLocked` (see
|
||||
* `useProgrammaticScrollLock`) so `followOutput` can't fight a reassertion
|
||||
* that fires while a live message is streaming in — without the lock, a
|
||||
* `followOutput` re-evaluation triggered by that same message could snap
|
||||
* the transcript back to the bottom and silently cancel the correction.
|
||||
*/
|
||||
export function useScrollToDividerOnceResolved(
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>,
|
||||
items: RenderItem[],
|
||||
firstItemIndex: number,
|
||||
dividerBeforeItemKey: string | null | undefined,
|
||||
anchoredBar: {
|
||||
offsetPx: number;
|
||||
scrollParent: HTMLDivElement;
|
||||
runLocked: (performScroll: () => void) => void;
|
||||
},
|
||||
) {
|
||||
const { offsetPx: anchoredBarOffsetPx, scrollParent, runLocked } = anchoredBar;
|
||||
const isUserScrollingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const markUserScrolling = () => {
|
||||
isUserScrollingRef.current = true;
|
||||
};
|
||||
scrollParent.addEventListener("wheel", markUserScrolling, { passive: true });
|
||||
scrollParent.addEventListener("touchstart", markUserScrolling, { passive: true });
|
||||
scrollParent.addEventListener("keydown", markUserScrolling);
|
||||
return () => {
|
||||
scrollParent.removeEventListener("wheel", markUserScrolling);
|
||||
scrollParent.removeEventListener("touchstart", markUserScrolling);
|
||||
scrollParent.removeEventListener("keydown", markUserScrolling);
|
||||
};
|
||||
}, [scrollParent]);
|
||||
|
||||
// Mirrors useScrollToDividerOrBottom's 4s settling window: bounds how
|
||||
// long the correction below can keep re-asserting after mount,
|
||||
// independent of user interaction.
|
||||
const mountedAtRef = useRef<number | null>(null);
|
||||
if (mountedAtRef.current === null) mountedAtRef.current = Date.now();
|
||||
const isWithinSettlingWindow = () => Date.now() - (mountedAtRef.current ?? 0) < 4000;
|
||||
|
||||
const didScrollRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!dividerBeforeItemKey) return;
|
||||
const dividerIndex = items.findIndex((item) => getItemKey(item) === dividerBeforeItemKey);
|
||||
if (dividerIndex < 0) return;
|
||||
const canReassert = canReassertDividerScroll({
|
||||
hasDividerTarget: true,
|
||||
didScrollToDivider: didScrollRef.current,
|
||||
isUserScrolling: isUserScrollingRef.current,
|
||||
isWithinSettlingWindow: isWithinSettlingWindow(),
|
||||
});
|
||||
if (!canReassert) return;
|
||||
runLocked(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: firstItemIndex + dividerIndex,
|
||||
align: "start",
|
||||
offset: -anchoredBarOffsetPx || 0,
|
||||
});
|
||||
});
|
||||
didScrollRef.current = true;
|
||||
}, [
|
||||
virtuosoRef,
|
||||
items,
|
||||
firstItemIndex,
|
||||
dividerBeforeItemKey,
|
||||
anchoredBarOffsetPx,
|
||||
scrollParent,
|
||||
runLocked,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Debounced `startReached` handler for lazy-loading older messages: a
|
||||
* 500ms cooldown after each `loadMore()` settles prevents Virtuoso from
|
||||
* re-firing `startReached` (e.g. while the prepended page is still
|
||||
* settling scroll position) into a runaway fetch loop. */
|
||||
function useLoadOlderOnStartReached(
|
||||
hasMore: boolean,
|
||||
isLoadingMore: boolean,
|
||||
loadMore: () => Promise<number>,
|
||||
) {
|
||||
const loadCooldownRef = useRef(false);
|
||||
return useCallback(() => {
|
||||
if (hasMore && !isLoadingMore && !loadCooldownRef.current) {
|
||||
loadCooldownRef.current = true;
|
||||
loadMore().finally(() => {
|
||||
setTimeout(() => {
|
||||
loadCooldownRef.current = false;
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
}, [hasMore, isLoadingMore, loadMore]);
|
||||
}
|
||||
|
||||
function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
|
||||
const {
|
||||
items,
|
||||
messages,
|
||||
sessionId,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
} = props;
|
||||
const { worktreePath, onOpenFile, lastTurnGroupId, activeTurnId, dividerBeforeItemKey } = props;
|
||||
const { hasMore, isLoadingMore, loadMore } = props;
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const itemCount = items.length;
|
||||
const streamingMessageId = getStreamingAgentMessageId(messages);
|
||||
const firstItemIndex = useStableFirstItemIndex(items);
|
||||
const { isLocked, runLocked } = useProgrammaticScrollLock(props.scrollParent);
|
||||
useScrollToDividerOnceResolved(virtuosoRef, items, firstItemIndex, dividerBeforeItemKey, {
|
||||
offsetPx: anchoredBarScrollOffsetPx(props.anchoredBarHeight),
|
||||
scrollParent: props.scrollParent,
|
||||
runLocked,
|
||||
});
|
||||
const handleStartReached = useLoadOlderOnStartReached(hasMore, isLoadingMore, loadMore);
|
||||
|
||||
const handleScrollToMessage = useCallback(
|
||||
(messageId: string, options?: { align?: "start" | "center" }) => {
|
||||
const idx = findMessageItemIndex(items, messageId);
|
||||
if (idx >= 0)
|
||||
runLocked(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: firstItemIndex + idx,
|
||||
align: options?.align === "start" ? "start" : "center",
|
||||
});
|
||||
});
|
||||
},
|
||||
[items, firstItemIndex, runLocked],
|
||||
);
|
||||
|
||||
const followOutput = useGuardedFollowOutput(isLocked, props.enabled);
|
||||
|
||||
const { handleAtBottomStateChange, restoreStateFrom } = useVirtuosoAutoScrollLifecycle({
|
||||
sessionId,
|
||||
enabled: props.enabled,
|
||||
messages,
|
||||
virtuosoRef,
|
||||
firstItemIndex,
|
||||
itemCount,
|
||||
});
|
||||
|
||||
const handleRangeChanged = useTranscriptRangeTracking(
|
||||
items,
|
||||
firstItemIndex,
|
||||
{
|
||||
lastPromptMessageId: props.lastPromptMessageId,
|
||||
onLastPromptEdgeChange: props.onLastPromptEdgeChange,
|
||||
firstMessageId: props.firstMessageId,
|
||||
onFirstMessageHiddenChange: props.onFirstMessageHiddenChange,
|
||||
},
|
||||
props.scrollParent,
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index - firstItemIndex];
|
||||
if (!item) return index;
|
||||
return getItemKey(item);
|
||||
},
|
||||
[items, firstItemIndex],
|
||||
);
|
||||
|
||||
const renderItem = useVirtuosoRenderItem({
|
||||
items,
|
||||
firstItemIndex,
|
||||
sessionId,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
lastTurnGroupId,
|
||||
activeTurnId,
|
||||
streamingMessageId,
|
||||
lastPromptMessageId: props.lastPromptMessageId,
|
||||
firstMessageId: props.firstMessageId,
|
||||
onScrollToMessage: handleScrollToMessage,
|
||||
dividerBeforeItemKey,
|
||||
});
|
||||
|
||||
return {
|
||||
virtuosoRef,
|
||||
itemCount,
|
||||
firstItemIndex,
|
||||
handleStartReached,
|
||||
computeItemKey,
|
||||
renderItem,
|
||||
handleScrollToMessage,
|
||||
handleRangeChanged,
|
||||
followOutput,
|
||||
handleAtBottomStateChange,
|
||||
restoreStateFrom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the `<Virtuoso>` element once a scroll parent is available,
|
||||
* wiring together the stable item index, render callbacks, last-prompt/
|
||||
* first-message range tracking, and the auto-scroll lifecycle
|
||||
* (follow/catch-up/restore) from {@link useVirtuosoAutoScrollLifecycle}.
|
||||
*/
|
||||
const VirtuosoBody = forwardRef<MessageListHandle, VirtuosoBodyProps>(
|
||||
function VirtuosoBody(props, ref) {
|
||||
const { scrollParent, Header, Footer } = props;
|
||||
const {
|
||||
virtuosoRef,
|
||||
itemCount,
|
||||
firstItemIndex,
|
||||
handleStartReached,
|
||||
computeItemKey,
|
||||
renderItem,
|
||||
handleScrollToMessage,
|
||||
handleRangeChanged,
|
||||
followOutput,
|
||||
handleAtBottomStateChange,
|
||||
restoreStateFrom,
|
||||
} = useVirtuosoCallbacks(props);
|
||||
useImperativeHandle(ref, () => ({ scrollToMessage: handleScrollToMessage }), [
|
||||
handleScrollToMessage,
|
||||
]);
|
||||
|
||||
// Captured once on mount — `initialTopMostItemIndex` only takes effect on
|
||||
// Virtuoso's first render, so logging it here tells us which item Virtuoso
|
||||
// anchored on for that lifecycle.
|
||||
const mountSnapshotRef = useRef<{ itemCount: number; firstItemIndex: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isDebug()) return;
|
||||
if (mountSnapshotRef.current) return;
|
||||
mountSnapshotRef.current = { itemCount, firstItemIndex };
|
||||
debugVirtuoso("mount", {
|
||||
itemCount,
|
||||
firstItemIndex,
|
||||
initialTopMostItemIndex: itemCount - 1,
|
||||
hasMore: props.hasMore,
|
||||
activeTurnId: props.activeTurnId,
|
||||
lastTurnGroupId: props.lastTurnGroupId ?? "-",
|
||||
});
|
||||
}, [itemCount, firstItemIndex, props.hasMore, props.activeTurnId, props.lastTurnGroupId]);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
/* Suppress Virtuoso's verbose internal logging in all environments */
|
||||
logLevel={Number.MAX_SAFE_INTEGER}
|
||||
customScrollParent={scrollParent}
|
||||
totalCount={itemCount}
|
||||
firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={itemCount - 1}
|
||||
restoreStateFrom={restoreStateFrom}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderItem}
|
||||
followOutput={followOutput}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
startReached={handleStartReached}
|
||||
rangeChanged={handleRangeChanged}
|
||||
increaseViewportBy={200}
|
||||
atBottomThreshold={100}
|
||||
components={{ Header, Footer }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type HeaderFooterArgs = {
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
showLoadingState: boolean;
|
||||
messagesLoading: boolean;
|
||||
isInitialLoading: boolean;
|
||||
messages: Message[];
|
||||
loadMore: () => Promise<number>;
|
||||
sessionState?: TaskSessionState;
|
||||
sessionId: string | null;
|
||||
isWorking: boolean;
|
||||
footerActionMessages?: Message[];
|
||||
};
|
||||
|
||||
/** Memoized Virtuoso Header (load-more status) and Footer (agent status + actions). */
|
||||
function useVirtuosoHeaderFooter(args: HeaderFooterArgs) {
|
||||
const { isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading } = args;
|
||||
const { messages, loadMore, sessionState, sessionId, isWorking, footerActionMessages } = args;
|
||||
const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]);
|
||||
|
||||
const Header = useCallback(
|
||||
() => (
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
),
|
||||
[
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
showLoadingState,
|
||||
messagesLoading,
|
||||
isInitialLoading,
|
||||
messages.length,
|
||||
loadMore,
|
||||
],
|
||||
);
|
||||
|
||||
const Footer = useCallback(
|
||||
() => (
|
||||
<MessageListFooter
|
||||
sessionState={sessionState}
|
||||
sessionId={sessionId}
|
||||
messages={messages}
|
||||
isWorking={isWorking}
|
||||
footerActionMessages={footerActions}
|
||||
/>
|
||||
),
|
||||
[sessionId, sessionState, messages, isWorking, footerActions],
|
||||
);
|
||||
|
||||
return { Header, Footer, footerActions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed transcript renderer for very long conversations (1000+ messages),
|
||||
* backed by react-virtuoso. Shows the loading/empty state until the scroll
|
||||
* parent and initial page are ready, then delegates to {@link VirtuosoBody}.
|
||||
*/
|
||||
export const VirtuosoMessageList = memo(
|
||||
forwardRef<MessageListHandle, MessageListProps>(function VirtuosoMessageList(props, ref) {
|
||||
const {
|
||||
items,
|
||||
messages,
|
||||
footerActionMessages,
|
||||
sessionId,
|
||||
messagesLoading,
|
||||
isWorking,
|
||||
sessionState,
|
||||
stickyPromptBar,
|
||||
} = props;
|
||||
const { scrollParent, setScrollRef } = useVisibleScrollParent();
|
||||
const { isInitialLoading, showLoadingState } = getConversationLoadingState({
|
||||
messagesLoading,
|
||||
messagesCount: messages.length,
|
||||
isWorking,
|
||||
sessionState,
|
||||
});
|
||||
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId);
|
||||
const { activeTurnId } = useSessionTurn(sessionId);
|
||||
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
|
||||
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
|
||||
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
|
||||
|
||||
const { Header, Footer, footerActions } = useVirtuosoHeaderFooter({
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
showLoadingState,
|
||||
messagesLoading,
|
||||
isInitialLoading,
|
||||
messages,
|
||||
loadMore,
|
||||
sessionState,
|
||||
sessionId,
|
||||
isWorking,
|
||||
footerActionMessages,
|
||||
});
|
||||
|
||||
if (isInitialLoading || items.length === 0) {
|
||||
return (
|
||||
<SessionPanelContent className="relative chat-message-list p-0">
|
||||
{stickyPromptBar}
|
||||
<div className="p-4">
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
onLoadMore={loadMore}
|
||||
/>
|
||||
<MessageListFooter
|
||||
sessionState={sessionState}
|
||||
sessionId={sessionId}
|
||||
messages={messages}
|
||||
isWorking={isWorking}
|
||||
footerActionMessages={footerActions}
|
||||
/>
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPanelContent ref={setScrollRef} className="relative chat-message-list p-0">
|
||||
{stickyPromptBar}
|
||||
<div className="p-4">
|
||||
{scrollParent && (
|
||||
<VirtuosoBody
|
||||
ref={ref}
|
||||
{...props}
|
||||
scrollParent={scrollParent}
|
||||
activeTurnId={effectiveActiveTurnId}
|
||||
lastTurnGroupId={lastTurnGroupId}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMore={loadMore}
|
||||
Header={Header}
|
||||
Footer={Footer}
|
||||
enabled={autoScrollEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SessionPanelContent>
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MessageListProps } from "./message-list-shared";
|
||||
|
||||
vi.mock("./message-list-native", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
NativeMessageList: React.forwardRef<HTMLDivElement, Record<string, unknown>>((_props, ref) =>
|
||||
React.createElement("div", { ref, "data-testid": "native-message-list" }),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { MessageList } from "./message-list";
|
||||
|
||||
const emptyMessageListProps: MessageListProps = {
|
||||
items: [],
|
||||
messages: [],
|
||||
permissionsByToolCallId: new Map(),
|
||||
childrenByParentToolCallId: new Map(),
|
||||
sessionId: null,
|
||||
messagesLoading: false,
|
||||
isWorking: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState({}, "", "/");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("MessageList", () => {
|
||||
it("keeps the transcript on the native renderer when the old Virtuoso override is requested", () => {
|
||||
window.history.replaceState({}, "", "/t/session-a?renderer=virtuoso");
|
||||
|
||||
render(<MessageList {...emptyMessageListProps} />);
|
||||
|
||||
expect(screen.queryByTestId("native-message-list")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
type ForwardRefExoticComponent,
|
||||
type RefAttributes,
|
||||
} from "react";
|
||||
import { NativeMessageList } from "./message-list-native";
|
||||
import { VirtuosoMessageList } from "./message-list-virtuoso";
|
||||
import type { MessageListProps, MessageListHandle } from "./message-list-shared";
|
||||
|
||||
type Strategy = ForwardRefExoticComponent<MessageListProps & RefAttributes<MessageListHandle>>;
|
||||
|
||||
const strategies: Record<string, Strategy> = {
|
||||
native: NativeMessageList,
|
||||
virtuoso: VirtuosoMessageList,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendering strategy for the message list.
|
||||
* - "native": simple DOM rendering with overflow-anchor for scroll pinning.
|
||||
* Better for short/medium conversations; avoids Virtuoso measurement quirks.
|
||||
* - "virtuoso": react-virtuoso windowed rendering.
|
||||
* Better for very long conversations (1000+ messages) where DOM node count matters.
|
||||
*
|
||||
* Overridable at runtime via ?renderer=virtuoso|native query param (used by E2E
|
||||
* coverage to exercise both paths without redeploying).
|
||||
*/
|
||||
const STRATEGY = "native";
|
||||
|
||||
function resolveStrategy(): string {
|
||||
if (typeof window === "undefined") return STRATEGY;
|
||||
const override = new URLSearchParams(window.location.search).get("renderer");
|
||||
return override && override in strategies ? override : STRATEGY;
|
||||
}
|
||||
|
||||
export const MessageList = memo(
|
||||
forwardRef<MessageListHandle, MessageListProps>(function MessageList(props, ref) {
|
||||
const key = useMemo(() => resolveStrategy(), []);
|
||||
const Renderer = strategies[key] ?? NativeMessageList;
|
||||
return <Renderer {...props} ref={ref} />;
|
||||
}),
|
||||
);
|
||||
export { NativeMessageList as MessageList } from "./message-list-native";
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
shouldAutoScrollOnMessagesChange,
|
||||
shouldAutoScrollOnWorkingStart,
|
||||
hasTranscriptProgressedPastView,
|
||||
hasTranscriptAppendedSinceBaseline,
|
||||
shouldCatchUpOnAutoScrollEnable,
|
||||
resolveNativeInitialScrollTop,
|
||||
resolveFollowOutput,
|
||||
isPrependUpdate,
|
||||
createFrameCoalescer,
|
||||
} from "./transcript-auto-scroll";
|
||||
@@ -68,30 +65,6 @@ describe("isPrependUpdate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoScrollOnMessagesChange", () => {
|
||||
it("scrolls when enabled and near the bottom", () => {
|
||||
expect(shouldAutoScrollOnMessagesChange(true, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not scroll when enabled but scrolled away from the bottom", () => {
|
||||
expect(shouldAutoScrollOnMessagesChange(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("never scrolls while auto-scroll is disabled, even near the bottom", () => {
|
||||
expect(shouldAutoScrollOnMessagesChange(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoScrollOnWorkingStart", () => {
|
||||
it("forces scroll on turn start when enabled", () => {
|
||||
expect(shouldAutoScrollOnWorkingStart(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses the forced scroll when disabled", () => {
|
||||
expect(shouldAutoScrollOnWorkingStart(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTranscriptProgressedPastView", () => {
|
||||
it("is false when the viewport bottom already matches the content bottom", () => {
|
||||
expect(
|
||||
@@ -314,21 +287,6 @@ describe("resolveNativeInitialScrollTop", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFollowOutput", () => {
|
||||
it("never follows while auto-scroll is disabled", () => {
|
||||
expect(resolveFollowOutput(false, true)).toBe(false);
|
||||
expect(resolveFollowOutput(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("follows smoothly when enabled and at the bottom", () => {
|
||||
expect(resolveFollowOutput(true, true)).toBe("smooth");
|
||||
});
|
||||
|
||||
it("does not follow when enabled but scrolled away from the bottom", () => {
|
||||
expect(resolveFollowOutput(true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFrameCoalescer", () => {
|
||||
let frameCallbacks: Map<number, () => void>;
|
||||
let nextFrameId: number;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Pure decision logic for the transcript auto-scroll toggle, shared by the
|
||||
* native and Virtuoso message list renderers so the two stay behaviorally
|
||||
* consistent. Kept side-effect free for direct unit testing — the renderers
|
||||
* own all DOM / Virtuoso imperative calls and just consult these functions.
|
||||
* Pure decision logic for the native transcript auto-scroll toggle. Kept
|
||||
* side-effect free for direct unit testing — the renderer owns all DOM calls
|
||||
* and just consults these functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -27,24 +26,6 @@ export function isPrependUpdate(params: {
|
||||
return params.nextFirstKey !== params.prevFirstKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether new messages arriving should force-scroll the transcript to the
|
||||
* bottom. The native renderer already gates this on `isNearBottom`; auto-scroll
|
||||
* being disabled overrides that and suppresses the jump entirely.
|
||||
*/
|
||||
export function shouldAutoScrollOnMessagesChange(enabled: boolean, isNearBottom: boolean): boolean {
|
||||
return enabled && isNearBottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent starting a turn (isWorking transitioning to true) should
|
||||
* force-scroll to the bottom. Disabled auto-scroll suppresses this too — while
|
||||
* off, nothing should move the view.
|
||||
*/
|
||||
export function shouldAutoScrollOnWorkingStart(enabled: boolean): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
// Matches the settle tolerance used elsewhere for "has this scrolled past
|
||||
// view" checks (see the last-prompt scroll-nav feature) — sub-pixel jitter
|
||||
// from layout rounding shouldn't count as "progressed".
|
||||
@@ -171,15 +152,3 @@ export function createFrameCoalescer(run: () => void): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FOLLOW_SMOOTH = "smooth" as const;
|
||||
|
||||
/**
|
||||
* Virtuoso `followOutput` resolver: identical to the renderer's original
|
||||
* always-on behavior, except disabled auto-scroll unconditionally suppresses
|
||||
* following regardless of Virtuoso's own atBottom tracking.
|
||||
*/
|
||||
export function resolveFollowOutput(enabled: boolean, isAtBottom: boolean): "smooth" | false {
|
||||
if (!enabled) return false;
|
||||
return isAtBottom ? FOLLOW_SMOOTH : false;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import { isTranscriptAutoScrollEnabled } from "@/lib/state/slices/ui/transcript-
|
||||
|
||||
/**
|
||||
* Reactive read of the per-session auto-scroll preference. Shared by the
|
||||
* toggle button and both message list renderers so all three consistently
|
||||
* default new sessions to enabled. Non-reactive reads/writes (from scroll
|
||||
* listeners, imperative Virtuoso callbacks) go straight through
|
||||
* `useAppStore.getState()` instead — see message-list-native.tsx /
|
||||
* message-list-virtuoso.tsx.
|
||||
* toggle button and the message list so both consistently default new
|
||||
* sessions to enabled. Non-reactive reads/writes from scroll listeners go
|
||||
* straight through `useAppStore.getState()` instead — see
|
||||
* message-list-native.tsx.
|
||||
*
|
||||
* Falls back to the sessionStorage-persisted preference (see
|
||||
* setTranscriptAutoScrollEnabled) when the in-memory store hasn't seen this
|
||||
|
||||
@@ -1892,7 +1892,9 @@ export class ApiClient {
|
||||
executor_profile_id?: string;
|
||||
prompt: string;
|
||||
intent?: string;
|
||||
session_id?: string;
|
||||
workflow_step_id?: string;
|
||||
launch_workspace?: boolean;
|
||||
auto_start?: boolean;
|
||||
},
|
||||
timeoutMs = 30_000,
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
// re-arms and older messages stop loading. The explicit button does not depend
|
||||
// on scroll/intersection state, so it always makes progress.
|
||||
//
|
||||
// Covers the native renderer (the production default — `STRATEGY = "native"`).
|
||||
// The virtuoso renderer shares the same MessageListStatus button, but its
|
||||
// windowed rendering keeps off-screen items out of the DOM, so asserting the
|
||||
// oldest message is *visible* there is a virtualization concern, not a
|
||||
// pagination one — out of scope for this regression.
|
||||
// Covers the native transcript renderer, including its explicit load-more
|
||||
// path and visible oldest-message assertion.
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
@@ -167,25 +167,4 @@ test.describe("@search session chat panel search", () => {
|
||||
await close.click();
|
||||
await expect(panelSearchBar(testPage)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Virtuoso renderer coverage — session-search should work with the virtualized path.
|
||||
test("C-virtuoso basic search works on the virtuoso renderer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { session } = await seedTask(testPage, apiClient, seedData, "session-search-virtuoso", {
|
||||
description: seedMessagesDescription(["virtuoso fox alpha", "virtuoso fox beta"]),
|
||||
rendererOverride: "virtuoso",
|
||||
});
|
||||
await expect(session.chat.getByText("virtuoso", { exact: false }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openPanelSearch(testPage, "session");
|
||||
await panelSearchInput(testPage).fill("virtuoso fox");
|
||||
const hitsContainer = testPage.locator(HITS_LIST_SELECTOR);
|
||||
await expect(hitsContainer.locator("button").first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function seedTask(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
title: string,
|
||||
opts: { description: string; rendererOverride?: "native" | "virtuoso" },
|
||||
opts: { description: string },
|
||||
): Promise<{ session: SessionPage; taskId: string; sessionId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
@@ -26,8 +26,7 @@ export async function seedTask(
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
const query = opts.rendererOverride ? `?renderer=${opts.rendererOverride}` : "";
|
||||
await page.goto(`/t/${task.id}${query}`);
|
||||
await page.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad();
|
||||
await session.waitForChatIdle({ timeout: 30_000 });
|
||||
|
||||
@@ -35,21 +35,46 @@ test.describe("mobile: session stream overload isolation", () => {
|
||||
sessionId: `mobile-quiet-${noisyTask.id}`,
|
||||
});
|
||||
|
||||
// Prepare the noisy session before opening its browser. Starting the
|
||||
// agent first creates a race where a fast mock run can finish before the
|
||||
// second page has registered its session subscription, leaving no live
|
||||
// frames to assert on in CI.
|
||||
const prepared = await apiClient.launchSession({
|
||||
task_id: noisyTask.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: "",
|
||||
intent: "prepare",
|
||||
launch_workspace: true,
|
||||
});
|
||||
const noisySessionId = prepared.session_id;
|
||||
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTaskEnvironment(noisyTask.id))?.status ?? null, {
|
||||
timeout: 60_000,
|
||||
message: "prepared noisy task environment did not become ready",
|
||||
})
|
||||
.toBe("ready");
|
||||
|
||||
await testPage.goto(`/t/${noisyTask.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
const layout = testPage.locator("[data-testid='mobile-task-layout']:visible");
|
||||
await layout.waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
const launched = await apiClient.launchSession({
|
||||
task_id: noisyTask.id,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
prompt: 'e2e:message("noisy-ready")',
|
||||
});
|
||||
const noisySessionId = launched.session_id;
|
||||
|
||||
await session.waitForLoad();
|
||||
const quietPill = testPage.getByTestId("mobile-sessions-pill");
|
||||
await expect(quietPill).toBeVisible({ timeout: 30_000 });
|
||||
await quietPill.tap();
|
||||
const initialQuietSheet = testPage.getByRole("dialog", { name: "Sessions" });
|
||||
await expect(initialQuietSheet).toBeVisible({ timeout: 30_000 });
|
||||
const initialQuietRow = initialQuietSheet.getByTestId(
|
||||
`mobile-session-row-${quietSession.session_id}`,
|
||||
);
|
||||
await expect(initialQuietRow).toBeVisible({ timeout: 30_000 });
|
||||
await initialQuietRow.tap();
|
||||
await session.waitForLoad();
|
||||
|
||||
const noisyPage = await testPage.context().newPage();
|
||||
const noisyCapture = attachGatewayTrafficCapture(noisyPage);
|
||||
await noisyPage.goto(`/t/${noisyTask.id}`);
|
||||
@@ -79,6 +104,19 @@ test.describe("mobile: session stream overload isolation", () => {
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
noisyCapture.frames.some(
|
||||
(frame) =>
|
||||
frame.direction === "received" &&
|
||||
frame.action === "session.subscribe" &&
|
||||
frame.sessionId === noisySessionId,
|
||||
),
|
||||
{ timeout: 30_000, message: "noisy page did not acknowledge its session subscription" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const pill = layout.getByTestId("mobile-sessions-pill");
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await pill.tap();
|
||||
@@ -104,7 +142,14 @@ test.describe("mobile: session stream overload isolation", () => {
|
||||
|
||||
noisyCapture.frames.length = 0;
|
||||
capture.frames.length = 0;
|
||||
await noisySession.sendMessageViaButton(reasoningBurstPrompt());
|
||||
await apiClient.launchSession({
|
||||
task_id: noisyTask.id,
|
||||
session_id: noisySessionId,
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
prompt: reasoningBurstPrompt(),
|
||||
intent: "start_created",
|
||||
});
|
||||
|
||||
await session.sendMessageViaButton("mobile-quiet-followup");
|
||||
await session.expectChatResponseVisible("mobile-quiet-followup", 0, { timeout: 60_000 });
|
||||
|
||||
|
||||
@@ -6945,14 +6945,6 @@
|
||||
"license_text": "BSD 3-Clause License\n\nCopyright (c) 2018, React Community\nForked from React (https://github.com/facebook/react) Copyright 2013-present, Facebook, Inc.\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n* Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n",
|
||||
"ecosystem": "npm"
|
||||
},
|
||||
{
|
||||
"name": "react-virtuoso",
|
||||
"version": "4.18.1",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/petyosi/react-virtuoso",
|
||||
"license_text": "MIT License\n\nCopyright (c) 2020 Petyo Ivanov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.",
|
||||
"ecosystem": "npm"
|
||||
},
|
||||
{
|
||||
"name": "recast",
|
||||
"version": "0.23.11",
|
||||
|
||||
@@ -118,29 +118,10 @@
|
||||
*
|
||||
* Chat panel rendering (bug: remote-executor agent reply persisted but UI
|
||||
* doesn't render it until the user refreshes the page)
|
||||
* [chat:virtuoso] VirtuosoMessageList render-branch snapshots
|
||||
* (fallback vs virtuoso) and VirtuosoBody mount —
|
||||
* captures itemCount / firstItemIndex /
|
||||
* initialTopMostItemIndex at the moment Virtuoso
|
||||
* first anchors its scroll. If itemCount at
|
||||
* `mount` is < the final item count, Virtuoso
|
||||
* anchored on an earlier item and the new last
|
||||
* item lands below the fold.
|
||||
* [chat:virtuoso:scrollParent] `useVisibleScrollParent` lifecycle —
|
||||
* ref-callback-ready / ref-callback-defer /
|
||||
* ro-attach / ro-ready. A long delay between
|
||||
* items growing and `ro-ready` firing is the
|
||||
* smoking gun for the mount-too-early race.
|
||||
* [chat:virtuoso:firstIndex] `useStableFirstItemIndex` transitions —
|
||||
* init + key-list deltas. A non-monotonic
|
||||
* `delta` between two transitions means
|
||||
* Virtuoso saw the keyspace shift in a way
|
||||
* that throws off scroll anchoring.
|
||||
* [chat:prepare-progress] PrepareProgress status / autoExpand / expanded
|
||||
* transitions per session. Status stuck on
|
||||
* "preparing" with `expanded=true` while
|
||||
* Virtuoso is mounted explains the
|
||||
* agent-reply-pushed-below-fold scenario.
|
||||
* "preparing" with `expanded=true` explains
|
||||
* the agent-reply-pushed-below-fold scenario.
|
||||
*
|
||||
* Agent running-state (bug: ACP turn completes but chat still shows the agent
|
||||
* as running). The chat "agent is working" indicator is driven by
|
||||
|
||||
@@ -44,12 +44,4 @@ describe("transcript auto-scroll actions", () => {
|
||||
store.getState().setTranscriptScrollTop("session-a", 400);
|
||||
expect(store.getState().transcriptAutoScroll.scrollTopBySessionId["session-a"]).toBe(400);
|
||||
});
|
||||
|
||||
it("setTranscriptVirtuosoState records an opaque per-session snapshot", () => {
|
||||
const snapshot = { ranges: [{ startIndex: 0, endIndex: 10, size: 40 }], scrollTop: 300 };
|
||||
store.getState().setTranscriptVirtuosoState("session-a", snapshot);
|
||||
expect(store.getState().transcriptAutoScroll.virtuosoStateBySessionId["session-a"]).toEqual(
|
||||
snapshot,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ConnectionIssueSeverity, ConnectionStatus } from "@/lib/types/connection";
|
||||
import type { HealthCheckSummary, HealthIssue, SystemHealthResponse } from "@/lib/types/health";
|
||||
import type { StateSnapshot } from "react-virtuoso";
|
||||
import type {
|
||||
FilterClause,
|
||||
GroupKey,
|
||||
@@ -64,9 +63,6 @@ export type TranscriptAutoScrollState = {
|
||||
/** Last known scrollTop for the native renderer, captured continuously so
|
||||
* a disabled session's position survives a dockview panel remount. */
|
||||
scrollTopBySessionId: Record<string, number>;
|
||||
/** Last captured Virtuoso state snapshot (scroll offset + measured item
|
||||
* sizes) for the virtuoso renderer, captured on disable/unmount. */
|
||||
virtuosoStateBySessionId: Record<string, StateSnapshot>;
|
||||
};
|
||||
|
||||
export type ReviewPRSelectionState = {
|
||||
@@ -228,7 +224,6 @@ export type UISliceActions = {
|
||||
setCancelTurnPending: (sessionId: string, pending: boolean) => void;
|
||||
setTranscriptAutoScrollEnabled: (sessionId: string, enabled: boolean) => void;
|
||||
setTranscriptScrollTop: (sessionId: string, scrollTop: number) => void;
|
||||
setTranscriptVirtuosoState: (sessionId: string, state: StateSnapshot) => void;
|
||||
setReviewPRSelection: (taskId: string, selectedKey: string) => void;
|
||||
setActiveDocument: (sessionId: string, doc: ActiveDocument | null) => void;
|
||||
setSystemHealth: (response: SystemHealthResponse) => void;
|
||||
|
||||
@@ -91,7 +91,6 @@ export const defaultUIState: UISliceState = {
|
||||
transcriptAutoScroll: {
|
||||
enabledBySessionId: {},
|
||||
scrollTopBySessionId: {},
|
||||
virtuosoStateBySessionId: {},
|
||||
},
|
||||
reviewPRSelection: { selectedKeyByTaskId: {} },
|
||||
documentPanel: { activeDocumentBySessionId: {} },
|
||||
@@ -482,10 +481,6 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
|
||||
draft.transcriptAutoScroll.scrollTopBySessionId[sessionId] = scrollTop;
|
||||
setStoredAutoScrollTop(sessionId, scrollTop);
|
||||
}),
|
||||
setTranscriptVirtuosoState: (sessionId, state) =>
|
||||
set((draft) => {
|
||||
draft.transcriptAutoScroll.virtuosoStateBySessionId[sessionId] = state;
|
||||
}),
|
||||
setReviewPRSelection: (taskId, selectedKey) =>
|
||||
set((draft) => {
|
||||
draft.reviewPRSelection.selectedKeyByTaskId[taskId] = selectedKey;
|
||||
|
||||
@@ -331,7 +331,6 @@ export type AppState = KanbanSlice & {
|
||||
setCancelTurnPending: UIA["setCancelTurnPending"];
|
||||
setTranscriptAutoScrollEnabled: UIA["setTranscriptAutoScrollEnabled"];
|
||||
setTranscriptScrollTop: UIA["setTranscriptScrollTop"];
|
||||
setTranscriptVirtuosoState: UIA["setTranscriptVirtuosoState"];
|
||||
setReviewPRSelection: UIA["setReviewPRSelection"];
|
||||
setActiveDocument: (sessionId: string, doc: UISliceTypes.ActiveDocument | null) => void;
|
||||
setSystemHealth: (response: SystemHealthResponse) => void;
|
||||
|
||||
@@ -114,7 +114,6 @@
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
|
||||
@@ -64,7 +64,8 @@ The desktop anchored last-prompt bar currently appears once the prompt's top cro
|
||||
## Constraints
|
||||
|
||||
- Preserve the existing `show_anchored_prompt_bar` setting and desktop-only behavior.
|
||||
- Native and Virtuoso transcript strategies must use the same directional (above/below/visible) threshold where their rendered prompt node is available; Virtuoso may use its range fallback only once that node is unmounted.
|
||||
- The native transcript renderer must use the directional (above/below/visible)
|
||||
threshold for the rendered prompt node.
|
||||
- The repair does not change prompt persistence, API contracts, or the task transcript data model.
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -4,11 +4,11 @@ created: 2026-04-28
|
||||
owner: cfl
|
||||
---
|
||||
|
||||
# Comment Markdown Rendering and Virtual Scrolling
|
||||
# Comment Markdown Rendering
|
||||
|
||||
## Why
|
||||
|
||||
Task comments render as plain text with `whitespace-pre-wrap`. Agent responses routinely include markdown — code blocks, headers, bold text, lists — which renders as raw punctuation instead of formatted output. Long threads also mount every comment to the DOM, causing visible lag on tasks with many turns.
|
||||
Task comments render as plain text with `whitespace-pre-wrap`. Agent responses routinely include markdown — code blocks, headers, bold text, lists — which renders as raw punctuation instead of formatted output.
|
||||
|
||||
## What
|
||||
|
||||
@@ -18,7 +18,7 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r
|
||||
- Bare URLs in comment text are auto-linked (handled by `remark-gfm` linkify).
|
||||
- Markdown links to files in the active worktree open the in-app file editor; absolute worktree paths and repo-root paths may include `:line` or `:line:column` suffixes without navigating the browser away from the task.
|
||||
- Patterns matching `[A-Z]+-\d+` (e.g., `KAN-42`, `QA-7`) are rendered as links to `/office/tasks/<identifier>`. The link uses the raw identifier as the URL segment; resolution to an internal task ID (if needed) is handled by the issue page.
|
||||
- The comment list in `TaskChat` uses `react-virtuoso` (already in dependencies). Only visible comments plus a small buffer are mounted.
|
||||
- The comment list in `TaskChat` renders the available comments in transcript order.
|
||||
- Scroll position is preserved when new comments arrive if the user has scrolled away from the bottom.
|
||||
- When the user is at (or near) the bottom, new comments auto-scroll into view.
|
||||
|
||||
@@ -29,7 +29,6 @@ Task comments render as plain text with `whitespace-pre-wrap`. Agent responses r
|
||||
- **GIVEN** a comment containing the text `see KAN-42 for context`, **WHEN** rendered, **THEN** `KAN-42` is a clickable link navigating to `/office/tasks/KAN-42`.
|
||||
- **GIVEN** a comment containing `https://example.com`, **WHEN** rendered, **THEN** the URL is a clickable hyperlink.
|
||||
- **GIVEN** a comment contains a markdown link to `/root/.kandev/tasks/example/kandev/.github/workflows/build.yml:12`, **WHEN** the user clicks it while the active worktree is `/root/.kandev/tasks/example/kandev`, **THEN** the in-app editor opens `.github/workflows/build.yml` instead of navigating to that absolute URL.
|
||||
- **GIVEN** a thread with 200 comments, **WHEN** the user opens the issue, **THEN** fewer than 30 comment nodes are in the DOM at any time.
|
||||
- **GIVEN** the user has scrolled up to read earlier comments and a new comment arrives, **WHEN** the comment is appended, **THEN** the scroll position does not move.
|
||||
- **GIVEN** the user is at the bottom of the thread and a new comment arrives, **WHEN** the comment is appended, **THEN** the viewport auto-scrolls to show the new comment.
|
||||
|
||||
|
||||
@@ -38,6 +38,5 @@ the asynchronous hand-off it is designed to protect.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing the transcript toggle's location, labels, persisted preference, or
|
||||
Virtuoso renderer behavior.
|
||||
- Changing the transcript toggle's location, labels, or persisted preference.
|
||||
- Changing clarification recovery production behavior or ownership rules.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-make-shells — assert that apps/backend/Makefile's run/dev/start-debug
|
||||
# targets expand to the correct per-shell command line, without running them.
|
||||
# and deadcode targets expand to the correct per-shell command line, without
|
||||
# running them.
|
||||
#
|
||||
# The Makefile dispatches on two independent axes:
|
||||
# * EXE suffix — keyed on $(OS): any Windows build (native cmd/PowerShell
|
||||
@@ -28,7 +29,7 @@ status=0
|
||||
|
||||
# expect <label> <OS> <MSYSTEM> <target> <expected recipe tail>
|
||||
expect() {
|
||||
local label=$1 os=$2 msys=$3 target=$4 want=$5 got
|
||||
local label=$1 os=$2 msys=$3 target=$4 want=$5 marker=${6:-__backend} got
|
||||
# The OS=/MSYSTEM= overrides only steer which branch the Makefile picks;
|
||||
# $(shell ...) still runs under this harness's POSIX shell, and it expands
|
||||
# even under `make -n`. So neutralise the build-stamp probes rather than let
|
||||
@@ -38,7 +39,7 @@ expect() {
|
||||
# a /dev/null argument to `nul` on its way to native make. None of this
|
||||
# affects the run/dev/start-debug recipes under assertion.
|
||||
got=$(make -C "$backend_dir" OS="$os" MSYSTEM="$msys" NULL_REDIR= BUILD_TIME=simulated -n "$target" 2>/dev/null \
|
||||
| grep -F __backend \
|
||||
| grep -F "$marker" \
|
||||
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]]\{1,\}/ /g' \
|
||||
|| true)
|
||||
if [ "$got" = "$want" ]; then
|
||||
@@ -65,6 +66,18 @@ expect git-bash Windows_NT MINGW64 run './bin/kandev.exe __backend'
|
||||
expect git-bash Windows_NT MINGW64 dev 'KANDEV_DEBUG_PPROF_ENABLED=true exec ./bin/kandev.exe __backend'
|
||||
expect git-bash Windows_NT MINGW64 start-debug 'KANDEV_DEBUG_PPROF_ENABLED=true KANDEV_LOG_LEVEL=debug exec ./bin/kandev.exe __backend'
|
||||
|
||||
# deadcode — the analyzer is invoked through the same shell-specific env-prefix
|
||||
# mechanism, but its command has no __backend marker, so match its module path.
|
||||
expect unix '' '' deadcode \
|
||||
'GOTOOLCHAIN=go1.26.0 go run golang.org/x/tools/cmd/deadcode@v0.48.0 -tags fts5 ./...' \
|
||||
'golang.org/x/tools/cmd/deadcode'
|
||||
expect windows-native Windows_NT '' deadcode \
|
||||
'set GOTOOLCHAIN=go1.26.0& go run golang.org/x/tools/cmd/deadcode@v0.48.0 -tags fts5 ./...' \
|
||||
'golang.org/x/tools/cmd/deadcode'
|
||||
expect git-bash Windows_NT MINGW64 deadcode \
|
||||
'GOTOOLCHAIN=go1.26.0 go run golang.org/x/tools/cmd/deadcode@v0.48.0 -tags fts5 ./...' \
|
||||
'golang.org/x/tools/cmd/deadcode'
|
||||
|
||||
# Static guard: no literal /dev/null inside $(shell ...).
|
||||
#
|
||||
# Unlike a recipe, which only ever runs on a platform its target supports,
|
||||
@@ -96,6 +109,6 @@ else
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "All Makefile shell-dispatch checks passed (3 shells x 3 targets, plus static guard)."
|
||||
echo "All Makefile shell-dispatch checks passed (3 shells x 4 targets, plus static guard)."
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
Reference in New Issue
Block a user