Skip to content
Open
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ After installing, do the one-time setup (below) and you're done.
claude.ai mobile app** automatically — you only run the extension on desktop.
3. Click the toolbar icon for a **“waiting on you” queue** of your 🔴 chats; the
badge shows the count.
4. Not ready to deal with a chat yet? **Snooze it** from the popup (1 hour,
tomorrow 9am, 3 days, 1 week, or a custom time). It leaves the queue and the
badge, its title becomes `💤🔴 …` (synced to mobile like any label), and it
sits in a collapsed **Snoozed** section until it wakes — automatically, via a
one-shot alarm, even between sweeps. Wake times live in local extension
storage, so snooze schedules are per-machine.

Classification is deterministic — a string match on the marker Claude wrote,
never an after-the-fact guess.
Expand Down Expand Up @@ -106,10 +112,10 @@ bulk-archive, and multi-AI adapters).
## Development

Pure JS, no build step. Logic in `extension/lib/` (`classify.js`,
`titleTransform.js`) is unit-testable with plain Node:
`titleTransform.js`, `snooze.js`) is unit-testable with plain Node:

```sh
node --input-type=module -e "import {classify} from './extension/lib/classify.js'; console.log(classify('x\n✅ Resolved — safe to archive this chat.'))"
node --test extension/lib/*.test.js
```

## Feedback & contributing
Expand Down
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ provider-neutral (Claude today, more later).
- **Star mirroring** (optional, off by default) — 🔴 ⇒ starred, ✅ ⇒ unstarred.
- **Popup queue** — toolbar icon shows the "waiting on you" list as deep links;
badge shows the count.
- **Snooze** — per-chat mute from the popup (presets + custom time). Snoozed
chats leave the queue/badge, get a `💤🔴` title (syncs to mobile), and sit in
a collapsed Snoozed section until a one-shot alarm wakes them. Wake schedule
is local (`chrome.storage.local`); first unit tests added
(`node --test extension/lib/*.test.js`).
- **Settings page** — enabled / star mirroring / sweep interval.
- **Onboarding page** — opens on install: copy the preference prompt + deep link
to Claude settings.
Expand Down
96 changes: 85 additions & 11 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { classify } from './lib/classify.js';
import { titleTransform } from './lib/titleTransform.js';
import { getSettings } from './lib/settings.js';
import { isSnoozed, partitionBySnooze, pruneSnoozes } from './lib/snooze.js';

const API = 'https://claude.ai/api';
const LIST_LIMIT = 50;
Expand All @@ -36,7 +37,9 @@ chrome.runtime.onStartup.addListener(() => {
resetAlarm();
sweep();
});
chrome.alarms.onAlarm.addListener((a) => a.name === 'sweep' && sweep());
// 'snooze-wake' is a one-shot set for the earliest wake time; the sweep it
// triggers prunes the expired snooze, restores the title/badge, and re-arms.
chrome.alarms.onAlarm.addListener((a) => (a.name === 'sweep' || a.name === 'snooze-wake') && sweep());

