From c3fd434a24c963331822969a9ea09f313cad1e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 4 Aug 2026 22:15:32 +0100 Subject: [PATCH] refactor: remove unused Virtuoso code (#2224) --- Makefile | 12 + apps/backend/Makefile | 17 +- .../transport/acp/shell_output_test.go | 10 +- apps/knip.json | 11 + apps/package.json | 2 + apps/pnpm-lock.yaml | 632 ++++++++++++++-- .../components/session/prepare-progress.tsx | 2 +- .../task/chat/auto-scroll-toggle-button.tsx | 4 +- .../task/chat/message-list-native-scroll.ts | 29 +- .../task/chat/message-list-native.test.tsx | 98 ++- .../task/chat/message-list-native.tsx | 96 ++- .../task/chat/message-list-shared.tsx | 7 +- .../chat/message-list-virtuoso-auto-scroll.ts | 120 --- .../chat/message-list-virtuoso-edges.test.ts | 151 ---- .../task/chat/message-list-virtuoso-edges.ts | 61 -- .../task/chat/message-list-virtuoso-index.ts | 71 -- .../message-list-virtuoso-scroll-lock.test.ts | 30 - .../chat/message-list-virtuoso-scroll-lock.ts | 61 -- .../message-list-virtuoso-scroll-parent.ts | 54 -- .../task/chat/message-list-virtuoso.test.tsx | 162 ---- .../task/chat/message-list-virtuoso.tsx | 691 ------------------ .../task/chat/message-list.test.tsx | 40 + .../web/components/task/chat/message-list.tsx | 44 +- .../task/chat/transcript-auto-scroll.test.ts | 42 -- .../task/chat/transcript-auto-scroll.ts | 37 +- .../use-transcript-auto-scroll-enabled.ts | 9 +- apps/web/e2e/helpers/api-client.ts | 2 + .../e2e/tests/chat/message-pagination.spec.ts | 7 +- .../e2e/tests/search/session-search.spec.ts | 21 - apps/web/e2e/tests/search/shared.ts | 5 +- ...-session-stream-overload-isolation.spec.ts | 65 +- apps/web/generated/licenses.json | 8 - apps/web/lib/debug/log.ts | 23 +- .../ui/transcript-auto-scroll-actions.test.ts | 8 - apps/web/lib/state/slices/ui/types.ts | 5 - apps/web/lib/state/slices/ui/ui-slice.ts | 5 - apps/web/lib/state/store.ts | 1 - apps/web/package.json | 1 - .../last-prompt-pinning-regressions/spec.md | 3 +- docs/specs/ui/comment-markdown.md | 7 +- docs/specs/ui/transcript-auto-scroll.md | 3 +- scripts/check-make-shells | 21 +- 42 files changed, 952 insertions(+), 1726 deletions(-) create mode 100644 apps/knip.json delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-auto-scroll.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-edges.test.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-edges.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-index.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-scroll-lock.test.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-scroll-lock.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso-scroll-parent.ts delete mode 100644 apps/web/components/task/chat/message-list-virtuoso.test.tsx delete mode 100644 apps/web/components/task/chat/message-list-virtuoso.tsx create mode 100644 apps/web/components/task/chat/message-list.test.tsx diff --git a/Makefile b/Makefile index cc6b90ac8b..e72a853a89 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/apps/backend/Makefile b/apps/backend/Makefile index e8d7091caf..8e0c7243cb 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -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..." diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output_test.go index eee71baecf..d525f7e9db 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/shell_output_test.go @@ -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 diff --git a/apps/knip.json b/apps/knip.json new file mode 100644 index 0000000000..65fe306fb4 --- /dev/null +++ b/apps/knip.json @@ -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"] + } + } + } +} diff --git a/apps/package.json b/apps/package.json index 06c75ccf1e..0d91dcf45e 100644 --- a/apps/package.json +++ b/apps/package.json @@ -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" }, diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index bc7dfbc34c..b6f51293f4 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.28 + knip: + specifier: 6.31.0 + version: 6.31.0 prettier: specifier: ^3.6.2 version: 3.8.1 @@ -96,7 +99,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) desktop: devDependencies: @@ -108,7 +111,7 @@ importers: version: 5.9.3 vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + version: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) packages/theme: dependencies: @@ -485,9 +488,6 @@ importers: react-resizable-panels: specifier: ^4.4.1 version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react-virtuoso: - specifier: ^4.18.1 - version: 4.18.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -560,25 +560,25 @@ importers: version: 19.2.3(@types/react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 eslint: specifier: ^9 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@2.7.0) eslint-plugin-i18next: specifier: ^6.1.5 version: 6.1.5 eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.1.1(eslint@9.39.2(jiti@2.6.1)) + version: 7.1.1(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-sonarjs: specifier: ^3.0.7 - version: 3.0.7(eslint@9.39.2(jiti@2.6.1)) + version: 3.0.7(eslint@9.39.2(jiti@2.7.0)) eslint-plugin-unused-imports: specifier: ^4.4.1 - version: 4.4.1(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)) + version: 4.4.1(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0)) happy-dom: specifier: ^20.8.9 version: 20.8.9 @@ -593,13 +593,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.53.0 - version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) vite: specifier: 8.0.16 - version: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + version: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + version: 4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -969,6 +969,9 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -978,6 +981,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1565,9 +1571,226 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.142.0': + resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.142.0': + resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.142.0': + resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.142.0': + resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@pierre/diffs@1.1.22': resolution: {integrity: sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg==} peerDependencies: @@ -4163,6 +4386,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4206,6 +4432,11 @@ packages: flatted@3.4.3: resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4286,10 +4517,13 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -4595,6 +4829,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -4661,6 +4899,11 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@6.31.0: + resolution: {integrity: sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -5244,6 +5487,13 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-parser@0.142.0: + resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5572,12 +5822,6 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' - react-virtuoso@4.18.1: - resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} - peerDependencies: - react: '>=16 || >=17 || >= 18 || >= 19' - react-dom: '>=16 || >=17 || >= 18 || >=19' - react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -5802,6 +6046,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -5885,6 +6133,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -6043,6 +6295,10 @@ packages: ufo@1.6.2: resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + unbash@4.0.5: + resolution: {integrity: sha512-EE9xv9cr93DSppe086Rnbq0jwG7MBCEe22JZ4UQ8Bn9RyUwDSoUpBmzdb5+7PzocdqBEb/K73FGPaQUMmLxTQQ==} + engines: {node: '>=14'} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6233,6 +6489,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -6307,6 +6567,11 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6345,6 +6610,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.10: resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} engines: {node: '>=12.20.0'} @@ -6927,6 +7195,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -6942,6 +7216,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -7020,9 +7299,9 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.7.0))': dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -7424,6 +7703,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -7453,8 +7739,135 @@ snapshots: '@open-draft/until@2.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.142.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.142.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.142.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.142.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.142.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.142.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.142.0': + optional: true + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.142.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@pierre/diffs@1.1.22(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 0.0.28 @@ -8324,14 +8737,14 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.5 rolldown: 1.0.3 optionalDependencies: '@babel/runtime': 7.29.7 - vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) optional: true '@rolldown/pluginutils@1.0.1': {} @@ -9020,15 +9433,15 @@ snapshots: dependencies: '@types/node': 20.19.28 - '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.53.0 - '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.53.0 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -9036,14 +9449,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/types': 8.53.0 '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.53.0 debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9066,13 +9479,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.53.0 '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -9095,13 +9508,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.53.0 '@typescript-eslint/types': 8.53.0 '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -9167,12 +9580,12 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -9183,14 +9596,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': + '@vitest/mocker@4.1.10(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.7(@types/node@20.19.28)(typescript@5.9.3) - vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -9915,23 +10328,23 @@ snapshots: dependencies: requireindex: 1.1.0 - eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.28.6 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color - eslint-plugin-sonarjs@3.0.7(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-sonarjs@3.0.7(eslint@9.39.2(jiti@2.7.0)): dependencies: '@eslint-community/regexpp': 4.12.2 builtin-modules: 3.3.0 bytes: 3.1.2 - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) functional-red-black-tree: 1.0.1 jsx-ast-utils-x: 0.1.0 lodash.merge: 4.6.2 @@ -9940,11 +10353,11 @@ snapshots: semver: 7.7.4 typescript: 5.9.3 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0)): dependencies: - eslint: 9.39.2(jiti@2.6.1) + eslint: 9.39.2(jiti@2.7.0) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -9955,9 +10368,9 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.2(jiti@2.6.1): + eslint@9.39.2(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -9992,7 +10405,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -10126,6 +10539,10 @@ snapshots: dependencies: reusify: 1.1.0 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -10172,6 +10589,10 @@ snapshots: flatted@3.4.3: {} + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -10241,6 +10662,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 @@ -10550,6 +10975,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.1.3: {} js-tokens@4.0.0: {} @@ -10598,6 +11025,22 @@ snapshots: kleur@4.1.5: {} + knip@6.31.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.142.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.5 + smol-toml: 1.7.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.5 + yaml: 2.9.0 + zod: 4.4.3 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -11368,6 +11811,53 @@ snapshots: outvariant@1.4.3: {} + oxc-parser@0.142.0: + dependencies: + '@oxc-project/types': 0.142.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.142.0 + '@oxc-parser/binding-android-arm64': 0.142.0 + '@oxc-parser/binding-darwin-arm64': 0.142.0 + '@oxc-parser/binding-darwin-x64': 0.142.0 + '@oxc-parser/binding-freebsd-x64': 0.142.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 + '@oxc-parser/binding-linux-arm64-musl': 0.142.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-gnu': 0.142.0 + '@oxc-parser/binding-linux-x64-musl': 0.142.0 + '@oxc-parser/binding-openharmony-arm64': 0.142.0 + '@oxc-parser/binding-wasm32-wasi': 0.142.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 + '@oxc-parser/binding-win32-x64-msvc': 0.142.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11795,11 +12285,6 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-virtuoso@4.18.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react@19.2.3: {} recast@0.23.11: @@ -12163,6 +12648,8 @@ snapshots: sisteransi@1.0.5: {} + smol-toml@1.7.1: {} + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -12229,6 +12716,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + style-mod@4.1.3: {} style-to-js@1.1.21: @@ -12358,13 +12847,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@typescript-eslint/utils': 8.53.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -12375,6 +12864,8 @@ snapshots: ufo@1.6.2: {} + unbash@4.0.5: {} + undici-types@6.21.0: {} unicorn-magic@0.3.0: {} @@ -12496,7 +12987,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0): + vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -12507,13 +12998,14 @@ snapshots: '@types/node': 20.19.28 esbuild: 0.27.2 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 + yaml: 2.9.0 - vitest@4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)): + vitest@4.1.10(@types/node@20.19.28)(happy-dom@20.8.9)(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + '@vitest/mocker': 4.1.10(msw@2.12.7(@types/node@20.19.28)(typescript@5.9.3))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -12530,7 +13022,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.28 @@ -12540,6 +13032,8 @@ snapshots: w3c-keyname@2.2.8: {} + walk-up-path@4.0.0: {} + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} @@ -12593,6 +13087,8 @@ snapshots: yallist@5.0.0: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -12625,6 +13121,8 @@ snapshots: zod@3.25.76: {} + zod@4.4.3: {} + zustand@5.0.10(@types/react@19.2.8)(immer@10.2.0)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.2.8 diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx index 3e81a88177..9f4e8fa3b3 100644 --- a/apps/web/components/session/prepare-progress.tsx +++ b/apps/web/components/session/prepare-progress.tsx @@ -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(null); useEffect(() => { if (!isDebug()) return; diff --git a/apps/web/components/task/chat/auto-scroll-toggle-button.tsx b/apps/web/components/task/chat/auto-scroll-toggle-button.tsx index a029548de2..38967c73ce 100644 --- a/apps/web/components/task/chat/auto-scroll-toggle-button.tsx +++ b/apps/web/components/task/chat/auto-scroll-toggle-button.tsx @@ -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 diff --git a/apps/web/components/task/chat/message-list-native-scroll.ts b/apps/web/components/task/chat/message-list-native-scroll.ts index b6d5bda906..d3eb4e85ea 100644 --- a/apps/web/components/task/chat/message-list-native-scroll.ts +++ b/apps/web/components/task/chat/message-list-native-scroll.ts @@ -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; 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; @@ -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 }; } diff --git a/apps/web/components/task/chat/message-list-native.test.tsx b/apps/web/components/task/chat/message-list-native.test.tsx index 5e528d7269..eaf336d815 100644 --- a/apps/web/components/task/chat/message-list-native.test.tsx +++ b/apps/web/components/task/chat/message-list-native.test.tsx @@ -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(null); - useScrollToDividerOrBottom(scrollRef, itemCount, dividerKey, anchoredBarOffsetPx); + useScrollToDividerOrBottom( + scrollRef, + itemCount, + dividerKey, + anchoredBarOffsetPx, + onDividerScroll, + ); return (
@@ -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(null); + const { markNotNearBottom } = useAutoScroll({ + scrollRef, + messages, + isWorking, + sessionId: null, + enabled: true, + hasUnreadDivider, + isProgrammaticScrollLocked: NEVER_LOCKED, + }); + if (markRef) markRef.current = markNotNearBottom; + return
; +} + +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(); + + expect(onDividerScroll).toHaveBeenCalledTimes(1); + }); + + it("does not follow the bottom when work starts with an unread divider", () => { + const { rerender } = render(); + const scrollContainer = document.querySelector( + '[data-testid="auto-scroll-container"]', + ); + if (!scrollContainer) throw new Error("auto-scroll container did not render"); + setScrollMetrics(scrollContainer); + scrollContainer.scrollTop = 123; + + rerender(); + + 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( + , + ); + const scrollContainer = document.querySelector( + '[data-testid="auto-scroll-container"]', + ); + if (!scrollContainer) throw new Error("auto-scroll container did not render"); + setScrollMetrics(scrollContainer); + scrollContainer.scrollTop = 123; + markRef.current?.(); + + rerender( + , + ); + + 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; diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 5c54fea7ae..8c25088fc4 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -72,6 +72,80 @@ function useTranscriptEdgeTracking( ]); } +type NativeMessageListScrollParams = { + scrollRef: React.RefObject; + ref: React.ForwardedRef; + items: RenderItem[]; + messages: Message[]; + isWorking: boolean; + sessionId: string | null; + enabled: boolean; + dividerBeforeItemKey?: string | null; + anchoredBarHeight?: number; + hasMore: boolean; + isLoadingMore: boolean; + loadMore: () => Promise; + 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(`[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 ( 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)); } diff --git a/apps/web/components/task/chat/message-list-virtuoso-auto-scroll.ts b/apps/web/components/task/chat/message-list-virtuoso-auto-scroll.ts deleted file mode 100644 index 3ff05b0941..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-auto-scroll.ts +++ /dev/null @@ -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; - 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(() => { - if (enabled || !sessionId) return undefined; - return storeApi.getState().transcriptAutoScroll.virtuosoStateBySessionId[sessionId]; - }); - - return { handleAtBottomStateChange, restoreStateFrom }; -} diff --git a/apps/web/components/task/chat/message-list-virtuoso-edges.test.ts b/apps/web/components/task/chat/message-list-virtuoso-edges.test.ts deleted file mode 100644 index ce6b274969..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-edges.test.ts +++ /dev/null @@ -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", - ); - }); -}); diff --git a/apps/web/components/task/chat/message-list-virtuoso-edges.ts b/apps/web/components/task/chat/message-list-virtuoso-edges.ts deleted file mode 100644 index 6bacbbf03b..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-edges.ts +++ /dev/null @@ -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( - 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"; -} diff --git a/apps/web/components/task/chat/message-list-virtuoso-index.ts b/apps/web/components/task/chat/message-list-virtuoso-index.ts deleted file mode 100644 index 0f5b110b8f..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-index.ts +++ /dev/null @@ -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(() => { - 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; -} diff --git a/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.test.ts b/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.test.ts deleted file mode 100644 index 0dc46ba51b..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.ts b/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.ts deleted file mode 100644 index 5b1b0364ac..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-scroll-lock.ts +++ /dev/null @@ -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], - ); -} diff --git a/apps/web/components/task/chat/message-list-virtuoso-scroll-parent.ts b/apps/web/components/task/chat/message-list-virtuoso-scroll-parent.ts deleted file mode 100644 index e1b5709b02..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso-scroll-parent.ts +++ /dev/null @@ -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(null); - const nodeRef = useRef(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 }; -} diff --git a/apps/web/components/task/chat/message-list-virtuoso.test.tsx b/apps/web/components/task/chat/message-list-virtuoso.test.tsx deleted file mode 100644 index ab76f47e9c..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso.test.tsx +++ /dev/null @@ -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(); - - 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(); - - 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(); - expect(scrollToIndex).toHaveBeenCalledTimes(1); - expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: 0 }); - - rerender(); - - 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(); - expect(scrollToIndex).toHaveBeenCalledTimes(1); - - scrollParent.dispatchEvent(new Event("wheel", { bubbles: true })); - - rerender(); - - 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(); - expect(scrollToIndex).not.toHaveBeenCalled(); - - rerender(); - - 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(); - expect(scrollToIndex).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(4001); - rerender(); - - 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(); - 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(); - - expect(scrollToIndex).toHaveBeenCalledTimes(2); - expect(scrollToIndex).toHaveBeenLastCalledWith({ index: 1, align: "start", offset: -76 }); - }); -}); diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx deleted file mode 100644 index 1de2ec633d..0000000000 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ /dev/null @@ -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; - 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( - '[data-last-prompt-row="true"]', - ); - onLastPromptEdgeChange?.( - resolveVirtuosoEdgeState(lastPromptRow, scrollParent, lastPromptItemIndex, renderedRange, { - geometryCheck: resolveLastPromptEdge, - fromRangePosition: rangePositionToLastPromptEdge, - }), - ); - const firstMessageRow = scrollParent.querySelector( - '[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; - childrenByParentToolCallId: Map; - 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
; - 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 ( -
- {dividerBeforeItemKey === getItemKey(item) && } - -
- ); - }, - [ - 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, - 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(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, -) { - 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(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 `` 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( - 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 ( - - ); - }, -); - -type HeaderFooterArgs = { - isLoadingMore: boolean; - hasMore: boolean; - showLoadingState: boolean; - messagesLoading: boolean; - isInitialLoading: boolean; - messages: Message[]; - loadMore: () => Promise; - 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( - () => ( - - ), - [ - isLoadingMore, - hasMore, - showLoadingState, - messagesLoading, - isInitialLoading, - messages.length, - loadMore, - ], - ); - - const Footer = useCallback( - () => ( - - ), - [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(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 ( - - {stickyPromptBar} -
- - -
-
- ); - } - - return ( - - {stickyPromptBar} -
- {scrollParent && ( - - )} -
-
- ); - }), -); diff --git a/apps/web/components/task/chat/message-list.test.tsx b/apps/web/components/task/chat/message-list.test.tsx new file mode 100644 index 0000000000..29d5ca81af --- /dev/null +++ b/apps/web/components/task/chat/message-list.test.tsx @@ -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>((_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(); + + expect(screen.queryByTestId("native-message-list")).not.toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/message-list.tsx b/apps/web/components/task/chat/message-list.tsx index 387c6676c0..4cfd376e7f 100644 --- a/apps/web/components/task/chat/message-list.tsx +++ b/apps/web/components/task/chat/message-list.tsx @@ -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>; - -const strategies: Record = { - 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(function MessageList(props, ref) { - const key = useMemo(() => resolveStrategy(), []); - const Renderer = strategies[key] ?? NativeMessageList; - return ; - }), -); +export { NativeMessageList as MessageList } from "./message-list-native"; diff --git a/apps/web/components/task/chat/transcript-auto-scroll.test.ts b/apps/web/components/task/chat/transcript-auto-scroll.test.ts index 6b4b588cf3..246842b64a 100644 --- a/apps/web/components/task/chat/transcript-auto-scroll.test.ts +++ b/apps/web/components/task/chat/transcript-auto-scroll.test.ts @@ -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 void>; let nextFrameId: number; diff --git a/apps/web/components/task/chat/transcript-auto-scroll.ts b/apps/web/components/task/chat/transcript-auto-scroll.ts index 953f13195c..c0a3e28dc5 100644 --- a/apps/web/components/task/chat/transcript-auto-scroll.ts +++ b/apps/web/components/task/chat/transcript-auto-scroll.ts @@ -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; -} diff --git a/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts b/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts index 7c9dcaeec7..cf53c7baef 100644 --- a/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts +++ b/apps/web/components/task/chat/use-transcript-auto-scroll-enabled.ts @@ -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 diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 35930c2772..5e1db28334 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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, diff --git a/apps/web/e2e/tests/chat/message-pagination.spec.ts b/apps/web/e2e/tests/chat/message-pagination.spec.ts index 778fed9654..0c7232a35b 100644 --- a/apps/web/e2e/tests/chat/message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/message-pagination.spec.ts @@ -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"; diff --git a/apps/web/e2e/tests/search/session-search.spec.ts b/apps/web/e2e/tests/search/session-search.spec.ts index b58bfbeca0..e31aebe6b6 100644 --- a/apps/web/e2e/tests/search/session-search.spec.ts +++ b/apps/web/e2e/tests/search/session-search.spec.ts @@ -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 }); - }); }); diff --git a/apps/web/e2e/tests/search/shared.ts b/apps/web/e2e/tests/search/shared.ts index 43e2aa845c..0735ececae 100644 --- a/apps/web/e2e/tests/search/shared.ts +++ b/apps/web/e2e/tests/search/shared.ts @@ -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 }); diff --git a/apps/web/e2e/tests/session/mobile-session-stream-overload-isolation.spec.ts b/apps/web/e2e/tests/session/mobile-session-stream-overload-isolation.spec.ts index 0baf5c5015..b3ccf83592 100644 --- a/apps/web/e2e/tests/session/mobile-session-stream-overload-isolation.spec.ts +++ b/apps/web/e2e/tests/session/mobile-session-stream-overload-isolation.spec.ts @@ -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 }); diff --git a/apps/web/generated/licenses.json b/apps/web/generated/licenses.json index a165f13777..70214d925d 100644 --- a/apps/web/generated/licenses.json +++ b/apps/web/generated/licenses.json @@ -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", diff --git a/apps/web/lib/debug/log.ts b/apps/web/lib/debug/log.ts index da5aafe4a8..a6e4cd6b4e 100644 --- a/apps/web/lib/debug/log.ts +++ b/apps/web/lib/debug/log.ts @@ -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 diff --git a/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts b/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts index 3379b15920..f328696e4b 100644 --- a/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts +++ b/apps/web/lib/state/slices/ui/transcript-auto-scroll-actions.test.ts @@ -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, - ); - }); }); diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 6e2784311c..b21ee09ac2 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -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; - /** Last captured Virtuoso state snapshot (scroll offset + measured item - * sizes) for the virtuoso renderer, captured on disable/unmount. */ - virtuosoStateBySessionId: Record; }; 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; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 4b39f16c41..cb664e7fd9 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -91,7 +91,6 @@ export const defaultUIState: UISliceState = { transcriptAutoScroll: { enabledBySessionId: {}, scrollTopBySessionId: {}, - virtuosoStateBySessionId: {}, }, reviewPRSelection: { selectedKeyByTaskId: {} }, documentPanel: { activeDocumentBySessionId: {} }, @@ -482,10 +481,6 @@ export const createUISlice: StateCreator - set((draft) => { - draft.transcriptAutoScroll.virtuosoStateBySessionId[sessionId] = state; - }), setReviewPRSelection: (taskId, selectedKey) => set((draft) => { draft.reviewPRSelection.selectedKeyByTaskId[taskId] = selectedKey; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 76e0f45f96..da8149dac1 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -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; diff --git a/apps/web/package.json b/apps/web/package.json index f1bf0579dc..605f9a2a6a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/docs/specs/last-prompt-pinning-regressions/spec.md b/docs/specs/last-prompt-pinning-regressions/spec.md index 5110eb108b..585eaf2ed5 100644 --- a/docs/specs/last-prompt-pinning-regressions/spec.md +++ b/docs/specs/last-prompt-pinning-regressions/spec.md @@ -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 diff --git a/docs/specs/ui/comment-markdown.md b/docs/specs/ui/comment-markdown.md index fe15606a68..b827f33ff4 100644 --- a/docs/specs/ui/comment-markdown.md +++ b/docs/specs/ui/comment-markdown.md @@ -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/`. 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. diff --git a/docs/specs/ui/transcript-auto-scroll.md b/docs/specs/ui/transcript-auto-scroll.md index b356500dd4..e960d7fcf5 100644 --- a/docs/specs/ui/transcript-auto-scroll.md +++ b/docs/specs/ui/transcript-auto-scroll.md @@ -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. diff --git a/scripts/check-make-shells b/scripts/check-make-shells index a4786fba65..53a9e3e406 100755 --- a/scripts/check-make-shells +++ b/scripts/check-make-shells @@ -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