feat(sidebar): 3-state session indicators driven by real processing state

This commit is contained in:
Zac Gaetano 2026-05-30 11:21:54 -04:00
parent 8e30d9c3cf
commit 4b08855e13
9 changed files with 77 additions and 7 deletions

View file

@ -0,0 +1,34 @@
# Session Status Indicators - Design
Date: 2026-05-30
Status: Approved (3-distinct-states)
## Problem
Sidebar session indicator is an animated green dot driven purely by timestamp
(isActive: diffInMinutes < 10 in sidebar/utils/utils.ts). It lights up for any
session touched in the last 10 min regardless of whether an agent is running.
The real processing state (processingSessions Set, event-driven) is only shown
in the chat-area status bar, never in the sidebar.
## Three States
- Processing: processingSessions.has(session.id) -> amber dot, animate-ping halo + solid core. Title "Agent running".
- Recently active: sessionView.isActive && !isProcessing -> static sky-blue dot. Title "Recently active".
- Idle: neither -> no indicator.
## Data flow (new threading, parallel to currentTime)
AppContent -> useProjectsState({processingSessions}) -> sidebarSharedProps
-> Sidebar (SidebarProps) -> projectListProps -> SidebarProjectList
-> SidebarProjectItem -> SidebarProjectSessions -> SidebarSessionItem.
processingSessions optional in leaf types -> graceful degrade.
## Files
1. app/AppContent.tsx - pass processingSessions into useProjectsState
2. hooks/useProjectsState.ts - accept + sidebarSharedProps + deps
3. sidebar/types/types.ts - SidebarProps gains processingSessions
4. sidebar/view/Sidebar.tsx - destructure + projectListProps
5. sidebar/view/subcomponents/SidebarProjectList.tsx - type + passthrough
6. sidebar/view/subcomponents/SidebarProjectItem.tsx - type + passthrough
7. sidebar/view/subcomponents/SidebarProjectSessions.tsx - type + passthrough
8. sidebar/view/subcomponents/SidebarSessionItem.tsx - 3-state indicator
## Risk: low, additive prop threading + presentational swap. Feature branch, no deploy until reviewed.

View file

