From edea580b932de2950a61867d9ce9ebd9b180d59b Mon Sep 17 00:00:00 2001 From: Review <5129532+JasprW@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:57:37 +0800 Subject: [PATCH] feat(search): mark the hit's words in the item it opens and unfold it The focus request now carries the hit's field and matched words; the targeted note, check, idea, artifact or Progress card marks them until the sidebar closes and shows the full text (note line limit lifted, check output shown, Show more opened, full link or path). DRAWER-SPEC Focus from search 6-7. --- client/content-card.tsx | 33 +++++++++++++++++++----- client/drawer-focus-logic.test.ts | 13 ++++++++++ client/drawer-focus-logic.ts | 17 ++++++++++--- client/drawer-focus.tsx | 34 ++++++++++++++++++++++--- client/drawer.tsx | 18 ++++++++++--- client/task-artifacts.tsx | 14 ++++++++--- client/task-ideas.tsx | 32 ++++++++++++++++++----- client/task-progress.tsx | 31 +++++++++++++++++------ client/task-workpad.tsx | 19 +++++++++----- docs/DRAWER-SPEC.md | 2 ++ docs/SEARCH-SPEC.md | 2 ++ server/search.ts | 28 +++------------------ shared/search-text.test.ts | 19 ++++++++++++++ shared/search-text.ts | 42 +++++++++++++++++++++++++++++++ 14 files changed, 240 insertions(+), 64 deletions(-) create mode 100644 shared/search-text.test.ts create mode 100644 shared/search-text.ts diff --git a/client/content-card.tsx b/client/content-card.tsx index 49a12f87..5c779d64 100644 --- a/client/content-card.tsx +++ b/client/content-card.tsx @@ -1,4 +1,5 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; +import { Marked } from "./drawer-focus"; import { Animated, Pressable, View } from "react-native"; import { Icon } from "@getpaseo/plugin/client/react-native"; import { useMotionHeight, useMotionValue } from "./motion"; @@ -23,19 +24,22 @@ export function ContentCard({ footer, after, defaultOpen = false, + openWhen, children, }: { footer?: ReactNode; after?: ReactNode; /** Start expanded (the welcome tour shows every field); Show less still collapses it. */ defaultOpen?: boolean; + /** Expands whenever this changes to a value (a search hit on the card); Show less still collapses it. */ + openWhen?: unknown; children: ReactNode; }) { const { colors } = useTheme(); return ( - + {children} @@ -67,15 +71,17 @@ export function CardText({ children }: { children: ReactNode }) { ); } -/** A titled block (Target, Recap, What to do…). Renders nothing for empty text. */ -export function CardField({ title, text }: { title: string; text: string }) { +/** A titled block (Target, Recap, What to do…). Renders nothing for empty text. `terms` are marked (search). */ +export function CardField({ title, text, terms }: { title: string; text: string; terms?: readonly string[] | null }) { if (!text.trim()) return null; return ( - {text} + + + ); } @@ -114,9 +120,24 @@ const CLAMP_HEIGHT = 104; * bottom-left corner and `footer` on the right of the same row. * `background` is the surface the fade blends into. */ -function Clamped({ background, footer, defaultOpen = false, children }: { background: string; footer?: ReactNode; defaultOpen?: boolean; children: ReactNode }) { +function Clamped({ + background, + footer, + defaultOpen = false, + openWhen, + children, +}: { + background: string; + footer?: ReactNode; + defaultOpen?: boolean; + openWhen?: unknown; + children: ReactNode; +}) { const [natural, setNatural] = useState(0); const [open, setOpen] = useState(defaultOpen); + useEffect(() => { + if (openWhen) setOpen(true); + }, [openWhen]); const overflows = natural > CLAMP_HEIGHT + 8; const target = natural ? (open || !overflows ? natural : CLAMP_HEIGHT) : null; const height = useMotionHeight(target); diff --git a/client/drawer-focus-logic.test.ts b/client/drawer-focus-logic.test.ts index f3294e11..dfca598e 100644 --- a/client/drawer-focus-logic.test.ts +++ b/client/drawer-focus-logic.test.ts @@ -12,6 +12,19 @@ describe("focus from search (DRAWER-SPEC)", () => { expect(focusOfHit({ source: "card" })).toBeNull(); expect(focusOfHit({ source: "memory", refId: "m" })).toBeNull(); }); + test("a hit carries its field and matched words for the highlight", () => { + expect(focusOfHit({ source: "note", refId: "e1", field: "note", snippet: "ran the Search test", ranges: [[8, 14]] })).toEqual({ + section: "activity", + target: "event:e1", + field: "note", + terms: ["search"], + }); + expect(focusOfHit({ source: "summary", refId: "s1", field: "target", snippet: "ship it", ranges: [[0, 4]] })).toEqual({ + section: "progress", + field: "target", + terms: ["ship"], + }); + }); test("Activity keeps its last 12 and widens back to an older target only", () => { const ids = Array.from({ length: 30 }, (_, i) => `e${i}`); expect(activityStart(ids, "")).toBe(18); diff --git a/client/drawer-focus-logic.ts b/client/drawer-focus-logic.ts index 23fcd920..3936aa7c 100644 --- a/client/drawer-focus-logic.ts +++ b/client/drawer-focus-logic.ts @@ -1,20 +1,29 @@ import type { SearchHit } from "../shared/search"; +import { termsOfHit } from "../shared/search-text"; /** Focus from search (DRAWER-SPEC "Focus from search", SEARCH-SPEC §5.3): which drawer section and item a hit lands on. */ export type FocusSection = "progress" | "workpad" | "artifacts" | "ideas" | "activity"; -export type DrawerFocus = { key: string; section: FocusSection; target?: string }; +export type DrawerFocus = { + key: string; + section: FocusSection; + target?: string; + /** The hit's field (`recap`, `evidence`, `detail`…) and its matched words, for the highlight. */ + field?: string; + terms?: string[]; +}; /** How long the target keeps its ring. */ export const FOCUS_RING_MS = 1600; /** Without a rendered target by then, the section itself is the target. */ export const FOCUS_FALLBACK_MS = 1500; /** Card-field hits have no section: the drawer just opens. */ -export function focusOfHit(hit: Pick): Omit | null { - const item = (prefix: string) => (hit.refId ? { target: `${prefix}:${hit.refId}` } : {}); +export function focusOfHit(hit: Pick & Partial>): Omit | null { + const mark = hit.snippet !== undefined && hit.ranges ? { field: hit.field, terms: termsOfHit({ snippet: hit.snippet, ranges: hit.ranges }) } : {}; + const item = (prefix: string) => ({ ...mark, ...(hit.refId ? { target: `${prefix}:${hit.refId}` } : {}) }); switch (hit.source) { case "summary": - return { section: "progress" }; + return { section: "progress", ...mark }; case "note": return { section: "activity", ...item("event") }; case "workpad": diff --git a/client/drawer-focus.tsx b/client/drawer-focus.tsx index 65595a99..97e68ba6 100644 --- a/client/drawer-focus.tsx +++ b/client/drawer-focus.tsx @@ -1,11 +1,14 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; -import { View } from "react-native"; -import { CueRing, radius } from "./ui"; +import { Text, View } from "react-native"; +import { matchRanges } from "../shared/search-text"; +import { CueRing, radius, useTheme } from "./ui"; export * from "./drawer-focus-logic"; import { FOCUS_RING_MS } from "./drawer-focus-logic"; -type FocusState = { key: string; target: string; found(key: string, node: View): void }; +/** The words to mark in one item until the sidebar closes (DRAWER-SPEC "Focus from search" 6–7). */ +export type FocusMark = { target: string; terms: readonly string[]; field?: string }; +type FocusState = { key: string; target: string; found(key: string, node: View): void; mark: FocusMark | null }; const FocusContext = createContext(null); export const DrawerFocusProvider = FocusContext.Provider; @@ -14,6 +17,31 @@ export function useFocusTarget(): string { return useContext(FocusContext)?.target ?? ""; } +/** The mark for this item (`event:…`, `section:progress`…), or null when it is not the one a hit opened. */ +export function useMark(id: string): FocusMark | null { + const mark = useContext(FocusContext)?.mark; + return mark && mark.target === id && mark.terms.length ? mark : null; +} + +/** `text` with the marked words in bold accent (like the Search page snippets); plain text without terms. Put inside a Label. */ +export function Marked({ text, terms }: { text: string; terms?: readonly string[] | null }) { + const { colors } = useTheme(); + if (!terms?.length) return <>{text}; + const parts: ReactNode[] = []; + let at = 0; + matchRanges(text, terms).forEach(([start, end], i) => { + if (start > at) parts.push(text.slice(at, start)); + parts.push( + + {text.slice(start, end)} + , + ); + at = end; + }); + if (at < text.length) parts.push(text.slice(at)); + return <>{parts}; +} + /** * Wraps a section or row. When it is the focus target it reports its node to the drawer (which scrolls to it) * and shows the accent ring for a moment. Always a View, so becoming the target never remounts the children. diff --git a/client/drawer.tsx b/client/drawer.tsx index c5bf2420..bbfecc43 100644 --- a/client/drawer.tsx +++ b/client/drawer.tsx @@ -48,7 +48,7 @@ import { useSessionReads } from "./session-reads"; import { sessionAttention } from "../shared/session-attention"; import type { SessionEntry } from "./session-link"; import { Enter, MotionReveal, useReducedMotion, useSettled } from "./motion"; -import { DrawerFocusProvider, FOCUS_FALLBACK_MS, FOCUS_RING_MS, FocusTarget, activityStart, type DrawerFocus } from "./drawer-focus"; +import { DrawerFocusProvider, FOCUS_FALLBACK_MS, FOCUS_RING_MS, FocusTarget, Marked, activityStart, useMark, type DrawerFocus } from "./drawer-focus"; import { perfLap, perfStart, perfEnd, usePerfRender } from "./perf"; import { TaskMetadata } from "./task-metadata"; import { Chevron, Collapse, Disclosure, Section, useSectionStates } from "./disclosure"; @@ -242,7 +242,16 @@ function TaskDrawerView({ () => undefined, ); }); - const focusState = useMemo(() => (focusTarget ? { ...focusTarget, found: focusFound } : null), [focusTarget, focusFound]); + // The highlight follows the focus prop itself: the board clears it when the sidebar closes or another card opens, + // and a new hit replaces it (DRAWER-SPEC "Focus from search" 6). + const mark = useMemo( + () => (focus?.terms?.length ? { target: focus.target || `section:${focus.section}`, terms: focus.terms, field: focus.field } : null), + [focus], + ); + const focusState = useMemo( + () => (focusTarget || mark ? { key: focusTarget?.key ?? "", target: focusTarget?.target ?? "", found: focusFound, mark } : null), + [focusTarget, focusFound, mark], + ); // Heavy sections and their reads start once the open transition is over. const settled = useSettled(task.id); const ready = active && settled; @@ -1326,6 +1335,7 @@ function ActivityItem({ last: boolean; }) { const { colors } = useTheme(); + const mark = useMark(`event:${event.id}`); const question = event.reportKind === "question"; const verb = question ? t("Asked") @@ -1359,8 +1369,8 @@ function ActivityItem({ )} {!!event.note && !isNoMoves(event) && ( -