Skip to content

Commit 0e8f5f8

Browse files
nedtwiggclaude
andcommitted
Implement leaky-bucket mechanism for soft-TODOs
Instead of clearing a soft-TODO on the first keypress, use a filling/leaky bucket: typing drains it (5 keypresses to empty), idling refills it (3 seconds to full). The TODO pill visually shrinks and fades as the bucket drains. Unifies TodoState into a single number — TODO_OFF (-1), [0..TODO_SOFT_FULL] for soft, TODO_HARD (2) — eliminating the separate todoBucketLevel field and preventing desync bugs. Includes an interactive Storybook story (TodoBucket) for tuning feel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9deb58d commit 0e8f5f8

20 files changed

Lines changed: 546 additions & 199 deletions

lib/src/cfg.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ export const cfg = {
2727
/** ms — attention idle expiry. How long before "looking at this pane" wears off. */
2828
userAttention: 15_000,
2929
},
30+
todoBucket: {
31+
/** Seconds for a fully-drained soft-TODO bucket to refill to full when idle. */
32+
timeToFullSeconds: 3,
33+
/** Number of printable keypresses to drain a full bucket to zero. */
34+
keypressesToEmpty: 5,
35+
},
3036
};

lib/src/components/Baseboard.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
141141
key={item.id}
142142
title={item.title}
143143
status={sessionState.status}
144-
145144
todo={sessionState.todo}
145+
146146
/>
147147
);
148148
})}
@@ -176,7 +176,6 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
176176
title={item.title}
177177
isActive={activeId === item.id}
178178
status={sessionState.status}
179-
180179
todo={sessionState.todo}
181180
onClick={() => onReattach(item)}
182181
/>

