From a17a1185992756dad682770af2df4dd367851b11 Mon Sep 17 00:00:00 2001 From: Chris Romp Date: Sun, 1 Mar 2026 22:07:20 -0800 Subject: [PATCH 1/5] fix: UI/UX improvements for mobile and session state sync - Fix agent/mode/model selectors not reflecting local CLI state: Handle session.mode_changed events from events.jsonl, broadcast via WebSocket, and re-fetch on idle transitions with stale guards - Fix mobile scrolling: prevent page-level overscroll, add overflow-x-hidden to chat, constrain wide content in messages - Fix mobile sidebar not closing on session select: use direct window.matchMedia check instead of potentially stale closure - Fix scroll-to-bottom after history load: use double RAF for reliable DOM layout timing - Move branch info from session cards to directory picker: add branch aggregation to /directories endpoint - Add background task indicator: wire subagent.started/completed events through server to client, show task count badge in header Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- client/src/app.css | 8 ++++ client/src/components/chat/ChatPanel.tsx | 7 +++- client/src/components/chat/MessageBubble.tsx | 4 +- client/src/components/layout/AppShell.tsx | 41 ++++++++++++++++++- .../components/sessions/DirectoryPicker.tsx | 12 +++++- .../src/components/sessions/SessionList.tsx | 9 +--- client/src/hooks/useWebSocket.ts | 26 ++++++++++++ client/src/types.ts | 3 ++ server/src/channels/web/routes.ts | 8 +++- server/src/channels/web/ws-handler.ts | 12 ++++++ server/src/core/session-manager.ts | 23 +++++++++++ server/src/types.ts | 4 ++ 12 files changed, 140 insertions(+), 17 deletions(-) diff --git a/client/src/app.css b/client/src/app.css index 136cf79..4adccc1 100644 --- a/client/src/app.css +++ b/client/src/app.css @@ -3,3 +3,11 @@ /* Use class-based dark mode so the theme toggle works */ @custom-variant dark (&:where(.dark, .dark *)); + +html, body { + overflow: hidden; + overscroll-behavior: none; + height: 100%; + width: 100%; + position: fixed; +} diff --git a/client/src/components/chat/ChatPanel.tsx b/client/src/components/chat/ChatPanel.tsx index 50b8707..d68a9ae 100644 --- a/client/src/components/chat/ChatPanel.tsx +++ b/client/src/components/chat/ChatPanel.tsx @@ -57,8 +57,11 @@ export function ChatPanel({ useEffect(() => { if (needsScrollRef.current && messages.length > 0) { needsScrollRef.current = false; + // Double RAF ensures DOM layout is complete before scrolling requestAnimationFrame(() => { - bottomRef.current?.scrollIntoView(); + requestAnimationFrame(() => { + bottomRef.current?.scrollIntoView(); + }); }); } }, [messages]); @@ -97,7 +100,7 @@ export function ChatPanel({ const isActive = status === 'thinking' || status === 'tool_use' || streamingContent !== null; return ( -
+
{messages.map((msg, idx) => (
diff --git a/client/src/components/chat/MessageBubble.tsx b/client/src/components/chat/MessageBubble.tsx index 2c7aa2a..086d608 100644 --- a/client/src/components/chat/MessageBubble.tsx +++ b/client/src/components/chat/MessageBubble.tsx @@ -61,7 +61,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { return (
{message.content}

) : ( -
+
{ + ws.onConfigChanged((data) => { + if (data.sessionId !== activeSessionId) return; + if (data.mode) setSessionMode(data.mode); + if (data.modelId) setSessionModelId(data.modelId); + if (data.agent !== undefined) setCurrentAgent(data.agent || null); + }); + }, [ws, activeSessionId]); + // Close sidebar on mobile when selecting a session const handleSelectSession = useCallback( async (sessionId: string) => { @@ -97,9 +107,12 @@ export function AppShell() { ws.subscribe(sessionId); // History is loaded via WS subscribe (server sends history frame) - if (isMobile) setSidebarOpen(false); + // Check media query directly to avoid stale closure issues + if (window.matchMedia('(max-width: 767px)').matches) { + setSidebarOpen(false); + } }, - [activeSessionId, isMobile, ws], + [activeSessionId, ws], ); const handleNewSession = useCallback(() => { @@ -194,6 +207,23 @@ export function AppShell() { return () => { stale = true; }; }, [activeSessionId]); + // Re-fetch mode/model/agent when session goes idle (may have changed during turn) + const prevStatus = useRef(ws.status); + const prevSessionForStatus = useRef(activeSessionId); + useEffect(() => { + let stale = false; + // Only re-fetch on genuine idle transitions, not session switches + const isSessionSwitch = prevSessionForStatus.current !== activeSessionId; + if (!isSessionSwitch && prevStatus.current !== 'idle' && ws.status === 'idle' && activeSessionId) { + api.getSessionMode(activeSessionId).then((m) => { if (!stale) setSessionMode(m.mode); }).catch(() => {}); + api.getSessionModel(activeSessionId).then((m) => { if (!stale) setSessionModelId(m.modelId); }).catch(() => {}); + api.getSessionAgent(activeSessionId).then((r) => { if (!stale) setCurrentAgent(r.name || null); }).catch(() => {}); + } + prevStatus.current = ws.status; + prevSessionForStatus.current = activeSessionId; + return () => { stale = true; }; + }, [ws.status, activeSessionId]); + // Fetch quota on mount and periodically useEffect(() => { const fetchQuota = () => { @@ -344,6 +374,13 @@ export function AppShell() { Reconnecting... )} + {ws.activeTaskCount > 0 && ( + 1 ? 's' : ''} running`}> + + {ws.activeTaskCount} task{ws.activeTaskCount > 1 ? 's' : ''} + {ws.activeTaskCount} + + )} ))}
- {session.context?.branch && ( -
- - - - {session.context.branch} -
- )} +
); diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index 444dde6..bc30620 100644 --- a/client/src/hooks/useWebSocket.ts +++ b/client/src/hooks/useWebSocket.ts @@ -11,6 +11,7 @@ interface UseWebSocketReturn { liveToolCalls: ToolUseInfo[]; permissionRequest: unknown | null; userInputRequest: (UserInputRequest & { sessionId: string }) | null; + activeTaskCount: number; planChanged: string | null; sendMessage: (sessionId: string, content: string) => void; subscribe: (sessionId: string) => void; @@ -22,6 +23,7 @@ interface UseWebSocketReturn { addMessages: (msgs: ChatMessage[]) => void; onLifecycle: (cb: () => void) => void; onReconnect: (cb: () => void) => void; + onConfigChanged: (cb: (data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) => void; forceReconnect: () => void; clearPlanChanged: () => void; } @@ -38,6 +40,7 @@ export function useWebSocket(): UseWebSocketReturn { const [userInputRequest, setUserInputRequest] = useState<(UserInputRequest & { sessionId: string }) | null>(null); const [reasoning, setReasoning] = useState(null); const [planChanged, setPlanChanged] = useState(null); + const [activeTaskCount, setActiveTaskCount] = useState(0); const reconnectTimer = useRef | undefined>(undefined); const reconnectDelay = useRef(1000); const disposedRef = useRef(false); @@ -46,6 +49,7 @@ export function useWebSocket(): UseWebSocketReturn { const activeSubscriptions = useRef>(new Set()); const lifecycleCallback = useRef<(() => void) | null>(null); const reconnectCallback = useRef<(() => void) | null>(null); + const configChangedCallback = useRef<((data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) | null>(null); const hasConnectedOnce = useRef(false); const connect = useCallback(() => { @@ -229,6 +233,21 @@ export function useWebSocket(): UseWebSocketReturn { setPlanChanged(msg.operation); break; + case 'tasks_changed': + setActiveTaskCount((msg as any).count ?? 0); + break; + + case 'config_changed': + if (configChangedCallback.current) { + configChangedCallback.current({ + sessionId: (msg as any).sessionId, + mode: (msg as any).mode, + modelId: (msg as any).modelId, + agent: (msg as any).agent, + }); + } + break; + case 'history': { // Convert file-based events to ChatMessages + ToolCallEvents const historyMessages: ChatMessage[] = []; @@ -377,6 +396,7 @@ export function useWebSocket(): UseWebSocketReturn { setReasoning(null); setLiveToolCalls([]); setPlanChanged(null); + setActiveTaskCount(0); streamBuffer.current = ''; reasoningBuffer.current = ''; }, []); @@ -393,6 +413,10 @@ export function useWebSocket(): UseWebSocketReturn { reconnectCallback.current = cb; }, []); + const onConfigChanged = useCallback((cb: (data: { sessionId: string; mode?: string; modelId?: string; agent?: string }) => void) => { + configChangedCallback.current = cb; + }, []); + const forceReconnect = useCallback(() => { if (wsRef.current) { reconnectDelay.current = 0; @@ -412,6 +436,7 @@ export function useWebSocket(): UseWebSocketReturn { liveToolCalls, permissionRequest, userInputRequest, + activeTaskCount, planChanged, sendMessage, subscribe, @@ -423,6 +448,7 @@ export function useWebSocket(): UseWebSocketReturn { addMessages, onLifecycle, onReconnect, + onConfigChanged, forceReconnect, clearPlanChanged, }; diff --git a/client/src/types.ts b/client/src/types.ts index 4df33d4..7916547 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -30,6 +30,7 @@ export interface DirectoryInfo { path: string; sessionCount: number; lastUsed: string; + branch?: string; } export type SessionStatus = 'idle' | 'thinking' | 'tool_use'; @@ -83,4 +84,6 @@ export type ServerMessage = | { type: 'user_message'; sessionId: string; content: string } | { type: 'task_complete'; sessionId: string; summary: string } | { type: 'plan_changed'; sessionId: string; operation: string } + | { type: 'config_changed'; sessionId: string; mode?: string; modelId?: string; agent?: string } + | { type: 'tasks_changed'; sessionId: string; count: number } | { type: 'pong' }; diff --git a/server/src/channels/web/routes.ts b/server/src/channels/web/routes.ts index fddefff..05ce13a 100644 --- a/server/src/channels/web/routes.ts +++ b/server/src/channels/web/routes.ts @@ -492,19 +492,23 @@ export function createRoutes(sessionManager: SessionManager): Router { try { // Extract unique working directories from session metadata const sessions = await sessionManager.listSessions(); - const dirs = new Map(); + const dirs = new Map(); for (const s of sessions) { if (s.context?.cwd) { const existing = dirs.get(s.context.cwd); if (existing) { existing.sessionCount++; - if (s.modifiedTime > existing.lastUsed) existing.lastUsed = s.modifiedTime; + if (s.modifiedTime > existing.lastUsed) { + existing.lastUsed = s.modifiedTime; + if (s.context.branch) existing.branch = s.context.branch; + } } else { dirs.set(s.context.cwd, { path: s.context.cwd, sessionCount: 1, lastUsed: s.modifiedTime, + branch: s.context.branch, }); } } diff --git a/server/src/channels/web/ws-handler.ts b/server/src/channels/web/ws-handler.ts index a693845..344c81b 100644 --- a/server/src/channels/web/ws-handler.ts +++ b/server/src/channels/web/ws-handler.ts @@ -98,6 +98,18 @@ export function setupWebSocket(server: Server, sessionManager: SessionManager): sessionManager.events.on('session:plan_changed', (e) => { broadcast(e.sessionId, { type: 'plan_changed', sessionId: e.sessionId, operation: e.operation }); }), + sessionManager.events.on('session:config_changed', (e) => { + broadcast(e.sessionId, { + type: 'config_changed', + sessionId: e.sessionId, + mode: e.mode, + modelId: e.modelId, + agent: e.agent, + }); + }), + sessionManager.events.on('session:tasks_changed', (e) => { + broadcast(e.sessionId, { type: 'tasks_changed', sessionId: e.sessionId, count: e.count }); + }), ); // Handle upgrade diff --git a/server/src/core/session-manager.ts b/server/src/core/session-manager.ts index a197771..22a7941 100644 --- a/server/src/core/session-manager.ts +++ b/server/src/core/session-manager.ts @@ -146,6 +146,7 @@ export class SessionManager { private timeoutMs: number; // Track toolCallId → toolName so tool_complete can include the correct name private toolCallNames = new Map(); + private activeTaskCounts = new Map(); // File-based session event watching for external (CLI-initiated) work. // Watches ~/.copilot/session-state/{id}/events.jsonl for new lines. @@ -718,6 +719,26 @@ export class SessionManager { operation: (e.data.operation as string) ?? 'update', }); break; + case 'session.mode_changed': + this.eventBus.emit('session:config_changed', { + sessionId, + mode: e.data.mode as string | undefined, + modelId: e.data.modelId as string | undefined, + agent: e.data.agent as string | undefined, + }); + break; + case 'subagent.started': { + const count = (this.activeTaskCounts.get(sessionId) ?? 0) + 1; + this.activeTaskCounts.set(sessionId, count); + this.eventBus.emit('session:tasks_changed', { sessionId, count }); + break; + } + case 'subagent.completed': { + const count = Math.max(0, (this.activeTaskCounts.get(sessionId) ?? 0) - 1); + this.activeTaskCounts.set(sessionId, count); + this.eventBus.emit('session:tasks_changed', { sessionId, count }); + break; + } } } @@ -727,6 +748,7 @@ export class SessionManager { this.sessionWorkspaces.delete(sessionId); this.sessionStates.delete(sessionId); this.cleanupToolCallNames(sessionId); + this.activeTaskCounts.delete(sessionId); this.stopPolling(sessionId); await this.bridge.destroySession(sessionId); } @@ -737,6 +759,7 @@ export class SessionManager { this.sessionWorkspaces.delete(sessionId); this.sessionStates.delete(sessionId); this.cleanupToolCallNames(sessionId); + this.activeTaskCounts.delete(sessionId); this.stopPolling(sessionId); await this.bridge.deleteSession(sessionId); } diff --git a/server/src/types.ts b/server/src/types.ts index 69d5651..aa7e2ec 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -98,6 +98,8 @@ export type SessionEventMap = { 'session:lifecycle': SessionLifecycleEvent; 'session:task_complete': SessionTaskCompleteEvent; 'session:plan_changed': SessionPlanChangedEvent; + 'session:config_changed': { sessionId: string; mode?: string; modelId?: string; agent?: string }; + 'session:tasks_changed': { sessionId: string; count: number }; }; // ---- WebSocket protocol types ---- @@ -127,6 +129,8 @@ export type ServerMessage = | { type: 'user_message'; sessionId: string; content: string } | { type: 'task_complete'; sessionId: string; summary: string } | { type: 'plan_changed'; sessionId: string; operation: string } + | { type: 'config_changed'; sessionId: string; mode?: string; modelId?: string; agent?: string } + | { type: 'tasks_changed'; sessionId: string; count: number } | { type: 'pong' }; // ---- Session Manager types ---- From 8e0d7a77424b64ad18ca8c3fcf6432448865c06b Mon Sep 17 00:00:00 2001 From: Chris Romp Date: Sun, 1 Mar 2026 22:37:57 -0800 Subject: [PATCH 2/5] fix: mode/agent display and branch resolution for monitored sessions - Fix session.mode_changed event parsing: use data.newMode (not data.mode) - Add sessionConfigs fallback map for mode when bridge RPCs fail on CLI-owned sessions (seeded from events.jsonl on subscribe) - Return graceful defaults instead of throwing when RPC fallback has no data (empty mode hides switcher; empty agent shows default) - Fix /directories endpoint: use getLiveBranch() for real git branch instead of stale session metadata snapshot - Restyle DirectoryPicker: branch shown below folder name in muted color instead of inline with session count Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/sessions/DirectoryPicker.tsx | 16 ++--- server/src/channels/web/routes.ts | 9 ++- server/src/core/session-manager.ts | 67 +++++++++++++++---- 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/client/src/components/sessions/DirectoryPicker.tsx b/client/src/components/sessions/DirectoryPicker.tsx index 479bfb1..0bd0f7a 100644 --- a/client/src/components/sessions/DirectoryPicker.tsx +++ b/client/src/components/sessions/DirectoryPicker.tsx @@ -93,18 +93,18 @@ export function DirectoryPicker({ currentDirectory, onSelect }: DirectoryPickerP currentDirectory === d.path ? 'bg-blue-50 dark:bg-blue-950/30 text-blue-600' : 'hover:bg-gray-50 dark:hover:bg-gray-900' }`} > - {d.path.split('/').pop()} - +
+
{d.path.split('/').pop()}
{d.branch && ( - - +
+ - {d.branch} - + {d.branch} +
)} - {d.sessionCount} -
+
+ {d.sessionCount} ))}