// Recreate the alarm when the poll cadence changes.
chrome.storage.onChanged.addListener((changes, area) => {
Expand All @@ -54,6 +57,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
'check-conversation': () => checkConversation(msg.uuid).then((name) => ({ name })),
'get-waiting': () => listWaiting(),
'run-sweep': () => sweep().then(() => listWaiting()),
'snooze': () => setSnooze(msg.uuid, msg.wakeAt).then(() => listWaiting()),
'unsnooze': () => clearSnooze(msg.uuid).then(() => listWaiting()),
};
const handler = handlers[msg?.type];
if (!handler) return;
Expand Down Expand Up @@ -82,6 +87,47 @@ async function getOrgId() {
return org.uuid;
}

/**
* Snooze schedule: { [uuid]: wakeEpochMs } in chrome.storage.local. Local
* source of truth for when a chat wakes; the 💤🔴 title is derived from it
* by titleTransform, so the sweep keeps (not reverts) the 💤 while active.
*/
async function getSnoozes() {
const { snoozes = {} } = await chrome.storage.local.get('snoozes');
return snoozes;
}

async function setSnooze(uuid, wakeAt) {
if (!Number.isFinite(wakeAt) || wakeAt <= Date.now()) {
throw new Error('snooze wake time must be in the future');
}
const snoozes = await getSnoozes();
snoozes[uuid] = wakeAt;
await chrome.storage.local.set({ snoozes });
await resetWakeAlarm(snoozes);
await checkConversation(uuid); // decorate the title with 💤 right away
}

async function clearSnooze(uuid) {
const snoozes = await getSnoozes();
delete snoozes[uuid];
await chrome.storage.local.set({ snoozes });
await resetWakeAlarm(snoozes);
await checkConversation(uuid); // strip the 💤 right away
}

/** One chained one-shot alarm at the earliest wake; sweep re-arms the next. */
async function resetWakeAlarm(snoozes) {
const wakes = Object.values(snoozes).filter((w) => w > Date.now());
if (wakes.length) chrome.alarms.create('snooze-wake', { when: Math.min(...wakes) });
else await chrome.alarms.clear('snooze-wake');
}

/** A title that means "waiting on you", snoozed or not. */
function isWaitingTitle(name) {
return name.startsWith('🔴') || name.startsWith('💤🔴');
}

/** Classify a fully-fetched conversation by its last assistant message. */
function statusOf(full) {
const lastAssistant = [...(full.chat_messages ?? [])]
Expand All @@ -95,11 +141,11 @@ function statusOf(full) {
* (optionally) mirror the star. Returns the resulting title. Single source
* of truth for the sweep and the on-demand checker.
*/
async function applyStatus(orgId, full, settings) {
async function applyStatus(orgId, full, settings, snoozed = false) {
const status = statusOf(full);
let name = full.name;

const newTitle = titleTransform(name, status);
const newTitle = titleTransform(name, status, snoozed);
if (newTitle && newTitle !== name) {
console.log(`[claude-chat-status] "${name}" -> "${newTitle}"`);
await api(`/organizations/${orgId}/chat_conversations/${full.uuid}`, {
Expand Down Expand Up @@ -129,21 +175,33 @@ async function checkConversation(uuid) {
const orgId = await getOrgId();
const full = await api(`/organizations/${orgId}/chat_conversations/${uuid}`);
if (full.is_temporary) return full.name;
const name = await applyStatus(orgId, full, settings);
const snoozes = await getSnoozes();
const name = await applyStatus(orgId, full, settings, isSnoozed(snoozes, uuid, Date.now()));
const { seen = {} } = await chrome.storage.local.get('seen');
seen[uuid] = full.updated_at;
await chrome.storage.local.set({ seen });
return name;
}

/** The current "waiting on you" queue, for the popup. */
/** The current queue for the popup: waiting (unsnoozed) + snoozed 🔴 chats. */
async function listWaiting() {
const now = Date.now();
const orgId = await getOrgId();
const convos = await api(`/organizations/${orgId}/chat_conversations?limit=${LIST_LIMIT}`);
const waiting = convos
.filter((c) => !c.is_temporary && c.name.startsWith('🔴'))
const items = convos
.filter((c) => !c.is_temporary && isWaitingTitle(c.name))
.map((c) => ({ uuid: c.uuid, name: c.name, updated_at: c.updated_at }));
return { waiting };

// Self-heal here too, so a snooze that expired since the last sweep is
// already back in the waiting list when the popup opens.
const snoozes = await getSnoozes();
const pruned = pruneSnoozes(snoozes, new Set(items.map((i) => i.uuid)), now);
if (Object.keys(pruned).length !== Object.keys(snoozes).length) {
await chrome.storage.local.set({ snoozes: pruned });
await resetWakeAlarm(pruned);
}

return partitionBySnooze(items, pruned, now);
}

let sweeping = false;
Expand All @@ -161,12 +219,19 @@ async function sweep() {
const orgId = await getOrgId();
const convos = await api(`/organizations/${orgId}/chat_conversations?limit=${LIST_LIMIT}`);
const { seen = {} } = await chrome.storage.local.get('seen');
const snoozes = await getSnoozes();

for (const convo of convos) {
if (convo.is_temporary) continue;
if (seen[convo.uuid] === convo.updated_at) continue;
// A title whose 💤 disagrees with the snooze schedule (typically an
// expired snooze on an otherwise-quiet chat) must be re-applied even
// though updated_at hasn't changed — the `seen` shortcut would keep
// the stale 💤 forever.
const snoozed = isSnoozed(snoozes, convo.uuid, Date.now());
const staleZzz = isWaitingTitle(convo.name) && convo.name.startsWith('💤') !== snoozed;
if (seen[convo.uuid] === convo.updated_at && !staleZzz) continue;
const full = await api(`/organizations/${orgId}/chat_conversations/${convo.uuid}`);
convo.name = await applyStatus(orgId, full, settings); // keep name fresh for the badge
convo.name = await applyStatus(orgId, full, settings, snoozed); // keep name fresh for the badge
seen[convo.uuid] = convo.updated_at;
}

Expand All @@ -175,7 +240,16 @@ async function sweep() {
const pruned = Object.fromEntries(Object.entries(seen).filter(([id]) => listed.has(id)));
await chrome.storage.local.set({ seen: pruned });

const waiting = convos.filter((c) => c.name.startsWith('🔴')).length;
// Keep snoozes only while unexpired and the chat is still waiting —
// a chat that flipped to ✅ (or left the window) loses its snooze.
const stillWaiting = new Set(convos.filter((c) => isWaitingTitle(c.name)).map((c) => c.uuid));
const nextSnoozes = pruneSnoozes(snoozes, stillWaiting, Date.now());
await chrome.storage.local.set({ snoozes: nextSnoozes });
await resetWakeAlarm(nextSnoozes);

const waiting = convos.filter(
(c) => isWaitingTitle(c.name) && !isSnoozed(nextSnoozes, c.uuid, Date.now())
).length;
chrome.action.setBadgeBackgroundColor({ color: '#e23f33' }); // bliptracker contact-red
chrome.action.setBadgeText({ text: waiting ? String(waiting) : '' });
} catch (e) {
Expand Down
104 changes: 104 additions & 0 deletions extension/lib/snooze.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Snooze bookkeeping: pure helpers over the `snoozes` map stored in
* chrome.storage.local as { [conversationUuid]: wakeEpochMs }.
*
* The map is the source of truth for WHEN a chat wakes; the 💤🔴 title
* prefix (see titleTransform.js) is derived display. A chat counts as
* snoozed only while its wake time is in the future — expired entries are
* ignored everywhere and pruned by the sweep.
*
* Every function takes `now` (epoch ms) instead of reading the clock, so
* all of this is deterministic and unit-testable with plain Node.
*/

export const SNOOZE_PRESETS = [
{ id: '1h', label: '1 hour' },
{ id: 'tomorrow', label: 'Tomorrow 9am' },
{ id: '3d', label: '3 days' },
{ id: '1w', label: '1 week' },
];

const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;

/** Epoch ms a preset wakes at. 'tomorrow' = next calendar day 09:00 local. */
export function presetWakeTime(presetId, now = Date.now()) {
switch (presetId) {
case '1h':
return now + HOUR;
case '3d':
return now + 3 * DAY;
case '1w':
return now + 7 * DAY;
case 'tomorrow': {
const d = new Date(now);
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
return d.getTime();
}
default:
throw new Error(`unknown snooze preset: ${presetId}`);
}
}

/** True if uuid has an unexpired snooze. */
export function isSnoozed(snoozes, uuid, now) {
return (snoozes?.[uuid] ?? 0) > now;
}

/**
* Split the popup's waiting items into { waiting, snoozed }. Snoozed items
* gain a wakeAt field and are sorted soonest-first.
*/
export function partitionBySnooze(items, snoozes, now) {
const waiting = [];
const snoozed = [];
for (const item of items) {
if (isSnoozed(snoozes, item.uuid, now)) {
snoozed.push({ ...item, wakeAt: snoozes[item.uuid] });
} else {
waiting.push(item);
}
}
snoozed.sort((a, b) => a.wakeAt - b.wakeAt);
return { waiting, snoozed };
}

/**
* Drop expired snoozes and snoozes for chats no longer worth tracking
* (keepUuids = chats still in the list window AND still 🔴). Bounds storage
* growth the same way the sweep's `seen` prune does.
*/
export function pruneSnoozes(snoozes, keepUuids, now) {
return Object.fromEntries(
Object.entries(snoozes).filter(([uuid, wakeAt]) => wakeAt > now && keepUuids.has(uuid))
);
}

/**
* Short human wake time for the popup: "in 45 min", "today 17:30",
* "tomorrow 09:00", "Mon 09:00", "Mon 21 Jul, 09:00". Uses the default
* locale so 12/24-hour clock follows the OS.
*/
export function formatWake(wakeMs, now = Date.now()) {
const time = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
if (wakeMs - now < HOUR) return `in ${Math.max(1, Math.round((wakeMs - now) / MIN))} min`;

const wake = new Date(wakeMs);
const daysAhead = calendarDaysBetween(new Date(now), wake);
if (daysAhead === 0) return `today ${time.format(wake)}`;
if (daysAhead === 1) return `tomorrow ${time.format(wake)}`;
if (daysAhead < 7) {
const weekday = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
return `${weekday.format(wake)} ${time.format(wake)}`;
}
const date = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
return `${date.format(wake)}, ${time.format(wake)}`;
}

/** Whole local-calendar-days from a to b (0 = same day). */
function calendarDaysBetween(a, b) {
const startOfDay = (d) => new Date(d).setHours(0, 0, 0, 0);
return Math.round((startOfDay(b) - startOfDay(a)) / DAY);
}
84 changes: 84 additions & 0 deletions extension/lib/snooze.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SNOOZE_PRESETS,
presetWakeTime,
isSnoozed,
partitionBySnooze,
pruneSnoozes,
formatWake,
} from './snooze.js';

const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;

// A fixed local-time anchor keeps the calendar math deterministic.
const at = (h, m = 0) => new Date(2026, 6, 14, h, m).getTime(); // Tue 14 Jul 2026 local

test('every preset id resolves to a future wake time', () => {
const now = at(10);
for (const { id } of SNOOZE_PRESETS) {
assert.ok(presetWakeTime(id, now) > now, id);
}
});

test('fixed-duration presets', () => {
const now = at(10);
assert.equal(presetWakeTime('1h', now), now + HOUR);
assert.equal(presetWakeTime('3d', now), now + 3 * DAY);
assert.equal(presetWakeTime('1w', now), now + 7 * DAY);
});

test("'tomorrow' is next calendar day 09:00 local, morning or night", () => {
const tomorrow9 = new Date(2026, 6, 15, 9, 0, 0, 0).getTime();
assert.equal(presetWakeTime('tomorrow', at(8)), tomorrow9); // before 9am still skips to tomorrow
assert.equal(presetWakeTime('tomorrow', at(23)), tomorrow9);
});

test('unknown preset throws', () => {
assert.throws(() => presetWakeTime('nope', at(10)));
});

test('isSnoozed: future yes, expired/absent/exact-now no', () => {
const now = at(10);
assert.equal(isSnoozed({ a: now + 1 }, 'a', now), true);
assert.equal(isSnoozed({ a: now - 1 }, 'a', now), false);
assert.equal(isSnoozed({ a: now }, 'a', now), false);
assert.equal(isSnoozed({}, 'a', now), false);
assert.equal(isSnoozed(undefined, 'a', now), false);
});

test('partitionBySnooze splits, annotates wakeAt, sorts soonest-first', () => {
const now = at(10);
const items = [
{ uuid: 'late', name: 'late' },
{ uuid: 'awake', name: 'awake' },
{ uuid: 'soon', name: 'soon' },
{ uuid: 'expired', name: 'expired' },
];
const snoozes = { late: now + 2 * DAY, soon: now + HOUR, expired: now - MIN };
const { waiting, snoozed } = partitionBySnooze(items, snoozes, now);
assert.deepEqual(waiting.map((i) => i.uuid), ['awake', 'expired']);
assert.deepEqual(snoozed.map((i) => i.uuid), ['soon', 'late']);
assert.equal(snoozed[0].wakeAt, now + HOUR);
});

test('pruneSnoozes drops expired entries and uuids outside the keep set', () => {
const now = at(10);
const snoozes = { keep: now + HOUR, expired: now - 1, evicted: now + HOUR };
const pruned = pruneSnoozes(snoozes, new Set(['keep', 'expired']), now);
assert.deepEqual(pruned, { keep: now + HOUR });
});

test('formatWake buckets', () => {
const now = at(10);
assert.equal(formatWake(now + 45 * MIN, now), 'in 45 min');
assert.equal(formatWake(now + 30_000, now), 'in 1 min'); // never "in 0 min"
assert.match(formatWake(now + 3 * HOUR, now), /^today /);
assert.match(formatWake(now + DAY, now), /^tomorrow /);
assert.match(formatWake(presetWakeTime('tomorrow', at(23)), at(23)), /^tomorrow /);
assert.match(formatWake(now + 3 * DAY, now), /^\S+ \d/); // weekday + time
const far = formatWake(now + 30 * DAY, now);
assert.match(far, /,/); // full date form
});
Loading