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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5J49W4DPK93Q9SVS23VW59A
anchor: function useAttention
created: 2026-10-22T15:40:01Z
norm: '1'
sig: 764bb872be391a4f
body_hash: 5ea3135588da2978
raw_hash: b0b3e34b9fb9b707
lines: 100-195
---

mention counts are ignored until the person's GitHub login loads; with no names every count reads zero, and recording that would re-notify old mentions
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5J49ZNB9XYK6GFYV3SBXKAP
anchor: '@file'
created: 2026-10-22T15:40:05Z
norm: '1'
sig: 9d222e7bbdcbbb9b
body_hash: a48e796f66372ad6
raw_hash: 99a80a221071bf27
lines: 1-195
supersedes: 01M4XQJSWS1YHDS9TRD27C41WG
---

`told` and `called` persist per host and user so reloads and reconnects never re-notify the same run end or mention; `since` keeps history from lighting up on a first visit
58 changes: 51 additions & 7 deletions apps/app/src/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import { host } from "./host";

/**
* Run ends and messages this person has seen or been told about, by channel, and how many mentions
* of them each channel had when they last saw it. Anything before `since` predates this browser
* profile's first visit, so history never lights up. Profiles from before mentions have no `heard`.
* of them each channel had when they last saw it (`heard`) or were told (`called`). Anything before
* `since` predates this browser profile's first visit, so history never lights up. Profiles from
* before mentions have no `heard`, and from before mention notices no `called`.
*/
type State = {
since: number;
seen: Record<string, number>;
told: Record<string, number>;
heard?: Record<string, number>;
called?: Record<string, number>;
};

export type Outcome = Exclude<RunEnd["outcome"], "stopped">;
Expand All @@ -37,6 +39,18 @@ const NOTICES: Record<Outcome, string> = {
/** The pulse holds its glow this long, then keeps these props until it has faded out. */
export const glow = { hold: 1800, props: { glowOpacity: 0.6, glowFadeIn: 0.8, glowFadeOut: 1.3 } };

// Listings outlive the socket and refresh just after each connect, behind other updates, so mentions
// found that soon after one arrived while this person was away.
const CATCH_UP = 10_000;
let opened = host.status === "open" ? Date.now() : 0;
let caught = false;
host.subscribe(() => {
if (host.status !== "open") opened = 0;
if (host.status !== "open" || opened) return;
opened = Date.now();
caught = false;
});

const seenAt = (channel: Listing, state: State) =>
Math.max(state.since, state.seen[channel.id] || 0);

Expand Down Expand Up @@ -66,11 +80,22 @@ function baseline(channel: Listing, names: string[], state: State): number {
?? (channel.created > state.since ? 0 : mentioned(channel, names));
}

const times = (count: number) => count === 1 ? "Mentioned you" : `Mentioned you ${count} times`;

/** One notice for every mention that arrived while this person was away, so a reconnect never floods. */
function missed(found: { channel: Listing; count: number }[]): [string, string] {
const [{ channel, count }] = found;
if (found.length === 1) return [channel.name, `${times(count)} while you were away`];
const list = found.map((value) => `${value.count} in ${value.channel.name}`).join(", ");
return ["Mentions while you were away", list];
}

/**
* Runs this person invoked that ended, or that wait on a question, while they were not watching the
* channel. Each pulses the window's glow or sends one desktop notification, then marks its row
* until they open it. Others' messages and @mentions of `names` (lowercased GitHub logins) mark the
* row too, without a notification.
* channel, and new @mentions of `names` (lowercased GitHub logins). Each pulses the window's glow or
* sends one desktop notification, then marks its row until they open it; mentions that arrived
* while Ace was closed or disconnected notify once together. Others' messages mark the row too,
* without a notification.
*/
export function useAttention(
channels: Listing[],
Expand All @@ -94,12 +119,21 @@ export function useAttention(
const seen: Record<string, number> = {};
const told: Record<string, number> = {};
const heard: Record<string, number> = {};
const called: Record<string, number> = {};
const mentions: { channel: Listing; count: number }[] = [];
// Until the person's GitHub login loads, every count reads zero and would re-tell old mentions.
const named = names.length > 0;
const away = named && !caught && Date.now() - opened < CATCH_UP;
for (const channel of channels) {
const { id, ran, said } = channel;
const looking = watching && id === open;
// Mentions are counts, not times, so each channel keeps the count it was last seen at.
const count = looking ? mentioned(channel, names) : baseline(channel, names, state);
if (state.heard?.[id] !== count) heard[id] = count;
const total = mentioned(channel, names);
const before = state.called?.[id] ?? baseline(channel, names, state);
if (named && total !== before) called[id] = total;
if (named && total > before && !looking) mentions.push({ channel, count: total - before });
if (looking) {
const last = Math.max(ran?.at || 0, said?.at || 0);
if ((state.seen[id] || 0) < last) seen[id] = last;
Expand All @@ -114,15 +148,25 @@ export function useAttention(
desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {});
}
}
if (!Object.keys(seen).length && !Object.keys(told).length && !Object.keys(heard).length) {
return;
if (away && mentions.length) {
caught = true;
const [title, body] = missed(mentions);
desktop?.notify(title, body).catch(() => {});
} else if (mentions.length && watching) setPulse("on");
else {
for (const { channel, count } of mentions) {
desktop?.notify(channel.name, times(count)).catch(() => {});
}
}
const changes = [seen, told, heard, called].some((value) => Object.keys(value).length);
if (!changes) return;
setState((value) =>
value && {
...value,
seen: { ...value.seen, ...seen },
told: { ...value.told, ...told },
heard: { ...value.heard, ...heard },
called: { ...value.called, ...called },
}
);
}, [channels, user, names, state, setState, watching, open]);
Expand Down