diff --git a/client/board.tsx b/client/board.tsx
index 181f8fe4..2665cd49 100644
--- a/client/board.tsx
+++ b/client/board.tsx
@@ -100,7 +100,8 @@ import { OverviewBar } from "./overview-bar";
import { InsightsPage } from "./insights";
import { IdeasPage } from "./ideas-page";
import { SearchPage } from "./search-page";
-import { searchSources as allSearchSources, type SearchCard, type SearchMemoryHit } from "../shared/search";
+import { focusOfHit, type DrawerFocus } from "./drawer-focus";
+import { searchSources as allSearchSources, type SearchCard, type SearchHit, type SearchMemoryHit } from "../shared/search";
import { MemoryPage } from "./memory";
import { lastViewedProject, pickProject, rememberViewedProject } from "./project-memory";
import { ProjectTabs } from "./project-tabs";
@@ -329,6 +330,8 @@ function BoardContent(
[page, setPage] = useState<"insights" | "ideas" | "memory" | "search" | null>(null),
// Search page (SEARCH-SPEC §5): the text it opens with and a key that remounts it for a new seed.
[searchSeed, setSearchSeed] = useState({ text: "", key: 0 }),
+ // A search hit for the drawer to land on (DRAWER-SPEC "Focus from search"); any other way of opening a card clears it.
+ [drawerFocus, setDrawerFocus] = useState<(DrawerFocus & { taskId: string }) | null>(null),
// Ideas page: only this card's ideas ("All ideas" from a card).
[ideaCard, setIdeaCard] = useState(""),
// `idea`: Start session prefilled from an idea on the Ideas page (IDEAS-SPEC §6.3).
@@ -928,12 +931,16 @@ function BoardContent(
// Stable for every card (memoized cards; PERFORMANCE-SPEC §3).
const openCard = useStableCallback((t: Task) => {
perfMark("drawer.open");
+ setDrawerFocus(null);
setTask(t.id);
});
const openCardActions = useStableCallback((t: Task) => setActionTask(t));
const cardMenuFor = useStableCallback((t: Task) => cardMenu(t));
// Stable drawer callbacks (memoized drawer).
- const closeDrawer = useStableCallback(() => setTask(""));
+ const closeDrawer = useStableCallback(() => {
+ setDrawerFocus(null);
+ setTask("");
+ });
const editDrawerTask = useStableCallback(() => task && setTaskForm({ task, status: task.status }));
// IDEAS-SPEC §6: the new-task form, prefilled from the idea and its source card.
const createFromIdea = useStableCallback((idea: Idea) =>
@@ -1022,6 +1029,7 @@ function BoardContent(
);
const linkFrom = useStableCallback((source: Task) => setLinking(source));
const openTaskById = useStableCallback((id: string) => {
+ setDrawerFocus(null);
const target = currentDetail?.relatedTasks.find(t => t.id === id) ?? board?.tasks.find(t => t.id === id);
if (target) { setDetailTarget(target); setProject(target.projectId); }
setTask(id);
@@ -1035,8 +1043,10 @@ function BoardContent(
const closePage = useStableCallback(() => setPage(null));
const openMemoryPage = useStableCallback(() => { ignoredLink.current = linkKey; setPage("memory"); });
// SEARCH-SPEC §5.3: a result opens its card, switching project first; the Search page stays open.
- const openSearchCard = useStableCallback((card: SearchCard) => {
+ const openSearchCard = useStableCallback((card: SearchCard, hit?: SearchHit) => {
ignoredLink.current = linkKey;
+ const focus = hit ? focusOfHit(hit) : null;
+ setDrawerFocus(focus ? { ...focus, key: `${card.taskId}:${Date.now()}`, taskId: card.taskId } : null);
setDetailTarget({ id: card.taskId, workspaceId: card.workspaceId, projectId: card.projectId });
setProject(card.projectId);
setTask(card.taskId);
@@ -1608,6 +1618,7 @@ function BoardContent(
onAttention={toggleDrawerAttention}
sectionOrder={(view.values?.drawerOrder || []).join(",")}
sectionOpen={JSON.stringify(view.values?.drawerOpen || {})}
+ focus={drawerFocus?.taskId === drawer.item.task.id ? drawerFocus : undefined}
/>
diff --git a/client/disclosure.tsx b/client/disclosure.tsx
index 9fb90885..c4211c50 100644
--- a/client/disclosure.tsx
+++ b/client/disclosure.tsx
@@ -3,6 +3,7 @@ import { Animated, Pressable, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { useMotionPresence, lerp } from "./motion";
import { Label, SectionHeader, font, space, touch, useTheme, noFocusRing } from "./ui";
+import { FocusTarget } from "./drawer-focus";
import { t, tn } from "../shared/i18n";
export function Disclosure({ title, summary, titleMinWidth, open, disabled, flush, onChange, children }: {
@@ -105,7 +106,7 @@ function HeightCollapse({ open, id, style, children }: CollapseProps) {
* the body revealed by `Collapse`. The drawer owns `open`, so the user's
* defaults apply each time the sidebar opens.
*/
-export function Section({ id, title, count, ai, summary, open, onToggle, actions, empty, children }: {
+export function Section({ id, title, count, ai, summary, open, onToggle, actions, empty, focusId, children }: {
id: string;
title: string;
count?: number;
@@ -116,9 +117,11 @@ export function Section({ id, title, count, ai, summary, open, onToggle, actions
actions?: ReactNode;
/** Nothing to show yet: a plain header (with its actions), no chevron and no body. */
empty?: boolean;
+ /** The drawer section this is, so a search hit can land on it (DRAWER-SPEC "Focus from search"). */
+ focusId?: string;
children?: ReactNode;
}) {
- return (
+ const body = (
{actions}
@@ -130,6 +133,7 @@ export function Section({ id, title, count, ai, summary, open, onToggle, actions
)}
);
+ return focusId ? {body} : body;
}
/** "Show N more": adds the next rows to the same list, lined up with the rows' icons. There is no Show less. */
diff --git a/client/drawer-focus-logic.test.ts b/client/drawer-focus-logic.test.ts
new file mode 100644
index 00000000..f3294e11
--- /dev/null
+++ b/client/drawer-focus-logic.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "vitest";
+import { activityStart, focusOfHit, limitFor } from "./drawer-focus-logic";
+
+describe("focus from search (DRAWER-SPEC)", () => {
+ test("each hit source lands on its section and item; card fields only open the drawer", () => {
+ expect(focusOfHit({ source: "summary", refId: "s1" })).toEqual({ section: "progress" });
+ expect(focusOfHit({ source: "note", refId: "e1" })).toEqual({ section: "activity", target: "event:e1" });
+ expect(focusOfHit({ source: "workpad", refId: "w1" })).toEqual({ section: "workpad", target: "workpad:w1" });
+ expect(focusOfHit({ source: "idea", refId: "i1" })).toEqual({ section: "ideas", target: "idea:i1" });
+ expect(focusOfHit({ source: "artifact", refId: "a1" })).toEqual({ section: "artifacts", target: "artifact:a1" });
+ expect(focusOfHit({ source: "workpad" })).toEqual({ section: "workpad" });
+ expect(focusOfHit({ source: "card" })).toBeNull();
+ expect(focusOfHit({ source: "memory", refId: "m" })).toBeNull();
+ });
+ 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);
+ expect(activityStart(ids, "event:e25")).toBe(18);
+ expect(activityStart(ids, "event:e3")).toBe(3);
+ expect(activityStart(ids, "event:missing")).toBe(18);
+ expect(activityStart(ids, "idea:e3")).toBe(18);
+ expect(activityStart(ids.slice(0, 5), "event:e0")).toBe(0);
+ });
+ test("a paged list grows in whole pages to include the target", () => {
+ expect(limitFor(2, 5, 5)).toBe(5);
+ expect(limitFor(5, 5, 5)).toBe(10);
+ expect(limitFor(12, 5, 5)).toBe(15);
+ expect(limitFor(3, 10, 5)).toBe(10);
+ });
+});
diff --git a/client/drawer-focus-logic.ts b/client/drawer-focus-logic.ts
new file mode 100644
index 00000000..23fcd920
--- /dev/null
+++ b/client/drawer-focus-logic.ts
@@ -0,0 +1,42 @@
+import type { SearchHit } from "../shared/search";
+
+/** 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 };
+/** 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}` } : {});
+ switch (hit.source) {
+ case "summary":
+ return { section: "progress" };
+ case "note":
+ return { section: "activity", ...item("event") };
+ case "workpad":
+ return { section: "workpad", ...item("workpad") };
+ case "idea":
+ return { section: "ideas", ...item("idea") };
+ case "artifact":
+ return { section: "artifacts", ...item("artifact") };
+ default:
+ return null;
+ }
+}
+
+/** Activity lists the last `size` events (oldest first here); the window widens back to the target. */
+export function activityStart(ids: readonly string[], target: string, size = 12): number {
+ const base = Math.max(0, ids.length - size);
+ const at = target.startsWith("event:") ? ids.indexOf(target.slice(6)) : -1;
+ return at >= 0 && at < base ? at : base;
+}
+
+/** A paged list's limit raised (in whole pages) so the item at `index` is shown. */
+export function limitFor(index: number, limit: number, page: number): number {
+ return index < limit ? limit : Math.ceil((index + 1) / page) * page;
+}
+
diff --git a/client/drawer-focus.tsx b/client/drawer-focus.tsx
new file mode 100644
index 00000000..65595a99
--- /dev/null
+++ b/client/drawer-focus.tsx
@@ -0,0 +1,49 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
+import { View } from "react-native";
+import { CueRing, radius } 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 };
+const FocusContext = createContext(null);
+export const DrawerFocusProvider = FocusContext.Provider;
+
+/** The current target id (`section:…`, `event:…`, `workpad:…`, `idea:…`, `artifact:…`), or "". */
+export function useFocusTarget(): string {
+ return useContext(FocusContext)?.target ?? "";
+}
+
+/**
+ * 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.
+ */
+export function FocusTarget({ id, round = radius.md, children }: { id: string; round?: number; children: ReactNode }) {
+ const focus = useContext(FocusContext);
+ const hit = !!focus && focus.target === id;
+ const key = hit ? focus.key : "";
+ const node = useRef(null);
+ const [ring, setRing] = useState(false);
+ const report = useRef(focus?.found);
+ report.current = focus?.found;
+ useEffect(() => {
+ if (!key) return;
+ setRing(true);
+ // Once laid out, and again after content above (sessions, sections loading) has settled.
+ const send = () => node.current && report.current?.(key, node.current);
+ const first = setTimeout(send, 60);
+ const again = setTimeout(send, 450);
+ const off = setTimeout(() => setRing(false), FOCUS_RING_MS);
+ return () => {
+ clearTimeout(first);
+ clearTimeout(again);
+ clearTimeout(off);
+ };
+ }, [key]);
+ return (
+
+ {children}
+ {hit && }
+
+ );
+}
diff --git a/client/drawer.tsx b/client/drawer.tsx
index 266a14f4..c5bf2420 100644
--- a/client/drawer.tsx
+++ b/client/drawer.tsx
@@ -47,7 +47,8 @@ import { SessionPreview } from "./session-preview";
import { useSessionReads } from "./session-reads";
import { sessionAttention } from "../shared/session-attention";
import type { SessionEntry } from "./session-link";
-import { Enter, MotionReveal, useSettled } from "./motion";
+import { Enter, MotionReveal, useReducedMotion, useSettled } from "./motion";
+import { DrawerFocusProvider, FOCUS_FALLBACK_MS, FOCUS_RING_MS, FocusTarget, activityStart, 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";
@@ -108,6 +109,7 @@ function TaskDrawerView({
onAttention,
sectionOrder,
sectionOpen,
+ focus,
}: {
task: Task;
project: Project;
@@ -155,6 +157,8 @@ function TaskDrawerView({
sectionOrder?: string;
/** The stored open/closed choices as JSON, compared by value (DRAWER-SPEC, Section order). */
sectionOpen?: string;
+ /** A search hit to land on (DRAWER-SPEC "Focus from search"); each key is handled once. */
+ focus?: DrawerFocus;
}) {
usePerfRender("switch.drawer.commit");
const switchStart = perfStart();
@@ -198,6 +202,47 @@ function TaskDrawerView({
if (active) setOpened(openDefaults);
}, [active, openDefaults]);
const sectionStates = useSectionStates(opened, setOpened);
+ // Focus from search: open the section for this opening only (never the stored choice), let it render the
+ // target, scroll there and ring it; without a rendered target in time, the section itself is the target.
+ const reduced = useReducedMotion();
+ const scroller = useRef<{ scrollTo(to: { y: number; animated?: boolean }): void } | null>(null);
+ const content = useRef(null);
+ const [focusTarget, setFocusTarget] = useState<{ key: string; target: string } | null>(null);
+ // An older event a hit pointed at stays listed until the sidebar closes, after its ring is gone.
+ const [keptEvent, setKeptEvent] = useState("");
+ useEffect(() => {
+ if (!active) setKeptEvent("");
+ }, [active]);
+ const handledFocus = useRef("");
+ const foundFocus = useRef("");
+ const focusTimers = useRef[]>([]);
+ useEffect(() => () => focusTimers.current.forEach(clearTimeout), []);
+ useEffect(() => {
+ if (!active || !focus || handledFocus.current === focus.key) return;
+ handledFocus.current = focus.key;
+ foundFocus.current = "";
+ focusTimers.current.forEach(clearTimeout);
+ setOpened((current) => ({ ...current, [focus.section]: true }));
+ setFocusTarget({ key: focus.key, target: focus.target || `section:${focus.section}` });
+ setKeptEvent(focus.target?.startsWith("event:") ? focus.target : "");
+ focusTimers.current = [
+ setTimeout(() => {
+ if (foundFocus.current !== focus.key) setFocusTarget({ key: focus.key, target: `section:${focus.section}` });
+ }, FOCUS_FALLBACK_MS),
+ // Once shown, the request is over: reopening or toggling a section later never applies it again.
+ setTimeout(() => setFocusTarget((current) => (current?.key === focus.key ? null : current)), FOCUS_FALLBACK_MS + FOCUS_RING_MS + 600),
+ ];
+ }, [active, focus]);
+ const focusFound = useStableCallback((key: string, node: View) => {
+ if (!focusTarget || key !== focusTarget.key || !content.current) return;
+ foundFocus.current = key;
+ node.measureLayout(
+ content.current as never,
+ (_x: number, y: number) => scroller.current?.scrollTo({ y: Math.max(0, y - space[3]), animated: !reduced }),
+ () => undefined,
+ );
+ });
+ const focusState = useMemo(() => (focusTarget ? { ...focusTarget, found: focusFound } : null), [focusTarget, focusFound]);
// Heavy sections and their reads start once the open transition is over.
const settled = useSettled(task.id);
const ready = active && settled;
@@ -875,15 +920,19 @@ function TaskDrawerView({
{!compact && heading}
+
+ {/* One measured node for search focus (DRAWER-SPEC "Focus from search"). */}
+
{compact && {heading}}
{!!resourceIssue && !wrapped && (
@@ -1029,18 +1078,20 @@ function TaskDrawerView({
{meaningful.length > 0 && (
+ {/* The last 12, widened back to an older event a search hit points at. */}
{meaningful
- .slice(-12)
+ .slice(activityStart(meaningful.map((e) => e.id), keptEvent))
.reverse()
.map((e, index, list) => (
+
laneName(project, status)}
last={index === list.length - 1}
@@ -1054,13 +1105,16 @@ function TaskDrawerView({
: t("Agent")
}
/>
+
))}
)}
)}
+
+
>
)}
diff --git a/client/search-page.tsx b/client/search-page.tsx
index 0843632b..911b7c8b 100644
--- a/client/search-page.tsx
+++ b/client/search-page.tsx
@@ -10,7 +10,8 @@ import { searchLimits, searchSources, type SearchCard, type SearchHit, type Sear
import { FilterChip } from "./ideas-page";
import { kindLabels } from "./memory";
import { StatusIcon } from "./status";
-import { Button, Choices, Label, Notice, TextBox, font, message, noFocusRing, pageBarHeight, radius, space, touch, useTheme, verticalContent, verticalScroll } from "./ui";
+import { useStableCallback } from "./stable";
+import { Button, Choices, Label, Notice, TextBox, font, message, noFocusRing, pageBarHeight, radius, space, tint, touch, useTheme, verticalContent, verticalScroll } from "./ui";
import { t, msg } from "../shared/i18n";
/** The Search page (SEARCH-SPEC §5). Replaces the board area while open, like Ideas and Memory. */
@@ -71,10 +72,20 @@ function Snippet({ snippet, ranges, lines = 2 }: { snippet: string; ranges: read
);
}
-const HitRow = memo(function HitRow({ hit }: { hit: SearchHit }) {
+const HitRow = memo(function HitRow({ hit, onOpen }: { hit: SearchHit; onOpen(hit: SearchHit): void }) {
+ const { colors } = useTheme();
const label = hit.status && hit.status !== "open" ? `${t(hitLabels[hit.source])} · ${t(ideaStatusLabels[hit.status])}` : t(hitLabels[hit.source]);
+ // Opens the card at this hit (DRAWER-SPEC "Focus from search").
return (
-
+ onOpen(hit)}
+ style={(state) => {
+ const { pressed, hovered } = state as typeof state & { hovered?: boolean };
+ return { ...noFocusRing, flexDirection: "row", gap: space[2], alignItems: "flex-start", marginHorizontal: -space[1.5], paddingHorizontal: space[1.5], paddingVertical: 2, borderRadius: radius.sm, backgroundColor: pressed || hovered ? tint(colors.accent, 0.08) : "transparent" };
+ }}
+ >
+
);
});
@@ -96,9 +107,10 @@ const CardResult = memo(function CardResult({
card: SearchCard;
showProject: boolean;
nameOf(status: Status): string;
- onOpen(card: SearchCard): void;
+ onOpen(card: SearchCard, hit?: SearchHit): void;
}) {
const { colors } = useTheme();
+ const openHit = useStableCallback((hit: SearchHit) => onOpen(card, hit));
const hits = card.hits.filter((h) => !(h.source === "card" && h.field === "title"));
const meta = [nameOf(card.status), card.type ? taskTypeLabels[card.type] : "", showProject ? card.projectTitle : "", card.archived ? t("Archived") : ""].filter(Boolean).join(" · ");
return (
@@ -131,7 +143,7 @@ const CardResult = memo(function CardResult({
{meta}
{hits.map((hit, i) => (
-
+
))}
);
@@ -189,7 +201,7 @@ export const SearchPage = memo(function SearchPage({
/** Sources that are turned on (Ideas, deliverables and memory follow their switches). */
sources: readonly SearchSource[];
nameOf(status: Status): string;
- onOpenCard(card: SearchCard): void;
+ onOpenCard(card: SearchCard, hit?: SearchHit): void;
onOpenMemory(hit: SearchMemoryHit): void;
onBack(): void;
}) {
diff --git a/client/task-artifacts.tsx b/client/task-artifacts.tsx
index eb64d721..f4a90ae0 100644
--- a/client/task-artifacts.tsx
+++ b/client/task-artifacts.tsx
@@ -11,6 +11,7 @@ import { IdeaRow, useExitList } from "./task-ideas";
import { useTaskDetail, invalidateCardViews } from "./card-detail-query";
import { SessionMarkdown } from "./session-content";
import { PagedRows, Section, ShowMore, pageSize, type SectionState } from "./disclosure";
+import { FocusTarget, limitFor, useFocusTarget } from "./drawer-focus";
import { AiMark, Actions, Button, Choices, Field, IconButton, Label, Notice, Theme, font, message, space, touch, useTheme, noFocusRing } from "./ui";
import { t, tn } from "../shared/i18n";
import { BrandIcon } from "./brand-icons";
@@ -51,6 +52,15 @@ export function TaskArtifacts({ task, active, disabled, section }: { task: Task;
() => (detail ? artifactsOf(detail.artifacts, task.id).filter((a) => !removing.has(a.id)) : []),
[detail, task.id, removing],
);
+ // A search hit on an artifact past the first page shows the pages up to it (DRAWER-SPEC "Focus from search").
+ const focusTarget = useFocusTarget();
+ useEffect(() => {
+ if (!focusTarget.startsWith("artifact:")) return;
+ const target = list.find((a) => a.id === focusTarget.slice("artifact:".length));
+ if (!target) return;
+ const at = list.filter((a) => a.role === target.role).indexOf(target);
+ setLimit((current) => ({ ...current, [target.role]: limitFor(at, current[target.role], pageSize) }));
+ }, [focusTarget, list]);
const fileKey = list.filter((a) => a.kind === "file").map((a) => `${a.id}:${a.target}`).join("|");
// One batched check when the drawer shows the card or its files change (CARD-ARTIFACTS-SPEC §9).
const status = useQuery({
@@ -118,6 +128,7 @@ export function TaskArtifacts({ task, active, disabled, section }: { task: Task;
<>
{t(group.title)}
(
+
+
)} />
{rest > 0 && (
diff --git a/client/task-ideas.tsx b/client/task-ideas.tsx
index 08700cf9..75ca6a29 100644
--- a/client/task-ideas.tsx
+++ b/client/task-ideas.tsx
@@ -9,6 +9,7 @@ import { continuesInCard } from "../shared/ideas";
import { eventPoint, useMenuOpener, type MenuEntry, type Point } from "./context-menu";
import { CardBody, CardField, CardHeadline, CardMeta, CardMetaRow, CardText, ContentCard } from "./content-card";
import { Disclosure, Section, type SectionState } from "./disclosure";
+import { FocusTarget, useFocusTarget } from "./drawer-focus";
import { lerp, motionDuration, useReducedMotion } from "./motion";
import { exitRows } from "./idea-rows";
import { useTaskDetail, invalidateCardViews } from "./card-detail-query";
@@ -58,11 +59,18 @@ export function TaskIdeas({
const closed = ideas.filter((i) => i.status !== "open" || hidden.has(i.id)).reverse();
const rows = useExitList(open);
const closedRows = useExitList(closed);
+ // A search hit on an accepted or dismissed idea opens Closed (DRAWER-SPEC "Focus from search").
+ const focusTarget = useFocusTarget();
+ const closedTarget = focusTarget.startsWith("idea:") && closed.some((i) => `idea:${i.id}` === focusTarget);
+ useEffect(() => {
+ if (closedTarget) setClosedOpen(true);
+ }, [closedTarget]);
if (!ideas.length) return null;
const titleOf = (id: string) => detail?.relatedTasks.find((t) => t.id === id)?.title;
return (
{rows.map((row) => (
-
+
+
+
))}
@@ -91,6 +101,7 @@ export function TaskIdeas({
{closedRows.map((row) => (
+
+
))}
diff --git a/client/task-progress.tsx b/client/task-progress.tsx
index 1b18c1ed..186f2cd8 100644
--- a/client/task-progress.tsx
+++ b/client/task-progress.tsx
@@ -52,6 +52,7 @@ export function TaskProgress({ taskId, active, section }: { taskId: string; acti
perfEnd("switch.progress.own", switchStart);
return (
(checks: readonly T[], head: Workpad["head"]): T[] {
+ return checks
+ .map((item, index) => ({ item, index, rank: item.state === "fail" ? 0 : item.state === "unrun" ? 2 : validationFreshness(item, head) === "stale" ? 1 : 3 }))
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
+ .map((x) => x.item);
+}
+
/** The next state when a person taps a check's status (WORKPAD-SPEC §6.3). */
const nextState: Record = { unrun: "pass", pass: "fail", fail: "unrun" };
@@ -62,6 +71,21 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
if (active) setLimit(firstPage);
}, [active]);
const more = (group: keyof typeof firstPage) => setLimit((current) => ({ ...current, [group]: current[group] + pageSize }));
+ // A search hit on an item past the first page shows the pages up to it (DRAWER-SPEC "Focus from search").
+ const focusTarget = useFocusTarget();
+ useEffect(() => {
+ if (!focusTarget.startsWith("workpad:") || !workpad) return;
+ const id = focusTarget.slice("workpad:".length);
+ const groups: [keyof typeof firstPage, readonly { id: string }[]][] = [
+ ["acceptance", workpad.acceptance],
+ ["checks", checkOrder(workpad.validation, workpad.head)],
+ ["confusions", workpad.confusions],
+ ];
+ for (const [group, list] of groups) {
+ const at = list.findIndex((item) => item.id === id);
+ if (at >= 0) setLimit((current) => ({ ...current, [group]: limitFor(at, current[group], pageSize) }));
+ }
+ }, [focusTarget, workpad]);
const storedSha = workpad?.head?.sha || "",
storedDirty = !!workpad?.head?.dirty;
useEffect(() => {
@@ -93,11 +117,7 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
const p = pending.get(v.id);
return p && p !== "removed" && p.state ? { ...v, state: p.state, by: "human" as const } : v;
});
- // What needs a look first: failed, out of date, not run, then passed (stable within each).
- const shownChecks = checks
- .map((item, index) => ({ item, index, rank: item.state === "fail" ? 0 : item.state === "unrun" ? 2 : validationFreshness(item, workpad!.head) === "stale" ? 1 : 3 }))
- .sort((a, b) => a.rank - b.rank || a.index - b.index)
- .map((x) => x.item);
+ const shownChecks = checkOrder(checks, workpad!.head);
const confusions = (workpad?.confusions || []).filter((c) => pending.get(c.id) !== "removed");
const updatedBy = !workpad
? ""
@@ -111,6 +131,7 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
return (
(
+
@@ -144,6 +165,7 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
)
}
/>
+
)} />
{acceptance.length > limit.acceptance && more("acceptance")} />}
@@ -151,8 +173,8 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
{!!checks.length && (
(
+
+
)} />
{shownChecks.length > limit.checks && more("checks")} />}
@@ -176,12 +199,13 @@ export const TaskWorkpad = memo(function TaskWorkpad({ task, disabled, active, s
{!!confusions.length && (
(
+
void edit(item.id, "removed", () => resolveRpc({ taskId: task.id, itemId: item.id }))}
/>
+
)} />
{confusions.length > limit.confusions && more("confusions")} />}
diff --git a/docs/DRAWER-SPEC.md b/docs/DRAWER-SPEC.md
index df47e62c..a17985de 100644
--- a/docs/DRAWER-SPEC.md
+++ b/docs/DRAWER-SPEC.md
@@ -44,6 +44,16 @@ Status: implemented 2026-10-19. Card: UI/UX 调整. Title kept for links; covers
5. **Phones.** On compact layouts only the action row (Back, project, Edit, More) stays fixed; the title, Status/Priority/Type and tags scroll with the sections, so the content gets the screen.
6. A `Collapse` mounted open stays in normal layout until it is measured, so an expanded section does not flash at zero height. Progress → History rows start at the section's left edge (`Disclosure` `flush`) and an opened version's fields line up with its title. Each version shows a compact time (`shared/compact-time.ts`): today the time only, this year month, day and time, earlier years the date with its year; no seconds.
+## Focus from search
+
+Status: implemented 2026-10-27. Card: 搜索功能 (SEARCH-SPEC §5.3).
+
+1. The board passes the drawer a focus request: a fresh key, a section (Progress, Workpad, Artifacts, Ideas or Activity) and an optional item target (`event:`, `workpad:- `, `idea:`, `artifact:`). Each new key is handled once.
+2. The drawer opens that section in its own open state for this opening only (item 3 above); the stored *Starts open* choices are never written.
+3. The section makes the target rendered: Activity widens its last-12 window back to the target event, Workpad and Artifacts raise their page limit to include it, Ideas opens *Closed* for an accepted or dismissed idea.
+4. When the target is laid out the drawer scrolls it near the top of the sidebar's scroll area (`measureLayout` against the scroll content) and rings it with the accent `CueRing` for about 1.6 s. With Reduce Motion the scroll is not animated. If the target has not rendered 1.5 s after the request (removed item, section off), the section itself is the target.
+5. Toggling any section, scrolling or closing the sidebar never re-applies an old request.
+
## Type scale
Status: implemented 2026-10-19. Card: UI/UX 调整. One scale for every sidebar section (`font` in `client/ui.tsx`):
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 6216b169..0bc821e8 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -2,6 +2,7 @@
## Search (card "搜索功能") — 2026-10-27
+- Focus from search: a hit row opens the card at its section and item (Progress, Activity, Workpad, Ideas, Artifacts), widening Activity's last-12 window, Workpad/Artifacts pages and Ideas → Closed to reach it, then a short accent ring; the stored section choices are untouched ([DRAWER-SPEC](DRAWER-SPEC.md#focus-from-search), `client/drawer-focus.tsx`).
- [SEARCH-SPEC](SEARCH-SPEC.md): `search.query` scans the cached board, the latest summary per card and active project memory in the daemon (`server/search.ts`); summaries/memory are re-read only when their file changes and the text index is rebuilt per board revision.
- Search page (`client/search-page.tsx`): header icon, `S`, Command Center `Flowly: Search`, Search everywhere in the filter dialog; This project / All projects, source chips, results open their card (switching project).
- `flowly_search` MCP tool, CLI `search`, HTTP `/api` action `search`; skill text points agents to it. Ideas, deliverables and memory follow their switches.
diff --git a/docs/SEARCH-SPEC.md b/docs/SEARCH-SPEC.md
index 300a6cc8..193701bb 100644
--- a/docs/SEARCH-SPEC.md
+++ b/docs/SEARCH-SPEC.md
@@ -118,7 +118,19 @@ output = {
### 5.3 点击
-- 卡片行或它的命中行:打开这张卡的侧栏。卡在别的项目时先切到那个项目(复用卡片链接的打开路径,`client/session-link.ts`)。v1 不滚动到对应分区(§10)。
+- 卡片行:打开这张卡的侧栏。卡在别的项目时先切到那个项目(复用卡片链接的打开路径,`client/session-link.ts`)。
+- 命中行:同样打开侧栏,再定位到命中的那一条(DRAWER-SPEC“Focus from search”):
+
+ | 命中来源 | 分区 | 定位到 |
+ |---|---|---|
+ | `summary` | Progress | 分区本身(只有一份最新摘要) |
+ | `note` | Activity | 那条事件 |
+ | `workpad` | Workpad | 那条验收、检查或疑问(`refId` 是条目 id) |
+ | `idea` | Ideas | 那条想法;已关闭的想法先展开“Closed” |
+ | `artifact` | Artifacts | 那条交付物 |
+ | `card` | 无 | 只打开侧栏(描述就在顶部) |
+
+ 分区只在这次打开时展开,不改用户保存的展开偏好。目标不在当前显示范围(Activity 只列最近 12 条、Workpad/Artifacts 分页)时先把范围扩到它;1.5 秒内还没渲染出来就退回到分区本身。滚到目标后给它约 1.6 秒的强调色描边;系统开了 Reduce Motion 时直接跳过去,不做滚动动画。
- 记忆行:打开 Memory 页。
- Search 页保持打开,关掉侧栏后结果还在。
@@ -185,7 +197,6 @@ output = {
## 10. 不在 v1
- FTS5 索引。在以下任一情况出现时另开卡:daemon 不再缓存整板;或单次搜索在真实数据上超过 50 ms。届时用同一个库,建 trigram FTS5 表,在写入事务里随变化的行一起更新,1–2 个字的词走逐条比对。
-- 点击命中后滚动到侧栏对应分区。
- 搜索会话记录、分页、高级语法(`type:`、`status:`、日期)、保存的搜索。
## 11. 验收
diff --git a/server/search.test.ts b/server/search.test.ts
index b19f2111..2609afb0 100644
--- a/server/search.test.ts
+++ b/server/search.test.ts
@@ -153,6 +153,11 @@ describe("sources (§2)", () => {
expect(found.cards.map((c) => c.taskId)).toEqual(["a"]);
if (source) expect(found.cards[0].hits[0]).toMatchObject({ source, field });
});
+ test("workpad hits name their item, so the drawer can land on it", () => {
+ expect(search(board, "checkword").cards[0].hits[0].refId).toBe("v");
+ expect(search(board, "acceptword").cards[0].hits[0].refId).toBe("x");
+ expect(search(board, "confusionword").cards[0].hits[0].refId).toBe("c");
+ });
test("ideas carry their status", () => {
expect(search(board, "ideaword").cards[0].hits[0].status).toBe("dismissed");
});
diff --git a/server/search.ts b/server/search.ts
index 6e59ecf6..8b2b9c63 100644
--- a/server/search.ts
+++ b/server/search.ts
@@ -104,13 +104,13 @@ export function buildSearchIndex(data: SearchData): SearchIndex {
for (const w of data.workpads as readonly Workpad[]) {
const card = cards.get(w.taskId);
if (!card) continue;
- for (const a of w.acceptance) doc(card.docs, "workpad", "acceptance", a.text, 2, { at: w.updatedAt });
+ for (const a of w.acceptance) doc(card.docs, "workpad", "acceptance", a.text, 2, { refId: a.id, at: w.updatedAt });
for (const v of w.validation) {
- doc(card.docs, "workpad", "check", v.text, 2, { at: w.updatedAt });
- doc(card.docs, "workpad", "command", v.command, 2, { at: w.updatedAt });
- doc(card.docs, "workpad", "evidence", v.evidence, 2, { at: w.updatedAt });
+ doc(card.docs, "workpad", "check", v.text, 2, { refId: v.id, at: w.updatedAt });
+ doc(card.docs, "workpad", "command", v.command, 2, { refId: v.id, at: w.updatedAt });
+ doc(card.docs, "workpad", "evidence", v.evidence, 2, { refId: v.id, at: w.updatedAt });
}
- for (const c of w.confusions) doc(card.docs, "workpad", "confusion", c.text, 2, { at: w.updatedAt });
+ for (const c of w.confusions) doc(card.docs, "workpad", "confusion", c.text, 2, { refId: c.id, at: w.updatedAt });
}
for (const i of data.ideas as readonly Idea[]) {
const card = cards.get(i.taskId);