From f687c7795e43a627dedb1f5eda5e35a0f870a33a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 18:10:56 -0700 Subject: [PATCH 01/23] Collapse TODO state to boolean on/off. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the off/soft/hard trichotomy and its bucket/recovery/strike machinery. A TODO is now either on or off. Clicking the pill dismisses it (reuses the existing ✓ flourish), and pressing Enter in passthrough also clears it — the user confirming an action is the reminder being done. Attending or dismissing a ringing alert turns TODO on. The right-click popup drops the hard/off buttons for a simpler [on] [off] radio pair on both TODO and alert rows, with updated help text describing the new Enter-clears behavior. Persisted schema bumps v2 → v3: PersistedAlertState.todo becomes boolean. migrateSessionV2toV3 maps any non-(-1) numeric encoding to true; v1 blobs chain through v2 to v3. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/cfg.ts | 6 - lib/src/components/Door.tsx | 11 +- lib/src/components/Pond.tsx | 55 ++-- lib/src/components/TodoPillBody.tsx | 47 +--- lib/src/lib/alert-manager.test.ts | 241 ++---------------- lib/src/lib/alert-manager.ts | 123 ++------- lib/src/lib/platform/fake-adapter.ts | 1 - lib/src/lib/platform/types.ts | 1 - lib/src/lib/platform/vscode-adapter.ts | 4 - lib/src/lib/reconnect.test.ts | 9 +- lib/src/lib/session-migration.test.ts | 129 +++++++++- lib/src/lib/session-restore.test.ts | 3 +- lib/src/lib/session-save.test.ts | 12 +- lib/src/lib/session-save.ts | 2 +- lib/src/lib/session-types.ts | 80 +++++- lib/src/lib/terminal-registry.alert.test.ts | 237 +++++------------ lib/src/lib/terminal-registry.ts | 25 +- lib/src/stories/Baseboard.stories.tsx | 20 +- lib/src/stories/Door.stories.tsx | 13 +- lib/src/stories/Pond.stories.tsx | 22 +- .../stories/TerminalPaneHeader.stories.tsx | 44 ++-- lib/src/stories/TodoBucket.stories.tsx | 148 ----------- lib/src/theme.css | 25 +- standalone/src/tauri-adapter.ts | 4 - vscode-ext/src/message-router.ts | 3 - vscode-ext/src/message-types.ts | 3 +- 26 files changed, 403 insertions(+), 865 deletions(-) delete mode 100644 lib/src/stories/TodoBucket.stories.tsx diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index e2b7317..06d7938 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -29,10 +29,4 @@ export const cfg = { /** When true, the ALERT_RINGING bell-ring animation is frozen at T=0 (for deterministic Chromatic snapshots). */ ringingPaused: false, }, - todoBucket: { - /** Seconds of idle time needed to un-strike one letter of the soft-TODO pill. - * The word TODO has 4 letters; each printable keypress strikes one letter, - * and each `recoverySecondsPerLetter` of idle time un-strikes one. */ - recoverySecondsPerLetter: 1, - }, }; diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index bde0860..572e77a 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,5 +1,5 @@ import { BellIcon } from '@phosphor-icons/react'; -import { TODO_OFF, isSoftTodo, type SessionStatus, type TodoState } from '../lib/terminal-registry'; +import type { SessionStatus, TodoState } from '../lib/terminal-registry'; import { useTodoPillContent } from './TodoPillBody'; import { bellIconClass } from './bell-icon-class'; @@ -19,7 +19,7 @@ export function Door({ isActive = false, windowFocused = true, status = 'ALERT_DISABLED', - todo = TODO_OFF, + todo = false, onClick, }: DoorProps) { // Doors can only be active in command mode (navigated to via arrow keys). @@ -55,12 +55,7 @@ export function Door({ {(todoPill.visible || alertEnabled) && ( {todoPill.visible && ( - + {todoPill.body} )} diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index e3ff624..bfa3c31 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -14,7 +14,7 @@ import { createPortal } from 'react-dom'; import { TerminalPane } from './TerminalPane'; import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; -import { PopupButtonRow, popupButton, renderShortcuts } from './design'; +import { PopupButtonRow, popupButton, renderShortcuts, Shortcut } from './design'; import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react'; import { DEFAULT_MOUSE_SELECTION_STATE, @@ -48,9 +48,6 @@ import { setPendingShellOpts, getDefaultShellOpts, type SessionStatus, - isSoftTodo, - isHardTodo, - TODO_OFF, } from '../lib/terminal-registry'; import { resolvePanelElement, findPanelInDirection, findReattachNeighbor } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; @@ -414,42 +411,42 @@ function TodoAlertDialog({ aria-label="TODO and alert settings" > {/* TODO row */} -
- [t] - TODO -
- -
{/* Alert row */} -
- [a] - alert -
+
+ a + alert +
{/* Help text */} -
- When an alerting tab is selected,
- the alert is cleared and the tab gets a soft TODO.
- Typing drains the soft TODO; stop typing and it refills. +
+ When a tab with a ringing alert is selected,
+ the alert is cleared and the tab gets a TODO.
+ Pressing [Enter] into the tab will clear the TODO.
, document.body, @@ -739,7 +736,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { {showTodoPill && ( todoPill.flourishing ? ( {todoPill.body} @@ -748,16 +745,12 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { - - -
-
- ); -} - -const meta: Meta = { - title: 'Interactions/TodoBucket', - component: TodoBucketDemo, - args: { - width: 300, - }, - argTypes: { - width: { control: 'number' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Interactive: Story = {}; diff --git a/lib/src/theme.css b/lib/src/theme.css index d5779ab..26158a2 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -207,29 +207,7 @@ body.vscode-light { .ring-shrinking-to-br { animation: none; } } -/* Soft-TODO pill: one letter per keypress gets a strike line. - * The line draws left-to-right on strike, retracts on un-strike. */ -.strike-letter { - position: relative; - display: inline-block; -} -.strike-letter::after { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 50%; - height: 1px; - background-color: currentColor; - transform: scaleX(0); - transform-origin: left center; - transition: transform 150ms ease-out; -} -.strike-letter[data-strike='true']::after { - transform: scaleX(1); -} - -/* 4th-strike flourish: struck letters fade out while a ✓ pulses in, then fades. */ +/* TODO dismiss flourish: letters fade out while a ✓ pulses in, then fades. */ @keyframes todo-flourish-letters { 0% { opacity: 1; } 30% { opacity: 0; } @@ -260,7 +238,6 @@ body.vscode-light { } @media (prefers-reduced-motion: reduce) { - .strike-letter::after { transition: none; } .todo-pill-flourish__letters { animation: none; opacity: 0; } .todo-pill-flourish__check { animation: none; opacity: 1; } } diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 0cbc96f..1416b77 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -235,10 +235,6 @@ export class TauriAdapter implements PlatformAdapter { this.alertManager.clearTodo(id); } - alertDrainTodoBucket(id: string): void { - this.alertManager.drainTodoBucket(id); - } - onAlertState(handler: (detail: AlertStateDetail) => void): void { this.alertStateHandlers.add(handler); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index b4a3795..d178958 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -338,9 +338,6 @@ export function attachRouter( case 'alert:clearTodo': alertManager.clearTodo(msg.id); break; - case 'alert:drainTodoBucket': - alertManager.drainTodoBucket(msg.id); - break; } }); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index fb8d8b3..52860eb 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -25,8 +25,7 @@ export type WebviewMessage = | { type: 'alert:clearAttention'; id?: string } | { type: 'alert:toggleTodo'; id: string } | { type: 'alert:markTodo'; id: string } - | { type: 'alert:clearTodo'; id: string } - | { type: 'alert:drainTodoBucket'; id: string }; + | { type: 'alert:clearTodo'; id: string }; export interface PtyInfo { id: string; From 4c2026434bf6c9525b7eee25b5914ef92944742e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 17:11:10 -0700 Subject: [PATCH 02/23] Improve the popup dialog. --- lib/src/components/Pond.tsx | 102 ++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index bfa3c31..3dffac1 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -394,55 +394,46 @@ function TodoAlertDialog({ }; }, [sessionId]); - const toggleBtn = (active: boolean) => [ - 'rounded px-2 py-1 text-[11px] font-medium transition-colors', - active - ? 'bg-accent/20 text-accent border border-accent/40' - : 'text-muted border border-border hover:bg-foreground/10 hover:text-foreground', - ].join(' '); - return createPortal(
- {/* TODO row */} -
+ + +
+ {/* TODO row */} t - TODO -
- - -
-
+ TODO + markSessionTodo(sessionId)} + onDisable={() => clearSessionTodo(sessionId)} + label="TODO" + /> - {/* Alert row */} -
+ {/* Alert row */} a - alert -
- - -
+ alert + toggleSessionAlert(sessionId)} + onDisable={() => disableSessionAlert(sessionId)} + label="alert" + />
- {/* Help text */}
When a tab with a ringing alert is selected,
the alert is cleared and the tab gets a TODO.
@@ -453,6 +444,37 @@ function TodoAlertDialog({ ); } +function OnOffSwitch({ + on, + onEnable, + onDisable, + label, +}: { + on: boolean; + onEnable: () => void; + onDisable: () => void; + label: string; +}) { + return ( + + ); +} + // --- Contexts --- // We own selection/focus, not dockview. These contexts let panel components read our state. @@ -718,7 +740,11 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { } triggerAlertButtonAction(activity.status, e.currentTarget); }} - onContextMenu={(e) => { e.preventDefault(); setDialogPosition({ x: e.clientX, y: e.clientY }); }} + onContextMenu={(e) => { + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + setDialogPosition({ x: rect.left, y: rect.bottom + 8 }); + }} ariaLabel={alertButtonAriaLabel} tooltip={alertButtonTooltip} tooltipDetail={alertButtonTooltipDetail} From 543c12bbbd210e48bc41bbef5ceb7607a15e6fe2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 17:16:10 -0700 Subject: [PATCH 03/23] Fix hover. --- lib/src/components/Pond.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 3dffac1..e3a9514 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -186,8 +186,6 @@ function HeaderActionButton({ aria-label={ariaLabel} onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => setIsVisible(false)} - onFocus={() => setIsVisible(true)} - onBlur={() => setIsVisible(false)} > {children} From f915a1d7c40faa71b309c7f193e93b8131465fa8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 17:25:29 -0700 Subject: [PATCH 04/23] Close the popup dialog when the mouse leaves its hot area. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hot area = the dialog itself ∪ a trapezoid connecting the top corners of the bell button to the top corners of the dialog, so the cursor can travel from button to dialog without dismissing. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 48 ++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index e3a9514..97dd4f8 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -274,6 +274,19 @@ function MouseOverrideBanner({ ); } +function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; y: number }>): boolean { + let sign = 0; + for (let i = 0; i < vertices.length; i++) { + const a = vertices[i]; + const b = vertices[(i + 1) % vertices.length]; + const cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x); + if (cross === 0) continue; + if (sign === 0) sign = cross > 0 ? 1 : -1; + else if ((cross > 0 ? 1 : -1) !== sign) return false; + } + return true; +} + function clampOverlayPosition({ left, top, width, height }: { left: number; top: number; @@ -352,7 +365,7 @@ function TodoAlertDialog({ sessionId, onClose, }: { - position: { x: number; y: number }; + position: { x: number; y: number; triggerRect: DOMRect }; sessionId: string; onClose: () => void; }) { @@ -392,10 +405,32 @@ function TodoAlertDialog({ }; }, [sessionId]); + // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top). + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const trigger = position.triggerRect; + const handler = (e: MouseEvent) => { + const dialogRect = dialog.getBoundingClientRect(); + const { clientX: x, clientY: y } = e; + if (x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom) return; + const funnel = [ + { x: trigger.left, y: trigger.top }, + { x: trigger.right, y: trigger.top }, + { x: dialogRect.right, y: dialogRect.top }, + { x: dialogRect.left, y: dialogRect.top }, + ]; + if (pointInConvexPolygon(x, y, funnel)) return; + onClose(); + }; + window.addEventListener('mousemove', handler); + return () => window.removeEventListener('mousemove', handler); + }, [position.triggerRect, onClose]); + return createPortal(
(null); const suppressAlertClickRef = useRef(false); const [tier, setTier] = useState('full'); - const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number } | null>(null); + const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number; triggerRect: DOMRect } | null>(null); const todoPill = useTodoPillContent(activity.todo); const showTodoPill = todoPill.visible && tier !== 'minimal'; const alertButtonAriaLabel = activity.status === 'ALERT_RINGING' @@ -660,8 +695,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const openDialogFromButton = useCallback((button: HTMLButtonElement) => { const rect = button.getBoundingClientRect(); setDialogPosition({ - x: rect.left + rect.width / 2 - 140, - y: rect.bottom + 6, + x: rect.left, + y: rect.bottom + 8, + triggerRect: rect, }); }, []); @@ -741,7 +777,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { onContextMenu={(e) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); - setDialogPosition({ x: rect.left, y: rect.bottom + 8 }); + setDialogPosition({ x: rect.left, y: rect.bottom + 8, triggerRect: rect }); }} ariaLabel={alertButtonAriaLabel} tooltip={alertButtonTooltip} From c9535a2323fde4e6eef6ca88a86aaf921c984428 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 17:49:20 -0700 Subject: [PATCH 05/23] Smooth the TODO dismiss animation, fix dialog key repeats, rename splits. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dismiss animation: one continuous motion — letters fade out while a ✓ springs in, brief hold, then the pill shell dissolves out together. Unifies the pill DOM across steady/flourishing states so there is no element swap, no width reflow, and the border no longer disappears abruptly at the end. - Popup dialog: stabilize `onClose` with `useCallback` so the focus-trap effect does not clean up on every parent re-render and restore focus to the bell button, which was bailing out subsequent `a`/`t` presses. - Split buttons: rename "Split horizontal" → "Split left/right" and "Split vertical" → "Split top/bottom" in UI, specs, and tutorial (and fix the matching shortcut hints in layout.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/layout.md | 4 +- docs/specs/shortcuts.md | 4 +- docs/specs/tutorial.md | 2 +- lib/src/components/Door.tsx | 5 ++- lib/src/components/Pond.tsx | 48 ++++++++++----------- lib/src/components/TodoPillBody.tsx | 26 +++++------- lib/src/components/design.tsx | 2 +- lib/src/theme.css | 65 ++++++++++++++++++++--------- website/src/lib/tutorial-shell.ts | 2 +- 9 files changed, 88 insertions(+), 70 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2449149..e7dc556 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -78,8 +78,8 @@ Elements from left to right: - Alert bell button (reflects session activity status) - TODO pill (if todo state is set; hidden in minimal tier) - Flexible gap -- SplitHorizontalIcon `split horizontal ["]` (full tier only) -- SplitVerticalIcon `split vertical [%]` (full tier only) +- SplitHorizontalIcon `split left/right [|]` (full tier only) +- SplitVerticalIcon `split top/bottom [-]` (full tier only) - ArrowsOutIcon / ArrowsInIcon `zoom / unzoom [z]` (full tier only) - ArrowLineDownIcon `minimize [m]` - XIcon `kill [x]` (hover turns error-red) diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 771597c..4ca3164 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -18,8 +18,8 @@ mouseterm has two modes: | Key | Action | Description | |-----|--------|-------------| -| `\|` or `%` | Split horizontal | Split the selected pane into two side-by-side panes. | -| `-` or `"` | Split vertical | Split the selected pane into two stacked panes. | +| `\|` or `%` | Split left/right | Split the selected pane into two side-by-side panes. | +| `-` or `"` | Split top/bottom | Split the selected pane into two stacked panes. | | `z` | Toggle zoom | Fullscreen the selected pane, or return to the normal layout. | | `m` or `d` | Minimize / reattach | Minimize the selected pane to the baseboard, or reattach a minimized door. | | `k` or `x` | Kill | Kill the selected pane or door. Prompts for a random character to confirm. | diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 4bfedc0..f899556 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -104,7 +104,7 @@ Detection: Watches `PondEvent.modeChange` for transition to `'command'`, then tr **Step 6 — Split using keyboard shortcuts** > Split a pane without leaving the keyboard. > -> *In command mode, press " to split horizontally or % to split vertically.* +> *In command mode, press " to split top/bottom or % to split left/right.* Detection: Watches `PondEvent.split` with `source: 'keyboard'` while in command mode. diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 572e77a..b7b5f6f 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -55,7 +55,10 @@ export function Door({ {(todoPill.visible || alertEnabled) && ( {todoPill.visible && ( - + {todoPill.body} )} diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 97dd4f8..e929a95 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -700,6 +700,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { triggerRect: rect, }); }, []); + const closeDialog = useCallback(() => setDialogPosition(null), []); const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => { const result = actions.onAlertButton(api.id, displayedStatus); @@ -794,28 +795,21 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { {showTodoPill && ( - todoPill.flourishing ? ( - - {todoPill.body} - - ) : ( - - ) + )}
{!isRenaming && ( @@ -855,14 +849,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { { e.stopPropagation(); actions.onSplitH(api.id); }} - ariaLabel="Split horizontal" - tooltip='Split horizontal [|] or [%]' + ariaLabel="Split left/right" + tooltip='Split left/right [|] or [%]' > { e.stopPropagation(); actions.onSplitV(api.id); }} - ariaLabel="Split vertical" - tooltip='Split vertical [-] or ["]' + ariaLabel="Split top/bottom" + tooltip='Split top/bottom [-] or ["]' > setDialogPosition(null)} + onClose={closeDialog} /> )}
diff --git a/lib/src/components/TodoPillBody.tsx b/lib/src/components/TodoPillBody.tsx index 03b00da..a0525db 100644 --- a/lib/src/components/TodoPillBody.tsx +++ b/lib/src/components/TodoPillBody.tsx @@ -7,8 +7,11 @@ const FLOURISH_MS = 500; * Shared render body + flourish state for the TODO pill. * * Returns `visible: false` when the pill should not render at all. - * Returns `flourishing: true` briefly after a TODO clears, so the - * caller can render a non-interactive wrapper (no click target). + * Returns `flourishing: true` briefly after a TODO clears so the + * caller can set `data-flourishing="true"` on its pill shell. + * + * The body is a grid-stacked so the pill width stays + * stable across steady/flourishing states — the CSS drives the animation. */ export function useTodoPillContent(todo: TodoState): { visible: boolean; @@ -41,19 +44,12 @@ export function useTodoPillContent(todo: TodoState): { const visible = todo || flourishing; - let body: ReactNode = null; - if (flourishing) { - body = ( - - TODO - - ✓ - - - ); - } else if (todo) { - body = <>TODO; - } + const body: ReactNode = visible ? ( + + TODO + + + ) : null; return { visible, flourishing, body }; } diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 6b2c56e..21db73f 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -51,7 +51,7 @@ export function Shortcut({ /** * Render a string with any `[...]` segments replaced by . Use when - * the shortcut is embedded inline in a label (e.g., "Split horizontal [" or |]"). + * the shortcut is embedded inline in a label (e.g., "Split left/right [" or |]"). */ export function renderShortcuts(text: string): ReactNode[] { const parts: ReactNode[] = []; diff --git a/lib/src/theme.css b/lib/src/theme.css index 26158a2..c720145 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -207,37 +207,62 @@ body.vscode-light { .ring-shrinking-to-br { animation: none; } } -/* TODO dismiss flourish: letters fade out while a ✓ pulses in, then fades. */ -@keyframes todo-flourish-letters { - 0% { opacity: 1; } - 30% { opacity: 0; } - 100% { opacity: 0; } -} -@keyframes todo-flourish-check { - 0% { opacity: 0; transform: scale(0.7); } - 30% { opacity: 1; transform: scale(1); } - 70% { opacity: 1; transform: scale(1); } - 100% { opacity: 0; transform: scale(1); } -} -.todo-pill-flourish { +/* TODO dismiss flourish. Sequence across 500ms: + * 0–30% letters fade out while the check springs in (with a small overshoot) + * 30–55% check settles, pill is visually still + * 55–100% whole pill shell (border, bg, check) dissolves together + * + * The pill body is a grid-stacked so width is stable + * throughout — no reflow when the animation starts. */ +.todo-pill-stack { display: inline-grid; } -.todo-pill-flourish > * { +.todo-pill-stack > * { grid-column: 1; grid-row: 1; } -.todo-pill-flourish__letters { - animation: todo-flourish-letters 500ms ease-out forwards; +.todo-pill-stack__letters { + opacity: 1; } -.todo-pill-flourish__check { +.todo-pill-stack__check { justify-self: center; align-self: center; color: var(--color-success); opacity: 0; - animation: todo-flourish-check 500ms ease-out forwards; +} + +.todo-pill-shell[data-flourishing='true'] { + animation: todo-pill-dissolve 500ms ease-out forwards; + pointer-events: none; +} +.todo-pill-shell[data-flourishing='true'] .todo-pill-stack__letters { + animation: todo-flourish-letters 500ms ease-out forwards; +} +.todo-pill-shell[data-flourishing='true'] .todo-pill-stack__check { + animation: todo-flourish-check 500ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +@keyframes todo-flourish-letters { + 0% { opacity: 1; } + 30% { opacity: 0; } + 100% { opacity: 0; } +} +@keyframes todo-flourish-check { + 0% { opacity: 0; transform: scale(0.5); } + 35% { opacity: 1; transform: scale(1.15); } + 55% { transform: scale(1); } + 100% { opacity: 1; transform: scale(1); } +} +@keyframes todo-pill-dissolve { + 0%, 55% { opacity: 1; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.92); } } @media (prefers-reduced-motion: reduce) { - .todo-pill-flourish__letters { animation: none; opacity: 0; } - .todo-pill-flourish__check { animation: none; opacity: 1; } + .todo-pill-shell[data-flourishing='true'], + .todo-pill-shell[data-flourishing='true'] .todo-pill-stack__letters, + .todo-pill-shell[data-flourishing='true'] .todo-pill-stack__check { + animation: none; + opacity: 0; + } } diff --git a/website/src/lib/tutorial-shell.ts b/website/src/lib/tutorial-shell.ts index b18aa28..90f514e 100644 --- a/website/src/lib/tutorial-shell.ts +++ b/website/src/lib/tutorial-shell.ts @@ -59,7 +59,7 @@ const STEPS: TutorialStep[] = [ phase: 'Keyboard Power', title: 'Split using keyboard shortcuts', description: 'Split a pane without leaving the keyboard.', - hint: 'In command mode, press " to split horizontally or % to split vertically.', + hint: 'In command mode, press " to split top/bottom or % to split left/right.', }, ]; From c46b00285e1291317895a49c4c418d8dc2c16d3c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 23 Apr 2026 18:00:57 -0700 Subject: [PATCH 06/23] Extract HeaderActionButton, TodoAlertDialog, and KillConfirm out of Pond.tsx. Pond.tsx goes from 2334 to 1820 lines. The three extracted pieces are self-contained widgets with clear boundaries: - HeaderActionButton.tsx (105 lines): generic icon-button + hover tooltip. - TodoAlertDialog.tsx (241 lines): the popup, its OnOffSwitch, the popover focus-trap hook, and a point-in-convex-polygon helper. Exposes isDialogKeyboardActive() so Pond's command-mode handler can bail while the dialog owns a/t. - KillConfirm.tsx (195 lines): KillConfirmCard, KillConfirmOverlay, orchestrateKill, randomKillChar, and the ConfirmKill type. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/HeaderActionButton.tsx | 105 +++++ lib/src/components/KillConfirm.tsx | 195 ++++++++ lib/src/components/Pond.tsx | 526 +--------------------- lib/src/components/TodoAlertDialog.tsx | 241 ++++++++++ lib/src/stories/KillModal.stories.tsx | 2 +- 5 files changed, 548 insertions(+), 521 deletions(-) create mode 100644 lib/src/components/HeaderActionButton.tsx create mode 100644 lib/src/components/KillConfirm.tsx create mode 100644 lib/src/components/TodoAlertDialog.tsx diff --git a/lib/src/components/HeaderActionButton.tsx b/lib/src/components/HeaderActionButton.tsx new file mode 100644 index 0000000..95c3d94 --- /dev/null +++ b/lib/src/components/HeaderActionButton.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { PopupButtonRow, renderShortcuts } from './design'; + +export interface HeaderActionButtonProps { + className: string; + ariaLabel: string; + tooltip?: string; + tooltipDetail?: string; + tooltipAlign?: 'left' | 'right'; + onMouseDownCapture?: (e: React.MouseEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onClick: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + children: React.ReactNode; + dataAlertButtonFor?: string; +} + +export function HeaderActionButton({ + className, + ariaLabel, + tooltip, + tooltipDetail, + tooltipAlign = 'right', + onMouseDownCapture, + onMouseDown, + onClick, + onContextMenu, + children, + dataAlertButtonFor, +}: HeaderActionButtonProps) { + const buttonRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState(null); + const tooltipPrimary = tooltip ?? ariaLabel; + + useEffect(() => { + if (!isVisible || !buttonRef.current) return; + + const updatePosition = () => { + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) return; + setTooltipStyle({ + position: 'fixed', + left: tooltipAlign === 'left' ? rect.left : rect.right, + top: rect.bottom + 8, + transform: tooltipAlign === 'left' ? 'translate(0, 0)' : 'translate(-100%, 0)', + }); + }; + + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [isVisible, tooltipAlign]); + + return ( + <> +
+ +
+ {isVisible && tooltipStyle && createPortal( + +
+
{renderShortcuts(tooltipPrimary)}
+ {tooltipDetail &&
{renderShortcuts(tooltipDetail)}
} +
+
, + document.body, + )} + + ); +} diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx new file mode 100644 index 0000000..e9edbcb --- /dev/null +++ b/lib/src/components/KillConfirm.tsx @@ -0,0 +1,195 @@ +import { useLayoutEffect, useState } from 'react'; +import type { DockviewApi } from 'dockview-react'; +import { resolvePanelElement } from '../lib/spatial-nav'; +import { disposeSession } from '../lib/terminal-registry'; + +export interface ConfirmKill { + id: string; + char: string; + shaking?: boolean; +} + +/** Random A-Z excluding X (prevents accidental double-tap on kill shortcut) */ +const KILL_CONFIRM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWYZ'; // no X +export function randomKillChar(): string { + return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)]; +} + +export function KillConfirmCard({ char, onCancel, shaking }: { char: string; onCancel?: () => void; shaking?: boolean }) { + return ( +
+

Kill Session?

+
+ {char} +
+
+
[{char}] to confirm
+ +
+
+ ); +} + +export function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: { + confirmKill: ConfirmKill; + panelElements: Map; + onCancel: () => void; +}) { + const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null); + + // useLayoutEffect (not useEffect) so the initial measurement + re-render happens + // before the browser paints. Otherwise the centered-in-viewport fallback below + // flashes for one frame before the overlay snaps to the panel. + useLayoutEffect(() => { + const panelEl = resolvePanelElement(panelElements.get(confirmKill.id)); + if (!panelEl) { setRect(null); return; } + + const update = () => { + const r = panelEl.getBoundingClientRect(); + setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); + }; + + update(); + const ro = new ResizeObserver(update); + ro.observe(panelEl); + window.addEventListener('resize', update); + return () => { ro.disconnect(); window.removeEventListener('resize', update); }; + }, [confirmKill.id, panelElements]); + + if (rect) { + return ( +
+ +
+ ); + } + + // Fallback: centered in viewport + return ( +
+ +
+ ); +} + + +// --- Kill animation --- +// +// Orchestrates the visual reclaim when a pane is killed: +// 1. Fade the real killed pane's group element in place (its actual content +// dissolves — a solid-color ghost over a same-colored background would be +// invisible). +// 2. After the fade completes, capture pre-rects of surviving panes, remove +// the panel (dockview snaps the layout), and FLIP each grower via +// clip-path so its newly claimed territory is hidden at start and swept +// in by the transition. clip-path (not transform) keeps +// getBoundingClientRect accurate so the SelectionOverlay doesn't lag. +// +// killInProgressRef is set across api.removePanel so the onDidRemovePanel +// auto-spawn handler knows we already waited for our own fade and can skip +// its own 440ms delay (avoids stacking 440ms + 440ms on last-pane kill). +export function orchestrateKill( + api: DockviewApi, + killedId: string, + selectPanel: (id: string) => void, + setSelectedId: (id: string | null) => void, + killInProgressRef: { current: boolean }, + overlayElRef: { current: HTMLElement | null }, +): void { + const panel = api.getPanel(killedId); + if (!panel) return; + + const bareRemove = () => { + killInProgressRef.current = true; + disposeSession(killedId); + api.removePanel(panel); + killInProgressRef.current = false; + if (api.panels.length > 0) selectPanel(api.panels[0].id); + else setSelectedId(null); + }; + + const reduceMotion = typeof window !== 'undefined' + && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const killedGroupEl = panel.api.group?.element; + if (reduceMotion || !killedGroupEl) { + bareRemove(); + return; + } + + // Fade the killed pane in place. Block input on it during the fade. + // For a last-pane kill (auto-spawn will create a replacement), also shrink + // the pane toward the bottom-right so the disappearance is visible — a plain + // fade offers no visual cue since the pane's space is reclaimed by a new one + // appearing in exactly the same rect from the opposite corner. The focus + // ring (SelectionOverlay element) gets a matching shrink animation so it + // scales with the pane rather than sitting over empty space. + const isLastPane = api.panels.length === 1; + const fadeClass = isLastPane ? 'pane-fading-and-shrinking-to-br' : 'pane-fading-out'; + const fadeAnimationName = isLastPane ? 'pane-fade-and-shrink-to-br' : 'pane-fade-out'; + killedGroupEl.style.pointerEvents = 'none'; + killedGroupEl.classList.add(fadeClass); + const overlayEl = isLastPane ? overlayElRef.current : null; + if (overlayEl) overlayEl.classList.add('ring-shrinking-to-br'); + + let finalized = false; + const finalize = () => { + if (finalized) return; + finalized = true; + + // Snapshot pre-rects just before removal. + interface Pre { el: HTMLElement; rect: DOMRect; } + const preRects = new Map(); + for (const p of api.panels) { + if (p.id === killedId) continue; + const el = p.api.group?.element; + if (el) preRects.set(p.id, { el, rect: el.getBoundingClientRect() }); + } + + bareRemove(); + + // FLIP each grower. + for (const p of api.panels) { + const pre = preRects.get(p.id); + if (!pre) continue; + const postRect = pre.el.getBoundingClientRect(); + const dw = postRect.width - pre.rect.width; + const dh = postRect.height - pre.rect.height; + if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue; + + // Clear any in-progress spawn animation before applying FLIP. + pre.el.classList.remove('pane-spawning-from-left', 'pane-spawning-from-top', 'pane-spawning-from-top-left'); + + const clipTop = Math.max(0, (pre.rect.top - postRect.top) / postRect.height * 100); + const clipBottom = Math.max(0, (postRect.bottom - pre.rect.bottom) / postRect.height * 100); + const clipLeft = Math.max(0, (pre.rect.left - postRect.left) / postRect.width * 100); + const clipRight = Math.max(0, (postRect.right - pre.rect.right) / postRect.width * 100); + + pre.el.style.transition = 'none'; + pre.el.style.clipPath = `inset(${clipTop}% ${clipRight}% ${clipBottom}% ${clipLeft}%)`; + void pre.el.offsetHeight; + pre.el.style.transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)'; + pre.el.style.clipPath = 'inset(0)'; + const cleanup = () => { + pre.el.style.transition = ''; + pre.el.style.clipPath = ''; + }; + pre.el.addEventListener('transitionend', cleanup, { once: true }); + setTimeout(cleanup, 1000); + } + + // Peel the ring-shrink class so the next selection's overlay renders at + // full scale. The element may have been reused by React for the next + // selected pane's overlay by the time the animation finishes. + if (overlayEl) overlayEl.classList.remove('ring-shrinking-to-br'); + }; + + killedGroupEl.addEventListener('animationend', (ev) => { + if ((ev as AnimationEvent).animationName !== fadeAnimationName) return; + finalize(); + }); + // Safety: if animationend never fires, still finalize. + setTimeout(finalize, 1000); +} diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index e929a95..9062027 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -14,7 +14,10 @@ import { createPortal } from 'react-dom'; import { TerminalPane } from './TerminalPane'; import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; -import { PopupButtonRow, popupButton, renderShortcuts, Shortcut } from './design'; +import { PopupButtonRow, popupButton } from './design'; +import { HeaderActionButton } from './HeaderActionButton'; +import { TodoAlertDialog, isDialogKeyboardActive } from './TodoAlertDialog'; +import { KillConfirmOverlay, orchestrateKill, randomKillChar, type ConfirmKill } from './KillConfirm'; import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react'; import { DEFAULT_MOUSE_SELECTION_STATE, @@ -33,17 +36,13 @@ import { clearSessionAttention, clearSessionTodo, DEFAULT_ACTIVITY_STATE, - disableSessionAlert, dismissOrToggleAlert, focusSession, getActivity, getActivitySnapshot, markSessionAttention, - markSessionTodo, subscribeToActivity, - toggleSessionAlert, toggleSessionTodo, - disposeSession, swapTerminals, setPendingShellOpts, getDefaultShellOpts, @@ -68,20 +67,12 @@ const mousetermTheme: DockviewTheme = { dndPanelOverlay: 'group', }; -let dialogKeyboardActive = false; - // --- Types --- export type DooredItem = Omit & { layoutAtMinimize: SerializedDockview | null; }; -interface ConfirmKill { - id: string; - char: string; - shaking?: boolean; -} - export type PondMode = 'command' | 'passthrough'; export type PondSelectionKind = 'pane' | 'door'; @@ -105,108 +96,6 @@ const tabVariant = tv({ }, }); -interface HeaderActionButtonProps { - className: string; - ariaLabel: string; - tooltip?: string; - tooltipDetail?: string; - tooltipAlign?: 'left' | 'right'; - onMouseDownCapture?: (e: React.MouseEvent) => void; - onMouseDown?: (e: React.MouseEvent) => void; - onClick: (e: React.MouseEvent) => void; - onContextMenu?: (e: React.MouseEvent) => void; - children: React.ReactNode; - dataAlertButtonFor?: string; -} - -function HeaderActionButton({ - className, - ariaLabel, - tooltip, - tooltipDetail, - tooltipAlign = 'right', - onMouseDownCapture, - onMouseDown, - onClick, - onContextMenu, - children, - dataAlertButtonFor, -}: HeaderActionButtonProps) { - const buttonRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); - const [tooltipStyle, setTooltipStyle] = useState(null); - const tooltipPrimary = tooltip ?? ariaLabel; - - useEffect(() => { - if (!isVisible || !buttonRef.current) return; - - const updatePosition = () => { - const rect = buttonRef.current?.getBoundingClientRect(); - if (!rect) return; - setTooltipStyle({ - position: 'fixed', - left: tooltipAlign === 'left' ? rect.left : rect.right, - top: rect.bottom + 8, - transform: tooltipAlign === 'left' ? 'translate(0, 0)' : 'translate(-100%, 0)', - }); - }; - - updatePosition(); - window.addEventListener('scroll', updatePosition, true); - window.addEventListener('resize', updatePosition); - return () => { - window.removeEventListener('scroll', updatePosition, true); - window.removeEventListener('resize', updatePosition); - }; - }, [isVisible, tooltipAlign]); - - return ( - <> -
- -
- {isVisible && tooltipStyle && createPortal( - -
-
{renderShortcuts(tooltipPrimary)}
- {tooltipDetail &&
{renderShortcuts(tooltipDetail)}
} -
-
, - document.body, - )} - - ); -} - // --- Alert context menu (right-click on bell) --- /** @@ -274,19 +163,6 @@ function MouseOverrideBanner({ ); } -function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; y: number }>): boolean { - let sign = 0; - for (let i = 0; i < vertices.length; i++) { - const a = vertices[i]; - const b = vertices[(i + 1) % vertices.length]; - const cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x); - if (cross === 0) continue; - if (sign === 0) sign = cross > 0 ? 1 : -1; - else if ((cross > 0 ? 1 : -1) !== sign) return false; - } - return true; -} - function clampOverlayPosition({ left, top, width, height }: { left: number; top: number; @@ -304,210 +180,6 @@ function clampOverlayPosition({ left, top, width, height }: { }; } -/** - * Manages focus trapping, Escape-to-close, and click-outside-to-close for - * portal-based popovers. Scopes keyboard handling to the popover's DOM subtree - * so Tab/Escape don't leak to the rest of the app. - */ -function usePopoverFocusTrap( - ref: React.RefObject, - onClose: () => void, - restoreFocusSelector?: string, -) { - useEffect(() => { - const el = ref.current; - if (!el) return; - - const handleMouseDown = (e: MouseEvent) => { - if (!el.contains(e.target as Node)) onClose(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle keys when focus is inside the popover - if (!el.contains(document.activeElement)) return; - - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - onClose(); - return; - } - if (e.key !== 'Tab') return; - - const focusables = Array.from( - el.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'), - ); - if (focusables.length === 0) return; - - const currentIndex = focusables.findIndex((f) => f === document.activeElement); - const nextIndex = currentIndex === -1 - ? 0 - : (currentIndex + (e.shiftKey ? -1 : 1) + focusables.length) % focusables.length; - - e.preventDefault(); - focusables[nextIndex]?.focus(); - }; - - window.addEventListener('mousedown', handleMouseDown); - window.addEventListener('keydown', handleKeyDown, true); - return () => { - window.removeEventListener('mousedown', handleMouseDown); - window.removeEventListener('keydown', handleKeyDown, true); - if (restoreFocusSelector) { - document.querySelector(restoreFocusSelector)?.focus(); - } - }; - }, [ref, onClose, restoreFocusSelector]); -} - -function TodoAlertDialog({ - position, - sessionId, - onClose, -}: { - position: { x: number; y: number; triggerRect: DOMRect }; - sessionId: string; - onClose: () => void; -}) { - const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); - const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; - const alertEnabled = activity.status !== 'ALERT_DISABLED'; - const dialogRef = useRef(null); - - usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); - - useEffect(() => { - dialogRef.current?.querySelector('button')?.focus(); - }, []); - - // Keyboard shortcuts within dialog - useEffect(() => { - const el = dialogRef.current; - if (!el) return; - dialogKeyboardActive = true; - const handler = (e: KeyboardEvent) => { - if (!el.contains(document.activeElement)) return; - if (e.key === 'a') { - e.preventDefault(); - e.stopImmediatePropagation(); - dismissOrToggleAlert(sessionId, getActivity(sessionId).status); - } - if (e.key === 't') { - e.preventDefault(); - e.stopImmediatePropagation(); - toggleSessionTodo(sessionId); - } - }; - window.addEventListener('keydown', handler, true); - return () => { - dialogKeyboardActive = false; - window.removeEventListener('keydown', handler, true); - }; - }, [sessionId]); - - // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top). - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - const trigger = position.triggerRect; - const handler = (e: MouseEvent) => { - const dialogRect = dialog.getBoundingClientRect(); - const { clientX: x, clientY: y } = e; - if (x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom) return; - const funnel = [ - { x: trigger.left, y: trigger.top }, - { x: trigger.right, y: trigger.top }, - { x: dialogRect.right, y: dialogRect.top }, - { x: dialogRect.left, y: dialogRect.top }, - ]; - if (pointInConvexPolygon(x, y, funnel)) return; - onClose(); - }; - window.addEventListener('mousemove', handler); - return () => window.removeEventListener('mousemove', handler); - }, [position.triggerRect, onClose]); - - return createPortal( -
- - -
- {/* TODO row */} - t - TODO - markSessionTodo(sessionId)} - onDisable={() => clearSessionTodo(sessionId)} - label="TODO" - /> - - {/* Alert row */} - a - alert - toggleSessionAlert(sessionId)} - onDisable={() => disableSessionAlert(sessionId)} - label="alert" - /> -
- -
- When a tab with a ringing alert is selected,
- the alert is cleared and the tab gets a TODO.
- Pressing [Enter] into the tab will clear the TODO. -
-
, - document.body, - ); -} - -function OnOffSwitch({ - on, - onEnable, - onDisable, - label, -}: { - on: boolean; - onEnable: () => void; - onDisable: () => void; - label: string; -}) { - return ( - - ); -} - // --- Contexts --- // We own selection/focus, not dockview. These contexts let panel components read our state. @@ -587,12 +259,6 @@ function idsMatch(a: string[], b: string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } -/** Random A-Z excluding X (prevents accidental double-tap on kill shortcut) */ -const KILL_CONFIRM_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWYZ'; // no X -function randomKillChar(): string { - return KILL_CONFIRM_CHARS[Math.floor(Math.random() * KILL_CONFIRM_CHARS.length)]; -} - // --- Panel content component --- function TerminalPanel({ api }: IDockviewPanelProps) { @@ -1097,186 +763,6 @@ function SelectionOverlay({ apiRef, selectedId, selectedType, mode, overlayElRef ); } -// --- Kill confirmation overlay --- - -export function KillConfirmCard({ char, onCancel, shaking }: { char: string; onCancel?: () => void; shaking?: boolean }) { - return ( -
-