lib/src/components/Door.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BellIcon } from '@phosphor-icons/react';
2-
import type { SessionStatus, TodoState } from '../lib/terminal-registry';
2+
import { TODO_OFF, isSoftTodo, hasTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry';
33

44
export interface DoorProps {
55
doorId?: string;
@@ -15,7 +15,7 @@ export function Door({
1515
title,
1616
isActive = false,
1717
status = 'ALARM_DISABLED',
18-
todo = false,
18+
todo = TODO_OFF,
1919
onClick,
2020
}: DoorProps) {
2121
// Doors can only be active in command mode (navigated to via arrow keys).
@@ -49,13 +49,20 @@ export function Door({
4949
<span className={['min-w-0 flex-1 truncate', isActive ? 'text-foreground' : 'text-muted'].join(' ')}>
5050
{title}
5151
</span>
52-
{(todo || alarmEnabled) && (
52+
{(hasTodo(todo) || alarmEnabled) && (
5353
<span className="flex shrink-0 items-center gap-1.5">
54-
{todo && (
55-
<span className={[
56-
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
57-
todo === 'soft' ? 'border border-dashed border-border' : 'border border-border',
58-
].join(' ')}>
54+
{hasTodo(todo) && (
55+
<span
56+
className={[
57+
'rounded bg-surface-raised px-1 py-px text-[8px] font-semibold tracking-[0.08em] text-foreground',
58+
isSoftTodo(todo) ? 'border border-dashed border-border' : 'border border-border',
59+
].join(' ')}
60+
style={isSoftTodo(todo) ? {
61+
opacity: 0.3 + 0.7 * todo,
62+
transform: `scale(${0.7 + 0.3 * todo})`,
63+
transition: 'opacity 0.15s ease, transform 0.15s ease',
64+
} : undefined}
65+
>
5966
TODO
6067
</span>
6168
)}

lib/src/components/Pond.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import {
3333
destroyTerminal,
3434
swapTerminals,
3535
type SessionStatus,
36+
isSoftTodo,
37+
isHardTodo,
38+
hasTodo,
3639
} from '../lib/terminal-registry';
3740
import { resolvePanelElement, findPanelInDirection, findRestoreNeighbor, type DetachDirection } from '../lib/spatial-nav';
3841
import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot';
@@ -279,7 +282,7 @@ function AlarmContextMenu({
279282
const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot);
280283
const sessionState = sessionStates.get(sessionId) ?? DEFAULT_SESSION_UI_STATE;
281284
const alarmEnabled = sessionState.status !== 'ALARM_DISABLED';
282-
const hasHardTodo = sessionState.todo === 'hard';
285+
const hasHardTodo = isHardTodo(sessionState.todo);
283286
const menuRef = useRef<HTMLDivElement>(null);
284287
const firstActionRef = useRef<HTMLButtonElement>(null);
285288

@@ -340,7 +343,7 @@ function TodoPillPrompt({
340343
const clearButtonRef = useRef<HTMLButtonElement>(null);
341344

342345
useEffect(() => {
343-
if (sessionState.todo !== 'soft') {
346+
if (!isSoftTodo(sessionState.todo)) {
344347
onClose();
345348
}
346349
}, [onClose, sessionState.todo]);
@@ -508,7 +511,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
508511
const [tier, setTier] = useState<HeaderTier>('full');
509512
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
510513
const [todoPrompt, setTodoPrompt] = useState<{ x: number; y: number } | null>(null);
511-
const showTodoPill = sessionState.todo !== false && tier !== 'minimal';
514+
const showTodoPill = hasTodo(sessionState.todo) && tier !== 'minimal';
512515
const alarmButtonAriaLabel = sessionState.status === 'ALARM_RINGING'
513516
? 'Alarm ringing'
514517
: sessionState.status === 'ALARM_DISABLED'
@@ -628,13 +631,18 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
628631
data-session-todo-for={api.id}
629632
className={[
630633
'shrink-0 rounded px-1.5 py-px text-[9px] font-semibold tracking-[0.08em] text-muted transition-colors hover:bg-foreground/10',
631-
sessionState.todo === 'soft' ? 'border border-dashed border-muted' : 'border border-muted',
634+
isSoftTodo(sessionState.todo) ? 'border border-dashed border-muted' : 'border border-muted',
632635
].join(' ')}
633-
aria-label={sessionState.todo === 'soft' ? 'Soft TODO options' : 'Clear TODO'}
636+
style={isSoftTodo(sessionState.todo) ? {
637+
opacity: 0.3 + 0.7 * sessionState.todo,
638+
transform: `scale(${0.7 + 0.3 * sessionState.todo})`,
639+
transition: 'opacity 0.15s ease, transform 0.15s ease',
640+
} : undefined}
641+
aria-label={isSoftTodo(sessionState.todo) ? 'Soft TODO options' : 'Clear TODO'}
634642
onMouseDown={(e) => e.stopPropagation()}
635643
onClick={(e) => {
636644
e.stopPropagation();
637-
if (sessionState.todo === 'soft') {
645+
if (isSoftTodo(sessionState.todo)) {
638646
const rect = e.currentTarget.getBoundingClientRect();
639647
setTodoPrompt({ x: rect.left + rect.width / 2, y: rect.bottom + 6 });
640648
return;

lib/src/lib/alarm-manager.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { AlarmManager } from './alarm-manager';
2+
import { AlarmManager, TODO_OFF, TODO_SOFT_FULL, TODO_HARD, isSoftTodo } from './alarm-manager';
33

44
describe('AlarmManager in isolation', () => {
55
let manager: AlarmManager;
@@ -153,4 +153,111 @@ describe('AlarmManager in isolation', () => {
153153
expect(states).toContain('MIGHT_NEED_ATTENTION');
154154
expect(states).toContain('ALARM_RINGING');
155155
});
156+
157+
// --- Soft-TODO bucket tests ---
158+
159+
function createSoftTodo(id: string): void {
160+
manager.toggleAlarm(id);
161+
manager.clearAttention(id);
162+
// Drive to BUSY → silence → ALARM_RINGING
163+
manager.onData(id);
164+
vi.advanceTimersByTime(1_600);
165+
manager.onData(id);
166+
manager.onData(id);
167+
vi.advanceTimersByTime(2_000);
168+
vi.advanceTimersByTime(3_000);
169+
expect(manager.getState(id).status).toBe('ALARM_RINGING');
170+
// Attend creates soft TODO
171+
manager.attend(id);
172+
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
173+
}
174+
175+
it('soft-TODO bucket starts full', () => {
176+
const id = 'bucket-full';
177+
createSoftTodo(id);
178+
expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
179+
});
180+
181+
it('5 rapid keypresses drain bucket to 0 and clear soft-TODO', () => {
182+
const id = 'bucket-drain';
183+
createSoftTodo(id);
184+
185+
for (let i = 0; i < 5; i++) {
186+
manager.drainTodoBucket(id);
187+
}
188+
189+
expect(manager.getState(id).todo).toBe(TODO_OFF);
190+
});
191+
192+
it('4 keypresses drain but do not clear soft-TODO', () => {
193+
const id = 'bucket-partial';
194+
createSoftTodo(id);
195+
196+
for (let i = 0; i < 4; i++) {
197+
manager.drainTodoBucket(id);
198+
}
199+
200+
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
201+
expect(manager.getState(id).todo).toBeCloseTo(0.2);
202+
});
203+
204+
it('bucket refills to full after timeToFull seconds of idle', () => {
205+
const id = 'bucket-refill';
206+
createSoftTodo(id);
207+
208+
manager.drainTodoBucket(id);
209+
manager.drainTodoBucket(id);
210+
manager.drainTodoBucket(id);
211+
expect(manager.getState(id).todo).toBeCloseTo(0.4);
212+
213+
// Wait for full refill (3 seconds for full, but only need 0.6 * 3 = 1.8s)
214+
vi.advanceTimersByTime(1_800);
215+
216+
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
217+
expect(manager.getState(id).todo).toBe(TODO_SOFT_FULL);
218+
});
219+
220+
it('partial refill + more keypresses — correct math', () => {
221+
const id = 'bucket-partial-refill';
222+
createSoftTodo(id);
223+
224+
// Drain 3 times → level = 0.4
225+
for (let i = 0; i < 3; i++) {
226+
manager.drainTodoBucket(id);
227+
}
228+
expect(manager.getState(id).todo).toBeCloseTo(0.4);
229+
230+
// Wait 1.5s → refill = 1.5/3 = 0.5, so level = min(1, 0.4 + 0.5) = 0.9
231+
vi.advanceTimersByTime(1_500);
232+
233+
// Drain once more → refill applied first, then drain: 0.9 - 0.2 = 0.7
234+
manager.drainTodoBucket(id);
235+
expect(manager.getState(id).todo).toBeCloseTo(0.7);
236+
expect(isSoftTodo(manager.getState(id).todo)).toBe(true);
237+
});
238+
239+
it('promoting a partially-drained soft-TODO resets to hard', () => {
240+
const id = 'bucket-promote';
241+
createSoftTodo(id);
242+
243+
manager.drainTodoBucket(id);
244+
manager.drainTodoBucket(id);
245+
expect(manager.getState(id).todo).toBeCloseTo(0.6);
246+
247+
manager.promoteTodo(id);
248+
expect(manager.getState(id).todo).toBe(TODO_HARD);
249+
});
250+
251+
it('hard TODO uses TODO_HARD constant', () => {
252+
const id = 'bucket-hard';
253+
manager.toggleTodo(id); // off → hard
254+
expect(manager.getState(id).todo).toBe(TODO_HARD);
255+
});
256+
257+
it('drainTodoBucket is a no-op for hard TODOs', () => {
258+
const id = 'bucket-hard-noop';
259+
manager.toggleTodo(id);
260+
manager.drainTodoBucket(id);
261+
expect(manager.getState(id).todo).toBe(TODO_HARD);
262+
});
156263
});

0 commit comments

Comments
 (0)