Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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);
}
}
});
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