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) && ( -