Kill Session?

-
- {char} -
-
-
[{char}] to confirm
- -
-
- ); -} - -function KillConfirmOverlay({ confirmKill, panelElements, onCancel }: { - confirmKill: ConfirmKill; - panelElements: Map; - onCancel: () => void; -}) { - const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null); - - // useLayoutEffect (not useEffect) so the initial measurement + re-render happens - // before the browser paints. Otherwise the centered-in-viewport fallback below - // flashes for one frame before the overlay snaps to the panel. - useLayoutEffect(() => { - const panelEl = resolvePanelElement(panelElements.get(confirmKill.id)); - if (!panelEl) { setRect(null); return; } - - const update = () => { - const r = panelEl.getBoundingClientRect(); - setRect({ top: r.top, left: r.left, width: r.width, height: r.height }); - }; - - update(); - const ro = new ResizeObserver(update); - ro.observe(panelEl); - window.addEventListener('resize', update); - return () => { ro.disconnect(); window.removeEventListener('resize', update); }; - }, [confirmKill.id, panelElements]); - - if (rect) { - return ( -
- -
- ); - } - - // Fallback: centered in viewport - return ( -
- -
- ); -} - - -// --- Kill animation --- -// -// Orchestrates the visual reclaim when a pane is killed: -// 1. Fade the real killed pane's group element in place (its actual content -// dissolves — a solid-color ghost over a same-colored background would be -// invisible). -// 2. After the fade completes, capture pre-rects of surviving panes, remove -// the panel (dockview snaps the layout), and FLIP each grower via -// clip-path so its newly claimed territory is hidden at start and swept -// in by the transition. clip-path (not transform) keeps -// getBoundingClientRect accurate so the SelectionOverlay doesn't lag. -// -// killInProgressRef is set across api.removePanel so the onDidRemovePanel -// auto-spawn handler knows we already waited for our own fade and can skip -// its own 440ms delay (avoids stacking 440ms + 440ms on last-pane kill). -function orchestrateKill( - api: DockviewApi, - killedId: string, - selectPanel: (id: string) => void, - setSelectedId: (id: string | null) => void, - killInProgressRef: { current: boolean }, - overlayElRef: { current: HTMLElement | null }, -): void { - const panel = api.getPanel(killedId); - if (!panel) return; - - const bareRemove = () => { - killInProgressRef.current = true; - disposeSession(killedId); - api.removePanel(panel); - killInProgressRef.current = false; - if (api.panels.length > 0) selectPanel(api.panels[0].id); - else setSelectedId(null); - }; - - const reduceMotion = typeof window !== 'undefined' - && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - const killedGroupEl = panel.api.group?.element; - if (reduceMotion || !killedGroupEl) { - bareRemove(); - return; - } - - // Fade the killed pane in place. Block input on it during the fade. - // For a last-pane kill (auto-spawn will create a replacement), also shrink - // the pane toward the bottom-right so the disappearance is visible — a plain - // fade offers no visual cue since the pane's space is reclaimed by a new one - // appearing in exactly the same rect from the opposite corner. The focus - // ring (SelectionOverlay element) gets a matching shrink animation so it - // scales with the pane rather than sitting over empty space. - const isLastPane = api.panels.length === 1; - const fadeClass = isLastPane ? 'pane-fading-and-shrinking-to-br' : 'pane-fading-out'; - const fadeAnimationName = isLastPane ? 'pane-fade-and-shrink-to-br' : 'pane-fade-out'; - killedGroupEl.style.pointerEvents = 'none'; - killedGroupEl.classList.add(fadeClass); - const overlayEl = isLastPane ? overlayElRef.current : null; - if (overlayEl) overlayEl.classList.add('ring-shrinking-to-br'); - - let finalized = false; - const finalize = () => { - if (finalized) return; - finalized = true; - - // Snapshot pre-rects just before removal. - interface Pre { el: HTMLElement; rect: DOMRect; } - const preRects = new Map(); - for (const p of api.panels) { - if (p.id === killedId) continue; - const el = p.api.group?.element; - if (el) preRects.set(p.id, { el, rect: el.getBoundingClientRect() }); - } - - bareRemove(); - - // FLIP each grower. - for (const p of api.panels) { - const pre = preRects.get(p.id); - if (!pre) continue; - const postRect = pre.el.getBoundingClientRect(); - const dw = postRect.width - pre.rect.width; - const dh = postRect.height - pre.rect.height; - if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue; - - // Clear any in-progress spawn animation before applying FLIP. - pre.el.classList.remove('pane-spawning-from-left', 'pane-spawning-from-top', 'pane-spawning-from-top-left'); - - const clipTop = Math.max(0, (pre.rect.top - postRect.top) / postRect.height * 100); - const clipBottom = Math.max(0, (postRect.bottom - pre.rect.bottom) / postRect.height * 100); - const clipLeft = Math.max(0, (pre.rect.left - postRect.left) / postRect.width * 100); - const clipRight = Math.max(0, (postRect.right - pre.rect.right) / postRect.width * 100); - - pre.el.style.transition = 'none'; - pre.el.style.clipPath = `inset(${clipTop}% ${clipRight}% ${clipBottom}% ${clipLeft}%)`; - void pre.el.offsetHeight; - pre.el.style.transition = 'clip-path 440ms cubic-bezier(0.22, 1, 0.36, 1)'; - pre.el.style.clipPath = 'inset(0)'; - const cleanup = () => { - pre.el.style.transition = ''; - pre.el.style.clipPath = ''; - }; - pre.el.addEventListener('transitionend', cleanup, { once: true }); - setTimeout(cleanup, 1000); - } - - // Peel the ring-shrink class so the next selection's overlay renders at - // full scale. The element may have been reused by React for the next - // selected pane's overlay by the time the animation finishes. - if (overlayEl) overlayEl.classList.remove('ring-shrinking-to-br'); - }; - - killedGroupEl.addEventListener('animationend', (ev) => { - if ((ev as AnimationEvent).animationName !== fadeAnimationName) return; - finalize(); - }); - // Safety: if animationend never fires, still finalize. - setTimeout(finalize, 1000); -} // --- Main component --- @@ -1994,7 +1480,7 @@ export function Pond({ } if (e.key === 't' && sid && selectedTypeRef.current === 'pane') { - if (dialogKeyboardActive) return; + if (isDialogKeyboardActive()) return; e.preventDefault(); e.stopPropagation(); toggleSessionTodo(sid); @@ -2002,7 +1488,7 @@ export function Pond({ } if (e.key === 'a' && sid && selectedTypeRef.current === 'pane') { - if (dialogKeyboardActive) return; + if (isDialogKeyboardActive()) return; e.preventDefault(); e.stopPropagation(); dismissOrToggleAlert(sid, getActivity(sid).status); diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx new file mode 100644 index 0000000..12272b3 --- /dev/null +++ b/lib/src/components/TodoAlertDialog.tsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useSyncExternalStore } from 'react'; +import { createPortal } from 'react-dom'; +import { XIcon } from '@phosphor-icons/react'; +import { Shortcut } from './design'; +import { + clearSessionTodo, + DEFAULT_ACTIVITY_STATE, + disableSessionAlert, + dismissOrToggleAlert, + getActivity, + getActivitySnapshot, + markSessionTodo, + subscribeToActivity, + toggleSessionAlert, + toggleSessionTodo, +} from '../lib/terminal-registry'; + +let dialogKeyboardActive = false; + +/** Pond's command-mode keyboard handler consults this to avoid reacting to + * `a`/`t` while the dialog is open (the dialog has its own handlers). */ +export function isDialogKeyboardActive(): boolean { + return dialogKeyboardActive; +} + +function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; y: number }>): boolean { + let sign = 0; + for (let i = 0; i < vertices.length; i++) { + const a = vertices[i]; + const b = vertices[(i + 1) % vertices.length]; + const cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x); + if (cross === 0) continue; + if (sign === 0) sign = cross > 0 ? 1 : -1; + else if ((cross > 0 ? 1 : -1) !== sign) return false; + } + return true; +} + +/** + * Manages focus trapping, Escape-to-close, and click-outside-to-close for + * portal-based popovers. Scopes keyboard handling to the popover's DOM subtree + * so Tab/Escape don't leak to the rest of the app. + */ +function usePopoverFocusTrap( + ref: React.RefObject, + onClose: () => void, + restoreFocusSelector?: string, +) { + useEffect(() => { + const el = ref.current; + if (!el) return; + + const handleMouseDown = (e: MouseEvent) => { + if (!el.contains(e.target as Node)) onClose(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle keys when focus is inside the popover + if (!el.contains(document.activeElement)) return; + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + + const focusables = Array.from( + el.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'), + ); + if (focusables.length === 0) return; + + const currentIndex = focusables.findIndex((f) => f === document.activeElement); + const nextIndex = currentIndex === -1 + ? 0 + : (currentIndex + (e.shiftKey ? -1 : 1) + focusables.length) % focusables.length; + + e.preventDefault(); + focusables[nextIndex]?.focus(); + }; + + window.addEventListener('mousedown', handleMouseDown); + window.addEventListener('keydown', handleKeyDown, true); + return () => { + window.removeEventListener('mousedown', handleMouseDown); + window.removeEventListener('keydown', handleKeyDown, true); + if (restoreFocusSelector) { + document.querySelector(restoreFocusSelector)?.focus(); + } + }; + }, [ref, onClose, restoreFocusSelector]); +} + +export function TodoAlertDialog({ + position, + sessionId, + onClose, +}: { + position: { x: number; y: number; triggerRect: DOMRect }; + sessionId: string; + onClose: () => void; +}) { + const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); + const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; + const alertEnabled = activity.status !== 'ALERT_DISABLED'; + const dialogRef = useRef(null); + + usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); + + useEffect(() => { + dialogRef.current?.querySelector('button')?.focus(); + }, []); + + // Keyboard shortcuts within dialog + useEffect(() => { + const el = dialogRef.current; + if (!el) return; + dialogKeyboardActive = true; + const handler = (e: KeyboardEvent) => { + if (!el.contains(document.activeElement)) return; + if (e.key === 'a') { + e.preventDefault(); + e.stopImmediatePropagation(); + dismissOrToggleAlert(sessionId, getActivity(sessionId).status); + } + if (e.key === 't') { + e.preventDefault(); + e.stopImmediatePropagation(); + toggleSessionTodo(sessionId); + } + }; + window.addEventListener('keydown', handler, true); + return () => { + dialogKeyboardActive = false; + window.removeEventListener('keydown', handler, true); + }; + }, [sessionId]); + + // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top). + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const trigger = position.triggerRect; + const handler = (e: MouseEvent) => { + const dialogRect = dialog.getBoundingClientRect(); + const { clientX: x, clientY: y } = e; + if (x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom) return; + const funnel = [ + { x: trigger.left, y: trigger.top }, + { x: trigger.right, y: trigger.top }, + { x: dialogRect.right, y: dialogRect.top }, + { x: dialogRect.left, y: dialogRect.top }, + ]; + if (pointInConvexPolygon(x, y, funnel)) return; + onClose(); + }; + window.addEventListener('mousemove', handler); + return () => window.removeEventListener('mousemove', handler); + }, [position.triggerRect, onClose]); + + return createPortal( +
+ + +
+ {/* TODO row */} + t + TODO + markSessionTodo(sessionId)} + onDisable={() => clearSessionTodo(sessionId)} + label="TODO" + /> + + {/* Alert row */} + a + alert + toggleSessionAlert(sessionId)} + onDisable={() => disableSessionAlert(sessionId)} + label="alert" + /> +
+ +
+ When a tab with a ringing alert is selected,
+ the alert is cleared and the tab gets a TODO.
+ Pressing [Enter] into the tab will clear the TODO. +
+
, + document.body, + ); +} + +function OnOffSwitch({ + on, + onEnable, + onDisable, + label, +}: { + on: boolean; + onEnable: () => void; + onDisable: () => void; + label: string; +}) { + return ( + + ); +} diff --git a/lib/src/stories/KillModal.stories.tsx b/lib/src/stories/KillModal.stories.tsx index a26ff7f..5486619 100644 --- a/lib/src/stories/KillModal.stories.tsx +++ b/lib/src/stories/KillModal.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { KillConfirmCard } from '../components/Pond'; +import { KillConfirmCard } from '../components/KillConfirm'; function KillModal({ char = 'G', onCancel, shaking }: { char?: string; onCancel?: () => void; shaking?: boolean }) { return ( From 2b18eeebe8298bea7496091984809f3121f64a5d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 01:24:53 +0000 Subject: [PATCH 07/23] Simplify TodoAlertDialog position API to just the trigger rect. The x/y fields were always derived from triggerRect (rect.left and rect.bottom + 8), so carrying them as separate fields just duplicated state. Drop them and compute the dialog's top-left inside the dialog. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/Pond.tsx | 23 +++++++---------------- lib/src/components/TodoAlertDialog.tsx | 13 ++++++------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 9062027..14a4e4b 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -341,7 +341,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const [mouseIconAnchor, setMouseIconAnchor] = useState(null); const suppressAlertClickRef = useRef(false); const [tier, setTier] = useState('full'); - const [dialogPosition, setDialogPosition] = useState<{ x: number; y: number; triggerRect: DOMRect } | null>(null); + const [dialogTriggerRect, setDialogTriggerRect] = useState(null); const todoPill = useTodoPillContent(activity.todo); const showTodoPill = todoPill.visible && tier !== 'minimal'; const alertButtonAriaLabel = activity.status === 'ALERT_RINGING' @@ -358,22 +358,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { ? 'Click to dismiss and show options' : 'Right-click for options'; - const openDialogFromButton = useCallback((button: HTMLButtonElement) => { - const rect = button.getBoundingClientRect(); - setDialogPosition({ - x: rect.left, - y: rect.bottom + 8, - triggerRect: rect, - }); - }, []); - const closeDialog = useCallback(() => setDialogPosition(null), []); + const closeDialog = useCallback(() => setDialogTriggerRect(null), []); const triggerAlertButtonAction = useCallback((displayedStatus: SessionStatus, button: HTMLButtonElement) => { const result = actions.onAlertButton(api.id, displayedStatus); if (result === 'dismissed') { - openDialogFromButton(button); + setDialogTriggerRect(button.getBoundingClientRect()); } - }, [actions, api.id, openDialogFromButton]); + }, [actions, api.id]); useEffect(() => { const el = tabRef.current; @@ -443,8 +435,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { }} onContextMenu={(e) => { e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - setDialogPosition({ x: rect.left, y: rect.bottom + 8, triggerRect: rect }); + setDialogTriggerRect(e.currentTarget.getBoundingClientRect()); }} ariaLabel={alertButtonAriaLabel} tooltip={alertButtonTooltip} @@ -549,9 +540,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
)} - {dialogPosition && ( + {dialogTriggerRect && ( diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 12272b3..998b700 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -93,11 +93,11 @@ function usePopoverFocusTrap( } export function TodoAlertDialog({ - position, + triggerRect, sessionId, onClose, }: { - position: { x: number; y: number; triggerRect: DOMRect }; + triggerRect: DOMRect; sessionId: string; onClose: () => void; }) { @@ -141,14 +141,13 @@ export function TodoAlertDialog({ useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; - const trigger = position.triggerRect; const handler = (e: MouseEvent) => { const dialogRect = dialog.getBoundingClientRect(); const { clientX: x, clientY: y } = e; if (x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom) return; const funnel = [ - { x: trigger.left, y: trigger.top }, - { x: trigger.right, y: trigger.top }, + { x: triggerRect.left, y: triggerRect.top }, + { x: triggerRect.right, y: triggerRect.top }, { x: dialogRect.right, y: dialogRect.top }, { x: dialogRect.left, y: dialogRect.top }, ]; @@ -157,13 +156,13 @@ export function TodoAlertDialog({ }; window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); - }, [position.triggerRect, onClose]); + }, [triggerRect, onClose]); return createPortal(
Date: Fri, 24 Apr 2026 01:30:51 +0000 Subject: [PATCH 08/23] docs: update TODO alert spec --- docs/specs/alert.md | 55 +++++++++++++++++++---------------------- docs/specs/shortcuts.md | 2 +- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 7e4f34a..d4994f8 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -46,14 +46,12 @@ Each Session owns: - Transitional states: `MIGHT_BE_BUSY`, `MIGHT_NEED_ATTENTION`. - When the user enables the alert, status transitions from `ALERT_DISABLED` to `NOTHING_TO_SHOW` and activity tracking begins fresh from that moment. - When the user disables the alert, activity tracking stops and status returns to `ALERT_DISABLED`. -- `todo: TodoState` (numeric) - - Reminder state for the Session. Default `TODO_OFF` (`-1`). - - `TODO_OFF` (`-1`): no TODO. - - `[0, 1]` (soft TODO): auto-created when a ringing alert is phantom-dismissed (any attention path). Dashed-outline pill rendered as the word `TODO`. The value is quantized to five strike levels (`1.0` = no strikes, `0.75 / 0.5 / 0.25` = 1 / 2 / 3 letters struck, `0` = about to clear). Each printable keypress strikes exactly one letter (4 keypresses clears the TODO). After `recoverySecondsPerLetter` seconds of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses. - - `TODO_HARD` (`2`): explicitly set by the user via `t` key or context menu. Solid-outline pill. Only clears via explicit toggle. - - Dismissing a ringing alert when `todo` is already soft or hard does not downgrade it. - - Helper functions: `isSoftTodo(todo)`, `isHardTodo(todo)`, `hasTodo(todo)`. - - Strike-timing tuning parameter is in `cfg.todoBucket.recoverySecondsPerLetter`. +- `todo: boolean` + - Reminder state for the Session. Default `false`. + - `false`: no TODO. + - `true`: TODO is shown. It may be set explicitly by the user, or auto-created when a ringing alert is dismissed by attention or by the bell. + - Dismissing a ringing alert when `todo` is already `true` leaves it `true`. + - Legacy persisted TODO encodings migrate into this boolean shape: `-1` / `false` / unknown values become `false`; numeric soft buckets, `2`, `'soft'`, and `'hard'` become `true`. Each Session also owns: @@ -203,10 +201,10 @@ The Session leaves `ALERT_RINGING` and returns to `NOTHING_TO_SHOW` when any of - the user attends to the Session (clicking into the Pane, typing in passthrough, restoring a Door via click/`Enter`) - the user dismisses the alert (clicking the ringing bell, pressing `a`) -- the user marks the Session as hard TODO (`t` key or context menu) +- the user marks the Session as TODO (`t` key or context menu) - new output arrives while the Session has attention (starts a new `MIGHT_BE_BUSY` cycle; without attention the alert stays ringing — see latch in transition rules) -All attention-based dismissals (the first three above) create a soft TODO if `todo` is not already `TODO_HARD`. If a partially-struck soft TODO already exists, the pill resets to fully un-struck — a fresh alert ring deserves a full strike cycle. This prevents phantom dismissals where the alert vanishes without a trace. Printable keypresses strike one letter of the `TODO` pill at a time (4 strikes clears it), so users who engage with the output don't accumulate breadcrumbs. After `cfg.todoBucket.recoverySecondsPerLetter` (default 1 s) of idle, one struck letter un-strikes; this repeats until the pill is fully un-struck. Synthetic terminal reports (focus events, cursor-position responses) do not count as keypresses. +All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by pressing `Enter` into that Session. Synthetic terminal reports (focus events, cursor-position responses) do not count as user input for clearing. The Session leaves `ALERT_RINGING` and returns to `ALERT_DISABLED` when: @@ -218,7 +216,7 @@ The Session's alert state is cleared entirely when: If more output arrives later and the Session makes a fresh transition back into `ALERT_RINGING`, the alert rings again. -Marking a Session as hard TODO resets the alert to `NOTHING_TO_SHOW` and sets `todo = TODO_HARD`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns. +Marking a Session as TODO resets the alert to `NOTHING_TO_SHOW` and sets `todo = true`, but it does **not** disable future alerts. `todo` and the alert toggle are separate concerns. Disabling alerts disposes the activity monitor and returns `status` to `ALERT_DISABLED`. @@ -233,13 +231,12 @@ The Pane header exposes two independent concepts: TODO pill: -- toggled in command mode with `t` (cycles: `TODO_OFF` → `TODO_HARD`, soft → `TODO_HARD`, `TODO_HARD` → `TODO_OFF`) -- shown when `hasTodo(todo)` is true (i.e. `todo !== TODO_OFF`) -- soft (`isSoftTodo(todo)`): dashed-outline pill — auto-created on alert dismiss; each printable keypress strikes one letter of the word `TODO` (4 keypresses clears it), and one letter un-strikes per `recoverySecondsPerLetter` of idle -- when the 4th strike lands and the soft TODO clears, the pill briefly morphs to a `✓` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently -- `TODO_HARD` (`isHardTodo(todo)`): solid-outline pill — explicitly set, only clears manually -- clicking a soft pill shows a prompt: "Clear" / "Keep" (keep promotes to hard) -- clicking a hard pill clears it +- toggled in command mode with `t` (`false` -> `true` -> `false`) +- shown when `todo === true` +- auto-created on alert dismiss or attention-based alert clearing +- pressing `Enter` into a Session with TODO clears it +- clicking the TODO pill clears it +- when TODO clears, the pill briefly morphs to a `✓` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently - no empty placeholder when off Alert button: @@ -259,15 +256,14 @@ Alert button: Interaction (`dismissOrToggleAlert` state machine): - left-click the bell while `ALERT_DISABLED`: enables the alert (creates activity monitor) -- left-click the bell while `ALERT_RINGING`: dismisses the alert, creates a soft TODO if none exists, then opens the context menu anchored below the button +- left-click the bell while `ALERT_RINGING`: dismisses the alert, creates a TODO if none exists, then opens the context menu anchored below the button - left-click the bell after an attention-based dismissal (`attentionDismissedRing` is set): clears the flag and opens the context menu. This lets the user access TODO/disable options after attending to a ringing Session without requiring a right-click. - left-click the bell in any other enabled state: disables the alert (destroys activity monitor) - pressing `a` on a selected Pane in command mode: same as left-click - right-click the bell (any state): opens a context menu with: - - a TODO row with `hard` and `off` options only; soft TODOs are never manually selectable here - - "Mark as TODO" / "Clear TODO" (toggles hard TODO), with `[t]` shortcut hint - - "Disable alerts" (only when alert is enabled) - - brief description of soft/hard TODO behavior + - a TODO on/off switch with `[t]` shortcut hint + - an alert on/off switch with `[a]` shortcut hint + - brief description of TODO clearing behavior - tooltip includes "Right-click for options" hint The alert control has higher layout priority than split or zoom controls. Long titles must truncate before the bell disappears. @@ -279,7 +275,7 @@ A Door is display-only for alert state in v1. It must not replace the existing D Door indicators: - show bell indicator only when `status !== 'ALERT_DISABLED'` -- show TODO pill when `hasTodo(todo)` (soft or hard) +- show TODO pill when `todo === true` - if `status === 'ALERT_RINGING'`, the Door bell icon uses warning color and the same rocking animation as the Pane header - the Door bell icon shows the same tilt angles as the Pane header for escalation states @@ -368,18 +364,17 @@ Consequences: - A Session rings. - User clicks into the pane to read the output. -- The alert clears, a soft TODO appears (dashed pill). -- User types a command → each printable keypress strikes one letter of the `TODO` pill; after 4 keypresses the pill morphs to a `✓` and clears (they engaged). +- The alert clears, and a TODO appears. +- User presses `Enter` into the Session → the `TODO` pill morphs to a `✓` and clears (they engaged). - The Session later emits new output, progresses through `BUSY`, and eventually reaches `ALERT_RINGING` again. ### User dismisses but doesn't engage - A Session rings. - User clicks into the pane briefly, then switches to another session. -- The alert clears, a soft TODO appears. -- User never types into the terminal → soft TODO persists. -- User later notices the dashed TODO pill and clicks it → "Clear" / "Keep". -- Choosing "Keep" promotes to a hard (solid) TODO. +- The alert clears, and a TODO appears. +- User never presses `Enter` into the terminal → TODO persists. +- User later notices the TODO pill and clicks it to clear it. ## Verification checklist diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 4ca3164..9801a8c 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -25,7 +25,7 @@ mouseterm has two modes: | `k` or `x` | Kill | Kill the selected pane or door. Prompts for a random character to confirm. | | `,` | Rename | Enter rename mode for the selected pane's title. | | `a` | Toggle alert | Dismiss or toggle the bell alert for the selected pane. | -| `t` | Toggle todo | Toggle the TODO marker (soft / hard) on the selected pane. | +| `t` | Toggle todo | Toggle the TODO marker on or off for the selected pane. | ## Navigation (workspace mode) From 755be2a860532eeabb35d941e66ae0e33feb6f54 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 04:32:05 +0000 Subject: [PATCH 09/23] Restore tooltip-on-focus in HeaderActionButton. onFocus/onBlur were present in the original in-Pond definition but got dropped during the extraction to its own file, so keyboard-tab navigation stopped triggering header tooltips. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/HeaderActionButton.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/components/HeaderActionButton.tsx b/lib/src/components/HeaderActionButton.tsx index 95c3d94..62cd259 100644 --- a/lib/src/components/HeaderActionButton.tsx +++ b/lib/src/components/HeaderActionButton.tsx @@ -83,6 +83,8 @@ export function HeaderActionButton({ aria-label={ariaLabel} onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} > {children} From 69d2154932688269a18a42e474afe6867a0f1263 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 04:32:44 +0000 Subject: [PATCH 10/23] Clamp TodoAlertDialog inside the viewport. The extraction from Pond dropped the clampOverlayPosition wrapper, so a dialog triggered from a bell near the right edge of the window would overflow off-screen. Measure the mounted dialog with useLayoutEffect and apply margin-based clamping before the browser paints. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/TodoAlertDialog.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 998b700..4400c36 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useSyncExternalStore } from 'react'; +import { useLayoutEffect, useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { XIcon } from '@phosphor-icons/react'; import { Shortcut } from './design'; @@ -105,6 +105,27 @@ export function TodoAlertDialog({ const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; const alertEnabled = activity.status !== 'ALERT_DISABLED'; const dialogRef = useRef(null); + const [position, setPosition] = useState<{ left: number; top: number }>({ + left: triggerRect.left, + top: triggerRect.bottom + 8, + }); + + // Clamp the dialog inside the viewport after mount. w-fit makes the width + // content-driven, so we have to measure before we can clamp. + useLayoutEffect(() => { + const el = dialogRef.current; + if (!el) return; + const margin = 12; + const rect = el.getBoundingClientRect(); + const desiredLeft = triggerRect.left; + const desiredTop = triggerRect.bottom + 8; + const maxLeft = Math.max(margin, window.innerWidth - rect.width - margin); + const maxTop = Math.max(margin, window.innerHeight - rect.height - margin); + setPosition({ + left: Math.min(Math.max(desiredLeft, margin), maxLeft), + top: Math.min(Math.max(desiredTop, margin), maxTop), + }); + }, [triggerRect]); usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); @@ -162,7 +183,7 @@ export function TodoAlertDialog({
Date: Fri, 24 Apr 2026 04:33:04 +0000 Subject: [PATCH 11/23] Arm TodoAlertDialog's mouse-leave-to-close only after cursor enters. The dialog can also be opened by keyboard (pressing 'a' on a ringing pane). Previously, the mousemove handler closed the dialog on the first move if the cursor was already outside the hot area at open time, making the dialog unusable with the keyboard unless the cursor happened to be parked near the bell. Now the close-on-leave trigger only arms once the cursor has actually entered the dialog or its funnel. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/TodoAlertDialog.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 4400c36..bcf3526 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -159,21 +159,29 @@ export function TodoAlertDialog({ }, [sessionId]); // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top). + // Only arms after the cursor has entered the hot area, so a keyboard-triggered + // open (cursor far away) doesn't auto-close on the first unrelated mousemove. useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; + let armed = false; const handler = (e: MouseEvent) => { const dialogRect = dialog.getBoundingClientRect(); const { clientX: x, clientY: y } = e; - if (x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom) return; + const inDialog = x >= dialogRect.left && x <= dialogRect.right && y >= dialogRect.top && y <= dialogRect.bottom; const funnel = [ { x: triggerRect.left, y: triggerRect.top }, { x: triggerRect.right, y: triggerRect.top }, { x: dialogRect.right, y: dialogRect.top }, { x: dialogRect.left, y: dialogRect.top }, ]; - if (pointInConvexPolygon(x, y, funnel)) return; - onClose(); + const inFunnel = pointInConvexPolygon(x, y, funnel); + const inside = inDialog || inFunnel; + if (!armed) { + if (inside) armed = true; + return; + } + if (!inside) onClose(); }; window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); From 56f873d679f4b2309943cb85900127cde6a77655 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 04:48:56 +0000 Subject: [PATCH 12/23] Clear TODO on command-mode enter --- lib/src/components/Pond.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 14a4e4b..eaddd12 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1373,12 +1373,16 @@ export function Pond({ if (e.key === 'Enter' && sid) { e.preventDefault(); e.stopPropagation(); + const hadTodo = getActivity(sid).todo; if (selectedTypeRef.current === 'door') { const item = doorsRef.current.find(d => d.id === sid); if (item) handleReattachRef.current(item); } else { enterTerminalMode(sid); } + if (hadTodo) { + clearSessionTodo(sid); + } return; } From b0ddc4d8c0ab62135657db11241cd8a392d001fb Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 04:49:31 +0000 Subject: [PATCH 13/23] Use alert button path for keyboard shortcut --- lib/src/components/Pond.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index eaddd12..fa3c784 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -180,6 +180,11 @@ function clampOverlayPosition({ left, top, width, height }: { }; } +function findAlertButtonForSession(id: string): HTMLButtonElement | null { + return Array.from(document.querySelectorAll('[data-alert-button-for]')) + .find((button) => button.dataset.alertButtonFor === id) ?? null; +} + // --- Contexts --- // We own selection/focus, not dockview. These contexts let panel components read our state. @@ -1486,7 +1491,12 @@ export function Pond({ if (isDialogKeyboardActive()) return; e.preventDefault(); e.stopPropagation(); - dismissOrToggleAlert(sid, getActivity(sid).status); + const alertButton = findAlertButtonForSession(sid); + if (alertButton) { + alertButton.click(); + } else { + dismissOrToggleAlert(sid, getActivity(sid).status); + } return; } From ab985ae88e026b38176f7e7ad80bd61e5330263c Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 04:49:49 +0000 Subject: [PATCH 14/23] Document v3 persisted session shape --- docs/specs/vscode.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 6210818..342f526 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -194,7 +194,7 @@ activationEvents: ["onWebviewPanel:mouseterm"] ```typescript interface PersistedSession { - version: 2; + version: 3; panes: PersistedPane[]; doors?: PersistedDoor[]; layout: unknown; // SerializedDockview @@ -209,6 +209,11 @@ interface PersistedPane { alert?: PersistedAlertState | null; } +interface PersistedAlertState { + status: SessionStatus; + todo: boolean; +} + interface PersistedDoor { id: string; title: string; From 716b65864871e4608a2791772f8039855312cab8 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:27:44 +0000 Subject: [PATCH 15/23] Replace dialogKeyboardActive module global with a React context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag that tells Pond's keyboard handler to stand down while TodoAlertDialog is open was a module-level mutable variable with an exported accessor — implicit coupling that would silently break with multiple simultaneous dialogs. Replace it with DialogKeyboardContext: Pond owns a ref and provides a setter via context; TerminalPaneHeader reads the context and threads it into TodoAlertDialog as an onKeyboardActiveChange prop. Co-Authored-By: Claude Sonnet 4.6 --- lib/src/components/Pond.tsx | 19 ++++++++++++++++--- lib/src/components/TodoAlertDialog.tsx | 15 +++++---------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index fa3c784..cc0d371 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -16,7 +16,7 @@ import { Baseboard } from './Baseboard'; import { tv } from 'tailwind-variants'; import { PopupButtonRow, popupButton } from './design'; import { HeaderActionButton } from './HeaderActionButton'; -import { TodoAlertDialog, isDialogKeyboardActive } from './TodoAlertDialog'; +import { TodoAlertDialog } from './TodoAlertDialog'; import { KillConfirmOverlay, orchestrateKill, randomKillChar, type ConfirmKill } from './KillConfirm'; import { BellIcon, BellSlashIcon, SplitHorizontalIcon, SplitVerticalIcon, ArrowsOutIcon, ArrowsInIcon, ArrowLineDownIcon, XIcon, CursorClickIcon, SelectionSlashIcon } from '@phosphor-icons/react'; import { @@ -242,6 +242,10 @@ export const RenamingIdContext = createContext(null); export const ZoomedContext = createContext(false); export const WindowFocusedContext = createContext(true); +// Lets TodoAlertDialog notify Pond's command-mode keyboard handler to stand +// down while the dialog is open (both listen on window capture, Pond first). +export const DialogKeyboardContext = createContext<(active: boolean) => void>(() => {}); + // Transient map of pane ids that were just created → their spawn direction. // TerminalPanel consumes (and removes) its id on first mount to trigger a directional spawn animation. // 'left' — born from horizontal split (new pane appeared to the right of the source) @@ -328,6 +332,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { const renamingId = useContext(RenamingIdContext); const zoomed = useContext(ZoomedContext); const windowFocused = useContext(WindowFocusedContext); + const setDialogKeyboardActive = useContext(DialogKeyboardContext); const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const mouseStates = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); const actions = useContext(PondActionsContext); @@ -550,6 +555,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { triggerRect={dialogTriggerRect} sessionId={api.id} onClose={closeDialog} + onKeyboardActiveChange={setDialogKeyboardActive} /> )}
@@ -802,6 +808,11 @@ export function Pond({ // animate the focus ring in sync with the killed pane's shrink (last-pane case). const overlayElRef = useRef(null); + const dialogKeyboardActiveRef = useRef(false); + const setDialogKeyboardActive = useCallback((active: boolean) => { + dialogKeyboardActiveRef.current = active; + }, []); + // Consumed once in handleReady to restore existing sessions const initialPaneIdsRef = useRef(initialPaneIds); const restoredLayoutRef = useRef(restoredLayout); @@ -1480,7 +1491,7 @@ export function Pond({ } if (e.key === 't' && sid && selectedTypeRef.current === 'pane') { - if (isDialogKeyboardActive()) return; + if (dialogKeyboardActiveRef.current) return; e.preventDefault(); e.stopPropagation(); toggleSessionTodo(sid); @@ -1488,7 +1499,7 @@ export function Pond({ } if (e.key === 'a' && sid && selectedTypeRef.current === 'pane') { - if (isDialogKeyboardActive()) return; + if (dialogKeyboardActiveRef.current) return; e.preventDefault(); e.stopPropagation(); const alertButton = findAlertButtonForSession(sid); @@ -1784,6 +1795,7 @@ export function Pond({ +
{/* Dockview */}
@@ -1812,6 +1824,7 @@ export function Pond({ )}
+ diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index bcf3526..8e0d1d4 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -15,13 +15,6 @@ import { toggleSessionTodo, } from '../lib/terminal-registry'; -let dialogKeyboardActive = false; - -/** Pond's command-mode keyboard handler consults this to avoid reacting to - * `a`/`t` while the dialog is open (the dialog has its own handlers). */ -export function isDialogKeyboardActive(): boolean { - return dialogKeyboardActive; -} function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; y: number }>): boolean { let sign = 0; @@ -96,10 +89,12 @@ export function TodoAlertDialog({ triggerRect, sessionId, onClose, + onKeyboardActiveChange, }: { triggerRect: DOMRect; sessionId: string; onClose: () => void; + onKeyboardActiveChange: (active: boolean) => void; }) { const activityStates = useSyncExternalStore(subscribeToActivity, getActivitySnapshot); const activity = activityStates.get(sessionId) ?? DEFAULT_ACTIVITY_STATE; @@ -137,7 +132,7 @@ export function TodoAlertDialog({ useEffect(() => { const el = dialogRef.current; if (!el) return; - dialogKeyboardActive = true; + onKeyboardActiveChange(true); const handler = (e: KeyboardEvent) => { if (!el.contains(document.activeElement)) return; if (e.key === 'a') { @@ -153,10 +148,10 @@ export function TodoAlertDialog({ }; window.addEventListener('keydown', handler, true); return () => { - dialogKeyboardActive = false; + onKeyboardActiveChange(false); window.removeEventListener('keydown', handler, true); }; - }, [sessionId]); + }, [sessionId, onKeyboardActiveChange]); // Hot area: close when mouse leaves (dialog ∪ funnel from trigger button to dialog top). // Only arms after the cursor has entered the hot area, so a keyboard-triggered From 3d797427f594d1dcb968c6f562e8103cf53dfa4d Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:27:59 +0000 Subject: [PATCH 16/23] Comment why 'a' key goes through the DOM button rather than calling directly Co-Authored-By: Claude Sonnet 4.6 --- lib/src/components/Pond.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index cc0d371..8cc2cf2 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1502,6 +1502,8 @@ export function Pond({ if (dialogKeyboardActiveRef.current) return; e.preventDefault(); e.stopPropagation(); + // Go through the real button so that a dismiss opens the dialog. The + // fallback handles the edge case where the header isn't mounted yet. const alertButton = findAlertButtonForSession(sid); if (alertButton) { alertButton.click(); From 06285be64d06aeffe6eaa214fdf695a383d21136 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:28:14 +0000 Subject: [PATCH 17/23] Comment why todo is read before enterTerminalMode on Enter Co-Authored-By: Claude Sonnet 4.6 --- lib/src/components/Pond.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 8cc2cf2..0378f93 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1389,6 +1389,9 @@ export function Pond({ if (e.key === 'Enter' && sid) { e.preventDefault(); e.stopPropagation(); + // Read todo before entering terminal mode so the clearSessionTodo call + // below is a no-op when there was nothing to clear (avoids spurious + // re-renders), and so the pill dismiss animation plays before passthrough. const hadTodo = getActivity(sid).todo; if (selectedTypeRef.current === 'door') { const item = doorsRef.current.find(d => d.id === sid); From 41c7ccacb890a79183c660532dc21b3687f196d1 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:28:24 +0000 Subject: [PATCH 18/23] Note in test file why soft-TODO bucket tests were removed Co-Authored-By: Claude Sonnet 4.6 --- lib/src/lib/alert-manager.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 5d13634..36ac5ea 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -135,6 +135,8 @@ describe('AlertManager in isolation', () => { }); // --- Boolean TODO tests --- + // (The previous soft-TODO bucket tests — 4-keypress letter-striking, per-letter + // recovery timers — were removed when TODO was simplified to a plain boolean.) function driveToRinging(id: string): void { manager.toggleAlert(id); From dd1eb70615a87ffb81463e292e18ef3dcdff4267 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:48:25 +0000 Subject: [PATCH 19/23] Keep auto TODO after Enter dismisses alert --- lib/src/lib/terminal-registry.alert.test.ts | 14 ++++++++++++++ lib/src/lib/terminal-registry.ts | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 56f0a17..8dd5125 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -530,6 +530,20 @@ describe('terminal-registry alert behavior', () => { expect(getActivity(id).todo).toBe(true); }); + it('Enter that dismisses a ringing alert leaves the auto-created TODO visible', () => { + const id = 'enter-dismisses-ringing'; + const entry = createSession(id); + toggleSessionAlert(id); + + driveToRingingNeedsAttention(id); + entry.terminal.emitInput('\r'); + + expect(getActivity(id)).toEqual({ + status: 'NOTHING_TO_SHOW', + todo: true, + }); + }); + it('no monitor is created until alert is enabled', () => { const id = 'no-monitor'; createSession(id); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index cc21dd7..e67b94c 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -358,9 +358,10 @@ function setupTerminalEntry(id: string): TerminalEntry { const isSyntheticTerminalReport = inputIsSyntheticTerminalReport(data); if (!isSyntheticTerminalReport) { - getPlatform().alertAttend(id); const entry = registry.get(id); - if (entry && entry.todo && inputContainsEnter(data)) { + const hadTodo = entry?.todo === true; + getPlatform().alertAttend(id); + if (hadTodo && inputContainsEnter(data)) { getPlatform().alertClearTodo(id); } } From fa852a77be8d56a0de649ecd14294251b34bf08b Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 24 Apr 2026 15:48:57 +0000 Subject: [PATCH 20/23] Clamp legacy TODO migration --- lib/src/lib/alert-manager.ts | 2 +- lib/src/lib/session-migration.test.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index b58d6cf..77bb5da 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -10,7 +10,7 @@ export type TodoState = boolean; export function migrateTodoState(todo: unknown): TodoState { if (typeof todo === 'boolean') return todo; // v2 numeric encoding: -1 = off, [0,1] = soft, 2 = hard - if (typeof todo === 'number') return todo !== -1; + if (typeof todo === 'number') return Number.isFinite(todo) && (todo === 2 || (todo >= 0 && todo <= 1)); // v1 string encoding: 'soft' | 'hard' | false if (todo === 'hard' || todo === 'soft') return true; return false; diff --git a/lib/src/lib/session-migration.test.ts b/lib/src/lib/session-migration.test.ts index 07a9861..dbf5e5e 100644 --- a/lib/src/lib/session-migration.test.ts +++ b/lib/src/lib/session-migration.test.ts @@ -140,6 +140,43 @@ describe('session migration v2 → v3', () => { expect(v3.panes[0].alert?.todo).toBe(false); }); + it('converts unknown numeric TODO values to boolean false', () => { + const v2: PersistedSessionV2 = { + version: 2, + layout: null, + panes: [ + { + id: 'pane-unknown-high', + title: 'unknown high', + cwd: null, + scrollback: null, + resumeCommand: null, + alert: { status: 'NOTHING_TO_SHOW', todo: 3 }, + }, + { + id: 'pane-unknown-low', + title: 'unknown low', + cwd: null, + scrollback: null, + resumeCommand: null, + alert: { status: 'NOTHING_TO_SHOW', todo: -2 }, + }, + { + id: 'pane-unknown-nan', + title: 'unknown nan', + cwd: null, + scrollback: null, + resumeCommand: null, + alert: { status: 'NOTHING_TO_SHOW', todo: Number.NaN }, + }, + ], + }; + const v3 = migrateSessionV2toV3(v2); + expect(v3.panes[0].alert?.todo).toBe(false); + expect(v3.panes[1].alert?.todo).toBe(false); + expect(v3.panes[2].alert?.todo).toBe(false); + }); + it('preserves panes with null alert', () => { const v2: PersistedSessionV2 = { version: 2, From 6b975ed9348a5cd3ff668e0b469623d712279c28 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 24 Apr 2026 08:56:55 -0700 Subject: [PATCH 21/23] Remove unnecessary keyboard focus. --- lib/src/components/TodoAlertDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/src/components/TodoAlertDialog.tsx b/lib/src/components/TodoAlertDialog.tsx index 8e0d1d4..2e649af 100644 --- a/lib/src/components/TodoAlertDialog.tsx +++ b/lib/src/components/TodoAlertDialog.tsx @@ -37,7 +37,6 @@ function pointInConvexPolygon(x: number, y: number, vertices: Array<{ x: number; function usePopoverFocusTrap( ref: React.RefObject, onClose: () => void, - restoreFocusSelector?: string, ) { useEffect(() => { const el = ref.current; @@ -78,11 +77,8 @@ function usePopoverFocusTrap( return () => { window.removeEventListener('mousedown', handleMouseDown); window.removeEventListener('keydown', handleKeyDown, true); - if (restoreFocusSelector) { - document.querySelector(restoreFocusSelector)?.focus(); - } }; - }, [ref, onClose, restoreFocusSelector]); + }, [ref, onClose]); } export function TodoAlertDialog({ @@ -122,10 +118,13 @@ export function TodoAlertDialog({ }); }, [triggerRect]); - usePopoverFocusTrap(dialogRef, onClose, `[data-alert-button-for="${sessionId}"]`); + usePopoverFocusTrap(dialogRef, onClose); + // Focus the dialog container itself (not a button inside) so our keyboard + // handlers fire via `el.contains(document.activeElement)`, without painting + // a native focus ring on any interactive element. useEffect(() => { - dialogRef.current?.querySelector('button')?.focus(); + dialogRef.current?.focus(); }, []); // Keyboard shortcuts within dialog @@ -185,7 +184,8 @@ export function TodoAlertDialog({ return createPortal(
Date: Fri, 24 Apr 2026 09:08:38 -0700 Subject: [PATCH 22/23] Revert "Clear TODO on command-mode enter" This reverts commit 56f873d679f4b2309943cb85900127cde6a77655. --- lib/src/components/Pond.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 0378f93..9657a87 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1389,19 +1389,12 @@ export function Pond({ if (e.key === 'Enter' && sid) { e.preventDefault(); e.stopPropagation(); - // Read todo before entering terminal mode so the clearSessionTodo call - // below is a no-op when there was nothing to clear (avoids spurious - // re-renders), and so the pill dismiss animation plays before passthrough. - const hadTodo = getActivity(sid).todo; if (selectedTypeRef.current === 'door') { const item = doorsRef.current.find(d => d.id === sid); if (item) handleReattachRef.current(item); } else { enterTerminalMode(sid); } - if (hadTodo) { - clearSessionTodo(sid); - } return; } From 2115bb74e561db9129cdbbd2ba00f5208573d287 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 24 Apr 2026 09:10:35 -0700 Subject: [PATCH 23/23] Fix ambiguity in spec. --- docs/specs/alert.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index d4994f8..23e0c54 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -204,7 +204,7 @@ The Session leaves `ALERT_RINGING` and returns to `NOTHING_TO_SHOW` when any of - the user marks the Session as TODO (`t` key or context menu) - new output arrives while the Session has attention (starts a new `MIGHT_BE_BUSY` cycle; without attention the alert stays ringing — see latch in transition rules) -All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by pressing `Enter` into that Session. Synthetic terminal reports (focus events, cursor-position responses) do not count as user input for clearing. +All attention-based dismissals (the first three above) set `todo = true` if it is not already set. This prevents phantom dismissals where the alert vanishes without a trace. Once the TODO is visible, the user can clear it explicitly from the pill/dialog or by typing `Enter` as passthrough input into that Session's shell (i.e., the keystroke is forwarded to the PTY). The command-mode `Enter` that *switches into* passthrough does not clear the TODO. Synthetic terminal reports (focus events, cursor-position responses) also do not count as user input for clearing. The Session leaves `ALERT_RINGING` and returns to `ALERT_DISABLED` when: @@ -234,7 +234,7 @@ TODO pill: - toggled in command mode with `t` (`false` -> `true` -> `false`) - shown when `todo === true` - auto-created on alert dismiss or attention-based alert clearing -- pressing `Enter` into a Session with TODO clears it +- typing `Enter` as passthrough input (forwarded to the Session's shell) clears the TODO; the command-mode `Enter` that switches *into* passthrough does not - clicking the TODO pill clears it - when TODO clears, the pill briefly morphs to a `✓` glyph in the success color (~500 ms) before unmounting — this marks the moment of completion so the pill never vanishes silently - no empty placeholder when off