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:
parent
bc85b456a1
commit
53d09d4c34
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue