Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
47e2132
Give pinned saved searches their own scrollable row
jarstelfox Jul 25, 2026
4064ad5
Stop pulling starved and region PRs out of the review queue
jarstelfox Jul 25, 2026
f93099c
Narrow Recently updated to recent, undone review work
jarstelfox Jul 25, 2026
ce2b87b
List shipped PRs in the toast and stop truncating
jarstelfox Jul 25, 2026
9bb8598
Preview popovers on real mouse hover, never on touch taps
jarstelfox Jul 25, 2026
b6dfb4d
Wake a snoozed PR on a new comment or review, not just a push
jarstelfox Jul 25, 2026
daedd94
Treat ifixit-systems and Copilot as bots
jarstelfox Jul 25, 2026
4bbcfe4
Keep bot activity out of the review nudge and the snooze
jarstelfox Jul 25, 2026
63eaad2
Hide bots from Review but keep them in CI and Ready
jarstelfox Jul 25, 2026
1024996
Address review findings: bot reveal, snooze wake, empty check, hover
jarstelfox Jul 25, 2026
46112f5
Add a staleness check for fired notifications
jarstelfox Jul 27, 2026
deafa03
Drop stale entries from the notification bell
jarstelfox Jul 27, 2026
f5cc711
Add matchesClosedQuery for the search lens
jarstelfox Jul 27, 2026
f0e35d8
Add the Search lens: find any PR, open or closed
jarstelfox Jul 27, 2026
736e59a
Route free text to the global Search lens
jarstelfox Jul 27, 2026
c7d5cc6
Show a search glyph, not the success check, on empty results
jarstelfox Jul 27, 2026
34d323a
Swap the empty-search glyph for lucide search-alert
jarstelfox Jul 27, 2026
931a876
Stop re-filtering the whole board on every search keystroke
jarstelfox Jul 27, 2026
cf4a530
Defer the search render so typing stays instant
jarstelfox Jul 27, 2026
0cc7448
Move the search deferral to App so the first keystroke doesn't block
jarstelfox Jul 27, 2026
84f7a24
Say what Recently updated shows, in the sub-line
jarstelfox Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions config.example.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ module.exports = {
// socket, and read by the /api/v1 endpoint. Both are optional.
//
// Bot logins beyond the `[bot]` suffix GitHub Apps carry (so pulldasher can
// fold/skip their PRs). Omit or [] for suffix-only detection.
bots: ['renovate-bot'],
// fold/skip their PRs). Omit or [] for suffix-only detection. ifixit-systems
// is our dependency-bumper and AI-agent account; Copilot is GitHub's coding
// agent (type Bot, no [bot] suffix). claude[bot] and the other suffixed apps
// are already detected without listing.
bots: ['renovate-bot', 'ifixit-systems', 'Copilot'],
// Label title -> review-weight bucket (XS|S|M|L|XL). A PR carrying one of
// these labels takes that weight instead of the diff-size guess, so an auto
// weight label (and any manual override) drives the board and the API. Omit
Expand Down
285 changes: 213 additions & 72 deletions frontend-v2/src/app.tsx

Large diffs are not rendered by default.

48 changes: 36 additions & 12 deletions frontend-v2/src/components/NotificationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { ArrowLeft, Bell, Settings as SettingsIcon, X } from 'lucide-react';
import { ago, githubUrl } from '../../../shared/format';
import { ago, githubUrl, pullKey, shortRepo } from '../../../shared/format';
import { type CheerGroup, CONFIGURABLE_TOASTS } from '../model/cheers';
import {
type Settings as SettingsShape,
Expand All @@ -15,6 +15,7 @@ import {
testNotification,
unlockSound,
} from '../notifications';
import { TOAST_PULLS_SHOWN } from '../model/toast';
import type { ToastRecord } from '../toasts';
import { HeaderIconButton, QuietButton, Segmented, Switch } from './bits';
import { Icon } from './Icon';
Expand Down Expand Up @@ -361,23 +362,46 @@ export function NotificationPanel({
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate font-semibold text-ink">
<span className="font-semibold text-ink">
{r.toast.count != null && `${r.toast.count} `}
{r.toast.title}
</span>
<span className="flex-none text-[10px] text-ink-3 tabular-nums">
{ago(r.at / 1000)}
</span>
</span>
{r.toast.pull && (
<a
href={githubUrl(r.toast.pull.repo, r.toast.pull.number)}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 block truncate font-medium text-brand hover:underline"
>
#{r.toast.pull.number}
{r.toast.pull.title ? ` ${r.toast.pull.title}` : ''}
</a>
{r.toast.pulls && r.toast.pulls.length > 0 ? (
<span className="mt-0.5 flex flex-col gap-0.5">
{r.toast.pulls.slice(0, TOAST_PULLS_SHOWN).map(p => (
<a
key={pullKey(p)}
href={githubUrl(p.repo, p.number)}
target="_blank"
rel="noopener noreferrer"
className="block font-medium text-brand hover:underline"
>
{shortRepo(p.repo)}#{p.number}
{p.title ? ` ${p.title}` : ''}
</a>
))}
{r.toast.pulls.length > TOAST_PULLS_SHOWN && (
<span className="block text-ink-3">
+{r.toast.pulls.length - TOAST_PULLS_SHOWN} more
</span>
)}
</span>
) : (
r.toast.pull && (
<a
href={githubUrl(r.toast.pull.repo, r.toast.pull.number)}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 block font-medium text-brand hover:underline"
>
{shortRepo(r.toast.pull.repo)}#{r.toast.pull.number}
{r.toast.pull.title ? ` ${r.toast.pull.title}` : ''}
</a>
)
)}
{r.toast.body && (
<span className="mt-0.5 block text-ink-2">{r.toast.body}</span>
Expand Down
15 changes: 11 additions & 4 deletions frontend-v2/src/components/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { useLayoutEffect, useState, type ReactNode, type Ref } from 'react';
import {
type PointerEvent as ReactPointerEvent,
useLayoutEffect,
useState,
type ReactNode,
type Ref,
} from 'react';
import { createPortal } from 'react-dom';
import { usePopover } from './usePopover';

Expand All @@ -8,9 +14,10 @@ export interface PopoverTriggerProps {
'aria-expanded': boolean;
onClick: () => void;
/** present only with `hoverTriggerOnly`: the hover handlers move off the
* root and onto the trigger element itself */
onMouseEnter?: () => void;
onMouseLeave?: () => void;
* root and onto the trigger element itself. Pointer events, not mouse, so
* the hover-arm can be gated to a real mouse and skip touch taps. */
onPointerEnter?: (e: ReactPointerEvent) => void;
onPointerLeave?: (e: ReactPointerEvent) => void;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion frontend-v2/src/components/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function useRowActions(pull: DerivedPull) {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
},
snooze: () => snoozePull(pullKey(pull.data)),
snooze: () => snoozePull(pull.data),
unsnooze: () => unsnoozePull(pullKey(pull.data)),
refresh: () => {
refreshPull(pull.data.repo, pull.data.number);
Expand Down
60 changes: 50 additions & 10 deletions frontend-v2/src/components/bits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@
// layer (z 0) — without it the pointer hit-tests the ::after,
// the span never sees mouseenter, and the door never opens;
// still inside the anchor, so a click navigates as before
trigger={({ onClick: _pin, ...t }) => (

Check warning on line 256 in frontend-v2/src/components/bits.tsx

View workflow job for this annotation

GitHub Actions / Build

'_pin' is defined but never used
<span {...t} className="pd-raise">
{title}
</span>
Expand Down Expand Up @@ -401,20 +401,60 @@
);
}

export function EmptyState({ title, sub }: { title: string; sub: string }) {
export function EmptyState({
title,
sub,
variant = 'ok',
}: {
title: string;
sub: string;
/** 'ok' is the green check: a genuine all-clear, nothing left to do (a good
* state). 'search' is the muted magnifying glass, for a null result from
* narrowing the board (a search or filter matched nothing) — where the check
* would wrongly read as success. Both draw the same stroke-in. */
variant?: 'ok' | 'search';
}) {
return (
<div className="flex flex-col items-center gap-2.5 px-6 py-10 text-center text-[13px] text-ink-3">
<svg viewBox="0 0 36 36" fill="none" aria-hidden className="h-9 w-9">
<circle cx="18" cy="18" r="16" stroke="var(--ok)" strokeWidth="2" />
<path
className="draw-check"
d="M11 18.5l5 5 9-11"
stroke="var(--ok)"
strokeWidth="2.5"
{variant === 'search' ? (
// search-alert (lucide): the magnifying glass draws in like the
// check (pathLength normalizes the lens+handle to 26 so the one
// draw-check keyframe animates it), then the exclamation pops in
// last to pull the eye to "nothing matched." currentColor keeps it
// in the muted ink of the copy: a heads-up, not a red alarm.
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden
className="h-9 w-9"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
>
<path
className="draw-check"
pathLength={26}
d="M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0M16.65 16.65 21 21"
/>
<g className="alert-pop">
<path d="M11 7v4" />
<path d="M11 15h.01" />
</g>
</svg>
) : (
<svg viewBox="0 0 36 36" fill="none" aria-hidden className="h-9 w-9">
<circle cx="18" cy="18" r="16" stroke="var(--ok)" strokeWidth="2" />
<path
className="draw-check"
d="M11 18.5l5 5 9-11"
stroke="var(--ok)"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
<span className="text-sm font-semibold text-ink-2">{title}</span>
<span>{sub}</span>
</div>
Expand Down
22 changes: 16 additions & 6 deletions frontend-v2/src/components/usePopover.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
import { getSettings } from '../settings';

/**
Expand Down Expand Up @@ -52,8 +52,18 @@ export function usePopover<Panel extends HTMLElement, Trigger extends HTMLElemen
setOpen(!open);
};

const onMouseEnter = () => {
if (!opts?.hover) return;
// Only touch is excluded from hover. Touch has no hover: a tap
// synthesizes pointerenter, which used to arm the open timer and fire the
// preview ~250ms LATER, after the tap's real action (a link navigation, a
// filter) had already run — an uninvited popover on top of the page you
// were just sent to. Gating on pointerType === 'touch' (rather than
// requiring 'mouse') keeps desktop hover intact, handles hybrid
// mouse+touch devices per interaction unlike a device-level (hover: hover)
// check, and lets a stylus ('pen') and any other non-touch pointer keep
// hover too. Button doors still open on tap via toggle() (a click), so
// touch keeps every tap-to-open popover.
const onPointerEnter = (e: ReactPointerEvent) => {
if (!opts?.hover || e.pointerType === 'touch') return;
clearClose();
// a short intent delay (a user setting): brushing the cursor across a row
// of triggers shouldn't flash their panels open one after another. Read
Expand All @@ -64,8 +74,8 @@ export function usePopover<Panel extends HTMLElement, Trigger extends HTMLElemen
if (delay <= 0) setOpen(true);
else openTimer.current = setTimeout(() => setOpen(true), delay);
};
const onMouseLeave = () => {
if (!opts?.hover) return;
const onPointerLeave = (e: ReactPointerEvent) => {
if (!opts?.hover || e.pointerType === 'touch') return;
clearOpen();
if (pinned.current) return;
clearClose();
Expand Down Expand Up @@ -117,6 +127,6 @@ export function usePopover<Panel extends HTMLElement, Trigger extends HTMLElemen
[]
);

const hoverProps = opts?.hover ? { onMouseEnter, onMouseLeave } : {};
const hoverProps = opts?.hover ? { onPointerEnter, onPointerLeave } : {};
return { open, toggle, rootRef, panelRef, triggerRef, hoverProps };
}
25 changes: 25 additions & 0 deletions frontend-v2/src/model/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,31 @@ describe('rowNote — the author matrix', () => {
note({ status: 'needs_cr', changesRequestedBy: ['carol'], headPushedAt: 200 }, me)
).toEqual({ action: 'Address feedback', context: 'changes requested by carol' });
});

it('a bot CHANGES_REQUESTED date never skews whether the author answered a human', () => {
// carol (human) requested changes at t=100; the author pushed at
// t=200, answering her. claude[bot] also left a later
// CHANGES_REQUESTED-flavored review at t=900 -- its date must not make
// carol's request look unanswered, or reset "Address feedback" over a
// human reviewer who's already been pushed past.
expect(
note(
{
status: 'needs_cr',
changesRequestedBy: ['carol'],
unstampedReviewers: [
{ login: 'carol', state: 'CHANGES_REQUESTED', date: 100 },
{ login: 'claude[bot]', state: 'CHANGES_REQUESTED', date: 900 },
],
headPushedAt: 200,
},
me
)
).toEqual({
action: null,
context: `waiting on carol to re-review · last commit ${ago(200)} ago`,
});
});
});

describe('parked (Cryogenic Storage) — asks nothing of anyone', () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend-v2/src/model/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ function authorNote(p: DerivedPull, me: string): RowNote {
* send them). */
function lastChangesRequestedAt(p: DerivedPull): number | null {
const dates = (p.data.status.unstamped_reviewers ?? [])
.filter(r => r.state === 'CHANGES_REQUESTED')
.filter(r => r.state === 'CHANGES_REQUESTED' && !isSuffixBot(r.login))
.map(r => r.date);
return dates.length ? Math.max(...dates) : null;
}
Expand Down
51 changes: 51 additions & 0 deletions frontend-v2/src/model/notificationRelevance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { SHIPPED_TOAST_KIND } from './cheers';
import { notificationStale } from './notificationRelevance';
import type { Toast } from './toast';

/** A toast carrying only the fields the relevance check reads. */
function toast(o: Partial<Toast>): Toast {
return { tone: 'info', icon: '🤝', title: 't', ...o };
}

const ref = (repo: string, number: number) => ({ repo, number });

describe('notificationStale', () => {
const open = new Set(['fixbot#3116', 'org/repo#7']);

it('drops a single-pull nudge once its PR leaves the open board', () => {
// Kyle's report: "return the favor on fixbot#3116" after #3116 merged
const merged = toast({ kind: 'return-the-favor', pull: ref('fixbot', 3116) });
expect(notificationStale(merged, new Set(['org/repo#7']))).toBe(true);
// while the PR is still open, the nudge stays
expect(notificationStale(merged, open)).toBe(false);
});

it('keeps a toast that points at no PR (board-wide rewards)', () => {
const inboxZero = toast({ kind: 'inbox-zero', pull: undefined });
expect(notificationStale(inboxZero, new Set())).toBe(false);
});

it('never stales the retrospective shipped recap, even though its PRs are merged', () => {
const shipped = toast({
kind: SHIPPED_TOAST_KIND,
pulls: [ref('org/repo', 900), ref('org/repo', 901)],
});
expect(notificationStale(shipped, new Set())).toBe(false);
});

it('stales a batched toast only once every referenced pull is gone', () => {
const batch = toast({ pulls: [ref('org/repo', 7), ref('fixbot', 3116)] });
// one of the two still open -> still relevant
expect(notificationStale(batch, new Set(['org/repo#7']))).toBe(false);
// both gone -> stale
expect(notificationStale(batch, new Set())).toBe(true);
});

it('applies to every PR-linked kind, not just return-the-favor', () => {
for (const kind of ['your-turn', 'review-requested', 'pr-ci-red', 're-stamp-owed']) {
const t = toast({ kind, pull: ref('gone', 1) });
expect(notificationStale(t, open)).toBe(true);
}
});
});
41 changes: 41 additions & 0 deletions frontend-v2/src/model/notificationRelevance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { pullKey } from '../../../shared/format';
import { SHIPPED_TOAST_KIND } from './cheers';
import type { Toast } from './toast';

/** Toast kinds that are ABOUT already-merged PRs, so a referenced pull missing
* from the open board is the whole point, not staleness. The shipped catch-up
* is the only one today; a new retrospective toast joins here. */
const RETROSPECTIVE_KINDS: ReadonlySet<string> = new Set([SHIPPED_TOAST_KIND]);

/** The pull keys a toast points you at: its single `pull`, its `pulls` batch,
* or neither (a board-wide reward like inbox-zero). */
function toastPullKeys(toast: Toast): string[] {
const refs = toast.pulls?.length ? toast.pulls : toast.pull ? [toast.pull] : [];
return refs.map(pullKey);
}

/**
* Whether a fired notification has gone stale: the PR it points you at has left
* the open board (merged or closed), so whatever it asked you to do can't be
* done anymore. The notification panel drops these, so a nudge like "return the
* favor on fixbot#3116" removes itself the moment #3116 merges instead of
* lingering as a dead link (prod report, Kyle 2026-07-27).
*
* Deliberately general -- every PR-linked kind, not just return-the-favor -- so
* one rule governs the whole panel. Two shapes are never stale: a toast with no
* PR (inbox-zero, board-cleared, a milestone count), since there's nothing to
* resolve, and a retrospective recap (shipped), which lists merged PRs on
* purpose. A toast pointing at several pulls is stale only once every one of
* them is gone.
*
* `openKeys` is the set of pull keys currently on the OPEN board; a referenced
* key that's absent has merged or closed. Feed it the FULL board rather than
* the hidden-filtered subset, so a still-open PR you've merely hidden isn't
* mistaken for done.
*/
export function notificationStale(toast: Toast, openKeys: ReadonlySet<string>): boolean {
if (toast.kind && RETROSPECTIVE_KINDS.has(toast.kind)) return false;
const keys = toastPullKeys(toast);
if (keys.length === 0) return false;
return keys.every(key => !openKeys.has(key));
}
Loading
Loading