Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions client/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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}
/>
</CardDetailProvider>
</Animated.View>
Expand Down
8 changes: 6 additions & 2 deletions client/disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }: {
Expand Down Expand Up @@ -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;
Expand All @@ -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 = (
<View style={{ gap: space[2] }}>
<SectionHeader title={title} count={count} ai={ai} open={open || empty} onToggle={empty ? undefined : onToggle} summary={summary}>
{actions}
Expand All @@ -130,6 +133,7 @@ export function Section({ id, title, count, ai, summary, open, onToggle, actions
)}
</View>
);
return focusId ? <FocusTarget id={`section:${focusId}`}>{body}</FocusTarget> : body;
}

/** "Show N more": adds the next rows to the same list, lined up with the rows' icons. There is no Show less. */
Expand Down
30 changes: 30 additions & 0 deletions client/drawer-focus-logic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions client/drawer-focus-logic.ts
Original file line number Diff line number Diff line change
@@ -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<SearchHit, "source" | "refId">): Omit<DrawerFocus, "key"> | 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;
}

49 changes: 49 additions & 0 deletions client/drawer-focus.tsx
Original file line number Diff line number Diff line change
@@ -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<FocusState | null>(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<View>(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 (
<View ref={node} collapsable={false}>
{children}
{hit && <CueRing on={ring} radius={round} inset={3} />}
</View>
);
}
62 changes: 58 additions & 4 deletions client/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -108,6 +109,7 @@ function TaskDrawerView({
onAttention,
sectionOrder,
sectionOpen,
focus,
}: {
task: Task;
project: Project;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<View>(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<ReturnType<typeof setTimeout>[]>([]);
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;
Expand Down Expand Up @@ -875,15 +920,19 @@ function TaskDrawerView({
{!compact && heading}
</View>
<Divider />
<DrawerFocusProvider value={focusState}>
<ScrollView
ref={scroller as never}
{...verticalScroll}
// Avoid recording offscreen section views on compact surfaces.
removeClippedSubviews={compact}
style={{ flex: 1, minHeight: 0 }}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ ...verticalContent, padding: space[4], paddingBottom: space[6] + space[2], gap: space[5] }}
contentContainerStyle={{ ...verticalContent, padding: space[4], paddingBottom: space[6] + space[2] }}
>
{/* One measured node for search focus (DRAWER-SPEC "Focus from search"). */}
<View ref={content} collapsable={false} style={{ gap: space[5] }}>
{compact && <View style={{ gap: space[2] }}>{heading}</View>}
{!!resourceIssue && !wrapped && (
<View style={{ gap: space[2] }}>
Expand Down Expand Up @@ -1029,18 +1078,20 @@ function TaskDrawerView({
{meaningful.length > 0 && (
<Section
id={task.id}
focusId="activity"
title={t("Activity")}
count={meaningful.length}
open={sectionStates.activity.open}
onToggle={sectionStates.activity.onToggle}
>
<View>
{/* 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) => (
<FocusTarget key={e.id} id={`event:${e.id}`}>
<ActivityItem
key={e.id}
event={e}
nameOf={(status) => laneName(project, status)}
last={index === list.length - 1}
Expand All @@ -1054,13 +1105,16 @@ function TaskDrawerView({
: t("Agent")
}
/>
</FocusTarget>
))}
</View>
</Section>
)}
</Enter>
)}
</View>
</ScrollView>
</DrawerFocusProvider>
</>
)}
</MotionReveal>
Expand Down
Loading
Loading