perf(kanban-search): defer the filter and stabilize its derived identities

Clearing a query re-mounts every hidden card, so it costs roughly what
opening the board costs. The input stays controlled and undebounced, but
the filter now reads a deferred query so React can interrupt that work and
the caret stays responsive.

The match set also keeps its identity when the matched ids are unchanged.
Board worktree identities churn on agent-status ticks, and a fresh Set on
every tick cascaded new identities through the lane views, the rendered
selection, and every memoized card.

Also harden the lane full-id channel: the identity guard in
resolveFullLaneDropIndex compares membership rather than length, so a stale
lane of equal size no longer skips translation; serialization declines ids
containing the newline delimiter instead of inventing phantom lane members;
the sidebar drop path scans lane cards once instead of twice; and the
unfiltered full-id fallback is no longer offsetParent-filtered, restoring
the pre-branch notion of lane membership.

Adds coverage for the stale-equal-length lane, the full-id round trip,
regex metacharacters and non-ASCII queries, and the over-bound query at the
drawer level.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-29 17:05:27 -07:00
co-authored by Orca
parent aad85a6bff
commit bfd183cf57
9 changed files with 176 additions and 38 deletions
@@ -3,6 +3,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WORKTREE_PALETTE_QUERY_MAX_BYTES } from '@/lib/worktree-palette-query-bounds'
import { useAppStore } from '@/store'
import type { Repo, Worktree, WorktreeMeta } from '../../../../shared/types'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
@@ -374,6 +375,19 @@ describe('WorkspaceKanbanDrawer search', () => {
expect(laneIds('todo')).toHaveLength(4)
})
it('leaves the whole board unfiltered for an over-bound query', () => {
// Why: searchWorktrees returns [] past the byte bound, which would read as
// "matched nothing" and blank every lane on a paste accident.
renderDrawer()
typeQuery('x'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES + 1))
expect(laneIds('todo')).toHaveLength(4)
expect(laneIds('in-review')).toEqual([omega.id])
expect(gridState.current?.hasQuery).toBe(false)
expect(headerState.current).toMatchObject({ isFiltering: false, matchCount: 5, totalCount: 5 })
})
it('ranks a drop into a filtered lane against the full lane, not the rendered one', () => {
renderDrawer()
typeQuery('gamma')
@@ -172,16 +172,16 @@ describe('WorkspaceKanbanSearchField', () => {
expect(document.activeElement).toBe(input())
})
it('reserves counter width so a large count cannot overlap typed text', () => {
it('reserves counter width in font-relative units so a large count cannot overlap typed text', () => {
// '298 / 1024' is 10 characters; a fixed reserve would let it overlap.
renderField({ query: 'orca', matchCount: 298, totalCount: 1024 })
const wide = Number.parseFloat(input().style.paddingRight)
expect(input().style.paddingRight).toContain('10ch')
renderField({ query: 'orca', matchCount: 3, totalCount: 9 })
const narrow = Number.parseFloat(input().style.paddingRight)
expect(input().style.paddingRight).toContain('5ch')
expect(wide).toBeGreaterThan(narrow)
// '298 / 1024' is 10 characters, so the reserve must clear the clear button
// plus the full counter rather than a fixed 64px guess.
expect(wide).toBeGreaterThanOrEqual(32 + 10 * 6)
// A non-filtering query shows no counter, so it reserves only the button.
renderField({ query: ' ', isFiltering: false })
expect(input().style.paddingRight).not.toContain('ch')
})
})
@@ -7,9 +7,10 @@ import { translate } from '@/i18n/i18n'
const ANNOUNCE_DEBOUNCE_MS = 400
// Why: the counter overlays the input, so its width has to be reserved rather
// than guessed — a fixed reserve overlaps typed text once counts reach 3 digits.
// Safe over-estimates at text-[10px] tabular-nums, where '/' and ' ' are narrower.
// `ch` keeps that reserve font-relative across platforms: it measures the input's
// own 12px font while the counter renders at 10px tabular-nums, so digits always
// over-reserve and '/' and ' ' are narrower still.
const CLEAR_BUTTON_RESERVE_PX = 32
const COUNTER_CHAR_PX = 6
const COUNTER_GAP_PX = 4
type WorkspaceKanbanSearchFieldProps = {
@@ -24,6 +25,13 @@ type WorkspaceKanbanSearchFieldProps = {
onClose: () => void
}
function counterReserve(counterText: string | null): string {
if (!counterText) {
return `${CLEAR_BUTTON_RESERVE_PX}px`
}
return `calc(${CLEAR_BUTTON_RESERVE_PX + COUNTER_GAP_PX}px + ${counterText.length}ch)`
}
function formatAnnouncement(matchCount: number, totalCount: number): string {
return matchCount === 0
? translate(
@@ -80,15 +88,7 @@ export default function WorkspaceKanbanSearchField({
'Search workspaces'
)}
className="h-7 border-worktree-sidebar-border bg-background pl-7 text-xs"
style={
hasText
? {
paddingRight:
CLEAR_BUTTON_RESERVE_PX +
(counterText ? counterText.length * COUNTER_CHAR_PX + COUNTER_GAP_PX : 0)
}
: undefined
}
style={hasText ? { paddingRight: counterReserve(counterText) } : undefined}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
// Why: useWorkspaceBoardPanel defers Escape to board text fields, so
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useMemo } from 'react'
import { Plus } from 'lucide-react'
import type {
Repo,
@@ -80,6 +80,17 @@ export default function WorkspaceKanbanStatusLane({
// only a lane whose cards were filtered away has anything to say about matches.
const laneTotalCount = totalCount ?? items.length
const isFiltered = hasQuery && laneTotalCount > 0
// Why: this joins every id in the lane, so it must not rerun on unrelated
// board re-renders — at a few hundred cards it is ~25KB of string per pass.
const laneFullIdsAttribute = useMemo(() => {
if (!hasQuery) {
return undefined
}
return (
serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id)) ??
undefined
)
}, [fullWorktreeIds, hasQuery, items])
const createTooltip = canCreateWorktree
? `New workspace in ${status.label}`
: 'Add a project to create workspaces'
@@ -105,11 +116,7 @@ export default function WorkspaceKanbanStatusLane({
// where a search query would otherwise leave them only the rendered cards.
// Unfiltered lanes stay off this channel — the rendered scan already is the
// full lane, and every board id in an attribute is real DOM weight.
data-workspace-lane-full-ids={
hasQuery
? serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id))
: undefined
}
data-workspace-lane-full-ids={laneFullIdsAttribute}
data-contextual-tour-target={
status.id === 'completed' ? 'workspace-board-done-lane' : undefined
}
@@ -1,7 +1,19 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react'
import type { Repo, Worktree } from '../../../../shared/types'
import { matchWorkspaceBoardWorktrees } from './workspace-kanban-search'
function areWorktreeIdSetsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) {
return false
}
for (const id of a) {
if (!b.has(id)) {
return false
}
}
return true
}
export function useWorkspaceKanbanSearch(args: {
open: boolean
worktrees: Worktree[]
@@ -21,16 +33,32 @@ export function useWorkspaceKanbanSearch(args: {
setQuery('')
}
const matchingWorktreeIds = useMemo(
// Why: the input stays fully controlled and undebounced, but a query change
// mounts or unmounts every hidden card — clearing one costs about what opening
// the board costs. Deferring only the filter keeps the caret responsive and
// lets React interrupt the board re-render.
const deferredQuery = useDeferredValue(query)
const matched = useMemo(
() =>
matchWorkspaceBoardWorktrees({
worktrees: args.worktrees,
query,
query: deferredQuery,
repoMap: args.repoMap
}),
[args.repoMap, args.worktrees, query]
[args.repoMap, args.worktrees, deferredQuery]
)
// Why: board identities churn on agent-status ticks, so an unchanged match set
// must keep its identity or every memoized card re-renders on every tick.
const stableMatchedRef = useRef<ReadonlySet<string> | null>(null)
const previousMatched = stableMatchedRef.current
const matchingWorktreeIds =
previousMatched && matched && areWorktreeIdSetsEqual(previousMatched, matched)
? previousMatched
: matched
stableMatchedRef.current = matchingWorktreeIds
const clearQuery = useCallback(() => setQuery(''), [])
return {
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { resolveFullLaneDropIndex } from './workspace-kanban-filtered-drop-index'
import {
parseWorkspaceLaneFullIds,
resolveFullLaneDropIndex,
serializeWorkspaceLaneFullIds
} from './workspace-kanban-filtered-drop-index'
const FULL = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
@@ -72,6 +76,18 @@ describe('resolveFullLaneDropIndex', () => {
).toBe(FULL.length)
})
it('still translates when a stale lane has the same length but different members', () => {
// Why: a length-only guard would take the identity branch here and skip
// translation, landing the card at an index that means nothing in FULL.
expect(
resolveFullLaneDropIndex({
fullLaneIds: ['a', 'b', 'c'],
renderedIds: ['a', 'x', 'c'],
filteredDropIndex: 1
})
).toBe(3)
})
it('clamps out-of-range filtered indices to the first and last branches', () => {
expect(
resolveFullLaneDropIndex({
@@ -89,3 +105,25 @@ describe('resolveFullLaneDropIndex', () => {
).toBe(5)
})
})
describe('workspace lane full-id channel', () => {
it('round-trips lane membership through the delimiter', () => {
const ids = ['repo-a::/Users/dev/projects/orca/main', 'repo-b::C:\\src\\atlas, v2']
const serialized = serializeWorkspaceLaneFullIds(ids)
expect(serialized).not.toBeNull()
expect(parseWorkspaceLaneFullIds(serialized ?? undefined)).toEqual(ids)
})
it('distinguishes an unpublished lane from an empty one', () => {
expect(parseWorkspaceLaneFullIds(undefined)).toBeNull()
expect(parseWorkspaceLaneFullIds('')).toEqual([])
expect(serializeWorkspaceLaneFullIds([])).toBe('')
})
it('refuses to publish a lane whose ids would split on the delimiter', () => {
// Why: a newline is legal in a POSIX path. Publishing would invent phantom
// lane members; declining makes the reader fall back to scanning cards.
expect(serializeWorkspaceLaneFullIds(['repo-a::/Users/dev/we\nird', 'repo-a::/ok'])).toBeNull()
})
})
@@ -2,7 +2,16 @@
// separator in the `data-workspace-lane-full-ids` channel; newlines are not.
export const WORKSPACE_LANE_FULL_IDS_DELIMITER = '\n'
export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string {
/**
* Returns `null` when the lane cannot be represented on this channel, so the
* caller omits the attribute and the reader falls back to scanning cards.
* Why: a newline is legal in a POSIX path, and joining over one would silently
* split a single id into two phantom lane members.
*/
export function serializeWorkspaceLaneFullIds(worktreeIds: readonly string[]): string | null {
if (worktreeIds.some((worktreeId) => worktreeId.includes(WORKSPACE_LANE_FULL_IDS_DELIMITER))) {
return null
}
return worktreeIds.join(WORKSPACE_LANE_FULL_IDS_DELIMITER)
}
@@ -29,7 +38,9 @@ export function resolveFullLaneDropIndex(args: {
filteredDropIndex: number
}): number {
const { fullLaneIds, renderedIds, filteredDropIndex } = args
if (renderedIds.length === fullLaneIds.length) {
// Why: equal lengths alone would take this branch for a stale DOM lane that
// holds the same card count but different membership, skipping translation.
if (isSameLane(fullLaneIds, renderedIds)) {
return filteredDropIndex
}
if (renderedIds.length === 0) {
@@ -46,6 +57,13 @@ export function resolveFullLaneDropIndex(args: {
return indexInFullLane(fullLaneIds, renderedIds[filteredDropIndex]!)
}
function isSameLane(fullLaneIds: readonly string[], renderedIds: readonly string[]): boolean {
return (
renderedIds.length === fullLaneIds.length &&
renderedIds.every((worktreeId, index) => worktreeId === fullLaneIds[index])
)
}
// Why: a rendered id missing from the full lane is a stale-DOM race. Returning
// -1 would clamp to 0 downstream and teleport the card to the top of the lane.
function indexInFullLane(fullLaneIds: readonly string[], worktreeId: string): number {
@@ -80,6 +80,29 @@ describe('matchWorkspaceBoardWorktrees', () => {
expect(match(worktrees, 'SEARCH')).toEqual(new Set(['a']))
})
it('treats regex metacharacters as literal text', () => {
// Why: matching is indexOf, never RegExp. This pins that, so swapping in a
// regex later fails here instead of silently changing what users can search.
const worktrees = [
worktree({ id: 'literal', displayName: 'feat.*fix' }),
worktree({ id: 'would-match-as-regex', displayName: 'featANYfix' })
]
expect(match(worktrees, 'feat.*fix')).toEqual(new Set(['literal']))
expect(match(worktrees, '(')).toEqual(new Set())
})
it('matches non-ASCII display names and comments', () => {
const worktrees = [
worktree({ id: 'cjk', displayName: '検索フィールド' }),
worktree({ id: 'accent', displayName: 'Other', comment: 'Añadir búsqueda' }),
worktree({ id: 'miss', displayName: 'Other' })
]
expect(match(worktrees, 'フィールド')).toEqual(new Set(['cjk']))
expect(match(worktrees, 'BÚSQUEDA')).toEqual(new Set(['accent']))
})
it('treats an over-bound query as no filtering rather than zero matches', () => {
const worktrees = [worktree({ id: 'a', displayName: 'Search field' })]
@@ -48,18 +48,27 @@ export function isWorkspaceKanbanSidebarDropPointInBoard(x: number, y: number):
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
}
// Mirrors getCardDropTarget's card scan so both sides share one index space.
function getRenderedLaneCardIds(lane: HTMLElement): string[] {
function getLaneCardIds(lane: HTMLElement): HTMLElement[] {
return Array.from(lane.querySelectorAll<HTMLElement>(CARD_SELECTOR))
}
// Mirrors getCardDropTarget's card scan so both sides share one index space.
// The offsetParent read forces layout, so callers pass the card list they
// already collected rather than re-querying.
function toRenderedCardIds(cards: readonly HTMLElement[]): string[] {
return cards
.filter((card) => card.offsetParent !== null)
.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
}
// Why: board search hides non-matching cards, so the rendered card scan is a
// filtered lane. Lanes publish their full membership for exactly this reader.
function getLaneFullWorktreeIds(lane: HTMLElement): string[] {
// The fallback is the unfiltered card list — a lane member the browser is not
// laying out is still a member for manual-order purposes.
function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] {
return (
parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? getRenderedLaneCardIds(lane)
parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ??
cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? [])
)
}
@@ -99,7 +108,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] {
if (!status) {
return []
}
return [{ key: status, worktreeIds: getLaneFullWorktreeIds(lane) }]
return [{ key: status, worktreeIds: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }]
})
}
@@ -130,9 +139,10 @@ export function resolveWorkspaceKanbanSidebarFullLaneDropIndex(
if (!lane) {
return renderedDropIndex
}
const cards = getLaneCardIds(lane)
return resolveFullLaneDropIndex({
fullLaneIds: getLaneFullWorktreeIds(lane),
renderedIds: getRenderedLaneCardIds(lane),
fullLaneIds: toLaneFullWorktreeIds(lane, cards),
renderedIds: toRenderedCardIds(cards),
filteredDropIndex: renderedDropIndex
})
}