Skip to content

feat(web): expose semantic search with IME-safe submission - #101

Merged
spinsirr merged 3 commits into
mainfrom
fix/100-semantic-search
Sep 9, 2026
Merged

feat(web): expose semantic search with IME-safe submission#101
spinsirr merged 3 commits into
mainfrom
fix/100-semantic-search

Conversation

@MiltonHeYan

@MiltonHeYan MiltonHeYan commented Sep 8, 2026

Copy link
Copy Markdown
Member

The standalone frontend now exposes a labelled Semantic search button and a natural-language hint, using the existing Workspace-scoped hybrid retrieval endpoint and server ranking. Typing retains the 220 ms debounce; Enter and button submission cancel pending timers, respect CJK IME composition (including Safari's compositionend-before-Enter sequence), and close the mobile drawer.

Review fixes hardened the interaction model:

  • Debouncing moved into a shared cancelable hook (src/lib/use-debounced-callback.ts); App drops the pending search through searchCancelRef on every query-context reset — Workspace change, tab change, type drill-down, route navigation (popstate), and opening a Memory — so a stale query can no longer fire after navigation.
  • The bare keyCode-229 Enter guard is bounded to a 500 ms post-composition window: Safari's candidate-confirming Enter stays guarded while soft keyboards that report 229 outside composition still submit.
  • The composition guard recovers on blur and from the live isComposing input flag; an unchanged composition no longer reschedules a search.
  • Styling joins the shared form-label and secondary-control recipes; hover uses --hairline-soft and the focus ring uses the contract's two-pixel --link ring; the desktop sidebar scrolls on short viewports.
  • Dependency security fixes required by the bun audit --audit-level=high CI gate (advisories published after this branch's last green run): Next.js → 16.3.4, sharp override → ^0.35.4, js-yaml override → ^4.3.2. Wrangler stays at 4.119.0 per the documented cf:typegen Buffer-collision hold.

Adds Sidebar interaction coverage (17 tests, happy-dom) and an API regression test for natural-language queries, and updates DESIGN.md and AGENTS.md.

Closes #100

Validation

Passed on d8a92b8: typecheck, full-repo lint, design:check, bun run test (614 vitest + 25 Python SDK), bun run build (incl. sdk:check), bun audit --audit-level=high clean, and full CI (both check jobs + python-sdk matrix). Live IME/browser interaction against a configured deployment remains unverified; the reviewer's P2 (preventDefault ordering ahead of the IME guards) is noted for a follow-up.

Add a labelled natural-language search form using existing Workspace hybrid retrieval. Preserve debouncing, cancel pending submission timers, guard IME confirmation, and close mobile navigation on explicit search.

Fixes #100
@MiltonHeYan
MiltonHeYan requested a review from spinsirr September 8, 2026 06:09
Cancel the pending debounced search from every query-context reset (tab
change, type drill, route navigation, opening a Memory, Workspace change)
through a shared cancelable debounce hook and an App-held cancel handle.
Bound the Safari keyCode 229 Enter guard to a 500ms post-composition
window so soft keyboards reporting 229 outside composition still submit,
skip rescheduling when a composition changed nothing, and recover the
composition guard on blur or a non-composing input event. Align the
search label, submit button hover, and focus ring with the DESIGN.md
token contract, and let a short desktop viewport scroll the sidebar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@corespeed-agent-ensemble corespeed-agent-ensemble Bot changed the title fix(web): expose semantic search with IME-safe submission feat(web): expose semantic search with IME-safe submission Sep 9, 2026
@corespeed-agent-ensemble

Copy link
Copy Markdown
Contributor

Reviewed and pushed review fixes in 57c7edf (requested by Yunpeng).

What the review found

  1. Stale debounced search survived navigation — the Workspace-change cleanup cancelled the pending 220ms timer for only one of App's query-clearing paths; tab change, type drill-down, browser Back (applyRoute), and opening a Memory could not reach Sidebar's private timer, so a stale onSearch fired up to 220ms later, yanked the user back to the search tab, and re-injected abandoned text.
  2. Enter could fail closedpreventDefault() ran before the keyCode === 229 guard, so on soft keyboards that report 229 for a deliberate Enter outside composition (some Android GBoard/Samsung predictive modes), both native implicit submission and requestSubmit were dead.
  3. composing ref could stick true — a composition aborted without compositionend (programmatic input writes on popstate/Workspace change) permanently deadened typing, Enter, and the button.
  4. DESIGN.md token deviations — focus ring used --ink where the contract mandates the two-pixel --link ring; button hover used --canvas (invisible on the --canvas sidebar) instead of --hairline-soft; the 12px label was sans where the contract types 10–12px labels as Geist Mono.
  5. Short desktop viewports clipped the sidebar — desktop .sidebar is 100vh with no scroll, and the taller search block pushed the bottom controls off-screen.

What the fix does

  • Extracts src/lib/use-debounced-callback.ts (single cancelable debounce, unmount-safe) and adds a searchCancelRef handle that App now calls from every query-context reset: Workspace change, tab change, type drill, route navigation, and opening a Memory.
  • Bounds the bare-229 Enter guard to a 500ms post-compositionend window: Safari's candidate-confirming Enter stays guarded, while a deliberate Enter that merely carries keyCode 229 submits.
  • Recovers the composition guard on blur and from the live isComposing flag on input events; compositionend no longer reschedules when the composition changed nothing.
  • Joins the label to the shared mono form-label recipe and the button to the shared secondary-control recipe; hover --hairline-soft, focus ring --link; desktop sidebar gets overflow-y: auto.
  • Docs now state the behavioral invariant ("an Enter consumed by IME composition never submits; a deliberate Enter always searches") instead of the browser-specific mechanism.

Also retitled the PR fix(web)feat(web): this adds a user-facing capability (issue #100 is feat(web) too).

Validation on 57c7edf: typecheck, full-repo lint, design:check, all 17 sidebar-search tests (5 new: Android bare-229 Enter, 229-window expiry + unchanged-composition skip, blur recovery, aborted-composition recovery, cancel handle), and the full vitest suite — 614/614 passing (tests/code-index.test.ts needs XDG_CONFIG_HOME neutralized on machines with a global gitignore, per the documented gotcha). Live browser verification against a configured deployment remains outstanding, as noted in the PR description.

Left as-is, deliberately: GraphView's declarative 250ms debounce (its effect cleanup already cancels by construction; unifying it with the sidebar's IME handling is a separate, riskier change), and the search hint stays 12px sans — it is descriptive body copy, not a label.

🤖 Generated with Claude Code

bun audit's high gate now fails on advisories published after this
branch's last green run: Next.js <16.3.3 (two critical RCEs), sharp
<0.35.4 via next and miniflare's exact 0.35.2 pin, and js-yaml <4.3.2
via openapi-typescript. Bump next to ^16.3.3 (resolves 16.3.4) and
raise the js-yaml override; sharp needs a new override because
miniflare pins it exactly. Wrangler itself stays at 4.119.0 per the
documented cf:typegen Buffer-collision hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@corespeed-hopper corespeed-hopper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at d8a92b8. This is the right change for #100: it adds a discoverable entry point over the existing Workspace-scoped hybrid retrieval instead of building a second retrieval path, so no authorization or ranking surface moves. Search still goes out as GET /api/memories?q with x-lore-workspace-id; RLS, scope, and server ranking are untouched.

Verified

  • Sidebar is rendered exactly once from App (App.tsx:421), so id="memory-search" stays unique, and its <form> is not nested inside any other form.
  • searchCancelRef is dropped from every query-context reset in App — the activeWorkspaceId effect, openMemory, applyRoute, handleTabChange, drillType — and Sidebar keeps its own cancel-on-Workspace-change/unmount as a backstop. Child effects commit before parent effects, so the ref is assigned before App's mount effect first calls it.
  • CSS: .sidebar { overflow-y: auto } is safe here — the aside has no absolutely positioned popovers (the Workspace picker is a native <select>), and the mobile drawer already carried the same rule. .sidebar-search-submit reuses the existing secondary-control recipe rather than inventing a token.
  • Lockfile: happy-dom@20.10.6 resolves cleanly, next is a 16.3.1→16.3.4 patch, and wrangler stayed at 4.119.0 — the cf:typegen / global Buffer collision documented in AGENTS.md is not reintroduced.
  • Docs updated in the same PR (DESIGN.md behavioral contract + AGENTS.md module note and the happy-dom test gotcha), per house rule.
  • CI covers everything the PR description lists as locally unrun (build, packages:smoke, bun audit, opennext build, wrangler dry-run), so that gap closes on the check job rather than on review.

P2 — should fix, can be a follow-up

  • Sidebar.tsx:202: e.preventDefault() runs before the IME guards, so a composition-confirming keydown gets cancelled too. See the inline note; it is a one-line reorder that loses nothing, and it is exactly the class of bug the happy-dom suite cannot detect because it fabricates isComposing/keyCode.

P3 — notes, not blocking

  • use-debounced-callback.ts:17 writes a ref during render.
  • Sidebar.tsx:330: an aborted composition can silently drop a search that was already pending when compositionstart cancelled it.
  • The button reads Semantic search unconditionally, but AGENTS.md defines a deployment with no working embedding provider as degraded to lexical retrieval (English FTS + the CJK substring channel). The label over-claims there. DESIGN.md is honest about it being hybrid; the UI string is the shorthand. Worth a thought, not a change I'd gate on.
  • d8a92b8 (Next/sharp/js-yaml) is unrelated to #100. Understandable — bun audit --audit-level=high gates CI — but it makes the PR two changes.

if (debounce.current) clearTimeout(debounce.current);
onSearch(inputRef.current?.value ?? "");
if (e.key !== "Enter") return;
e.preventDefault();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (non-blocking). preventDefault() fires before the composition guards below, so the keydown the IME is using to confirm a candidate is also cancelled. Browsers are widely believed to ignore preventDefault() on a keydown they dispatched with keyCode 229, and in Chrome/Firefox that keydown often reports key: "Process" and returns on the line above anyway — so this is probably inert. But it is unverifiable from synthetic events (the suite supplies isComposing/keyCode by hand), and the PR states live browser IME behaviour was not exercised.

The reorder costs nothing: handleSubmit already returns on composing.current, and browsers do not perform implicit submission for an Enter the IME consumed, so cancelling the composing keydown buys no protection you don't already have. Only the Safari path — compositionend then a bare keyCode 229 keydown, where composing.current is already false — actually needs the cancel, and that Enter is not composing.

Suggest running the guard first and calling preventDefault() only on the paths that reach requestSubmit() or the 229 short-circuit.

const latest = useRef(callback);
// Assigned during render so a timer firing before the next effect flush still
// sees the current closure.
latest.current = callback;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. Writing a ref during render is the well-worn "latest callback" pattern, but React explicitly asks you not to: a render that is interrupted or discarded still lands its closure in latest.current. Harmless in practice here — onSearch is handleSearch, and the next committed render overwrites it — so this is a note, not a change request. The comment already explains the motivation (a timer firing before the effect flush), which is the reason not to move it into useEffect naively.

compositionEndedAt.current = Date.now();
// A cancelled composition restores the pre-composition text;
// nothing new to search then.
if (e.currentTarget.value !== compositionStartValue.current) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. compositionstart cancels whatever was pending, and this guard then declines to reschedule when the composition ends with the value unchanged. An aborted composition (Escape) restores the pre-composition text, so a search the user had already committed to by typing — e.g. "hello", then a composition started and escaped inside the 220 ms window — is silently dropped, leaving stale results until the next keystroke or Enter. Narrow, and recoverable by the user. Rescheduling the restored value instead of skipping would close it.

@spinsirr
spinsirr merged commit 6f123b8 into main Sep 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): expose semantic search in the standalone frontend

2 participants