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
33 changes: 27 additions & 6 deletions client/content-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<View style={cardFrameStyle(colors)}>
<View style={{ padding: space[3], paddingBottom: space[3] }}>
<Clamped background={colors.surface1} footer={footer} defaultOpen={defaultOpen}>
<Clamped background={colors.surface1} footer={footer} defaultOpen={defaultOpen} openWhen={openWhen}>
{children}
</Clamped>
</View>
Expand Down Expand Up @@ -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 (
<View style={{ gap: 2 }}>
<Label muted size={font.xs} selectable={false}>
{title}
</Label>
<CardText>{text}</CardText>
<CardText>
<Marked text={text} terms={terms} />
</CardText>
</View>
);
}
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions client/drawer-focus-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 13 additions & 4 deletions client/drawer-focus-logic.ts
Original file line number Diff line number Diff line change
@@ -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<SearchHit, "source" | "refId">): Omit<DrawerFocus, "key"> | null {
const item = (prefix: string) => (hit.refId ? { target: `${prefix}:${hit.refId}` } : {});
export function focusOfHit(hit: Pick<SearchHit, "source" | "refId"> & Partial<Pick<SearchHit, "field" | "snippet" | "ranges">>): Omit<DrawerFocus, "key"> | 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":
Expand Down
34 changes: 31 additions & 3 deletions client/drawer-focus.tsx
Original file line number Diff line number Diff line change
@@ -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<FocusState | null>(null);
export const DrawerFocusProvider = FocusContext.Provider;

Expand All @@ -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 key={i} style={{ fontWeight: "600", color: colors.accent }}>
{text.slice(start, end)}
</Text>,
);
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.
Expand Down
18 changes: 14 additions & 4 deletions client/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1359,8 +1369,8 @@ function ActivityItem({
</View>
)}
{!!event.note && !isNoMoves(event) && (
<Label muted={!question} size={font.sm} lines={question ? 8 : 4}>
{event.action === "type_set" && (taskTypes as readonly string[]).includes(event.note) ? taskTypeLabels[event.note as Task["type"] & string] : event.note}
<Label muted={!question} size={font.sm} lines={mark ? undefined : question ? 8 : 4}>
{event.action === "type_set" && (taskTypes as readonly string[]).includes(event.note) ? taskTypeLabels[event.note as Task["type"] & string] : <Marked text={event.note} terms={mark?.terms} />}
</Label>
)}
<Label muted size={font.xs}>
Expand Down
14 changes: 11 additions & 3 deletions client/task-artifacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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 { FocusTarget, Marked, limitFor, useFocusTarget, useMark } 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";
Expand Down Expand Up @@ -222,6 +222,8 @@ const ArtifactRow = memo(function ArtifactRow({
onRemove(artifact: Artifact): void;
}) {
const { colors } = useTheme();
// A search hit on this row unclips it and adds the full link or path (DRAWER-SPEC "Focus from search" 7).
const mark = useMark(`artifact:${artifact.id}`);
const agent = artifact.createdBy === "agent";
const source = agent ? t("Added by an agent") : t("Added by you");
const role = artifact.role === "input" ? t("Input") : t("Output");
Expand All @@ -240,8 +242,14 @@ const ArtifactRow = memo(function ArtifactRow({
>
{external ? <BrandIcon family={familyOf(external.provider)} size={14} /> : <Icon name={icon} size={14} color={missing ? colors.foregroundMuted : colors.foreground} />}
<View style={{ flex: 1, minWidth: 0 }}>
<Label size={font.md} muted={missing} lines={1} selectable={false}>{artifact.title}</Label>
<Label size={font.sm} muted lines={1} selectable={false}>{missing ? `${t("Missing")} · ${detail}` : detail}</Label>
<Label size={font.md} muted={missing} lines={mark ? undefined : 1} selectable={false}><Marked text={artifact.title} terms={mark?.terms} /></Label>
<Label size={font.sm} muted lines={mark ? undefined : 1} selectable={false}>
{missing && `${t("Missing")} · `}
<Marked text={detail} terms={mark?.terms} />
</Label>
{!!mark && detail !== artifact.target && (
<Label size={font.sm} muted mono selectable={false}><Marked text={artifact.target} terms={mark.terms} /></Label>
)}
</View>
{agent ? <AiMark size={12} /> : <Icon name="User" size={12} color={colors.foregroundMuted} />}
</Pressable>
Expand Down
32 changes: 26 additions & 6 deletions client/task-ideas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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 { FocusTarget, Marked, useFocusTarget, useMark } from "./drawer-focus";
import { lerp, motionDuration, useReducedMotion } from "./motion";
import { exitRows } from "./idea-rows";
import { useTaskDetail, invalidateCardViews } from "./card-detail-query";
Expand Down Expand Up @@ -453,6 +453,9 @@ export const IdeaCard = memo(function IdeaCard({
onDismiss(idea: Idea): void;
}) {
const { colors } = useTheme();
// A search hit on this idea marks its words and opens it past Show more (DRAWER-SPEC "Focus from search" 7).
const mark = useMark(`idea:${idea.id}`);
const terms = mark?.terms;
const meta = [
<CardMeta
key="scope"
Expand All @@ -474,6 +477,7 @@ export const IdeaCard = memo(function IdeaCard({
return (
<ContentCard
after={source}
openWhen={mark}
footer={
<View style={{ flexShrink: 1, flexDirection: "row", justifyContent: "flex-end", gap: space[2] }}>
<Button quiet disabled={disabled} label={t("Dismiss {title}", { title: idea.title })} onPress={() => onDismiss(idea)}>
Expand All @@ -495,15 +499,21 @@ export const IdeaCard = memo(function IdeaCard({
<CardBody>
{/* Title and meta stay together. */}
<View style={{ gap: space[1.5] }}>
<CardHeadline selectable={false}>{idea.title}</CardHeadline>
<CardHeadline selectable={false}>
<Marked text={idea.title} terms={terms} />
</CardHeadline>
<CardMetaRow>
{meta}
<CardMeta text={relativeTime(idea.createdAt)} />
</CardMetaRow>
</View>
{!!idea.rationale.trim() && <CardText>{idea.rationale}</CardText>}
<CardField title={t("What to do")} text={idea.detail} />
<CardField title={t("Context")} text={idea.context} />
{!!idea.rationale.trim() && (
<CardText>
<Marked text={idea.rationale} terms={terms} />
</CardText>
)}
<CardField title={t("What to do")} text={idea.detail} terms={terms} />
<CardField title={t("Context")} text={idea.context} terms={terms} />
</CardBody>
</ContentCard>
);
Expand All @@ -527,11 +537,21 @@ export function ClosedIdea({
}) {
const { colors } = useTheme();
const accepted = idea.status === "accepted";
// A search hit here: mark the title and, when the words are in another field, show that field under it.
const mark = useMark(`idea:${idea.id}`);
const matched = mark && mark.field && mark.field !== "title" ? idea[mark.field as "detail" | "rationale" | "context"] : "";
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: space[2], minHeight: touch ? 44 : 32 }}>
<Icon name={accepted ? "ThumbsUp" : "CircleSlash"} size={14} color={accepted ? colors.statusSuccess : colors.foregroundMuted} />
<View style={{ flex: 1, minWidth: 0, gap: 1 }}>
<Label size={font.sm} muted={!accepted} lines={1} selectable={false}>{idea.title}</Label>
<Label size={font.sm} muted={!accepted} lines={mark ? undefined : 1} selectable={false}>
<Marked text={idea.title} terms={mark?.terms} />
</Label>
{!!matched && (
<Label size={font.sm} muted selectable={false}>
<Marked text={matched} terms={mark?.terms} />
</Label>
)}
{accepted &&
(sentTo ? (
<Label muted size={font.xs} lines={1} selectable={false}>{`→ ${t("Sent to {session}", { session: sentTo })}`}</Label>
Expand Down
31 changes: 24 additions & 7 deletions client/task-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query";
import * as rpc from "../shared/rpc";
import { modelLabel, targetText, type SummaryEntry, type SummaryOverview } from "../shared/summary";
import { CardBody, CardField, CardFootnote, CardHeadline, ContentCard } from "./content-card";
import { Marked, useMark } from "./drawer-focus";
import { Section, type SectionState } from "./disclosure";
import { summariesKey, useSummariesQuery } from "./queries";
import { workpadEnabled, workpadOf } from "../shared/workpad";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function TaskProgress({ taskId, active, section }: { taskId: string; acti
const refreshRpc = useRpc(rpc.refreshSummary);
const [requesting, setRequesting] = useState(false);
const latest = cardProgress(overview.data, taskId);
const mark = useMark("section:progress");
const running = !!overview.data?.running.includes(taskId);
const error = overview.data?.errors.find((e) => e.taskId === taskId);
const refresh = async () => {
Expand Down Expand Up @@ -66,8 +68,12 @@ export function TaskProgress({ taskId, active, section }: { taskId: string; acti
}
>
{latest ? (
<ContentCard footer={<CardFootnote>{t("Updated {time}", { time: relativeTime(latest.at) })} · {modelLabel(latest.model)}</CardFootnote>}>
<SummaryBody entry={hideTarget ? { ...latest, target: "" } : latest} />
<ContentCard
openWhen={mark}
footer={<CardFootnote>{t("Updated {time}", { time: relativeTime(latest.at) })} · {modelLabel(latest.model)}</CardFootnote>}
>
{/* A search hit in Target shows it even where the workpad replaces it (DRAWER-SPEC "Focus from search" 7). */}
<SummaryBody entry={hideTarget && mark?.field !== "target" ? { ...latest, target: "" } : latest} terms={mark?.terms} />
</ContentCard>
) : (
<Label muted size={font.sm}>
Expand All @@ -88,13 +94,24 @@ export function TaskProgress({ taskId, active, section }: { taskId: string; acti
}

/** Each field in full: brevity comes from the prompt, never from cutting text (TASK-SUMMARY-SPEC §3, §7). */
export function SummaryBody({ entry }: { entry: Pick<SummaryEntry, "state" | "target" | "recap" | "next"> }) {
export function SummaryBody({
entry,
terms,
}: {
entry: Pick<SummaryEntry, "state" | "target" | "recap" | "next">;
/** Search words to mark. */
terms?: readonly string[] | null;
}) {
return (
<CardBody>
{!!entry.state && <CardHeadline>{entry.state}</CardHeadline>}
{!!entry.target && <CardField title={t("Target")} text={targetText(entry.target)} />}
{!!entry.recap && <CardField title={t("Recap")} text={entry.recap} />}
{!!entry.next && <CardField title={t("Next")} text={entry.next} />}
{!!entry.state && (
<CardHeadline>
<Marked text={entry.state} terms={terms} />
</CardHeadline>
)}
{!!entry.target && <CardField title={t("Target")} text={targetText(entry.target)} terms={terms} />}
{!!entry.recap && <CardField title={t("Recap")} text={entry.recap} terms={terms} />}
{!!entry.next && <CardField title={t("Next")} text={entry.next} terms={terms} />}
</CardBody>
);
}
Loading
Loading