diff --git a/README.md b/README.md
index 2c414b19..2a26189e 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,7 @@ Each of these is a small thing. Together they compound fast.
| **Terminal Popover** | Highlight any text in a terminal and an intelligent popover offers the right action: copy, open in browser, or show in explorer. | |
| **Built-in Browser** | Preview any URL in a tab next to your terminals so every pane can see its own running dev server without alt-tabbing. | |
| **Resource Manager** | Built-in CPU and memory monitor broken down per pane and per process, so you can catch a runaway agent before it eats your laptop. | |
-| **Status Cues** | Project dots show where work is happening, pane names breathe while active, and a dashed underline marks panes that finished while you were looking elsewhere. | |
+| **Status Cues** | Every AI pane reports its state at a glance — a red dot when an agent is blocked waiting on your approval, an amber pulse while it works, and a "done" cue when it finishes while you're looking elsewhere. The same rollup colors the project dots and pane tabs, so a whole screen of parallel agents reads in one glance. | |
| **Jump + Refresh** | Jump to top, jump to bottom, or hard-refresh any terminal from the toolbar to unstick a frozen state in one click. | |
| **Auto Secrets Copy** | Every pane automatically mirrors `.env` files and secrets from your root project so your worktree is runnable the moment it's created. | |
| **Isolated Ports** | Each pane runs on its own port range automatically, so you can spin up five dev servers in parallel without a single conflict. | |
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index afbdf5f9..792ca095 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -27,6 +27,7 @@ import { DiscordPopup } from './components/DiscordPopup';
import { ResumeSessionsDialog } from './components/ResumeSessionsDialog';
import { useErrorStore } from './stores/errorStore';
import { useSessionStore } from './stores/sessionStore';
+import { rollupSessionAgentState } from './utils/agentStatus';
import { useConfigStore } from './stores/configStore';
import { usePanelStore } from './stores/panelStore';
import { API } from './utils/api';
@@ -175,6 +176,29 @@ function App() {
return () => unsubscribe?.();
}, []);
+ // Global agent status listener (blocked / working / done) for terminal panels.
+ useEffect(() => {
+ const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => {
+ const store = usePanelStore.getState();
+ const prevState = store.agentStatus[data.panelId];
+ store.setAgentStatus(data.panelId, data.sessionId, data.state);
+
+ // A background agent finishing should read as done (blue) right away —
+ // mark unseen completion from the unified working -> idle transition
+ // instead of waiting for the legacy 30s activity flip.
+ if (prevState === 'working' && data.state === 'idle') {
+ const next = usePanelStore.getState();
+ const activeSessionId = useSessionStore.getState().activeSessionId;
+ const sessionSettled =
+ rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle';
+ if (sessionSettled && activeSessionId !== data.sessionId) {
+ next.markUnviewedCompletedActivity(data.sessionId);
+ }
+ }
+ });
+ return () => unsubscribe?.();
+ }, []);
+
useEffect(() => {
const clearViewedCompletedActivity = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx
index de3cb2d8..9d359d8f 100644
--- a/frontend/src/components/ProjectSessionList.tsx
+++ b/frontend/src/components/ProjectSessionList.tsx
@@ -8,7 +8,12 @@ import { CreateSessionDialog } from './CreateSessionDialog';
import { AddProjectDialog } from './AddProjectDialog';
import { Dropdown } from './ui/Dropdown';
import { Tooltip } from './ui/Tooltip';
+import { StatusAccentBar } from './ui/StatusAccentBar';
+import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot';
import type { DropdownItem } from './ui/Dropdown';
+import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus';
+import { rollupAgentDisplayStatus, rollupSessionAgentState, toAgentDisplayStatus } from '../utils/agentStatus';
+import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat';
import { API } from '../utils/api';
import { cn } from '../utils/cn';
import type { Session, GitStatus } from '../types/session';
@@ -69,6 +74,7 @@ export function ProjectSessionList({
const activeView = useNavigationStore(s => s.activeView);
const navigateToSessions = useNavigationStore(s => s.navigateToSessions);
const navigateToPaneChat = useNavigationStore(s => s.navigateToPaneChat);
+ const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
const navigateToProject = useNavigationStore(s => s.navigateToProject);
const setSidebarNavigationScope = useNavigationStore(s => s.setSidebarNavigationScope);
// Expansion state lives in the navigation store so the always-mounted
@@ -76,8 +82,9 @@ export function ProjectSessionList({
const expandedProjects = useNavigationStore(s => s.expandedProjects);
const toggleProjectExpanded = useNavigationStore(s => s.toggleProjectExpanded);
const expandProject = useNavigationStore(s => s.expandProject);
- const panelPanels = usePanelStore(s => s.panels);
- const panelActivityStatus = usePanelStore(s => s.activityStatus);
+ const agentStatusByPanel = usePanelStore(s => s.agentStatus);
+ const agentPanelSessions = usePanelStore(s => s.agentStatusSession);
+ const unviewedBySession = usePanelStore(s => s.unviewedCompletedActivity);
// Load projects
const loadProjects = useCallback(async () => {
@@ -322,6 +329,7 @@ export function ProjectSessionList({
>
Pane Chat
+
{showRemoteDesktopLink && onRemoteDesktopClick && (
@@ -404,10 +412,15 @@ export function ProjectSessionList({
const isExpanded = expandedProjects.has(project.id);
const projectSessions = sessionsByProject.get(project.id) || [];
- const projectActivity = projectSessions.some(s => {
- const sessionPanels = panelPanels[s.id] || [];
- return sessionPanels.some(p => panelActivityStatus[p.id] === 'active');
- }) ? 'active' : 'idle';
+ // Display status rolled up across the project's sessions
+ // (blocked > working > done > idle), so unseen completion shows blue
+ // at the project level too.
+ const projectAgentState = rollupAgentDisplayStatus(
+ projectSessions.map(s => toAgentDisplayStatus(
+ rollupSessionAgentState(agentStatusByPanel, agentPanelSessions, s.id),
+ Boolean(unviewedBySession[s.id]),
+ ))
+ );
const projectMenuItems: DropdownItem[] = [
{
@@ -461,12 +474,11 @@ export function ProjectSessionList({
(session.gitStatus);
const initialGitStatusRequestRef = useRef(null);
- const sessionActivity = usePanelStore(s => {
- const sessionPanels = s.panels[session.id] || [];
- return sessionPanels.some(p => s.activityStatus[p.id] === 'active') ? 'active' : 'idle';
- });
const hasUnviewedCompletedActivity = usePanelStore(s => Boolean(s.unviewedCompletedActivity[session.id]));
+ const agentDisplayStatus = useSessionAgentDisplayStatus(session.id);
// Queue the initial refresh even when cached status is available, so cached
// PR state is corrected by the background git/PR refresh path.
@@ -721,19 +730,19 @@ function SessionRow({
const adds = (gs?.commitAdditions ?? 0) + (gs?.additions ?? 0);
const dels = (gs?.commitDeletions ?? 0) + (gs?.deletions ?? 0);
const hasDiff = adds > 0 || dels > 0;
- const showActivity = sessionActivity === 'active';
+ const showActivity = agentDisplayStatus === 'working';
const accessibleName = displayName || gs?.prTitle || session.name || 'Untitled';
return (
+ {/* Always-present left accent bar reflecting the agent status. */}
+ }
side="right"
diff --git a/frontend/src/components/SessionStatusBadge.tsx b/frontend/src/components/SessionStatusBadge.tsx
new file mode 100644
index 00000000..5599737f
--- /dev/null
+++ b/frontend/src/components/SessionStatusBadge.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus';
+import { AgentActivityDot, AgentStatusDot } from './ui/AgentStatusDot';
+
+interface SessionStatusBadgeProps {
+ sessionId: string;
+ size?: 'sm' | 'md';
+}
+
+/**
+ * Session dot for the sidebar / session list: the herd-of-agents status
+ * (blocked / working / done / idle) rolled up over the session's terminal
+ * panels. `unknown` means no terminal panel has reported yet (or the session
+ * has none); an inert placeholder holds the dot's footprint.
+ */
+export const SessionStatusBadge: React.FC = ({ sessionId, size = 'md' }) => {
+ const displayStatus = useSessionAgentDisplayStatus(sessionId);
+
+ if (displayStatus === 'unknown') {
+ return ;
+ }
+
+ return ;
+};
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index a5bb50c1..352a3249 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -16,7 +16,10 @@ import { Dropdown } from './ui/Dropdown';
import type { DropdownItem } from './ui/Dropdown';
import { useSessionStore } from '../stores/sessionStore';
import { useNavigationStore } from '../stores/navigationStore';
-import { usePanelStore } from '../stores/panelStore';
+import { SessionStatusBadge } from './SessionStatusBadge';
+import { AgentStatusDot } from './ui/AgentStatusDot';
+import { useSessionAgentDisplayStatus } from '../hooks/useAgentStatus';
+import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat';
import { API } from '../utils/api';
import type { Project } from '../types/project';
import { useSessionNavigationHotkeys } from '../hooks/useSessionNavigationHotkeys';
@@ -326,8 +329,6 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
const sessions = useSessionStore((state) => state.sessions);
const activeSessionId = useSessionStore((state) => state.activeSessionId);
const setActiveSession = useSessionStore((state) => state.setActiveSession);
- const activityStatus = usePanelStore(s => s.activityStatus);
- const panelsBySession = usePanelStore(s => s.panels);
const remoteFooterStatus = useMemo(
() => getRemoteFooterStatus(remoteConnectionState, remoteHostState),
[remoteConnectionState, remoteHostState],
@@ -352,6 +353,7 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
const activeView = useNavigationStore((state) => state.activeView);
const navigateToProject = useNavigationStore((state) => state.navigateToProject);
const navigateToPaneChat = useNavigationStore((state) => state.navigateToPaneChat);
+ const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
const setSidebarNavigationScope = useNavigationStore((state) => state.setSidebarNavigationScope);
useSessionNavigationHotkeys({ projects, sessionSortAscending });
@@ -421,13 +423,14 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
navigateToPaneChat();
}}
aria-label="Pane Chat"
- className={`w-8 h-8 rounded flex items-center justify-center transition-colors ${
+ className={`relative w-8 h-8 rounded flex items-center justify-center transition-colors ${
activeView === 'pane-chat'
? 'bg-interactive/20 text-interactive ring-1 ring-interactive/50'
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary'
}`}
>
+
{showRemoteDesktopLink && (
@@ -464,10 +467,6 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
{/* Session status badges — grouped under this project */}
{projectSessions.map((session) => {
const isActive = session.id === activeSessionId;
- const sessionPanels = panelsBySession[session.id] || [];
- const isSessionActive = sessionPanels.some(p => activityStatus[p.id] === 'active');
- const statusColor = isSessionActive ? 'bg-status-warning opacity-100 duration-150' : 'bg-text-muted/20 opacity-40 duration-[3s]';
- const isAnimated = isSessionActive;
return (
} side="right">
);
diff --git a/frontend/src/components/panels/PanelTabStatusDot.tsx b/frontend/src/components/panels/PanelTabStatusDot.tsx
new file mode 100644
index 00000000..3894d3d0
--- /dev/null
+++ b/frontend/src/components/panels/PanelTabStatusDot.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { usePanelAgentDisplayStatus } from '../../hooks/useAgentStatus';
+import { AgentActivityDot, AgentStatusDot } from '../ui/AgentStatusDot';
+
+interface PanelTabStatusDotProps {
+ panelId: string;
+ sessionId: string;
+}
+
+/**
+ * Per-tab status dot: the agent status (blocked / working / done / idle) for
+ * any terminal panel — bespoke detection for known agents, the generic tier
+ * otherwise. `unknown` only occurs before the first status emission, so it
+ * renders an inert placeholder that just holds the dot's footprint.
+ */
+export const PanelTabStatusDot: React.FC = ({ panelId, sessionId }) => {
+ const displayStatus = usePanelAgentDisplayStatus(panelId, sessionId);
+
+ if (displayStatus === 'unknown') {
+ return ;
+ }
+
+ return ;
+};
diff --git a/frontend/src/components/panels/PanelTabStrip.tsx b/frontend/src/components/panels/PanelTabStrip.tsx
index 7db7d681..0166c2ac 100644
--- a/frontend/src/components/panels/PanelTabStrip.tsx
+++ b/frontend/src/components/panels/PanelTabStrip.tsx
@@ -15,6 +15,7 @@ import { Tooltip } from '../ui/Tooltip';
import { Kbd } from '../ui/Kbd';
import { usePanelStore } from '../../stores/panelStore';
import { ClaudeIcon, OpenAIIcon } from '../ui/BrandIcons';
+import { PanelTabStatusDot } from './PanelTabStatusDot';
import type { PanelTabPresentationResolver } from '../../types/panelComponents';
// ---------------------------------------------------------------------------
@@ -130,7 +131,6 @@ export const PanelTabStrip: React.FC = React.memo(({
const [stripDropIndex, setStripDropIndex] = useState(null);
const [rovingPanelId, setRovingPanelId] = useState(activePanelId ?? panels[0]?.id ?? null);
const previousActivePanelIdRef = useRef(activePanelId);
- const getPanelActivityStatus = usePanelStore(s => s.getPanelActivityStatus);
useEffect(() => {
const activePanelChanged = previousActivePanelIdRef.current !== activePanelId;
@@ -445,17 +445,15 @@ export const PanelTabStrip: React.FC = React.memo(({
compact ? "gap-1" : "gap-2",
)}>
{panel.type === 'terminal' && (
-
+
)}
- {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5' : 'w-4 h-4')}
+ {getPanelIcon(panel.type, panel, compact ? 'w-3.5 h-3.5 flex-shrink-0' : 'w-4 h-4 flex-shrink-0')}
{/* Bold marks the primary group's strip: the group the top
- bar's tool tabs and the un-split gesture belong to */}
- {displayTitle}
+ bar's tool tabs and the un-split gesture belong to.
+ The title is the only shrinkable element in the tab, so
+ squeezed tabs truncate the text instead of crushing the
+ status dot / icon or spilling under the close button. */}
+ {displayTitle}
)}
diff --git a/frontend/src/components/ui/AgentStatusDot.tsx b/frontend/src/components/ui/AgentStatusDot.tsx
new file mode 100644
index 00000000..bd9cff3e
--- /dev/null
+++ b/frontend/src/components/ui/AgentStatusDot.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { cn } from '../../utils/cn';
+import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus';
+import { agentStatusVisual } from './agentStatusVisual';
+
+interface AgentStatusDotProps {
+ status: AgentDisplayStatus;
+ size?: 'sm' | 'md';
+ className?: string;
+}
+
+const sizeClasses = {
+ sm: 'w-2 h-2',
+ md: 'w-2.5 h-2.5',
+};
+
+const spinnerSizeClasses = {
+ sm: 'w-3 h-3 border-2',
+ md: 'w-3.5 h-3.5 border-2',
+};
+
+// Both variants render inside a container sized to the larger spinner, so the
+// footprint stays constant and status flips cause no layout shift.
+const containerSizeClasses = {
+ sm: 'w-3 h-3',
+ md: 'w-3.5 h-3.5',
+};
+
+/**
+ * At-a-glance agent status indicator. Working renders as a blue spinner; blocked
+ * (red), done (blue), and idle (green) render as a dot — the "dot + spinner"
+ * variation. Renders nothing for `unknown` so non-agent panels show no badge.
+ */
+interface AgentActivityDotProps {
+ active: boolean;
+ size?: 'sm' | 'md';
+ /** Color of the active dot; idle is always the muted dot. */
+ activeColorClass?: string;
+ /** Pulse while active. */
+ pulse?: boolean;
+ className?: string;
+}
+
+/**
+ * Legacy binary activity dot (active/idle) for panes without an agent, rendered
+ * at the same dot size and fixed footprint as AgentStatusDot so both indicator
+ * systems look identical and swap without layout shift.
+ */
+export const AgentActivityDot: React.FC = ({
+ active,
+ size = 'md',
+ activeColorClass = 'bg-status-info',
+ pulse = false,
+ className,
+}) => (
+
+
+
+);
+
+export const AgentStatusDot: React.FC = ({ status, size = 'md', className }) => {
+ const visual = agentStatusVisual(status);
+ if (!visual) return null;
+
+ if (status === 'working') {
+ // Amber ring spinner conveys active work more clearly than a pulsing dot.
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/components/ui/StatusAccentBar.tsx b/frontend/src/components/ui/StatusAccentBar.tsx
new file mode 100644
index 00000000..64a9a089
--- /dev/null
+++ b/frontend/src/components/ui/StatusAccentBar.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { cn } from '../../utils/cn';
+import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus';
+
+interface StatusAccentBarProps {
+ status: AgentDisplayStatus;
+ /** Whether this row is the selected one (used only to color the fallback bar). */
+ isActive?: boolean;
+ className?: string;
+}
+
+const barColor: Record, string> = {
+ blocked: 'bg-status-error',
+ working: 'bg-status-info',
+ done: 'bg-status-info',
+ idle: 'bg-status-success',
+};
+
+/**
+ * The always-present left accent bar on a session row. It follows the at-a-glance
+ * agent status: red = blocked, blue with an up/down loading sweep = working,
+ * blue = done, green = idle. For rows with no tracked agent (`unknown`) it shows
+ * the selection accent when active and nothing otherwise.
+ */
+export const StatusAccentBar: React.FC = ({ status, isActive, className }) => {
+ if (status === 'unknown') {
+ return (
+
+ );
+ }
+
+ return (
+
+ {status === 'working' && (
+
+ )}
+
+ );
+};
diff --git a/frontend/src/components/ui/agentStatusVisual.test.ts b/frontend/src/components/ui/agentStatusVisual.test.ts
new file mode 100644
index 00000000..6a5a5dd3
--- /dev/null
+++ b/frontend/src/components/ui/agentStatusVisual.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { agentStatusVisual } from './agentStatusVisual';
+
+describe('agentStatusVisual', () => {
+ it('maps each status to its token, label, and animation', () => {
+ expect(agentStatusVisual('blocked')).toEqual({ colorClass: 'bg-status-error', label: 'blocked', animate: true });
+ expect(agentStatusVisual('working')).toEqual({ colorClass: 'bg-status-info', label: 'working', animate: true });
+ expect(agentStatusVisual('done')).toEqual({ colorClass: 'bg-status-info', label: 'done', animate: false });
+ expect(agentStatusVisual('idle')).toEqual({ colorClass: 'bg-status-success', label: 'idle', animate: false });
+ });
+
+ it('returns null for unknown so no badge renders', () => {
+ expect(agentStatusVisual('unknown')).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ui/agentStatusVisual.ts b/frontend/src/components/ui/agentStatusVisual.ts
new file mode 100644
index 00000000..63f09edc
--- /dev/null
+++ b/frontend/src/components/ui/agentStatusVisual.ts
@@ -0,0 +1,32 @@
+import type { AgentDisplayStatus } from '../../../../shared/types/agentStatus';
+
+export interface AgentStatusVisual {
+ /** Tailwind background token for the status dot. */
+ colorClass: string;
+ /** Short human label, e.g. for tooltips / aria. */
+ label: string;
+ /** Whether the dot should animate (working/blocked draw the eye). */
+ animate: boolean;
+}
+
+/**
+ * Single source of truth for how an {@link AgentDisplayStatus} looks: blocked is
+ * red and pulses, working is the info blue (matching the label shimmer) and
+ * pulses, a freshly finished agent is a blue
+ * "done" cue, a seen-idle agent is calm green. `unknown` (no agent / plain shell)
+ * returns null so callers render no badge.
+ */
+export function agentStatusVisual(status: AgentDisplayStatus): AgentStatusVisual | null {
+ switch (status) {
+ case 'blocked':
+ return { colorClass: 'bg-status-error', label: 'blocked', animate: true };
+ case 'working':
+ return { colorClass: 'bg-status-info', label: 'working', animate: true };
+ case 'done':
+ return { colorClass: 'bg-status-info', label: 'done', animate: false };
+ case 'idle':
+ return { colorClass: 'bg-status-success', label: 'idle', animate: false };
+ case 'unknown':
+ return null;
+ }
+}
diff --git a/frontend/src/hooks/useAgentStatus.ts b/frontend/src/hooks/useAgentStatus.ts
new file mode 100644
index 00000000..26d474ad
--- /dev/null
+++ b/frontend/src/hooks/useAgentStatus.ts
@@ -0,0 +1,25 @@
+import { usePanelStore } from '../stores/panelStore';
+import { rollupSessionAgentState, toAgentDisplayStatus } from '../utils/agentStatus';
+import type { AgentDisplayStatus } from '../../../shared/types/agentStatus';
+
+/**
+ * Session-level at-a-glance status for the sidebar / session list: the panels'
+ * states rolled up (blocked > working > idle) and mapped to a display status,
+ * where a session that finished while the user was elsewhere reads as `done`.
+ *
+ * Rolls up by the sessionId carried on each status event (not `panels`), so
+ * background sessions and Pane Chat — whose panels aren't loaded into the store —
+ * still light up.
+ */
+export function useSessionAgentDisplayStatus(sessionId: string): AgentDisplayStatus {
+ const raw = usePanelStore((s) => rollupSessionAgentState(s.agentStatus, s.agentStatusSession, sessionId));
+ const unseen = usePanelStore((s) => Boolean(s.unviewedCompletedActivity[sessionId]));
+ return toAgentDisplayStatus(raw, unseen);
+}
+
+/** Per-panel display status for pane tabs. */
+export function usePanelAgentDisplayStatus(panelId: string, sessionId: string): AgentDisplayStatus {
+ const raw = usePanelStore((s) => s.agentStatus[panelId]);
+ const unseen = usePanelStore((s) => Boolean(s.unviewedCompletedActivity[sessionId]));
+ return toAgentDisplayStatus(raw, unseen);
+}
diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts
index 88ee8ae6..afbaa0da 100644
--- a/frontend/src/hooks/useNotifications.ts
+++ b/frontend/src/hooks/useNotifications.ts
@@ -4,6 +4,7 @@ import { usePanelStore } from '../stores/panelStore';
import { API } from '../utils/api';
import { useConfigStore } from '../stores/configStore';
import { ToolPanel } from '../../../shared/types/panels';
+import type { AgentState } from '../../../shared/types/agentStatus';
// Extend window interface for webkit audio context compatibility
declare global {
@@ -17,10 +18,11 @@ interface NotificationSettings {
enabled: boolean;
}
-// Extra delay on top of the 30s PTY idle threshold before firing a "finished"
-// notification. Guards against false positives from mid-task pauses: network
-// waits, slow tool calls, shells sitting between commands. Total silent time
-// before a notification fires is roughly 30s (dot flip) + 60s = 90s.
+// How long a panel must stay idle after a working -> idle agent-status flip
+// before a "finished" notification fires. The agent-status monitor settles to
+// idle within ~1.3s of output stopping, so this debounce is what guards
+// against mid-task pauses: network waits, slow tool calls, quiet builds.
+// Re-activation (working or blocked) cancels the pending notification.
const NOTIFICATION_DEBOUNCE_MS = 60_000;
export function useNotifications() {
@@ -65,12 +67,12 @@ export function useNotifications() {
return unsubscribe;
}, []);
- // Track previous activityStatus per panelId to detect active -> idle transitions.
- const prevActivityRef = useRef>({});
+ // Track previous agentStatus per panelId to detect transitions.
+ const prevAgentStatusRef = useRef>({});
- // Pending notification timers per panelId. A panel must stay idle for
- // NOTIFICATION_DEBOUNCE_MS after the 5s dot flip before we fire, so we
- // don't ping on mid-task pauses (network waits, slow tool calls, shells
+ // Pending "finished" timers per panelId. A panel must stay idle for
+ // NOTIFICATION_DEBOUNCE_MS after the working -> idle flip before we fire, so
+ // we don't ping on mid-task pauses (network waits, slow tool calls, shells
// sitting at a prompt between commands). Re-activation cancels the timer.
const pendingIdleTimersRef = useRef