fix(ui): inbox/task list parity, nesting alignment, hover perf, and routine detail polish (#9317)

## Thinking Path

> - Paperclip's UI is governed by the design system merged in #9134 and
the component convergence in #9240 — one Card, one Badge, one nav row,
one `IssueRow`, a single multiplicative radius ladder.
> - With those primitives in place, the remaining rough edges were
interaction and alignment details on the surfaces people use every day:
the inbox, the task list, the sidebar, and the routine/task detail
pages.
> - Each item here was reported from live use and fixed against a
running instance, then verified by measurement (pixel alignment, frame
timing) rather than by eye alone.
> - The result is that the inbox and task lists now behave as one system
(same hover, keyboard nav, tree-guide, and archive language), list
nesting reads correctly, list hover is smooth, and the routine detail
page scrolls and aligns like the rest of the app.

## Linked Issues or Issue Description

No public GitHub issue exists for this work; describing per the feature
template.

- **Problem**: after the design-system foundation (#9134) and component
convergence (#9240), the inbox/task lists still had interaction and
alignment gaps — hover lag on long lists, keyboard navigation that only
partly matched between the two lists, workspace/parent nesting whose
guides and chevrons didn't line up, an inbox that sat offset from the
task list, and a routine detail page with an odd double-scroll and a
bespoke sub-nav.
- **Proposed behavior**: the inbox and task lists share one interaction
contract (hover, keyboard nav, collapse, archive), list nesting aligns
to the status column with clean chevrons, list hover is CSS-only (no
per-hover re-render), and the routine detail page uses a fixed
header/sub-nav with a single scrolling content region and a sub-nav that
matches the primary nav.

## What Changed

- **List hover performance**: hover is now painted purely by CSS
`:hover` and records the hovered row in a ref, instead of writing
list-selection state on every `mouseenter` (which re-rendered 100–300
non-memoized rows per hover). Keyboard nav reads the ref so it still
continues from the hovered row; the keyboard band clears on the first
real mouse move so hover and keyboard selection never show two bands at
once. The row's `transition-colors` fade was removed so the highlight
snaps (no comet-tail). Measured on a 100-row scrub: worst frame **333 ms
→ 33 ms**, long frames (>50 ms) **12 → 0**, avg **41 → 60 fps**.
- **Inbox ↔ task-list parity**: keyboard navigation works on every inbox
tab (archive/read keys stay scoped to the archivable tab); group headers
and parent tasks collapse/expand with the arrow keys in both lists; the
task list gains the same j/k / arrows / Enter selection model as the
inbox; hover selection bands match; the `g` then `i` go-to-inbox chord
works app-wide.
- **List nesting & alignment**: the workspace group-header chevron lines
up exactly with the task chevrons below it (both lists); the
parent→child connector line drops from under the parent's **status
icon** rather than its chevron and breaks with a 14px gap around a
nested row's own chevron; and inbox rows line up with the task list
(read rows no longer reserve a mark-read column).
- **Inbox archive affordance**: moved from a bare `x` left of the status
icon to an `Archive` icon + label button on the right (before the
timestamp), revealed on row hover; the left slot now carries only the
unread dot; swipe-to-archive is unchanged.
- **Sidebar**: when no agent has a live run, the AGENTS section shows 3
recent agents (was 5) plus "See all agents"; the working-agents view is
unchanged.
- **Routine detail page**: the layout is bounded to the main scroll area
so the header (Run now + automation toggle) and the sub-nav stay fixed
and only the section content scrolls (was a page-level scroll competing
with a `sticky` sub-nav). The sub-nav items adopt the primary nav's
rhythm — row padding/height, inset rounded pill, type scale, and 16px
icons — and its background matches the main nav.
- **Task detail**: dropped the redundant `🔵` prefix the breadcrumb added
for live/in-progress tasks; the status glyph already conveys that state.

## Verification

- `pnpm check:token-gates` → 3/3 CLEAN
- `pnpm typecheck` → green (all packages)
- `cd ui && npx vitest run` → 2255/2255 (assertions updated in lockstep
where behavior changed)
- `pnpm --filter @paperclipai/ui build` → exit 0
- Storybook visual regression: run locally throughout (the
baseline-manifest archive is still unpublished, so CI cannot run this
suite — pre-existing condition from #9134). Every visible delta was
reviewed against a live instance and, where it was intentional (nesting
alignment, routine sub-nav restyle, unread-row shift), the affected
snapshots were re-baselined locally.
- Manual/measured: inbox + task lists (grouped and nested, light +
dark), hover-scrub frame timing, keyboard navigation, and the routine
detail scroll/alignment were exercised on a running instance.

## Risks

- Behavior-and-alignment changes concentrated in `IssueRow` /
`IssuesList` / `Inbox` (the shared task-row surfaces). The riskiest area
— the hover/keyboard-selection model — is covered by unit tests (updated
in lockstep) and was measured and driven live.
- The routine detail scroll change restructures that page's layout
container; verified the page's own scroll stays fixed while only the
section content scrolls.
- The visual suite cannot yet run in CI (unpublished baseline archive —
pre-existing); snapshot coverage is local-only until that lands.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic
coding with tool use (file editing, test execution, Playwright
measurement/screenshot verification); extended thinking enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-07-09 14:25:48 -07:00 committed by GitHub
parent bc85b456a1
commit 53d09d4c34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 345 additions and 151 deletions

View File

@ -66,6 +66,10 @@ User rulings from the tune-session gallery review; all intentionally visible, sn
- `pnpm typecheck` + full `pnpm test:storybook-visual` — re-running independently (in progress).
- Eyeball-pass note: the Phase 0 baseline was captured at the master fork point before any change, and the suite compares current rendering to it at `maxDiffPixels: 0` — pixel-equality with master-at-fork is machine-proven; side-by-side Storybook remains available on request (`pnpm storybook` here + `-p 6007` on master).
## List-interaction parity policy (Run 3 follow-up, Jul 9 2026)
**Policy (human-directed):** the inbox and tasks lists render the same material (task rows, status icons, parent/child trees, workspace groups), so their interactivity is the same by default — hover treatment (rounded `bg-accent/50` band), hover-follows-selection with the pointer-moved guard, j/k + arrow-key traversal that includes group headers, ArrowLeft/ArrowRight collapse/expand on both group headers and parent tasks, and Enter to open. View-specific capabilities stay view-specific (inbox: archive/read shortcuts on the archivable tab; tasks: kanban view). New list surfaces should adopt this contract rather than invent their own.
## Baseline reconcile — Run 3 setup (Jul 7, 2026)
Fresh worktree from origin/master (3b16ac380) on `design/component-convergence`. The baseline-manifest archive is still unpublished (placeholder URL), so the 510 local baselines were seeded from the Run 1 worktree (`~/Projects/DEV/paperclip-design-simplify`, `tests/storybook-visual/__snapshots__/`, branch head 44ab1ad17) into gitignored `tests/storybook-visual/.snapshots/`. First run: 497/510 green; 6 stories (12 snapshots, both themes) diffed, each verified against a specific master commit that postdates the baselines, then re-baselined — nothing else touched:

View File

@ -24,13 +24,17 @@ export function IssueGroupHeader({
{collapsible ? (
<button
type="button"
className="flex min-w-0 items-center gap-1.5 text-left"
className="flex min-w-0 items-center gap-2 text-left"
aria-expanded={!collapsed}
onClick={onToggle}
>
<ChevronRight
className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", !collapsed && "rotate-90")}
/>
{/* w-4 centered chevron box matches the task rows' chevron column so
the header chevron lines up exactly with the chevrons below it. */}
<span className="inline-flex w-4 shrink-0 items-center justify-center">
<ChevronRight
className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", !collapsed && "rotate-90")}
/>
</span>
<span className="truncate text-sm font-semibold uppercase tracking-wide">
{label}
</span>

View File

@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Eye, Flag, X } from "lucide-react";
import { Archive, Eye, Flag } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@ -51,6 +51,12 @@ interface IssueRowProps {
onMouseEnter?: () => void;
/** Ancestor levels; renders that many vertical tree-guide slots (desktop). */
treeGuides?: number;
/**
* This row has its own collapse chevron sitting in the innermost guide
* column (a nested parent). Breaks the guide line there so the chevron is
* not crossed out by it.
*/
chevronInGuide?: boolean;
/** Suppress the row divider (parents with expanded children keep visual attachment to their subtree). */
hideDivider?: boolean;
}
@ -79,11 +85,11 @@ export function IssueRow({
className,
onMouseEnter,
treeGuides = 0,
chevronInGuide = false,
hideDivider = false,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
const identifier = issue.identifier ?? issue.id.slice(0, 8);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
@ -131,7 +137,10 @@ export function IssueRow({
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
onMouseEnter={onMouseEnter}
className={cn(
"group flex items-start gap-2 rounded-lg py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors sm:items-center sm:py-2 sm:pl-1",
// No color transition on the row band: hover/selection must snap
// instantly. A fade (transition-colors) leaves a trail of fading bands
// when scrubbing the mouse fast across the list.
"group flex items-start gap-2 rounded-lg py-2.5 pl-2 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1",
!hideDivider && "border-b border-border last:border-b-0",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-(--sz-calc-11) sm:pl-(--sz-calc-12)" : null,
@ -155,20 +164,39 @@ export function IssueRow({
) : null}
<span className="flex items-center gap-2 self-stretch sm:order-1 sm:shrink-0">
{treeGuides > 0
? Array.from({ length: treeGuides }, (_, level) => (
? Array.from({ length: treeGuides }, (_, level) => {
// The innermost guide lands on THIS row's own chevron column; if
// the row has a chevron, break the line around it so it isn't
// crossed out.
const gapForChevron = chevronInGuide && level === treeGuides - 1;
return (
// Tree guide: occupies the same flex slot as the parent's
// chevron column so the line lands under the parent's status
// column; stretched past the row padding so consecutive rows
// read as one continuous line.
<span key={`guide-${level}`} aria-hidden="true" className="relative hidden w-4 shrink-0 self-stretch sm:block">
{/* bg-background underlay: dark-mode --border is translucent,
{/* The connector drops from under the ancestor's STATUS icon,
not its chevron: the status column sits one level (w-4 slot
+ gap-2 = 2rem) right of this guide slot's left edge.
bg-background underlay: dark-mode --border is translucent,
so overlapping row segments would stack brighter without
an opaque base. */}
<span className="absolute -inset-y-3 left-1/2 w-px bg-background">
<span className="absolute inset-0 bg-border" />
<span className="absolute -inset-y-3 left-8 w-px bg-background">
{gapForChevron ? (
// Two border segments centering a 14px (h-3.5) transparent
// gap for the row's own chevron.
<span className="absolute inset-0 flex flex-col">
<span className="flex-1 bg-border" />
<span className="h-3.5 shrink-0" />
<span className="flex-1 bg-border" />
</span>
) : (
<span className="absolute inset-0 bg-border" />
)}
</span>
</span>
))
);
})
: null}
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
@ -197,50 +225,9 @@ export function IssueRow({
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta || externalObjectSummary) ? (
{(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
) : onArchive ? (
{onArchive ? (
<button
type="button"
data-slot="icon-button"
@ -256,14 +243,56 @@ export function IssueRow({
onArchive();
}}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Archive"
>
<X className="h-3.5 w-3.5" />
<Archive className="h-3.5 w-3.5" />
Archive
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
) : null}
{externalObjectSummary ? (
<ExternalObjectStatusSummary summary={externalObjectSummary} compact />
) : null}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadDot ? (
// Only unread rows reserve this leading mark-read column; read rows
// omit it entirely so their content lines up with the tasks list
// (which has no such column). Archive lives on the right now.
<span className="order-first inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
)}
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
</span>
) : null}
</Link>

View File

@ -86,6 +86,21 @@ import { Badge } from "@/components/ui/badge";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
type IssuesListNavEntry =
| { type: "group"; key: string; collapsed: boolean }
| { type: "issue"; issue: Issue; hasChildren: boolean; expanded: boolean; budgetOrdinal: number };
function issuesListNavEntryKey(entry: IssuesListNavEntry): string {
return entry.type === "group" ? `group:${entry.key}` : `issue:${entry.issue.id}`;
}
// CSS.escape is missing in some non-browser environments (jsdom tests).
function escapeAttrValue(value: string): string {
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(value)
: value.replace(/["\\]/g, "\\$&");
}
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320;
@ -640,7 +655,7 @@ export function IssuesList({
// Keyboard selection for the list view (mirrors the inbox). Hover moves the
// selection only after real pointer movement, so keyboard-driven scrolling
// doesn't hand the selection to whatever row lands under the cursor.
const [selectedNavIssueId, setSelectedNavIssueId] = useState<string | null>(null);
const [selectedNavKey, setSelectedNavKey] = useState<string | null>(null);
const pointerMovedSinceKeyNavRef = useRef(true);
useEffect(() => {
const handlePointerMove = () => {
@ -649,9 +664,18 @@ export function IssuesList({
window.addEventListener("mousemove", handlePointerMove, { passive: true });
return () => window.removeEventListener("mousemove", handlePointerMove);
}, []);
const setNavSelectionFromPointer = useCallback((issueId: string) => {
// Which entry the cursor is over, tracked WITHOUT React state so scrubbing the
// list costs zero re-renders (hover paints via CSS `:hover`). Keyboard nav
// reads this to continue from the hovered row. Key-based, so it self-heals if
// the entry disappears (findIndex → -1).
const hoveredNavKeyRef = useRef<string | null>(null);
const setNavSelectionFromPointer = useCallback((navKey: string) => {
if (!pointerMovedSinceKeyNavRef.current) return;
setSelectedNavIssueId(issueId);
hoveredNavKeyRef.current = navKey;
// Drop any keyboard selection band the moment the mouse takes over, so we
// never show two identical highlights at once. React bails when already
// null, so continuous hovering triggers no re-render.
setSelectedNavKey((prev) => (prev === null ? prev : null));
}, []);
const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialogActions();
@ -1220,21 +1244,35 @@ export function IssuesList({
projectById,
]);
// Flattened visible row order (groups -> tree DFS, skipping collapsed
// groups/parents) — must match render order below for keyboard traversal.
const flatNavIssues = useMemo(() => {
if (viewState.viewMode !== "list") return [] as Issue[];
const out: Issue[] = [];
// Flattened visible order (group headers, then tree DFS per group —
// collapsed groups keep their header entry but skip their rows) — must
// match render order below for keyboard traversal. `budgetOrdinal` counts
// rows the way the progressive renderer consumes its budget (collapsed
// groups still consume rows; collapsed parents' subtrees do not).
const flatNavEntries = useMemo(() => {
if (viewState.viewMode !== "list") return [] as IssuesListNavEntry[];
const out: IssuesListNavEntry[] = [];
let budgetCount = 0;
for (const group of groupedContent) {
if (group.label && viewState.collapsedGroups.includes(group.key)) continue;
const collapsed = Boolean(group.label) && viewState.collapsedGroups.includes(group.key);
if (group.label) out.push({ type: "group", key: group.key, collapsed });
const { roots, childMap } = viewState.nestingEnabled
? buildIssueTree(group.items)
: { roots: group.items, childMap: new Map<string, Issue[]>() };
const walk = (issue: Issue) => {
out.push(issue);
if (!viewState.collapsedParents.includes(issue.id)) {
for (const child of childMap.get(issue.id) ?? []) walk(child);
budgetCount += 1;
const children = childMap.get(issue.id) ?? [];
const expanded = !viewState.collapsedParents.includes(issue.id);
if (!collapsed) {
out.push({
type: "issue",
issue,
hasChildren: children.length > 0,
expanded,
budgetOrdinal: budgetCount,
});
}
if (expanded) for (const child of children) walk(child);
};
for (const root of roots) walk(root);
}
@ -1247,11 +1285,35 @@ export function IssuesList({
viewState.nestingEnabled,
]);
const listNavStateRef = useRef({ flatNavIssues, selectedNavIssueId, viewMode: viewState.viewMode, issueLinkState });
listNavStateRef.current = { flatNavIssues, selectedNavIssueId, viewMode: viewState.viewMode, issueLinkState };
const listNavStateRef = useRef({
flatNavEntries,
selectedNavKey,
viewMode: viewState.viewMode,
issueLinkState,
collapsedGroups: viewState.collapsedGroups,
collapsedParents: viewState.collapsedParents,
updateView,
});
listNavStateRef.current = {
flatNavEntries,
selectedNavKey,
viewMode: viewState.viewMode,
issueLinkState,
collapsedGroups: viewState.collapsedGroups,
collapsedParents: viewState.collapsedParents,
updateView,
};
const findSelectedNavRowLink = useCallback((issueId: string) => {
const row = rootRef.current?.querySelector(`[data-issue-row-id="${CSS.escape(issueId)}"]`);
const findSelectedNavElement = useCallback((navKey: string) => {
if (navKey.startsWith("group:")) {
const header = rootRef.current?.querySelector(
`[data-issues-group-key="${escapeAttrValue(navKey.slice("group:".length))}"]`,
);
return header instanceof HTMLElement ? header : null;
}
const row = rootRef.current?.querySelector(
`[data-issue-row-id="${escapeAttrValue(navKey.slice("issue:".length))}"]`,
);
const link = row?.querySelector(":scope > [data-inbox-issue-link]");
return link instanceof HTMLElement ? link : null;
}, []);
@ -1272,10 +1334,16 @@ export function IssuesList({
return;
}
const st = listNavStateRef.current;
if (st.viewMode !== "list" || st.flatNavIssues.length === 0) return;
const currentIndex = st.selectedNavIssueId
? st.flatNavIssues.findIndex((issue) => issue.id === st.selectedNavIssueId)
: -1;
if (st.viewMode !== "list" || st.flatNavEntries.length === 0) return;
// The row a keystroke acts on: the hovered row when the mouse moved since
// the last key nav (so "hover a row → press Arrow/Enter" acts on it),
// otherwise the keyboard selection. Hover no longer writes selection
// state, so this threads the pointer position into every handler.
const indexOfKey = (key: string | null) =>
key ? st.flatNavEntries.findIndex((entry) => issuesListNavEntryKey(entry) === key) : -1;
const hoveredIndex = indexOfKey(hoveredNavKeyRef.current);
const fromHover = pointerMovedSinceKeyNavRef.current && hoveredIndex >= 0;
const currentIndex = fromHover ? hoveredIndex : indexOfKey(st.selectedNavKey);
switch (e.key) {
case "j":
case "ArrowDown":
@ -1284,23 +1352,54 @@ export function IssuesList({
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
const direction = e.key === "j" || e.key === "ArrowDown" ? "next" : "previous";
const nextIndex = getInboxKeyboardSelectionIndex(currentIndex, st.flatNavIssues.length, direction);
const nextIssue = st.flatNavIssues[nextIndex];
if (!nextIssue) break;
setSelectedNavIssueId(nextIssue.id);
const nextIndex = getInboxKeyboardSelectionIndex(currentIndex, st.flatNavEntries.length, direction);
const nextEntry = st.flatNavEntries[nextIndex];
if (!nextEntry) break;
setSelectedNavKey(issuesListNavEntryKey(nextEntry));
// The list renders progressively; make sure the selected row is
// within the render budget so the band mounts and can scroll into
// view (the +1 keeps the next row visible as a scroll cue).
setRenderedIssueRowLimit((current) => Math.max(current, nextIndex + 2));
if (nextEntry.type === "issue") {
setRenderedIssueRowLimit((current) => Math.max(current, nextEntry.budgetOrdinal + 1));
}
break;
}
case "ArrowLeft":
case "ArrowRight": {
// Groups and parent tasks collapse/expand with the same keys as the
// inbox.
const entry = st.flatNavEntries[currentIndex];
if (!entry) return;
const collapse = e.key === "ArrowLeft";
if (entry.type === "group") {
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
setSelectedNavKey(issuesListNavEntryKey(entry));
st.updateView({
collapsedGroups: collapse
? (st.collapsedGroups.includes(entry.key) ? st.collapsedGroups : [...st.collapsedGroups, entry.key])
: st.collapsedGroups.filter((k) => k !== entry.key),
});
break;
}
if (!entry.hasChildren) return;
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
setSelectedNavKey(issuesListNavEntryKey(entry));
st.updateView({
collapsedParents: collapse
? (st.collapsedParents.includes(entry.issue.id) ? st.collapsedParents : [...st.collapsedParents, entry.issue.id])
: st.collapsedParents.filter((id) => id !== entry.issue.id),
});
break;
}
case "Enter": {
if (currentIndex < 0) return;
const issue = st.flatNavIssues[currentIndex];
if (!issue) return;
const entry = st.flatNavEntries[currentIndex];
if (!entry || entry.type !== "issue") return;
e.preventDefault();
// Navigate from the entry data (like the inbox) rather than the DOM
// row — the selected row may sit past the mounted render batch.
const issue = entry.issue;
const pathId = issue.identifier ?? issue.id;
const detailState = withIssueDetailHeaderSeed(st.issueLinkState, issue);
rememberIssueDetailLocationState(pathId, detailState);
@ -1319,9 +1418,9 @@ export function IssuesList({
// render budget too: a selection past the mounted batch scrolls once its
// row mounts.
useEffect(() => {
if (!selectedNavIssueId) return;
findSelectedNavRowLink(selectedNavIssueId)?.scrollIntoView({ block: "nearest" });
}, [findSelectedNavRowLink, renderedIssueRowLimit, selectedNavIssueId]);
if (!selectedNavKey) return;
findSelectedNavElement(selectedNavKey)?.scrollIntoView({ block: "nearest" });
}, [findSelectedNavElement, renderedIssueRowLimit, selectedNavKey]);
useEffect(() => {
if (viewState.viewMode !== "list") return;
@ -1784,6 +1883,15 @@ export function IssuesList({
}}
>
{group.label && (
// Left inset aligns the header chevron with the nested task
// chevrons: tasks-list rows sit at pl-1 before their chevron
// (no unread column), so the band adds no extra left inset.
<div
data-issues-group-key={group.key}
className={cn("rounded-lg px-3 sm:pl-0 sm:pr-4", selectedNavKey === `group:${group.key}` ? "bg-accent/50" : "hover:bg-accent/50")}
onClick={() => setSelectedNavKey(`group:${group.key}`)}
onMouseEnter={() => setNavSelectionFromPointer(`group:${group.key}`)}
>
<IssueGroupHeader
label={group.label}
collapsible
@ -1808,6 +1916,7 @@ export function IssuesList({
</Button>
)}
/>
</div>
)}
<CollapsibleContent>
{(() => {
@ -1920,9 +2029,10 @@ export function IssuesList({
<IssueRow
issue={issue}
issueLinkState={issueLinkState}
selected={selectedNavIssueId === issue.id}
onMouseEnter={() => setNavSelectionFromPointer(issue.id)}
selected={selectedNavKey === `issue:${issue.id}`}
onMouseEnter={() => setNavSelectionFromPointer(`issue:${issue.id}`)}
treeGuides={depth}
chevronInGuide={depth > 0 && hasChildren}
hideDivider={hasChildren && isExpanded}
checklistStepNumber={checklistStepNumber}
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
@ -1965,7 +2075,7 @@ export function IssuesList({
) : null}
</>
)}
className={cn(isMutedIssue && "opacity-70", selectedNavIssueId === issue.id && "bg-accent/50")}
className={cn(isMutedIssue && "opacity-70", selectedNavKey === `issue:${issue.id}` && "bg-accent/50 hover:bg-accent/50")}
mobileLeading={
hasChildren ? (
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
@ -1983,13 +2093,13 @@ export function IssuesList({
<button
type="button"
data-slot="icon-button"
className="hidden shrink-0 items-center sm:inline-flex"
className="relative z-10 hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={toggleCollapse}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="hidden w-3.5 shrink-0 sm:block" />
<span className="hidden w-4 shrink-0 sm:block" />
)}
<InboxIssueMetaLeading
issue={issue}

View File

@ -110,11 +110,11 @@ export function RoutineSubSidebar({
return (
<nav
aria-label="Routine sections"
className="sticky top-0 hidden max-h-(--sz-100dvh) w-52 shrink-0 flex-col gap-4 self-start overflow-y-auto border-r border-border bg-sidebar/30 px-3 py-4 md:flex"
className="hidden h-full w-52 shrink-0 flex-col gap-4 overflow-y-auto border-r border-border bg-background px-3 py-4 md:flex"
>
{NAV_GROUPS.map((group) => (
<div key={group.label} className="flex flex-col gap-0.5">
<p className="px-3 py-2 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground/80">
<p className="mx-2 px-2 pb-1 text-(length:--text-nano) font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
{group.label}
</p>
{group.items.map((item) => {
@ -138,13 +138,15 @@ export function RoutineSubSidebar({
onKeyDown={(event) => handleKeyDown(event, index)}
onClick={() => onNavigate(item.key)}
className={cn(
"flex h-9 items-center gap-2 rounded-md px-3 text-sm transition-colors motion-safe:duration-150",
// Match the primary nav rows (SidebarNavItem): same rhythm,
// inset pill, type scale, and icon size.
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors motion-safe:duration-150",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.label}</span>
{showLiveDot ? (
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 motion-safe:animate-pulse" />

View File

@ -645,7 +645,7 @@ describe("SidebarAgents", () => {
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
it("shows up to 5 recently-active agents plus a See all link when none are running", async () => {
it("shows up to 3 recently-active agents plus a See all link when none are running", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
makeAgent({
@ -659,7 +659,7 @@ describe("SidebarAgents", () => {
await renderSidebarAgents();
expect(agentLinkLabels(container)).toHaveLength(5);
expect(agentLinkLabels(container)).toHaveLength(3);
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});

View File

@ -59,7 +59,7 @@ import type { Agent } from "@paperclipai/shared";
* When no agent is running, the sidebar falls back to showing at most this many
* recently-active agents plus a "See all agents" link (IA Phase 5).
*/
const RECENT_AGENT_LIMIT = 5;
const RECENT_AGENT_LIMIT = 3;
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "top", label: "Top" },

View File

@ -382,7 +382,7 @@ describe("Inbox toolbar", () => {
});
});
it("syncs hover with j/k selection on inbox rows", async () => {
it("paints row hover via CSS only, without moving React selection state", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
@ -413,27 +413,19 @@ describe("Inbox toolbar", () => {
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
// Hovering paints via CSS `:hover` only — it must NOT flip a row into the
// state-selected band (which would swap to hover:bg-transparent). Coupling
// hover to React state was the per-hover re-render storm behind the lag;
// scrubbing the list must not touch selection state. (Keyboard nav that
// continues from the hovered row is exercised in live/e2e verification —
// this unit mocks keyboardShortcutsEnabled off.)
await act(async () => {
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
// After hovering row 1, that row is "selected" — same visual state as j/k selection.
await vi.waitFor(() => {
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
});
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
await act(async () => {
rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[0]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
// Hovering a different row moves the selection to follow the mouse.
await vi.waitFor(() => {
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
});
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).not.toContain("hover:bg-transparent");
act(() => {
root.unmount();
@ -468,7 +460,7 @@ describe("Inbox toolbar", () => {
});
const initialArchiveButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Dismiss from inbox"]'),
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Archive"]'),
);
expect(initialArchiveButtons.length).toBeGreaterThanOrEqual(2);
@ -483,7 +475,7 @@ describe("Inbox toolbar", () => {
});
const remainingArchiveButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Dismiss from inbox"]',
'button[aria-label="Archive"]',
);
expect(remainingArchiveButton).not.toBeNull();
expect(remainingArchiveButton?.disabled).toBe(false);

View File

@ -1343,6 +1343,15 @@ export function Inbox() {
return next;
});
}, []);
const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => {
setCollapsedInboxParents((prev) => {
if (prev.has(parentId) === collapsed) return prev;
const next = new Set(prev);
if (collapsed) next.add(parentId);
else next.delete(parentId);
return next;
});
}, []);
// Build flat navigation list from visible rows so keyboard traversal respects collapsed groups.
const flatNavItems = useMemo((): NavEntry[] => {
@ -1553,9 +1562,17 @@ export function Inbox() {
window.addEventListener("mousemove", handlePointerMove, { passive: true });
return () => window.removeEventListener("mousemove", handlePointerMove);
}, []);
// Which row the cursor is over, tracked WITHOUT React state so scrubbing the
// list costs zero re-renders (hover paints via CSS `:hover`, see IssueRow).
// Keyboard nav reads this to continue from the hovered row.
const hoveredIndexRef = useRef<number | null>(null);
const setSelectedIndexFromPointer = useCallback((idx: number) => {
if (!pointerMovedSinceKeyNavRef.current) return;
setSelectedIndex(idx);
hoveredIndexRef.current = idx;
// Drop any keyboard selection band the moment the mouse takes over, so we
// never show two identical highlights at once. React bails out when the
// value is already -1, so continuous hovering triggers no re-render.
setSelectedIndex((prev) => (prev < 0 ? prev : -1));
}, []);
const invalidateInboxIssueQueryCaches = () => {
@ -1731,6 +1748,9 @@ export function Inbox() {
: `group:${entry.groupKey}`;
const selectedNavKeyRef = useRef<string | null>(null);
useEffect(() => {
// A reshaped list invalidates the numeric hover index; drop it so the next
// keypress falls back to the (key-reconciled) keyboard selection.
hoveredIndexRef.current = null;
setSelectedIndex((prev) => {
if (prev < 0) return resolveInboxSelectionIndex(prev, flatNavItems.length);
const prevKey = selectedNavKeyRef.current;
@ -1786,6 +1806,7 @@ export function Inbox() {
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
setGroupCollapsed,
setInboxParentCollapsed,
navigate,
});
kbActionsRef.current = {
@ -1797,6 +1818,7 @@ export function Inbox() {
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
setGroupCollapsed,
setInboxParentCollapsed,
navigate,
};
@ -1847,7 +1869,7 @@ export function Inbox() {
const navCount = navItems.length;
if (navCount === 0) return;
/** Resolve the nav entry at selectedIndex to an issue (for child entries) or work item. */
/** Resolve the nav entry at an index to an issue (for child entries) or work item. */
const resolveNavEntry = (idx: number): { issue?: Issue; item?: InboxWorkItem } => {
const entry = navItems[idx];
if (!entry) return {};
@ -1856,36 +1878,62 @@ export function Inbox() {
return {};
};
// The row a keystroke acts on: the hovered row when the mouse has moved
// since the last key nav (so "hover a row → press a/r/Enter/Arrow" acts on
// it), otherwise the keyboard selection. Hover no longer writes selection
// state, so this is what threads the pointer position into every handler.
const rawHovered = hoveredIndexRef.current;
const hoveredIndex = rawHovered != null && rawHovered >= 0 && rawHovered < navCount ? rawHovered : -1;
const fromHover = pointerMovedSinceKeyNavRef.current && hoveredIndex >= 0;
const effectiveIndex = fromHover ? hoveredIndex : st.selectedIndex;
switch (e.key) {
case "j":
case "ArrowDown": {
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "next"));
setSelectedIndex(getInboxKeyboardSelectionIndex(effectiveIndex, navCount, "next"));
break;
}
case "k":
case "ArrowUp": {
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, navCount, "previous"));
setSelectedIndex(getInboxKeyboardSelectionIndex(effectiveIndex, navCount, "previous"));
break;
}
case "ArrowLeft":
case "ArrowRight": {
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
const entry = navItems[st.selectedIndex];
if (!entry || entry.type !== "group") return;
if (effectiveIndex < 0 || effectiveIndex >= navCount) return;
const entry = navItems[effectiveIndex];
if (!entry) return;
if (entry.type === "group") {
e.preventDefault();
pointerMovedSinceKeyNavRef.current = false;
setSelectedIndex(effectiveIndex);
act.setGroupCollapsed(entry.groupKey, e.key === "ArrowLeft");
break;
}
// Parent tasks collapse/expand with the same keys as groups.
const { issue, item } = resolveNavEntry(effectiveIndex);
const targetIssue = issue ?? (item?.kind === "issue" ? item.issue : null);
if (!targetIssue) return;
const hasChildren = st.workItems.some(
(group) => (group.childrenByIssueId.get(targetIssue.id)?.length ?? 0) > 0,
);
if (!hasChildren) return;
e.preventDefault();
act.setGroupCollapsed(entry.groupKey, e.key === "ArrowLeft");
pointerMovedSinceKeyNavRef.current = false;
setSelectedIndex(effectiveIndex);
act.setInboxParentCollapsed(targetIssue.id, e.key === "ArrowLeft");
break;
}
case "a":
case "y": {
if (!st.canArchive) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
if (effectiveIndex < 0 || effectiveIndex >= navCount) return;
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
const { issue, item } = resolveNavEntry(effectiveIndex);
if (issue) {
if (!st.nonInboxSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) {
@ -1902,9 +1950,9 @@ export function Inbox() {
}
case "U": {
if (!st.canArchive) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
if (effectiveIndex < 0 || effectiveIndex >= navCount) return;
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
const { issue, item } = resolveNavEntry(effectiveIndex);
if (issue) {
act.markUnreadIssue(issue.id);
} else if (item) {
@ -1915,9 +1963,9 @@ export function Inbox() {
}
case "r": {
if (!st.canArchive) return;
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
if (effectiveIndex < 0 || effectiveIndex >= navCount) return;
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
const { issue, item } = resolveNavEntry(effectiveIndex);
if (issue) {
if (issue.isUnreadForMe && !st.fadingOutIssues.has(issue.id)) act.markRead(issue.id);
} else if (item) {
@ -1931,9 +1979,9 @@ export function Inbox() {
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= navCount) return;
if (effectiveIndex < 0 || effectiveIndex >= navCount) return;
e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex);
const { issue, item } = resolveNavEntry(effectiveIndex);
if (issue) {
const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
@ -2574,11 +2622,7 @@ export function Inbox() {
<div
key={`group-${group.key}`}
data-inbox-item
className={cn(
"px-3 sm:px-4",
groupIndex > 0 && "pt-2",
isGroupSelected && "bg-accent/50",
)}
className={cn(groupIndex > 0 && "pt-2")}
onClick={() => {
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
}}
@ -2586,6 +2630,11 @@ export function Inbox() {
if (groupNavIdx >= 0) setSelectedIndexFromPointer(groupNavIdx);
}}
>
{/* Left inset aligns the header chevron with the nested
task chevrons. Read rows no longer reserve a
mark-read column, so inbox rows sit at pl-1 before
their chevron same as the tasks list. */}
<div className={cn("rounded-lg px-3 sm:pl-0 sm:pr-4", isGroupSelected ? "bg-accent/50" : "hover:bg-accent/50")}>
<IssueGroupHeader
label={group.label}
collapsible
@ -2607,6 +2656,7 @@ export function Inbox() {
</Button>
) : null}
/>
</div>
</div>,
);
}

View File

@ -3069,9 +3069,9 @@ export function IssueDetail() {
setBreadcrumbs([
sourceBreadcrumb,
{
label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle,
// Prepend the task's status glyph (lg/20px) to the breadcrumb so the
// current task's state reads at a glance.
// The status glyph (leading) already conveys in-progress/live state;
// no redundant 🔵 emoji prefix on the title.
label: breadcrumbTitle,
leading: breadcrumbStatusLeading,
leadingKey: breadcrumbStatusKey,
},

View File

@ -777,8 +777,11 @@ export function RoutineDetail() {
Skip to section
</a>
<div className="-m-4 flex min-h-full flex-col md:-m-6">
{/* Slim page header — scrolls with the page (not sticky) */}
{/* Bounded to the main scroll area's height so the header + sub-nav stay
fixed and only the section content below scrolls (no page-level
scroll, no competing sticky points). */}
<div className="-m-4 flex h-full min-h-0 flex-col overflow-hidden md:-m-6">
{/* Slim page header — fixed at the top of the routine layout. */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background px-6">
<div className="flex min-w-0 flex-1 items-center gap-3">
<textarea
@ -845,7 +848,7 @@ export function RoutineDetail() {
<main
id="routine-section"
role="main"
className="min-w-0 flex-1 px-4 pb-6 pt-10 md:px-8"
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-6 pt-10 md:px-8"
>
<section
aria-labelledby="routine-section-title"