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