Skip to content

Commit 7300a89

Browse files
AmagiDDmxhgraydawncclaude
authored
Stacked features 7: dependency check + extra debounce search input (#418)
* chore: group dependency updates by risk * perf: debounce session-local find * fix(find): keep previous result visible while a new query is pending The debounce hid all find state for its 120ms window: highlights and the count blanked on every pause, Enter was silently swallowed, and a click into the list could get focus yanked back when the count settled. Chrome-style instead: the previous query's matches stay visible, counted, and navigable until the new projection lands; the count dims while pending ('No matches' still waits for the settle so it never flashes mid-typing). The find bar reclaims focus only when it sits on the bar's own controls, so focus moved into the message list stays there. The debounce interval gets a name. Two e2e tests pin the semantics: a MutationObserver asserts the status text never blanks across a retype (immune to debounce timing), and the focus test covers both the reclaim-after-button and the stay-in-list paths via the mod+arrow hotkey. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Chen <99816898+donteatfriedrice@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b72efb8 commit 7300a89

5 files changed

Lines changed: 278 additions & 10 deletions

File tree

.github/dependabot.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
version: 2
2+
3+
updates:
4+
- package-ecosystem: npm
5+
directory: /
6+
schedule:
7+
interval: weekly
8+
day: monday
9+
time: "04:00"
10+
timezone: Etc/UTC
11+
open-pull-requests-limit: 10
12+
groups:
13+
# Keep routine developer-tool churn reviewable while excluding every
14+
# runtime family that needs its own release or packaged-app validation.
15+
tooling-patch-minor:
16+
applies-to: version-updates
17+
dependency-type: development
18+
patterns:
19+
- "*"
20+
exclude-patterns:
21+
- "@agentclientprotocol/*"
22+
- "@anthropic-ai/claude-agent-sdk"
23+
- "@electron/*"
24+
- "@tailwindcss/*"
25+
- "@types/better-sqlite3"
26+
- "@types/react*"
27+
- "@vitejs/*"
28+
- "@void/*"
29+
- "acp-extension-*"
30+
- "better-sqlite3"
31+
- "bindings"
32+
- "electron*"
33+
- "file-uri-to-path"
34+
- "i18next"
35+
- "react*"
36+
- "rehype-*"
37+
- "remark*"
38+
- "shiki"
39+
- "tailwindcss"
40+
- "vite"
41+
- "void"
42+
update-types:
43+
- patch
44+
- minor
45+
46+
electron-native-patch-minor:
47+
applies-to: version-updates
48+
patterns:
49+
- "@electron/*"
50+
- "@types/better-sqlite3"
51+
- "better-sqlite3"
52+
- "bindings"
53+
- "electron*"
54+
- "file-uri-to-path"
55+
update-types:
56+
- patch
57+
- minor
58+
59+
acp-patch-minor:
60+
applies-to: version-updates
61+
patterns:
62+
- "@agentclientprotocol/*"
63+
- "@anthropic-ai/claude-agent-sdk"
64+
- "acp-extension-*"
65+
update-types:
66+
- patch
67+
- minor
68+
69+
renderer-framework-patch-minor:
70+
applies-to: version-updates
71+
patterns:
72+
- "@tailwindcss/*"
73+
- "@types/react*"
74+
- "@vitejs/*"
75+
- "@void/*"
76+
- "i18next"
77+
- "react*"
78+
- "rehype-*"
79+
- "remark*"
80+
- "shiki"
81+
- "tailwindcss"
82+
- "vite"
83+
- "void"
84+
update-types:
85+
- patch
86+
- minor
87+
88+
# Major updates intentionally match no group. Dependabot will open them
89+
# separately so Electron, ACP, React/Vite, Shiki, Void, and i18next cannot
90+
# be hidden inside a bulk upgrade. Security updates also remain separate.

docs/engineering-optimization-roadmap.md

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Spool Engineering Optimization Roadmap
22

3-
> Status: Proposed
4-
> Updated: 2026-07-12
3+
> Status: Implemented
4+
> Updated: 2026-07-12
55
> Base: `main@049c4b2`
6-
> Intended implementer: `sol high`
6+
> Implementation: stacked branches `feat/typecheck-baseline` through
7+
> `feat/dependency-maintenance`
78
89
## 1. Objective
910

@@ -547,6 +548,52 @@ Acceptance criteria:
547548

548549
Goal: make future upgrades smaller and safer.
549550

551+
Implementation status: complete on `feat/dependency-maintenance`.
552+
553+
Implementation notes:
554+
555+
1. Add weekly Dependabot version updates at the pnpm workspace root. Dependabot
556+
uses the `npm` ecosystem for pnpm lockfiles and discovers all workspace
557+
manifests from that root.
558+
2. Group patch/minor updates into four explicit risk lanes: tooling,
559+
Electron/native, ACP, and renderer/framework. The tooling catch-all excludes
560+
every higher-risk runtime family, so a dependency can never drift into the
561+
broad group because of manifest placement.
562+
3. Leave major updates and security updates ungrouped. Electron, ACP, React,
563+
Vite, Shiki, Void, and i18next therefore produce independently reviewable
564+
PRs with their own release, E2E, or migration evidence.
565+
4. Set the open PR limit to ten so the four routine groups and isolated majors
566+
can coexist without creating an unbounded maintenance queue.
567+
5. Do not perform the audited Electron 34-to-43 jump in this roadmap. It needs a
568+
dedicated branch with staged Electron release notes, native ABI validation,
569+
packaged E2E, signing, and CI notarization evidence.
570+
6. Do not split orchestration modules or add React memoization without a
571+
functional ownership boundary or profiler evidence. File size alone does
572+
not justify either change.
573+
7. Debounce session-local find by 120 ms while keeping the controlled input
574+
synchronous. The expensive Markdown-to-visible-text projection now runs
575+
once after typing settles instead of once per keystroke; the previous
576+
query's highlights and count stay visible (dimmed) and navigable until
577+
the new projection lands, and only "No matches" waits for the settle.
578+
579+
Verification on 2026-07-12:
580+
581+
- `.github/dependabot.yml` parses as YAML and contains one pnpm-root update
582+
entry with four non-overlapping patch/minor risk groups.
583+
- Every explicitly high-risk package family excluded from the tooling catch-all
584+
is owned by exactly one narrower group.
585+
- Major and security updates have no matching bulk group and remain isolated.
586+
- Frozen install, nine-package typecheck, type-aware Oxlint, and all unit tests
587+
passed. The complete serial app E2E run finished with 166 passed, two
588+
retry-passed flaky tests, and one fixture-dependent skip.
589+
- CLI build passed, the packaged macOS app passed deep strict codesign
590+
verification, and the globally linked `sp status` read the 393.6 MB local
591+
index after the final Node ABI restore.
592+
- App passed all 488 unit tests after the session-local debounce change. The 22
593+
focused Electron E2E cases for global search and session detail passed,
594+
including rendered-Markdown matching, keyboard navigation, and the
595+
1,500-message deep-find fixture.
596+
550597
Changes:
551598

552599
1. Add automated dependency update PRs grouped by risk:
@@ -568,6 +615,15 @@ Changes:
568615
5. Use React profiling before adding memoization. File size alone is not proof
569616
of a render bottleneck.
570617

618+
Acceptance criteria:
619+
620+
- [x] Routine patch/minor tooling updates are grouped.
621+
- [x] Electron/native and ACP updates are separated from general tooling.
622+
- [x] Renderer/framework updates have an explicit risk lane.
623+
- [x] High-risk major and security updates remain independently reviewable.
624+
- [x] No unprofiled module split, memoization, or bulk major upgrade is added.
625+
- [x] Session-local find does not reparse every loaded message per keystroke.
626+
571627
## 4. Required Verification Matrix
572628

573629
Every PR must run the narrow checks for its changed surface. Before merging a

packages/app/e2e/session-detail.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,88 @@ test('find-in-page matches rendered text, not markdown source', async () => {
124124
await expect(window.locator('[data-testid="session-find-status"]')).toContainText('No matches')
125125
})
126126

127+
test('find keeps the previous result visible and navigable while retyping', async () => {
128+
const { window } = ctx
129+
await waitForSync(window)
130+
131+
await window.locator('[data-testid="sidebar-project-row"]').first().click()
132+
await window
133+
.locator('[data-testid="session-row"][data-session-uuid="test-session-uuid-001"]')
134+
.click()
135+
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible({ timeout: 5000 })
136+
137+
const isMac = process.platform === 'darwin'
138+
await window.keyboard.press(isMac ? 'Meta+f' : 'Control+f')
139+
140+
const input = window.locator('[data-testid="session-find-input"]')
141+
const status = window.locator('[data-testid="session-find-status"]')
142+
143+
await input.fill('XYZMARKDOWN')
144+
await expect(status).toContainText(/^\d+ of \d+$/)
145+
const settled = await status.textContent()
146+
147+
// Record every status transition in-page: the previous count must stay
148+
// visible through the debounce window (no blank flash) until the new
149+
// result replaces it. A MutationObserver sees each DOM change
150+
// synchronously, so this cannot race the 120 ms debounce.
151+
await window.evaluate(() => {
152+
const el = document.querySelector('[data-testid="session-find-status"]')
153+
if (!el) throw new Error('find status missing')
154+
const w = window as unknown as { __findLog?: (string | null)[]; __findObs?: MutationObserver }
155+
w.__findLog = [el.textContent]
156+
w.__findObs = new MutationObserver(() => { w.__findLog?.push(el.textContent) })
157+
w.__findObs.observe(el, { childList: true, characterData: true, subtree: true })
158+
})
159+
160+
await input.fill('XYZMARKDOWN-no-such-text')
161+
// Enter during the debounce window must act on the still-visible previous
162+
// result instead of being swallowed.
163+
await window.keyboard.press('Enter')
164+
await expect(status).toContainText('No matches', { timeout: 5000 })
165+
166+
const log = await window.evaluate(() => {
167+
const w = window as unknown as { __findLog?: (string | null)[]; __findObs?: MutationObserver }
168+
w.__findObs?.disconnect()
169+
return w.__findLog ?? []
170+
})
171+
expect(log[0]).toBe(settled)
172+
expect(log).not.toContain('')
173+
})
174+
175+
test('find refocuses after its own buttons but not after clicks into the list', async () => {
176+
const { window } = ctx
177+
await waitForSync(window)
178+
179+
await window.locator('[data-testid="sidebar-project-row"]').first().click()
180+
await window
181+
.locator(`[data-testid="session-row"][data-session-uuid="${LARGE_SESSION_UUID}"]`)
182+
.click()
183+
await expect(window.locator('[data-testid="session-detail"]')).toBeVisible({ timeout: 10000 })
184+
185+
const isMac = process.platform === 'darwin'
186+
await window.keyboard.press(isMac ? 'Meta+f' : 'Control+f')
187+
188+
const status = window.locator('[data-testid="session-find-status"]')
189+
await window.locator('[data-testid="session-find-input"]').fill('Message 14')
190+
await expect(status).toContainText(/^1 of \d+$/, { timeout: 5000 })
191+
192+
const focusedTestId = () =>
193+
window.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.['testid'] ?? document.activeElement?.tagName ?? '')
194+
195+
// Clicking the bar's own next button advances and hands focus back to the
196+
// input so typing stays seamless.
197+
await window.locator('[data-testid="session-find-next"]').click()
198+
await expect(status).toContainText(/^2 of \d+$/)
199+
await expect.poll(focusedTestId).toBe('session-find-input')
200+
201+
// Focus moved into the message list stays there: navigating via the
202+
// hotkey must not yank it back to the find input.
203+
await window.locator('[data-testid="message-list-scroll"]').click({ position: { x: 10, y: 10 } })
204+
await window.keyboard.press(isMac ? 'Meta+ArrowRight' : 'Control+ArrowRight')
205+
await expect(status).toContainText(/^3 of \d+$/)
206+
await expect.poll(focusedTestId).not.toBe('session-find-input')
207+
})
208+
127209
test('handles 1500-message session: virtualization + deep find', async () => {
128210
const { window } = ctx
129211
await waitForSync(window)

packages/app/src/renderer/components/SessionDetail.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ type Props = {
2727
onShare: (session: Session, messages: Message[]) => void
2828
}
2929

30+
// Keystrokes inside this window coalesce into one find projection — the
31+
// projection re-parses every message's markdown, so it must not run per key.
32+
const FIND_DEBOUNCE_MS = 120
33+
3034
export default function SessionDetail({ sessionUuid, targetMessageId, onCopySessionId, onBack, onShare }: Props) {
3135
const { t } = useTranslation()
3236
const [session, setSession] = useState<Session | null>(null)
@@ -48,13 +52,31 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
4852
const [findFocusNonce, setFindFocusNonce] = useState(0)
4953
const [findResultNonce, setFindResultNonce] = useState(0)
5054
const [findQuery, setFindQuery] = useState('')
55+
const [settledFindQuery, setSettledFindQuery] = useState('')
5156
const [activeMatchIndex, setActiveMatchIndex] = useState(0)
5257
const listRef = useRef<MessageListHandle>(null)
5358
const activeFindMatchRef = useRef<HTMLElement | null>(null)
5459
const isDark = useIsDark()
5560
const hasDraft = useDraftCountForSession(sessionUuid) > 0
5661

5762
const normalizedFindQuery = findQuery.trim().toLocaleLowerCase()
63+
const normalizedSettledFindQuery = settledFindQuery.trim().toLocaleLowerCase()
64+
const findPending = normalizedFindQuery !== normalizedSettledFindQuery
65+
// While a new query is pending, the previous query's highlights and count
66+
// stay visible and navigable (Chrome-style) instead of blanking for the
67+
// debounce window.
68+
const effectiveFindQuery = normalizedSettledFindQuery
69+
70+
useEffect(() => {
71+
if (!normalizedFindQuery) {
72+
setSettledFindQuery('')
73+
return undefined
74+
}
75+
const timer = window.setTimeout(() => {
76+
setSettledFindQuery(findQuery)
77+
}, FIND_DEBOUNCE_MS)
78+
return () => window.clearTimeout(timer)
79+
}, [findQuery, normalizedFindQuery])
5880

5981
const {
6082
messageFindRanges,
@@ -65,11 +87,11 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
6587

6688
// Only project markdown → rendered text when a query is active. For a 1500-message
6789
// session this saves ~1500 remark.parse calls on session open.
68-
if (normalizedFindQuery) {
90+
if (effectiveFindQuery) {
6991
for (const message of messages) {
7092
const source = message.contentText || (message.role === 'system' ? '(summary)' : '')
7193
const text = extractRenderedText(source)
72-
const ranges = getFindRanges(text, normalizedFindQuery)
94+
const ranges = getFindRanges(text, effectiveFindQuery)
7395
if (ranges.length > 0) {
7496
rangesByMessage.set(message.id, { ranges, offset })
7597
offset += ranges.length
@@ -81,12 +103,13 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
81103
messageFindRanges: rangesByMessage,
82104
totalFindMatches: offset,
83105
}
84-
}, [messages, normalizedFindQuery])
106+
}, [messages, effectiveFindQuery])
85107

86108
const activeMatchOrdinal = totalFindMatches > 0 ? activeMatchIndex + 1 : 0
87109

88110
const clearFind = useCallback(() => {
89111
setFindQuery('')
112+
setSettledFindQuery('')
90113
setActiveMatchIndex(0)
91114
}, [])
92115

@@ -181,13 +204,13 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
181204
}, [sessionUuid, clearFind])
182205

183206
useEffect(() => {
184-
if (!normalizedFindQuery || totalFindMatches === 0) {
207+
if (!effectiveFindQuery || totalFindMatches === 0) {
185208
setActiveMatchIndex(0)
186209
return
187210
}
188211

189212
setActiveMatchIndex((value) => Math.min(value, totalFindMatches - 1))
190-
}, [normalizedFindQuery, totalFindMatches])
213+
}, [effectiveFindQuery, totalFindMatches])
191214

192215
useEffect(() => {
193216
if (!showFindBar) return
@@ -411,6 +434,7 @@ export default function SessionDetail({ sessionUuid, targetMessageId, onCopySess
411434
focusNonce={findFocusNonce}
412435
resultNonce={findResultNonce}
413436
query={findQuery}
437+
pending={findPending}
414438
matches={totalFindMatches}
415439
activeMatchOrdinal={activeMatchOrdinal}
416440
onChange={runFind}

0 commit comments

Comments
 (0)