From d4134f45cabe28af95c89852417e8718416854c5 Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Thu, 2 Apr 2026 12:52:28 -0400 Subject: [PATCH 1/6] feat: add horizontal overflow affordance cues --- src/components/TilingLayout.tsx | 356 ++++++++++++++++++-------------- src/styles.css | 87 ++++++++ 2 files changed, 291 insertions(+), 152 deletions(-) diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 64115cec..8a09ab22 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -1,4 +1,12 @@ -import { Show, createMemo, createEffect, onMount, onCleanup, ErrorBoundary } from 'solid-js'; +import { + Show, + createMemo, + createEffect, + createSignal, + onMount, + onCleanup, + ErrorBoundary, +} from 'solid-js'; import { store, pickAndAddProject, closeTerminal } from '../store/store'; import { closeTask } from '../store/tasks'; import { ResizablePanel, type PanelChild, type ResizablePanelHandle } from './ResizablePanel'; @@ -12,22 +20,65 @@ import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom'; export function TilingLayout() { let containerRef: HTMLDivElement | undefined; let panelHandle: ResizablePanelHandle | undefined; + const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); + const [hasOverflowRight, setHasOverflowRight] = createSignal(false); + + const updateOverflowAffordance = () => { + if (!containerRef) { + setHasOverflowLeft(false); + setHasOverflowRight(false); + return; + } + + const maxScrollLeft = containerRef.scrollWidth - containerRef.clientWidth; + const isOverflowing = maxScrollLeft > 1; + setHasOverflowLeft(isOverflowing && containerRef.scrollLeft > 1); + setHasOverflowRight(isOverflowing && containerRef.scrollLeft < maxScrollLeft - 1); + }; onMount(() => { if (!containerRef) return; const handleWheel = createCtrlShiftWheelResizeHandler((deltaPx) => { panelHandle?.resizeAll(deltaPx); }); + const handleScroll = () => updateOverflowAffordance(); + let resizeObserver: ResizeObserver | undefined; + const observeStrip = () => { + resizeObserver?.disconnect(); + if (!containerRef) return; + resizeObserver = new ResizeObserver(() => updateOverflowAffordance()); + resizeObserver.observe(containerRef); + const content = containerRef.firstElementChild; + if (content instanceof HTMLElement) resizeObserver.observe(content); + updateOverflowAffordance(); + }; + const mutationObserver = new MutationObserver(() => observeStrip()); + containerRef.addEventListener('wheel', handleWheel, { passive: false }); - onCleanup(() => containerRef?.removeEventListener('wheel', handleWheel)); + containerRef.addEventListener('scroll', handleScroll, { passive: true }); + mutationObserver.observe(containerRef, { childList: true }); + observeStrip(); + + onCleanup(() => { + containerRef?.removeEventListener('wheel', handleWheel); + containerRef?.removeEventListener('scroll', handleScroll); + mutationObserver.disconnect(); + resizeObserver?.disconnect(); + }); }); // Scroll the active task panel into view when selection changes createEffect(() => { const activeId = store.activeTaskId; - if (!activeId || !containerRef) return; + if (!containerRef) return; + if (!activeId) { + updateOverflowAffordance(); + return; + } + const el = containerRef.querySelector(`[data-task-id="${CSS.escape(activeId)}"]`); el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); + requestAnimationFrame(() => updateOverflowAffordance()); }); // Cache PanelChild objects by ID so sees stable references // and doesn't unmount/remount panels when taskOrder changes. @@ -169,181 +220,182 @@ export function TilingLayout() { }); return ( -
- 0} - fallback={ -
- -
- All tasks are collapsed -
-
- Click a task in the sidebar to restore it -
-
- } +
+
+ 0} + fallback={ +
0} + when={store.collapsedTaskOrder.length === 0} fallback={ - <> +
- + All tasks are collapsed
-
+
+ Click a task in the sidebar to restore it +
+
+ } + > + 0} + fallback={ + <>
- Link your first project to get started +
-
- A project is a local folder with your code +
+
+ Link your first project to get started +
+
+ A project is a local folder with your code +
-
- - - } - > - -
- No tasks yet + +
-
- Press{' '} - +
- {mod}+N - {' '} - to create a new task + No tasks yet +
+
+ Press{' '} + + {mod}+N + {' '} + to create a new task +
-
+
- -
- } - > - { - panelHandle = h; - }} - /> +
+ } + > + { + panelHandle = h; + }} + /> +
+
+ + +
+ + + +
); diff --git a/src/styles.css b/src/styles.css index 408a0f62..c026a78c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -386,6 +386,93 @@ textarea::placeholder { transform: translateY(1px); } +.tiling-layout-shell { + position: relative; + flex: 1; + min-width: 0; + height: 100%; +} + +.tiling-layout-strip { + height: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 2px 4px; +} + +.tiling-layout-scroll-affordance { + position: absolute; + top: 2px; + bottom: 2px; + width: 26px; + pointer-events: none; + z-index: 2; +} + +.tiling-layout-scroll-affordance::before, +.tiling-layout-scroll-affordance::after { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +.tiling-layout-scroll-affordance::before { + width: 1px; + height: 100%; + background: color-mix(in srgb, var(--border-focus) 68%, var(--accent) 12%); + opacity: 0.72; +} + +.tiling-layout-scroll-affordance::after { + width: 8px; + height: 8px; + border-top: 2px solid color-mix(in srgb, var(--accent-hover) 78%, white 22%); + opacity: 0.95; +} + +.tiling-layout-scroll-affordance-left { + left: 4px; + background: linear-gradient( + to right, + color-mix(in srgb, var(--border-focus) 16%, var(--accent) 6%), + color-mix(in srgb, var(--accent) 8%, transparent) 28%, + color-mix(in srgb, var(--accent) 4%, transparent) 52%, + transparent 100% + ); +} + +.tiling-layout-scroll-affordance-left::before { + left: 0; +} + +.tiling-layout-scroll-affordance-left::after { + left: 10px; + border-left: 2px solid color-mix(in srgb, var(--accent-hover) 78%, white 22%); + transform: translateY(-50%) rotate(-45deg); +} + +.tiling-layout-scroll-affordance-right { + right: 4px; + background: linear-gradient( + to left, + color-mix(in srgb, var(--border-focus) 16%, var(--accent) 6%), + color-mix(in srgb, var(--accent) 8%, transparent) 28%, + color-mix(in srgb, var(--accent) 4%, transparent) 52%, + transparent 100% + ); +} + +.tiling-layout-scroll-affordance-right::before { + right: 0; +} + +.tiling-layout-scroll-affordance-right::after { + right: 10px; + border-right: 2px solid color-mix(in srgb, var(--accent-hover) 78%, white 22%); + transform: translateY(-50%) rotate(45deg); +} + .btn-primary { position: relative; overflow: hidden; From 693a635ea971de011785aeed80bf78dba9e4874b Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Fri, 3 Apr 2026 00:22:18 -0400 Subject: [PATCH 2/6] feat: surface off-screen task attention --- src/components/Sidebar.tsx | 107 ++++++++++++++++++--- src/components/StatusDot.tsx | 31 ++++++- src/components/TilingLayout.tsx | 99 ++++++++++++++++++-- src/store/core.ts | 8 +- src/store/desktopNotifications.ts | 52 +++++++---- src/store/store.ts | 8 +- src/store/taskStatus.test.ts | 100 +++++++++++++++++++- src/store/taskStatus.ts | 148 +++++++++++++++++++++--------- src/store/types.ts | 3 + src/store/ui.ts | 10 +- src/styles.css | 52 +++++++++++ 11 files changed, 525 insertions(+), 93 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7d680270..94887b78 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,6 +9,8 @@ import { toggleSidebar, reorderTask, getTaskDotStatus, + getTaskAttentionState, + getTaskViewportVisibility, registerFocusFn, unregisterFocusFn, focusSidebar, @@ -39,6 +41,31 @@ const SIDEBAR_MIN_WIDTH = 160; const SIDEBAR_MAX_WIDTH = 480; const SIDEBAR_SIZE_KEY = 'sidebar:width'; +function getAttentionColor(attention: string): string | null { + if (attention === 'active') return theme.accent; + if (attention === 'needs_input') return theme.warning; + if (attention === 'error') return theme.error; + return null; +} + +function hasOffscreenAttention(taskId: string): boolean { + const visibility = getTaskViewportVisibility(taskId); + if (!visibility || visibility === 'visible') return false; + const attention = getTaskAttentionState(taskId); + return attention === 'active' || attention === 'needs_input' || attention === 'error'; +} + +function getOffscreenAttentionLabel(taskId: string): string | null { + if (!hasOffscreenAttention(taskId)) return null; + const visibility = getTaskViewportVisibility(taskId); + const attention = getTaskAttentionState(taskId); + const side = visibility === 'offscreen-left' ? 'left' : 'right'; + const prefix = visibility === 'offscreen-left' ? '←' : '→'; + if (attention === 'needs_input') return `${prefix} input (${side})`; + if (attention === 'error') return `${prefix} error (${side})`; + return null; +} + export function Sidebar() { const [confirmRemove, setConfirmRemove] = createSignal(null); const [editingProject, setEditingProject] = createSignal(null); @@ -725,6 +752,28 @@ function CurrentBranchBadge(props: { branchName: string }) { ); } +function OffscreenAttentionBadge(props: { taskId: string }) { + const label = () => getOffscreenAttentionLabel(props.taskId); + const color = () => getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.fgSubtle; + return ( + + {(text) => ( + + {text()} + + )} + + ); +} + function CollapsedTaskRow(props: { taskId: string }) { const task = () => store.tasks[props.taskId]; return ( @@ -746,29 +795,42 @@ function CollapsedTaskRow(props: { taskId: string }) { style={{ padding: '7px 10px', 'border-radius': '6px', - background: 'transparent', - color: theme.fgSubtle, + background: hasOffscreenAttention(props.taskId) + ? `color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 10%, transparent)` + : 'transparent', + color: hasOffscreenAttention(props.taskId) ? theme.fg : theme.fgSubtle, 'font-size': sf(12), 'font-weight': '400', cursor: 'pointer', 'white-space': 'nowrap', overflow: 'hidden', 'text-overflow': 'ellipsis', - opacity: '0.6', + opacity: hasOffscreenAttention(props.taskId) ? '1' : '0.6', display: 'flex', 'align-items': 'center', gap: '6px', border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` - : '1.5px solid transparent', + : hasOffscreenAttention(props.taskId) + ? `1.5px solid color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 38%, transparent)` + : '1.5px solid transparent', }} > - + - {t().name} + + {t().name} + +
)}
@@ -802,10 +864,18 @@ function TaskRow(props: TaskRowProps) { style={{ padding: '7px 10px', 'border-radius': '6px', - background: 'transparent', - color: store.activeTaskId === props.taskId ? theme.fg : theme.fgMuted, + background: hasOffscreenAttention(props.taskId) + ? `color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 10%, transparent)` + : 'transparent', + color: + store.activeTaskId === props.taskId || hasOffscreenAttention(props.taskId) + ? theme.fg + : theme.fgMuted, 'font-size': sf(12), - 'font-weight': store.activeTaskId === props.taskId ? '500' : '400', + 'font-weight': + store.activeTaskId === props.taskId || hasOffscreenAttention(props.taskId) + ? '500' + : '400', cursor: props.dragFromIndex() !== null ? 'grabbing' : 'pointer', 'white-space': 'nowrap', overflow: 'hidden', @@ -817,10 +887,20 @@ function TaskRow(props: TaskRowProps) { border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` - : '1.5px solid transparent', + : hasOffscreenAttention(props.taskId) + ? `1.5px solid color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 38%, transparent)` + : '1.5px solid transparent', }} > - + - {t().name} + + {t().name} + +
)} diff --git a/src/components/StatusDot.tsx b/src/components/StatusDot.tsx index a1295dcb..b0808113 100644 --- a/src/components/StatusDot.tsx +++ b/src/components/StatusDot.tsx @@ -1,23 +1,44 @@ -import type { TaskDotStatus } from '../store/taskStatus'; +import type { TaskAttentionState, TaskDotStatus } from '../store/taskStatus'; import { theme } from '../lib/theme'; const SIZES = { sm: 6, md: 8 } as const; -function getDotColor(status: TaskDotStatus): string { +function getDotColor(status: TaskDotStatus, attention?: TaskAttentionState): string { + if (attention === 'active') return theme.accent; + if (attention === 'needs_input') return theme.warning; + if (attention === 'error') return theme.error; + if (attention === 'ready') return theme.success; return { busy: theme.fgMuted, waiting: '#e5a800', ready: theme.success }[status]; } -export function StatusDot(props: { status: TaskDotStatus; size?: 'sm' | 'md' }) { +function getDotShadow(attention?: TaskAttentionState): string | undefined { + if (!attention || attention === 'idle' || attention === 'ready') return undefined; + const color = + attention === 'active' + ? theme.accent + : attention === 'needs_input' + ? theme.warning + : theme.error; + return `0 0 0 2px color-mix(in srgb, ${color} 22%, transparent)`; +} + +export function StatusDot(props: { + status: TaskDotStatus; + size?: 'sm' | 'md'; + attention?: TaskAttentionState; +}) { const px = () => SIZES[props.size ?? 'sm']; + const isPulsing = () => props.attention === 'active' || props.status === 'busy'; return ( diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 8a09ab22..1145ff70 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -7,7 +7,13 @@ import { onCleanup, ErrorBoundary, } from 'solid-js'; -import { store, pickAndAddProject, closeTerminal } from '../store/store'; +import { + store, + pickAndAddProject, + closeTerminal, + setTaskViewportVisibility, + getTaskAttentionState, +} from '../store/store'; import { closeTask } from '../store/tasks'; import { ResizablePanel, type PanelChild, type ResizablePanelHandle } from './ResizablePanel'; import { TaskPanel } from './TaskPanel'; @@ -17,16 +23,38 @@ import { theme } from '../lib/theme'; import { mod } from '../lib/platform'; import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom'; +const VIEWPORT_EPSILON_PX = 4; + export function TilingLayout() { let containerRef: HTMLDivElement | undefined; let panelHandle: ResizablePanelHandle | undefined; const [hasOverflowLeft, setHasOverflowLeft] = createSignal(false); const [hasOverflowRight, setHasOverflowRight] = createSignal(false); - const updateOverflowAffordance = () => { + const syncTaskViewportVisibility = ( + entries: Record, + ) => { + const current = store.taskViewportVisibility; + const currentKeys = Object.keys(current); + const nextKeys = Object.keys(entries); + if (currentKeys.length === nextKeys.length) { + let changed = false; + for (const key of nextKeys) { + if (current[key] !== entries[key]) { + changed = true; + break; + } + } + if (!changed) return; + } + setTaskViewportVisibility(entries); + }; + + const updateViewportState = () => { if (!containerRef) { setHasOverflowLeft(false); setHasOverflowRight(false); + syncTaskViewportVisibility({}); return; } @@ -34,29 +62,63 @@ export function TilingLayout() { const isOverflowing = maxScrollLeft > 1; setHasOverflowLeft(isOverflowing && containerRef.scrollLeft > 1); setHasOverflowRight(isOverflowing && containerRef.scrollLeft < maxScrollLeft - 1); + + const containerRect = containerRef.getBoundingClientRect(); + const nextVisibility: Record = {}; + const taskEls = containerRef.querySelectorAll('[data-task-id]'); + for (const el of taskEls) { + const taskId = el.dataset.taskId; + if (!taskId || !store.tasks[taskId]) continue; + const rect = el.getBoundingClientRect(); + if (rect.right <= containerRect.left + VIEWPORT_EPSILON_PX) { + nextVisibility[taskId] = 'offscreen-left'; + } else if (rect.left >= containerRect.right - VIEWPORT_EPSILON_PX) { + nextVisibility[taskId] = 'offscreen-right'; + } else { + nextVisibility[taskId] = 'visible'; + } + } + syncTaskViewportVisibility(nextVisibility); }; + const offscreenAttention = createMemo(() => { + let left = false; + let right = false; + for (const taskId of store.taskOrder) { + if (!store.tasks[taskId]) continue; + const visibility = store.taskViewportVisibility[taskId]; + if (!visibility || visibility === 'visible') continue; + const attention = getTaskAttentionState(taskId); + if (attention !== 'active' && attention !== 'needs_input' && attention !== 'error') continue; + if (visibility === 'offscreen-left') left = true; + if (visibility === 'offscreen-right') right = true; + if (left && right) break; + } + return { left, right }; + }); + onMount(() => { if (!containerRef) return; const handleWheel = createCtrlShiftWheelResizeHandler((deltaPx) => { panelHandle?.resizeAll(deltaPx); + requestAnimationFrame(() => updateViewportState()); }); - const handleScroll = () => updateOverflowAffordance(); + const handleScroll = () => updateViewportState(); let resizeObserver: ResizeObserver | undefined; const observeStrip = () => { resizeObserver?.disconnect(); if (!containerRef) return; - resizeObserver = new ResizeObserver(() => updateOverflowAffordance()); + resizeObserver = new ResizeObserver(() => updateViewportState()); resizeObserver.observe(containerRef); const content = containerRef.firstElementChild; if (content instanceof HTMLElement) resizeObserver.observe(content); - updateOverflowAffordance(); + updateViewportState(); }; const mutationObserver = new MutationObserver(() => observeStrip()); containerRef.addEventListener('wheel', handleWheel, { passive: false }); containerRef.addEventListener('scroll', handleScroll, { passive: true }); - mutationObserver.observe(containerRef, { childList: true }); + mutationObserver.observe(containerRef, { childList: true, subtree: true }); observeStrip(); onCleanup(() => { @@ -64,21 +126,28 @@ export function TilingLayout() { containerRef?.removeEventListener('scroll', handleScroll); mutationObserver.disconnect(); resizeObserver?.disconnect(); + setTaskViewportVisibility({}); }); }); + // Recompute viewport state when panel order/structure changes. + createEffect(() => { + void store.taskOrder.join('|'); + requestAnimationFrame(() => updateViewportState()); + }); + // Scroll the active task panel into view when selection changes createEffect(() => { const activeId = store.activeTaskId; if (!containerRef) return; if (!activeId) { - updateOverflowAffordance(); + updateViewportState(); return; } const el = containerRef.querySelector(`[data-task-id="${CSS.escape(activeId)}"]`); el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); - requestAnimationFrame(() => updateOverflowAffordance()); + requestAnimationFrame(() => updateViewportState()); }); // Cache PanelChild objects by ID so sees stable references // and doesn't unmount/remount panels when taskOrder changes. @@ -391,11 +460,21 @@ export function TilingLayout() {
-
+
-
+
); diff --git a/src/store/core.ts b/src/store/core.ts index c64663be..a1bdb41d 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -22,6 +22,7 @@ export const [store, setStore] = createStore({ panelSizes: {}, globalScale: 1, taskGitStatus: {}, + taskViewportVisibility: {}, focusedPanel: {}, sidebarFocused: false, sidebarFocusedProjectId: null, @@ -62,9 +63,14 @@ export const [store, setStore] = createStore({ showArena: false, }); +type CleanupPanelStore = Pick< + AppStore, + 'focusedPanel' | 'fontScales' | 'panelSizes' | 'taskOrder' | 'collapsedTaskOrder' +>; + /** Remove fontScales, panelSizes, focusedPanel, and taskOrder entries for a given ID. * Call inside a `produce` callback. Returns the index the item had in taskOrder. */ -export function cleanupPanelEntries(s: AppStore, id: string): number { +export function cleanupPanelEntries(s: CleanupPanelStore, id: string): number { const idx = s.taskOrder.indexOf(id); delete s.focusedPanel[id]; const prefix = id + ':'; diff --git a/src/store/desktopNotifications.ts b/src/store/desktopNotifications.ts index 77c1f1e6..14f5e2f1 100644 --- a/src/store/desktopNotifications.ts +++ b/src/store/desktopNotifications.ts @@ -1,19 +1,19 @@ import { createEffect, onCleanup, type Accessor } from 'solid-js'; import { store } from './store'; -import { getTaskDotStatus, type TaskDotStatus } from './taskStatus'; +import { getTaskAttentionState, type TaskAttentionState } from './taskStatus'; import { setActiveTask } from './navigation'; import { fireAndForget } from '../lib/ipc'; import { IPC } from '../../electron/ipc/channels'; const DEBOUNCE_MS = 3_000; -type NotificationType = 'ready' | 'waiting'; +type NotificationType = 'ready' | 'needs_input' | 'error'; export function startDesktopNotificationWatcher(windowFocused: Accessor): () => void { - const previousStatus = new Map(); + const previousAttention = new Map(); // Map keyed by taskId — naturally deduplicates and last transition wins. - // If a task goes busy→waiting→ready within the debounce window, only - // 'ready' is kept, avoiding contradictory notifications. + // If a task goes needs_input→error→ready within the debounce window, only + // the last meaningful notification is kept. let pending = new Map(); let debounceTimer: ReturnType | undefined; @@ -28,7 +28,8 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor pending = new Map(); const ready = items.filter(([, type]) => type === 'ready'); - const waiting = items.filter(([, type]) => type === 'waiting'); + const needsInput = items.filter(([, type]) => type === 'needs_input'); + const errored = items.filter(([, type]) => type === 'error'); if (ready.length > 0) { const taskIds = ready.map(([id]) => id); @@ -39,13 +40,22 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor fireAndForget(IPC.ShowNotification, { title: 'Task Ready', body, taskIds }); } - if (waiting.length > 0) { - const taskIds = waiting.map(([id]) => id); + if (needsInput.length > 0) { + const taskIds = needsInput.map(([id]) => id); const body = - waiting.length === 1 - ? `${taskName(taskIds[0])} needs your attention` - : `${waiting.length} tasks need your attention`; - fireAndForget(IPC.ShowNotification, { title: 'Task Waiting', body, taskIds }); + needsInput.length === 1 + ? `${taskName(taskIds[0])} needs your input` + : `${needsInput.length} tasks need your input`; + fireAndForget(IPC.ShowNotification, { title: 'Task Needs Input', body, taskIds }); + } + + if (errored.length > 0) { + const taskIds = errored.map(([id]) => id); + const body = + errored.length === 1 + ? `${taskName(taskIds[0])} encountered an error` + : `${errored.length} tasks encountered errors`; + fireAndForget(IPC.ShowNotification, { title: 'Task Error', body, taskIds }); } } @@ -61,16 +71,16 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor } } - // Track status transitions + // Track attention transitions createEffect(() => { const allTaskIds = [...store.taskOrder, ...store.collapsedTaskOrder]; const seen = new Set(); for (const taskId of allTaskIds) { seen.add(taskId); - const current = getTaskDotStatus(taskId); - const prev = previousStatus.get(taskId); - previousStatus.set(taskId, current); + const current = getTaskAttentionState(taskId); + const prev = previousAttention.get(taskId); + previousAttention.set(taskId, current); // Skip initial population if (prev === undefined) continue; @@ -78,14 +88,16 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor if (current === 'ready' && prev !== 'ready') { scheduleBatch('ready', taskId); - } else if (current === 'waiting' && prev === 'busy') { - scheduleBatch('waiting', taskId); + } else if (current === 'needs_input') { + scheduleBatch('needs_input', taskId); + } else if (current === 'error') { + scheduleBatch('error', taskId); } } // Clean up removed tasks - for (const taskId of previousStatus.keys()) { - if (!seen.has(taskId)) previousStatus.delete(taskId); + for (const taskId of previousAttention.keys()) { + if (!seen.has(taskId)) previousAttention.delete(taskId); } }); diff --git a/src/store/store.ts b/src/store/store.ts index 634f1c78..7d9e1106 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -79,7 +79,7 @@ export { sendActivePrompt, setSidebarFocusedProjectId, } from './focus'; -export type { PanelId, PendingAction } from './types'; +export type { PanelId, PendingAction, TaskViewportVisibility } from './types'; export { saveState, loadState } from './persistence'; export { getFontScale, @@ -90,6 +90,8 @@ export { resetGlobalScale, getPanelSize, setPanelSizes, + getTaskViewportVisibility, + setTaskViewportVisibility, toggleSidebar, toggleArena, setTerminalFont, @@ -106,6 +108,8 @@ export { } from './ui'; export { getTaskDotStatus, + getTaskAttentionState, + taskNeedsAttention, markAgentOutput, clearAgentActivity, getAgentOutputTail, @@ -121,7 +125,7 @@ export { stopTaskStatusPolling, rescheduleTaskStatusPolling, } from './taskStatus'; -export type { TaskDotStatus } from './taskStatus'; +export type { TaskAttentionState, TaskDotStatus } from './taskStatus'; export { showNotification, clearNotification } from './notification'; export { getCompletedTasksTodayCount, getMergedLineTotals } from './completion'; export { diff --git a/src/store/taskStatus.test.ts b/src/store/taskStatus.test.ts index c7664bdd..af76c467 100644 --- a/src/store/taskStatus.test.ts +++ b/src/store/taskStatus.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock the SolidJS store before importing the module under test. let mockAutoTrustFolders = false; let mockActiveTaskId: string | null = null; +let mockTasks: Record = {}; +let mockAgents: Record = {}; +let mockTaskGitStatus: Record = {}; vi.mock('./core', () => ({ store: new Proxy( {}, @@ -10,6 +13,9 @@ vi.mock('./core', () => ({ get(_target, prop) { if (prop === 'autoTrustFolders') return mockAutoTrustFolders; if (prop === 'activeTaskId') return mockActiveTaskId; + if (prop === 'tasks') return mockTasks; + if (prop === 'agents') return mockAgents; + if (prop === 'taskGitStatus') return mockTaskGitStatus; return undefined; }, }, @@ -47,19 +53,47 @@ import { looksLikeQuestion, isTrustQuestionAutoHandled, isAutoTrustSettling, + isAgentAskingQuestion, + getTaskAttentionState, + taskNeedsAttention, markAgentSpawned, markAgentOutput, clearAgentActivity, } from './taskStatus'; +function setMockTask(taskId: string, overrides: Record = {}): void { + mockTasks[taskId] = { + id: taskId, + name: taskId, + agentIds: [], + shellAgentIds: [], + ...overrides, + }; +} + +function setMockAgent(agentId: string, overrides: Record = {}): void { + mockAgents[agentId] = { + id: agentId, + status: 'running', + exitCode: null, + signal: null, + ...overrides, + }; +} + beforeEach(() => { vi.useFakeTimers(); mockAutoTrustFolders = false; mockActiveTaskId = 'task-1'; + mockTasks = {}; + mockAgents = {}; + mockTaskGitStatus = {}; }); afterEach(() => { - clearAgentActivity('agent-1'); + for (const agentId of ['agent-1', 'agent-2', 'agent-3', 'shell-1']) { + clearAgentActivity(agentId); + } vi.useRealTimers(); }); @@ -264,3 +298,67 @@ describe('isAutoTrustSettling', () => { expect(isAutoTrustSettling('agent-1')).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// task attention +// --------------------------------------------------------------------------- +describe('task attention state', () => { + it('returns ready for committed clean tasks without active attention', () => { + setMockTask('task-1', { agentIds: ['agent-1'] }); + setMockAgent('agent-1', { status: 'running' }); + mockTaskGitStatus['task-1'] = { + has_committed_changes: true, + has_uncommitted_changes: false, + }; + + expect(getTaskAttentionState('task-1')).toBe('ready'); + expect(taskNeedsAttention('task-1')).toBe(false); + }); + + it('returns active when a running task agent is currently producing output', () => { + setMockTask('task-1', { agentIds: ['agent-1'] }); + setMockAgent('agent-1', { status: 'running' }); + + markAgentSpawned('agent-1'); + + expect(getTaskAttentionState('task-1')).toBe('active'); + expect(taskNeedsAttention('task-1')).toBe(true); + }); + + it('returns error when a task agent exits non-zero', () => { + setMockTask('task-1', { agentIds: ['agent-1'] }); + setMockAgent('agent-1', { status: 'exited', exitCode: 1, signal: null }); + + expect(getTaskAttentionState('task-1')).toBe('error'); + expect(taskNeedsAttention('task-1')).toBe(true); + }); + + it('detects question state for background task agents', () => { + mockActiveTaskId = 'task-1'; + setMockTask('task-2', { agentIds: ['agent-2'] }); + setMockAgent('agent-2', { status: 'running' }); + + const question = new TextEncoder().encode('Continue? [Y/n]'); + markAgentOutput('agent-2', question, 'task-2'); + + expect(isAgentAskingQuestion('agent-2')).toBe(true); + expect(getTaskAttentionState('task-2')).toBe('needs_input'); + expect(taskNeedsAttention('task-2')).toBe(true); + }); + + it('preserves question state for throttled background prompt transitions', () => { + mockActiveTaskId = 'task-1'; + setMockTask('task-2', { agentIds: ['agent-2'] }); + setMockAgent('agent-2', { status: 'running' }); + + markAgentOutput('agent-2', new TextEncoder().encode('Building project...'), 'task-2'); + expect(getTaskAttentionState('task-2')).toBe('active'); + + vi.advanceTimersByTime(300); + markAgentOutput('agent-2', new TextEncoder().encode('Continue? [Y/n]'), 'task-2'); + + expect(isAgentAskingQuestion('agent-2')).toBe(true); + expect(getTaskAttentionState('task-2')).toBe('needs_input'); + expect(taskNeedsAttention('task-2')).toBe(true); + }); +}); diff --git a/src/store/taskStatus.ts b/src/store/taskStatus.ts index 66ca3a99..ba9d488e 100644 --- a/src/store/taskStatus.ts +++ b/src/store/taskStatus.ts @@ -36,6 +36,7 @@ interface AgentTrackingState { decoder: TextDecoder; lastAnalysisAt?: number; pendingAnalysis?: ReturnType; + pendingAnalysisDueAt?: number; } const agentStates = new Map(); @@ -89,6 +90,7 @@ function clearAutoTrustState(agentId: string): void { } export type TaskDotStatus = 'busy' | 'waiting' | 'ready'; +export type TaskAttentionState = 'idle' | 'active' | 'needs_input' | 'error' | 'ready'; // --- Prompt detection helpers --- @@ -311,7 +313,8 @@ const TAIL_BUFFER_MAX = 4096; const AUTO_TRUST_BG_THROTTLE_MS = 500; // Per-agent timestamp of last expensive analysis (question/prompt detection). -const ANALYSIS_INTERVAL_MS = 200; +const ACTIVE_ANALYSIS_INTERVAL_MS = 200; +const BACKGROUND_ANALYSIS_INTERVAL_MS = 1_200; function addToActive(agentId: string): void { setActiveAgents((s) => { @@ -341,6 +344,46 @@ function resetIdleTimer(agentId: string): void { }, IDLE_TIMEOUT_MS); } +function cancelPendingAnalysis(state: AgentTrackingState): void { + if (state.pendingAnalysis !== undefined) { + clearTimeout(state.pendingAnalysis); + state.pendingAnalysis = undefined; + } + state.pendingAnalysisDueAt = undefined; +} + +function runAgentAnalysis(agentId: string, now: number): void { + const state = getAgentState(agentId); + cancelPendingAnalysis(state); + state.lastAnalysisAt = now; + analyzeAgentOutput(agentId); +} + +function scheduleAgentAnalysis(agentId: string, intervalMs: number, now: number): void { + const state = getAgentState(agentId); + const lastAnalysis = state.lastAnalysisAt ?? 0; + if (now - lastAnalysis >= intervalMs) { + runAgentAnalysis(agentId, now); + return; + } + + const delay = intervalMs - (now - lastAnalysis); + const dueAt = now + delay; + if ( + state.pendingAnalysis !== undefined && + state.pendingAnalysisDueAt !== undefined && + state.pendingAnalysisDueAt <= dueAt + ) { + return; + } + + cancelPendingAnalysis(state); + state.pendingAnalysisDueAt = dueAt; + state.pendingAnalysis = setTimeout(() => { + runAgentAnalysis(agentId, Date.now()); + }, delay); +} + /** Mark an agent as active when it is first spawned. * Ensures agents start as "busy" before any PTY data arrives. */ export function markAgentSpawned(agentId: string): void { @@ -348,10 +391,7 @@ export function markAgentSpawned(agentId: string): void { state.outputTailBuffer = ''; clearAutoTrustState(agentId); state.lastAnalysisAt = undefined; - if (state.pendingAnalysis !== undefined) { - clearTimeout(state.pendingAnalysis); - state.pendingAnalysis = undefined; - } + cancelPendingAnalysis(state); state.lastDataAt = Date.now(); addToActive(agentId); resetIdleTimer(agentId); @@ -441,13 +481,14 @@ export function markAgentOutput(agentId: string, data: Uint8Array, taskId?: stri ? combined.slice(combined.length - TAIL_BUFFER_MAX) : combined; - // Expensive analysis (regex, ANSI strip) — only for active task's agents. + // Expensive analysis (regex, ANSI strip) now runs for all task agents, with a + // slower cadence for background tasks so off-screen attention still updates. const isActiveTask = !taskId || taskId === store.activeTaskId; // Auto-trust runs for ALL agents (including background tasks) so trust // dialogs are accepted immediately without needing to switch to the task. - // Active-task agents get this via analyzeAgentOutput; background agents - // are throttled to avoid ANSI strip + regex on every PTY chunk. + // Active-task agents also get full analysis; background agents keep a faster + // trust-only path plus a slower full analysis path for attention updates. if (store.autoTrustFolders && !isAutoTrustPending(agentId) && !isActiveTask) { const lastCheck = state.lastAutoTrustCheckAt ?? 0; if (now - lastCheck >= AUTO_TRUST_BG_THROTTLE_MS) { @@ -455,25 +496,12 @@ export function markAgentOutput(agentId: string, data: Uint8Array, taskId?: stri tryAutoTrust(agentId, state.outputTailBuffer); } } - if (isActiveTask) { - // Throttle expensive analysis (question/prompt/agent-ready detection). - const lastAnalysis = state.lastAnalysisAt ?? 0; - if (now - lastAnalysis >= ANALYSIS_INTERVAL_MS) { - state.lastAnalysisAt = now; - if (state.pendingAnalysis !== undefined) { - clearTimeout(state.pendingAnalysis); - state.pendingAnalysis = undefined; - } - analyzeAgentOutput(agentId); - } else if (state.pendingAnalysis === undefined) { - // Schedule a trailing analysis so the last chunk is always analyzed. - state.pendingAnalysis = setTimeout(() => { - state.pendingAnalysis = undefined; - state.lastAnalysisAt = Date.now(); - analyzeAgentOutput(agentId); - }, ANALYSIS_INTERVAL_MS); - } - } + + scheduleAgentAnalysis( + agentId, + isActiveTask ? ACTIVE_ANALYSIS_INTERVAL_MS : BACKGROUND_ANALYSIS_INTERVAL_MS, + now, + ); // Extract last non-empty line from recent output for prompt matching. // This check is UNTHROTTLED — it's cheap (single line, 6 patterns) and @@ -495,18 +523,14 @@ export function markAgentOutput(agentId: string, data: Uint8Array, taskId?: stri // Prompt detected — agent is idle. Remove from active set immediately. // Cancel any pending trailing analysis — question detection is irrelevant // once idle, and letting it fire could set a spurious question flag. - if (state.pendingAnalysis !== undefined) { - clearTimeout(state.pendingAnalysis); - state.pendingAnalysis = undefined; - } + cancelPendingAnalysis(state); - // Agent is at its prompt — clear stale question state so auto-send - // isn't blocked by old dialog text (e.g. trust dialogs that were already - // accepted). Only clear if the tail buffer is genuinely free of questions - // to avoid briefly hiding a real Y/n prompt that also matches looksLikePrompt. - if (!looksLikeQuestion(state.outputTailBuffer)) { - updateQuestionState(agentId, false); - } + // Preserve real question state even when the prompt arrives inside the + // analysis throttle window (common for background Y/n confirmations). + // Without this fast-path check, cancelling the pending analysis would drop + // the question signal and the task would incorrectly look idle. + const hasQuestion = looksLikeQuestion(state.outputTailBuffer); + updateQuestionState(agentId, hasQuestion); // The cancelled trailing analysis may have been the only chance to fire // the agentReady callback (used by PromptInput auto-send). Fire it here @@ -557,7 +581,7 @@ export function clearAgentActivity(agentId: string): void { if (state) { clearAutoTrustState(agentId); if (state.idleTimer !== undefined) clearTimeout(state.idleTimer); - if (state.pendingAnalysis !== undefined) clearTimeout(state.pendingAnalysis); + cancelPendingAnalysis(state); } agentStates.delete(agentId); agentReadyCallbacks.delete(agentId); @@ -567,6 +591,49 @@ export function clearAgentActivity(agentId: string): void { // --- Derived status --- +function isTaskReady(taskId: string): boolean { + const git = store.taskGitStatus[taskId]; + return Boolean(git?.has_committed_changes && !git?.has_uncommitted_changes); +} + +function hasTaskAgentError(taskId: string): boolean { + const task = store.tasks[taskId]; + if (!task) return false; + return task.agentIds.some((id) => { + const agent = store.agents[id]; + if (agent?.status !== 'exited') return false; + return agent.exitCode !== 0 || agent.signal !== null; + }); +} + +export function getTaskAttentionState(taskId: string): TaskAttentionState { + const task = store.tasks[taskId]; + if (!task) return 'idle'; + + if (hasTaskAgentError(taskId)) return 'error'; + + const active = activeAgents(); // reactive read + const hasQuestion = task.agentIds.some((id) => { + const agent = store.agents[id]; + return agent?.status === 'running' && isAgentAskingQuestion(id); + }); + if (hasQuestion) return 'needs_input'; + + const hasActive = task.agentIds.some((id) => { + const agent = store.agents[id]; + return agent?.status === 'running' && active.has(id); + }); + if (hasActive) return 'active'; + + if (isTaskReady(taskId)) return 'ready'; + return 'idle'; +} + +export function taskNeedsAttention(taskId: string): boolean { + const attention = getTaskAttentionState(taskId); + return attention === 'active' || attention === 'needs_input' || attention === 'error'; +} + export function getTaskDotStatus(taskId: string): TaskDotStatus { const task = store.tasks[taskId]; if (!task) return 'waiting'; @@ -577,8 +644,7 @@ export function getTaskDotStatus(taskId: string): TaskDotStatus { }); if (hasActive) return 'busy'; - const git = store.taskGitStatus[taskId]; - if (git?.has_committed_changes && !git?.has_uncommitted_changes) return 'ready'; + if (isTaskReady(taskId)) return 'ready'; return 'waiting'; } diff --git a/src/store/types.ts b/src/store/types.ts index fa40f663..bf674391 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -3,6 +3,8 @@ import type { LookPreset } from '../lib/look'; export type GitIsolationMode = 'worktree' | 'direct'; +export type TaskViewportVisibility = 'visible' | 'offscreen-left' | 'offscreen-right'; + export interface TerminalBookmark { id: string; command: string; @@ -168,6 +170,7 @@ export interface AppStore { panelSizes: Record; globalScale: number; taskGitStatus: Record; + taskViewportVisibility: Record; focusedPanel: Record; sidebarFocused: boolean; sidebarFocusedProjectId: string | null; diff --git a/src/store/ui.ts b/src/store/ui.ts index 8334f8c0..e28da97b 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -1,7 +1,7 @@ import { produce } from 'solid-js/store'; import { store, setStore } from './core'; import type { LookPreset } from '../lib/look'; -import type { PersistedWindowState } from './types'; +import type { PersistedWindowState, TaskViewportVisibility } from './types'; // --- Font Scale (per-panel) --- @@ -64,6 +64,14 @@ export function setPanelSizes(entries: Record): void { } } +export function getTaskViewportVisibility(taskId: string): TaskViewportVisibility | null { + return store.taskViewportVisibility[taskId] ?? null; +} + +export function setTaskViewportVisibility(entries: Record): void { + setStore('taskViewportVisibility', entries); +} + // --- Sidebar --- export function toggleSidebar(): void { diff --git a/src/styles.css b/src/styles.css index c026a78c..a4f1a251 100644 --- a/src/styles.css +++ b/src/styles.css @@ -372,6 +372,20 @@ textarea::placeholder { color: var(--fg) !important; } +.sidebar-offscreen-attention-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 1px 5px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + flex-shrink: 0; +} + .icon-btn { backdrop-filter: blur(6px); } @@ -473,6 +487,44 @@ textarea::placeholder { transform: translateY(-50%) rotate(45deg); } +.tiling-layout-scroll-affordance-attention::before { + background: color-mix(in srgb, var(--warning) 72%, var(--accent) 14%); + opacity: 0.95; +} + +.tiling-layout-scroll-affordance-attention::after { + border-top-color: color-mix(in srgb, var(--warning) 82%, white 18%); + animation: statusPulse 1.4s ease-in-out infinite; +} + +.tiling-layout-scroll-affordance-attention.tiling-layout-scroll-affordance-left { + background: linear-gradient( + to right, + color-mix(in srgb, var(--warning) 18%, transparent), + color-mix(in srgb, var(--warning) 10%, transparent) 28%, + color-mix(in srgb, var(--warning) 5%, transparent) 52%, + transparent 100% + ); +} + +.tiling-layout-scroll-affordance-attention.tiling-layout-scroll-affordance-right { + background: linear-gradient( + to left, + color-mix(in srgb, var(--warning) 18%, transparent), + color-mix(in srgb, var(--warning) 10%, transparent) 28%, + color-mix(in srgb, var(--warning) 5%, transparent) 52%, + transparent 100% + ); +} + +.tiling-layout-scroll-affordance-attention.tiling-layout-scroll-affordance-left::after { + border-left-color: color-mix(in srgb, var(--warning) 82%, white 18%); +} + +.tiling-layout-scroll-affordance-attention.tiling-layout-scroll-affordance-right::after { + border-right-color: color-mix(in srgb, var(--warning) 82%, white 18%); +} + .btn-primary { position: relative; overflow: hidden; From e304fba38d291a01288c66fc95b19a98cd25e709 Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Fri, 3 Apr 2026 00:22:28 -0400 Subject: [PATCH 3/6] feat: increase terminal scrollback --- src/components/TerminalView.tsx | 6 +++++- src/remote/AgentDetail.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index 396bcf2c..1d85d6a8 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -67,6 +67,10 @@ interface TerminalViewProps { // expensive full-chunk decoding during large terminal bursts. const STATUS_ANALYSIS_MAX_BYTES = 8 * 1024; +// Keep substantially more terminal history available for agent review/debugging +// without turning xterm into a memory landfill. +const TERMINAL_SCROLLBACK_LINES = 10_000; + export function TerminalView(props: TerminalViewProps) { let containerRef!: HTMLDivElement; let term: Terminal | undefined; @@ -85,7 +89,7 @@ export function TerminalView(props: TerminalViewProps) { fontFamily: getTerminalFontFamily(store.terminalFont), theme: getTerminalTheme(store.themePreset), allowProposedApi: true, - scrollback: 3000, + scrollback: TERMINAL_SCROLLBACK_LINES, }); fitAddon = new FitAddon(); diff --git a/src/remote/AgentDetail.tsx b/src/remote/AgentDetail.tsx index b6239331..96d06563 100644 --- a/src/remote/AgentDetail.tsx +++ b/src/remote/AgentDetail.tsx @@ -95,7 +95,7 @@ export function AgentDetail(props: AgentDetailProps) { fontSize: 10, fontFamily: "'JetBrains Mono', 'Courier New', monospace", theme: { background: '#0b0f14' }, - scrollback: 5000, + scrollback: 10000, cursorBlink: false, disableStdin: true, convertEol: false, From 4891572e79389a5088c8f0e404b294e62faaef85 Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Fri, 3 Apr 2026 00:29:53 -0400 Subject: [PATCH 4/6] chore: simplify sidebar attention cleanup --- src/components/Sidebar.tsx | 82 ++++++++++++++++++--------------- src/components/TerminalView.tsx | 5 +- src/components/TilingLayout.tsx | 5 +- src/lib/terminalConstants.ts | 3 ++ src/remote/AgentDetail.tsx | 3 +- 5 files changed, 53 insertions(+), 45 deletions(-) create mode 100644 src/lib/terminalConstants.ts diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 94887b78..df6af7a6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ import { reorderTask, getTaskDotStatus, getTaskAttentionState, + taskNeedsAttention, getTaskViewportVisibility, registerFocusFn, unregisterFocusFn, @@ -24,6 +25,7 @@ import { isProjectMissing, } from '../store/store'; import type { Project } from '../store/types'; +import type { TaskAttentionState } from '../store/store'; import { computeGroupedTasks } from '../store/sidebar-order'; import { ConnectPhoneModal } from './ConnectPhoneModal'; import { ConfirmDialog } from './ConfirmDialog'; @@ -41,29 +43,40 @@ const SIDEBAR_MIN_WIDTH = 160; const SIDEBAR_MAX_WIDTH = 480; const SIDEBAR_SIZE_KEY = 'sidebar:width'; -function getAttentionColor(attention: string): string | null { +function getAttentionColor(attention: TaskAttentionState): string | null { if (attention === 'active') return theme.accent; if (attention === 'needs_input') return theme.warning; if (attention === 'error') return theme.error; return null; } -function hasOffscreenAttention(taskId: string): boolean { - const visibility = getTaskViewportVisibility(taskId); - if (!visibility || visibility === 'visible') return false; - const attention = getTaskAttentionState(taskId); - return attention === 'active' || attention === 'needs_input' || attention === 'error'; +interface OffscreenAttentionInfo { + attention: TaskAttentionState; + color: string; + label: string | null; } -function getOffscreenAttentionLabel(taskId: string): string | null { - if (!hasOffscreenAttention(taskId)) return null; +function getOffscreenAttentionInfo(taskId: string): OffscreenAttentionInfo | null { const visibility = getTaskViewportVisibility(taskId); + if (!visibility || visibility === 'visible' || !taskNeedsAttention(taskId)) return null; const attention = getTaskAttentionState(taskId); + const color = getAttentionColor(attention) ?? theme.accent; const side = visibility === 'offscreen-left' ? 'left' : 'right'; const prefix = visibility === 'offscreen-left' ? '←' : '→'; - if (attention === 'needs_input') return `${prefix} input (${side})`; - if (attention === 'error') return `${prefix} error (${side})`; - return null; + let label: string | null = null; + if (attention === 'needs_input') label = `${prefix} input (${side})`; + if (attention === 'error') label = `${prefix} error (${side})`; + return { attention, color, label }; +} + +function createOffscreenAttentionState(taskId: () => string) { + const info = createMemo(() => getOffscreenAttentionInfo(taskId())); + return { + hasAttention: () => info() !== null, + attention: () => info()?.attention, + color: () => info()?.color ?? theme.accent, + label: () => info()?.label ?? null, + }; } export function Sidebar() { @@ -753,18 +766,17 @@ function CurrentBranchBadge(props: { branchName: string }) { } function OffscreenAttentionBadge(props: { taskId: string }) { - const label = () => getOffscreenAttentionLabel(props.taskId); - const color = () => getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.fgSubtle; + const offscreenAttention = createOffscreenAttentionState(() => props.taskId); return ( - + {(text) => ( {text()} @@ -776,6 +788,7 @@ function OffscreenAttentionBadge(props: { taskId: string }) { function CollapsedTaskRow(props: { taskId: string }) { const task = () => store.tasks[props.taskId]; + const offscreenAttention = createOffscreenAttentionState(() => props.taskId); return ( {(t) => ( @@ -795,34 +808,32 @@ function CollapsedTaskRow(props: { taskId: string }) { style={{ padding: '7px 10px', 'border-radius': '6px', - background: hasOffscreenAttention(props.taskId) - ? `color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 10%, transparent)` + background: offscreenAttention.hasAttention() + ? `color-mix(in srgb, ${offscreenAttention.color()} 10%, transparent)` : 'transparent', - color: hasOffscreenAttention(props.taskId) ? theme.fg : theme.fgSubtle, + color: offscreenAttention.hasAttention() ? theme.fg : theme.fgSubtle, 'font-size': sf(12), 'font-weight': '400', cursor: 'pointer', 'white-space': 'nowrap', overflow: 'hidden', 'text-overflow': 'ellipsis', - opacity: hasOffscreenAttention(props.taskId) ? '1' : '0.6', + opacity: offscreenAttention.hasAttention() ? '1' : '0.6', display: 'flex', 'align-items': 'center', gap: '6px', border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` - : hasOffscreenAttention(props.taskId) - ? `1.5px solid color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 38%, transparent)` + : offscreenAttention.hasAttention() + ? `1.5px solid color-mix(in srgb, ${offscreenAttention.color()} 38%, transparent)` : '1.5px solid transparent', }} > @@ -847,6 +858,7 @@ interface TaskRowProps { function TaskRow(props: TaskRowProps) { const task = () => store.tasks[props.taskId]; const idx = () => props.globalIndex(props.taskId); + const offscreenAttention = createOffscreenAttentionState(() => props.taskId); return ( {(t) => ( @@ -864,16 +876,16 @@ function TaskRow(props: TaskRowProps) { style={{ padding: '7px 10px', 'border-radius': '6px', - background: hasOffscreenAttention(props.taskId) - ? `color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 10%, transparent)` + background: offscreenAttention.hasAttention() + ? `color-mix(in srgb, ${offscreenAttention.color()} 10%, transparent)` : 'transparent', color: - store.activeTaskId === props.taskId || hasOffscreenAttention(props.taskId) + store.activeTaskId === props.taskId || offscreenAttention.hasAttention() ? theme.fg : theme.fgMuted, 'font-size': sf(12), 'font-weight': - store.activeTaskId === props.taskId || hasOffscreenAttention(props.taskId) + store.activeTaskId === props.taskId || offscreenAttention.hasAttention() ? '500' : '400', cursor: props.dragFromIndex() !== null ? 'grabbing' : 'pointer', @@ -887,19 +899,15 @@ function TaskRow(props: TaskRowProps) { border: store.sidebarFocused && store.sidebarFocusedTaskId === props.taskId ? `1.5px solid var(--border-focus)` - : hasOffscreenAttention(props.taskId) - ? `1.5px solid color-mix(in srgb, ${getAttentionColor(getTaskAttentionState(props.taskId)) ?? theme.accent} 38%, transparent)` + : offscreenAttention.hasAttention() + ? `1.5px solid color-mix(in srgb, ${offscreenAttention.color()} 38%, transparent)` : '1.5px solid transparent', }} > Date: Thu, 9 Apr 2026 08:21:03 -0400 Subject: [PATCH 5/6] perf: address review feedback on scroll and attention performance - Coalesce scroll handler with requestAnimationFrame to avoid synchronous layout reads (getBoundingClientRect) on every scroll event - Drop subtree:true from MutationObserver so internal panel renders don't trigger expensive disconnect/reconnect cycles (panel add/remove is already covered by the taskOrder createEffect + ResizeObserver) - Compute getTaskAttentionState once in getOffscreenAttentionInfo instead of calling both taskNeedsAttention and getTaskAttentionState (taskNeedsAttention internally calls getTaskAttentionState, so the attention state was derived twice per invocation) --- src/components/Sidebar.tsx | 4 ++-- src/components/TilingLayout.tsx | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index df6af7a6..493563b8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,7 +10,6 @@ import { reorderTask, getTaskDotStatus, getTaskAttentionState, - taskNeedsAttention, getTaskViewportVisibility, registerFocusFn, unregisterFocusFn, @@ -58,8 +57,9 @@ interface OffscreenAttentionInfo { function getOffscreenAttentionInfo(taskId: string): OffscreenAttentionInfo | null { const visibility = getTaskViewportVisibility(taskId); - if (!visibility || visibility === 'visible' || !taskNeedsAttention(taskId)) return null; + if (!visibility || visibility === 'visible') return null; const attention = getTaskAttentionState(taskId); + if (attention === 'idle' || attention === 'ready') return null; const color = getAttentionColor(attention) ?? theme.accent; const side = visibility === 'offscreen-left' ? 'left' : 'right'; const prefix = visibility === 'offscreen-left' ? '←' : '→'; diff --git a/src/components/TilingLayout.tsx b/src/components/TilingLayout.tsx index 4192232d..9eab1d70 100644 --- a/src/components/TilingLayout.tsx +++ b/src/components/TilingLayout.tsx @@ -102,7 +102,15 @@ export function TilingLayout() { panelHandle?.resizeAll(deltaPx); requestAnimationFrame(() => updateViewportState()); }); - const handleScroll = () => updateViewportState(); + let scrollRafPending = false; + const handleScroll = () => { + if (scrollRafPending) return; + scrollRafPending = true; + requestAnimationFrame(() => { + scrollRafPending = false; + updateViewportState(); + }); + }; let resizeObserver: ResizeObserver | undefined; const observeStrip = () => { resizeObserver?.disconnect(); @@ -117,7 +125,7 @@ export function TilingLayout() { containerRef.addEventListener('wheel', handleWheel, { passive: false }); containerRef.addEventListener('scroll', handleScroll, { passive: true }); - mutationObserver.observe(containerRef, { childList: true, subtree: true }); + mutationObserver.observe(containerRef, { childList: true }); observeStrip(); onCleanup(() => { From 935de8dd12f9075b302b93780f85e98c12e0dd6b Mon Sep 17 00:00:00 2001 From: Ahmad Ragab Date: Tue, 14 Apr 2026 00:30:51 -0400 Subject: [PATCH 6/6] fix: guard needs_input/error notifications against state re-fires Mirror the ready-branch transition guard so needs_input/error only fire on state entry, not on every reactive re-run while the task stays in that state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/store/desktopNotifications.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/store/desktopNotifications.ts b/src/store/desktopNotifications.ts index 14f5e2f1..285f36ac 100644 --- a/src/store/desktopNotifications.ts +++ b/src/store/desktopNotifications.ts @@ -88,9 +88,9 @@ export function startDesktopNotificationWatcher(windowFocused: Accessor if (current === 'ready' && prev !== 'ready') { scheduleBatch('ready', taskId); - } else if (current === 'needs_input') { + } else if (current === 'needs_input' && prev !== 'needs_input') { scheduleBatch('needs_input', taskId); - } else if (current === 'error') { + } else if (current === 'error' && prev !== 'error') { scheduleBatch('error', taskId); } }