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);