@ -58,6 +58,7 @@ function AppContentInner() {
latestMessage, latestMessage,
isMobile, isMobile,
activeSessions, activeSessions,
processingSessions,
}); });
usePaletteOpsRegister({ usePaletteOpsRegister({

View file

@ -56,6 +56,7 @@ export type SidebarProps = {
settingsInitialTab: string; settingsInitialTab: string;
onCloseSettings: () => void; onCloseSettings: () => void;
isMobile: boolean; isMobile: boolean;
processingSessions?: Set<string>;
}; };
export type SessionViewModel = { export type SessionViewModel = {

View file

@ -39,6 +39,7 @@ function Sidebar({
settingsInitialTab, settingsInitialTab,
onCloseSettings, onCloseSettings,
isMobile, isMobile,
processingSessions,
}: SidebarProps) { }: SidebarProps) {
const { t } = useTranslation(['sidebar', 'common']); const { t } = useTranslation(['sidebar', 'common']);
const { isPWA } = useDeviceSettings({ trackMobile: false }); const { isPWA } = useDeviceSettings({ trackMobile: false });
@ -152,6 +153,7 @@ function Sidebar({
editingName, editingName,
initialSessionsLoaded, initialSessionsLoaded,
currentTime, currentTime,
processingSessions,
editingSession, editingSession,
editingSessionName, editingSessionName,
deletingProjects, deletingProjects,

View file

@ -23,6 +23,7 @@ type SidebarProjectItemProps = {
initialSessionsLoaded: boolean; initialSessionsLoaded: boolean;
isLoadingMoreSessions: boolean; isLoadingMoreSessions: boolean;
currentTime: Date; currentTime: Date;
processingSessions?: Set<string>;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
tasksEnabled: boolean; tasksEnabled: boolean;
@ -69,6 +70,7 @@ export default function SidebarProjectItem({
initialSessionsLoaded, initialSessionsLoaded,
isLoadingMoreSessions, isLoadingMoreSessions,
currentTime, currentTime,
processingSessions,
editingSession, editingSession,
editingSessionName, editingSessionName,
tasksEnabled, tasksEnabled,
@ -412,6 +414,7 @@ export default function SidebarProjectItem({
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)} hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
isLoadingMoreSessions={isLoadingMoreSessions} isLoadingMoreSessions={isLoadingMoreSessions}
currentTime={currentTime} currentTime={currentTime}
processingSessions={processingSessions}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}
onEditingSessionNameChange={onEditingSessionNameChange} onEditingSessionNameChange={onEditingSessionNameChange}

View file

@ -19,6 +19,7 @@ export type SidebarProjectListProps = {
editingName: string; editingName: string;
initialSessionsLoaded: Set<string>; initialSessionsLoaded: Set<string>;
currentTime: Date; currentTime: Date;
processingSessions?: Set<string>;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
deletingProjects: Set<string>; deletingProjects: Set<string>;
@ -63,6 +64,7 @@ export default function SidebarProjectList({
editingName, editingName,
initialSessionsLoaded, initialSessionsLoaded,
currentTime, currentTime,
processingSessions,
editingSession, editingSession,
editingSessionName, editingSessionName,
deletingProjects, deletingProjects,
@ -131,6 +133,7 @@ export default function SidebarProjectList({
initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)} initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)} isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)}
currentTime={currentTime} currentTime={currentTime}
processingSessions={processingSessions}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}
tasksEnabled={tasksEnabled} tasksEnabled={tasksEnabled}

View file

@ -16,6 +16,7 @@ type SidebarProjectSessionsProps = {
hasMoreSessions: boolean; hasMoreSessions: boolean;
isLoadingMoreSessions: boolean; isLoadingMoreSessions: boolean;
currentTime: Date; currentTime: Date;
processingSessions?: Set<string>;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
onEditingSessionNameChange: (value: string) => void; onEditingSessionNameChange: (value: string) => void;
@ -62,6 +63,7 @@ export default function SidebarProjectSessions({
hasMoreSessions, hasMoreSessions,
isLoadingMoreSessions, isLoadingMoreSessions,
currentTime, currentTime,
processingSessions,
editingSession, editingSession,
editingSessionName, editingSessionName,
onEditingSessionNameChange, onEditingSessionNameChange,
@ -121,6 +123,7 @@ export default function SidebarProjectSessions({
session={session} session={session}
selectedSession={selectedSession} selectedSession={selectedSession}
currentTime={currentTime} currentTime={currentTime}
processingSessions={processingSessions}
editingSession={editingSession} editingSession={editingSession}
editingSessionName={editingSessionName} editingSessionName={editingSessionName}
onEditingSessionNameChange={onEditingSessionNameChange} onEditingSessionNameChange={onEditingSessionNameChange}

View file

@ -13,6 +13,7 @@ type SidebarSessionItemProps = {
session: SessionWithProvider; session: SessionWithProvider;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
currentTime: Date; currentTime: Date;
processingSessions?: Set<string>;
editingSession: string | null; editingSession: string | null;
editingSessionName: string; editingSessionName: string;
onEditingSessionNameChange: (value: string) => void; onEditingSessionNameChange: (value: string) => void;
@ -63,6 +64,7 @@ export default function SidebarSessionItem({
session, session,
selectedSession, selectedSession,
currentTime, currentTime,
processingSessions,
editingSession, editingSession,
editingSessionName, editingSessionName,
onEditingSessionNameChange, onEditingSessionNameChange,
@ -76,6 +78,8 @@ export default function SidebarSessionItem({
}: SidebarSessionItemProps) { }: SidebarSessionItemProps) {
const sessionView = createSessionViewModel(session, currentTime, t); const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id; const isSelected = selectedSession?.id === session.id;
const isProcessing = processingSessions?.has(session.id) ?? false;
const isRecentlyActive = sessionView.isActive && !isProcessing;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
// Sessions are owned by a project identified by `projectId` (DB primary key) // Sessions are owned by a project identified by `projectId` (DB primary key)
@ -95,20 +99,35 @@ export default function SidebarSessionItem({
return ( return (
<div className="group relative"> <div className="group relative">
{sessionView.isActive && ( {isProcessing ? (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"> <div
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" /> className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
title={t('sessions.agentRunning', { defaultValue: 'Agent running' })}
>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-amber-500" />
</span>
</div> </div>
)} ) : isRecentlyActive ? (
<div
className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"
title={t('sessions.recentlyActive', { defaultValue: 'Recently active' })}
>
<span className="block h-1.5 w-1.5 rounded-full bg-sky-400/70" />
</div>
) : null}
<div className="md:hidden"> <div className="md:hidden">
<div <div
className={cn( className={cn(
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative', 'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
isSelected ? 'bg-primary/5 border-primary/20' : '', isSelected ? 'bg-primary/5 border-primary/20' : '',
!isSelected && sessionView.isActive !isSelected && isProcessing
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5' ? 'border-amber-500/40 bg-amber-50/10 dark:bg-amber-900/10'
: 'border-border/30', : !isSelected && isRecentlyActive
? 'border-sky-500/20'
: 'border-border/30',
)} )}
onClick={selectMobileSession} onClick={selectMobileSession}
> >

View file

@ -18,6 +18,7 @@ type UseProjectsStateArgs = {
latestMessage: AppSocketMessage | null; latestMessage: AppSocketMessage | null;
isMobile: boolean; isMobile: boolean;
activeSessions: Set<string>; activeSessions: Set<string>;
processingSessions: Set<string>;
}; };
type FetchProjectsOptions = { type FetchProjectsOptions = {
@ -241,6 +242,7 @@ export function useProjectsState({
latestMessage, latestMessage,
isMobile, isMobile,
activeSessions, activeSessions,
processingSessions,
}: UseProjectsStateArgs) { }: UseProjectsStateArgs) {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
@ -830,6 +832,7 @@ export function useProjectsState({
settingsInitialTab, settingsInitialTab,
onCloseSettings: () => setShowSettings(false), onCloseSettings: () => setShowSettings(false),
isMobile, isMobile,
processingSessions,
}), }),
[ [
handleNewSession, handleNewSession,
@ -847,6 +850,7 @@ export function useProjectsState({
selectedProject, selectedProject,
selectedSession, selectedSession,
showSettings, showSettings,
processingSessions,
], ],
); );