Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. | <img src="images/qol-terminal-popover.png" alt="Terminal text selection popover" width="420"> |
| **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. | <img src="images/qol-browser.png" alt="Built-in browser tab previewing a local dev server" width="420"> |
| **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. | <img src="images/qol-resource-manager.png" alt="Built-in resource manager" width="420"> |
| **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. | <img src="images/qol-status-dots.png" alt="Session activity status dots" width="280"> |
| **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. | <img src="images/qol-status-dots.png" alt="Session agent status dots: blocked, working, done" width="280"> |
| **Jump + Refresh** | Jump to top, jump to bottom, or hard-refresh any terminal from the toolbar to unstick a frozen state in one click. | <img src="images/qol-jump-refresh.png" alt="Terminal jump and refresh controls" width="120"> |
| **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. | |
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -174,6 +175,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);
}
}
});
Comment thread
parsakhaz marked this conversation as resolved.
return () => unsubscribe?.();
}, []);

useEffect(() => {
const clearViewedCompletedActivity = (event: Event) => {
const sessionId = (event as CustomEvent<{ sessionId?: string }>).detail?.sessionId;
Expand Down
51 changes: 30 additions & 21 deletions frontend/src/components/ProjectSessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,15 +74,17 @@ 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
// session hotkeys (useSessionNavigationHotkeys) see the same visible ordering
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 () => {
Expand Down Expand Up @@ -322,6 +329,7 @@ export function ProjectSessionList({
>
<MessageSquare className="w-4 h-4" />
<span>Pane Chat</span>
<AgentStatusDot status={paneChatStatus} size="sm" className="ml-auto" />
</button>

{showRemoteDesktopLink && onRemoteDesktopClick && (
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -461,12 +474,11 @@ export function ProjectSessionList({
<div className="relative z-10 pointer-events-none flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="min-w-0 truncate text-xs font-semibold text-text-primary">{project.name}</span>
<span className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all",
projectActivity === 'active'
? 'bg-status-info opacity-100 duration-150'
: 'bg-text-muted/20 opacity-40 duration-[3s]'
)} />
{projectAgentState === 'unknown' ? (
<AgentActivityDot active={false} size="sm" className="flex-shrink-0" />
) : (
<AgentStatusDot status={projectAgentState} size="sm" className="flex-shrink-0" />
)}
</div>
</div>
<div
Expand Down Expand Up @@ -659,11 +671,8 @@ function SessionRow({
const [localGitStatus, setLocalGitStatus] = useState<GitStatus | undefined>(session.gitStatus);
const initialGitStatusRequestRef = useRef<string | null>(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.
Expand Down Expand Up @@ -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 (
<div
className={cn(
'group/session relative w-full text-left pl-2 pr-2 transition-colors flex items-center gap-1',
'group/session relative w-full text-left pl-3 pr-2 transition-colors flex items-center gap-1',
rowLayout === 'single' ? 'py-1.5' : 'py-2',
isActive
? 'bg-interactive/30 border-l-4 border-interactive'
: 'hover:bg-surface-hover border-l-4 border-transparent'
isActive ? 'bg-interactive/30' : 'hover:bg-surface-hover'
)}
>
{/* Always-present left accent bar reflecting the agent status. */}
<StatusAccentBar status={agentDisplayStatus} isActive={isActive} />
<Tooltip
content={<SessionDetailTooltip session={session} gitStatus={localGitStatus} showName={false} showDiffStats={false} globalIndex={globalIndex} />}
side="right"
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/SessionStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<SessionStatusBadgeProps> = ({ sessionId, size = 'md' }) => {
const displayStatus = useSessionAgentDisplayStatus(sessionId);

if (displayStatus === 'unknown') {
return <AgentActivityDot active={false} size={size} />;
}

return <AgentStatusDot status={displayStatus} size={size} />;
};
24 changes: 10 additions & 14 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand All @@ -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 });

Expand Down Expand Up @@ -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'
}`}
>
<MessageSquare className="w-4 h-4" />
<AgentStatusDot status={paneChatStatus} size="sm" className="absolute -top-0.5 -right-0.5" />
</button>
</Tooltip>
{showRemoteDesktopLink && (
Expand Down Expand Up @@ -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 (
<Tooltip key={session.id} content={<SessionDetailTooltip session={session} />} side="right">
<button
Expand All @@ -479,12 +478,9 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
isActive ? 'bg-interactive/20 ring-1 ring-interactive/50' : 'hover:bg-surface-hover'
}`}
>
{/**
* Session status badge — currently renders as a colored dot.
* TODO: Evolve into richer interactive badges with session identity
* (e.g., initials, mini name) and better click-to-navigate affordance.
*/}
<div className={`w-2.5 h-2.5 rounded-full transition-all ${statusColor} ${isAnimated ? 'animate-pulse' : ''}`} />
{/* At-a-glance agent status (blocked / working / done), with a
binary activity fallback for non-agent sessions. */}
<SessionStatusBadge sessionId={session.id} />
</button>
</Tooltip>
);
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/panels/PanelTabStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -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<PanelTabStatusDotProps> = ({ panelId, sessionId }) => {
const displayStatus = usePanelAgentDisplayStatus(panelId, sessionId);

if (displayStatus === 'unknown') {
return <AgentActivityDot active={false} size="sm" className="flex-shrink-0" />;
}

return <AgentStatusDot status={displayStatus} size="sm" className="flex-shrink-0" />;
};
18 changes: 8 additions & 10 deletions frontend/src/components/panels/PanelTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -130,7 +131,6 @@ export const PanelTabStrip: React.FC<PanelTabStripProps> = React.memo(({
const [stripDropIndex, setStripDropIndex] = useState<number | null>(null);
const [rovingPanelId, setRovingPanelId] = useState<string | null>(activePanelId ?? panels[0]?.id ?? null);
const previousActivePanelIdRef = useRef(activePanelId);
const getPanelActivityStatus = usePanelStore(s => s.getPanelActivityStatus);

useEffect(() => {
const activePanelChanged = previousActivePanelIdRef.current !== activePanelId;
Expand Down Expand Up @@ -445,17 +445,15 @@ export const PanelTabStrip: React.FC<PanelTabStripProps> = React.memo(({
compact ? "gap-1" : "gap-2",
)}>
{panel.type === 'terminal' && (
<span className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0 transition-all",
getPanelActivityStatus(panel.id) === 'active'
? 'bg-status-info opacity-100 duration-150'
: 'bg-text-muted/20 opacity-40 duration-[3s]'
)} />
<PanelTabStatusDot panelId={panel.id} sessionId={panel.sessionId} />
)}
{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 */}
<span className={cn(compact && isPrimary && "font-semibold")}>{displayTitle}</span>
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. */}
<span className={cn("min-w-0 truncate", compact && isPrimary && "font-semibold")}>{displayTitle}</span>
</span>
)}

Expand Down
Loading
Loading