feat(session-status): mobile session quick switch with running/unread indicators (#340)

* feat(session-status): implement server-authoritative session status tracking

- Add server-side session states and attention tracking with Map storage
- Implement SSE event broadcasting (openchamber:session-status)
- Add REST API endpoints for status/attention queries
- Replace client-side polling with HTTP polling + SSE push
- Track needsAttention based on user message + completion + unviewed
- Add MobileSessionStatusBar for mobile UI
- Handle session view/unview/message-sent state transitions
- Add 24h cleanup for old session states

* feat(session-status): add unread indicator to mobile session status bar

* refactor(session-status): extract SessionStatusHeader component

Extract the session status header into a reusable component and improve
layout structure in CollapsedView and ExpandedView.

* feat(session-status): optimize mobile session status bar UI

* refactor(session-status): remove interval polling, use snapshot sync on reconnect/visibility

- unifies session status/attention sync with existing SSE lifecycle, fixes no-op store churn bug, and drops duplicated client activity state while preserving mobile unread/running UX.

---------

Co-authored-by: Jovines <jovines@qq.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Jovines
2026-02-07 03:11:55 +02:00
committed by GitHub
co-authored by Jovines Bohdan Triapitsyn
parent aa85e31420
commit d5a2e49ae8
10 changed files with 1283 additions and 146 deletions
+22 -1
View File
@@ -8,6 +8,7 @@ import { getWorktreeStatus } from "@/lib/worktrees/worktreeStatus";
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
import { useDirectoryStore } from "./useDirectoryStore";
import { useProjectsStore } from "./useProjectsStore";
import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus";
import type { ProjectEntry } from "@/lib/api/types";
import { checkIsGitRepository } from "@/lib/gitApi";
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
@@ -1237,7 +1238,26 @@ export const useSessionStore = create<SessionStore>()(
},
setCurrentSession: (id: string | null) => {
const prevSessionId = get().currentSessionId;
set({ currentSessionId: id, error: null });
// Notify server of view state changes
// This enables server-side needs_attention tracking
if (prevSessionId && prevSessionId !== id) {
// Leaving previous session
fetch(`/api/sessions/${prevSessionId}/unview`, { method: 'POST' })
.catch(() => { /* ignore */ });
}
if (id) {
// Entering new session
fetch(`/api/sessions/${id}/view`, { method: 'POST' })
.catch(() => { /* ignore */ });
}
// Trigger immediate poll to get latest attention states
// This prevents stale state when switching sessions
triggerSessionStatusPoll();
const directory = opencodeClient.getDirectory() ?? null;
storeSessionForDirectory(directory, id);
},
@@ -1513,7 +1533,7 @@ export const useSessionStore = create<SessionStore>()(
const mergedSessions = dedupeSessionsById(persistedSessions);
return {
const mergedResult = {
...currentState,
...persistedState,
sessions: mergedSessions,
@@ -1527,6 +1547,7 @@ export const useSessionStore = create<SessionStore>()(
: currentState.availableWorktreesByProject,
lastLoadedDirectory,
};
return mergedResult;
},
}
),
+20 -8
View File
@@ -35,6 +35,7 @@ export interface SessionMemoryState {
hasMoreAbove?: boolean;
trimmedHeadMaxId?: string;
streamingCooldownUntil?: number;
lastUserMessageAt?: number; // Timestamp when user last sent a message
}
export interface SessionContextUsage {
@@ -136,11 +137,22 @@ export interface SessionStore {
// Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle).
// Use as the single source of truth for "assistant working" UI.
// confirmedAt: timestamp when idle was confirmed locally (prevents race with server polling)
sessionStatus?: Map<
string,
{ type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number }
{ type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number }
>;
// Server-authoritative session attention state
// Tracks which sessions need user attention based on server-side logic
sessionAttentionStates: Map<string, {
needsAttention: boolean;
lastUserMessageAt: number | null;
lastStatusChangeAt: number;
status: 'idle' | 'busy' | 'retry';
isViewed: boolean;
}>;
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
pendingInputText: string | null;
@@ -238,10 +250,10 @@ export interface SessionStore {
updateSession: (session: Session) => void;
removeSessionFromStore: (sessionId: string) => void;
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
handleSlashUndo: (sessionId: string) => Promise<void>;
handleSlashRedo: (sessionId: string) => Promise<void>;
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null;
}
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
handleSlashUndo: (sessionId: string) => Promise<void>;
handleSlashRedo: (sessionId: string) => Promise<void>;
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' } | null;
}
+21
View File
@@ -99,6 +99,7 @@ export const useSessionStore = create<SessionStore>()(
abortPromptSessionId: null,
abortPromptExpiresAt: null,
sessionStatus: new Map(),
sessionAttentionStates: new Map(),
userSummaryTitles: new Map(),
pendingInputText: null,
pendingInputMode: 'replace',
@@ -423,6 +424,26 @@ export const useSessionStore = create<SessionStore>()(
if (currentSessionId) {
setStatus(currentSessionId, 'busy');
const memoryState = get().sessionMemoryState.get(currentSessionId);
if (!memoryState || !memoryState.lastUserMessageAt) {
const currentMemoryState = get().sessionMemoryState;
const newMemoryState = new Map(currentMemoryState);
newMemoryState.set(currentSessionId, {
viewportAnchor: memoryState?.viewportAnchor ?? 0,
isStreaming: memoryState?.isStreaming ?? false,
lastAccessedAt: Date.now(),
backgroundMessageCount: memoryState?.backgroundMessageCount ?? 0,
lastUserMessageAt: Date.now(),
});
set({ sessionMemoryState: newMemoryState });
}
}
// Notify server that user sent a message in this session
if (currentSessionId) {
fetch(`/api/sessions/${currentSessionId}/message-sent`, { method: 'POST' })
.catch(() => { /* ignore */ });
}
try {
+7
View File
@@ -75,6 +75,7 @@ interface UIStore {
showTerminalQuickKeysOnDesktop: boolean;
persistChatDraft: boolean;
isMobileSessionStatusBarCollapsed: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
@@ -135,6 +136,7 @@ interface UIStore {
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void;
setPersistChatDraft: (value: boolean) => void;
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
openMultiRunLauncher: () => void;
openMultiRunLauncherWithPrompt: (prompt: string) => void;
}
@@ -199,6 +201,7 @@ export const useUIStore = create<UIStore>()(
showTerminalQuickKeysOnDesktop: false,
persistChatDraft: true,
isMobileSessionStatusBarCollapsed: false,
setTheme: (theme) => {
set({ theme });
@@ -688,6 +691,9 @@ export const useUIStore = create<UIStore>()(
setPersistChatDraft: (value) => {
set({ persistChatDraft: value });
},
setIsMobileSessionStatusBarCollapsed: (value) => {
set({ isMobileSessionStatusBarCollapsed: value });
},
}),
{
name: 'ui-store',
@@ -726,6 +732,7 @@ export const useUIStore = create<UIStore>()(
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
notifyOnSubtasks: state.notifyOnSubtasks,
persistChatDraft: state.persistChatDraft,
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
})
}
),