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,
isMobile,
activeSessions,
processingSessions,
});
usePaletteOpsRegister({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ type SidebarSessionItemProps = {
session: SessionWithProvider;
selectedSession: ProjectSession | null;
currentTime: Date;
processingSessions?: Set<string>;
editingSession: string | null;
editingSessionName: string;
onEditingSessionNameChange: (value: string) => void;
@ -63,6 +64,7 @@ export default function SidebarSessionItem({
session,
selectedSession,
currentTime,
processingSessions,
editingSession,
editingSessionName,
onEditingSessionNameChange,
@ -76,6 +78,8 @@ export default function SidebarSessionItem({
}: SidebarSessionItemProps) {
const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id;
const isProcessing = processingSessions?.has(session.id) ?? false;
const isRecentlyActive = sessionView.isActive && !isProcessing;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
// Sessions are owned by a project identified by `projectId` (DB primary key)
@ -95,20 +99,35 @@ export default function SidebarSessionItem({
return (
<div className="group relative">
{sessionView.isActive && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
{isProcessing ? (
<div
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>
)}
) : 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={cn(
'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 && sessionView.isActive
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
: 'border-border/30',
!isSelected && isProcessing
? 'border-amber-500/40 bg-amber-50/10 dark:bg-amber-900/10'
: !isSelected && isRecentlyActive
? 'border-sky-500/20'
: 'border-border/30',
)}
onClick={selectMobileSession}
>

View file

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