From d046169646b03d889bfadac3329970559b655a27 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 9 Jun 2026 09:24:25 -0500 Subject: [PATCH] fix(desktop): local-only recents, per-platform sidebar sections, and Ctrl+N regressions (#42537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): keep chat recents focused and reset hotkey target Exclude messaging platform threads from chat recents pagination so Load More returns chat sessions, and clear stale quick-create profile state before Ctrl+N starts a new session. * fix(desktop): surface new sessions in sidebar + unstick new-chat Thinking Two renderer regressions in the desktop chat app: - Sidebar ordering: orderByIds/reconcileOrderIds appended ids missing from the persisted order to the BOTTOM. Callers pass recency-sorted lists (newest first), so a brand-new Ctrl+N session sank below the saved order and read as "my latest session never showed up". Prepend fresh ids so new activity surfaces at the top. - New-chat stuck on "Thinking": terminal/attention state transitions (turn finished, error, or agent now waiting on user) were RAF-batched. Electron throttles requestAnimationFrame to ~0 while the window is backgrounded, occluded, or unfocused, stranding the deferred flush. Flush critical transitions (!busy || needsInput) synchronously; keep the busy heartbeat RAF-batched to avoid scroll churn. Does not touch the messaging-source exclusion in chat recents queries. * fix(desktop): stop excluding messaging platforms from chat recents The "keep chat recents focused" change excluded every messaging-platform source (telegram, discord, slack, …) from the recents query. That silently undid the messaging-source-folder feature already on main (ede4f5a4a): the sidebar builds those folders purely from the loaded recents page, so once the sources were filtered out the folders never rendered — telegram and friends vanished from the left sidebar. Only cron stays excluded (it has its own dedicated section). Messaging sessions belong in the sidebar and render with their platform folder/icon. Removes the now-unused MESSAGING_SESSION_SOURCE_IDS export. * fix(desktop): give each messaging platform its own self-managed sidebar section Recents are local-only again: cron and every messaging platform are excluded from the chat-recents query, so "Load more" pages through interactive local chats instead of interleaving gateway threads that bury them. Each messaging platform (telegram, discord, ...) is now fetched as its own slice (refreshMessagingSessions) and rendered as a self-managed sidebar section with its platform icon, count, and per-platform "load more" — no source-grouping magic inside recents. Handed-off sessions (live source becomes local after a handoff) keep their origin-platform badge on the row via handoff_platform, so a Telegram thread continued in the desktop still reads as Telegram. * fix(desktop): self-heal a stranded routed session in route-resume An intermittent create/stream race can leave selected/active session ids null while the route stays on /:sid — the transcript then sticks empty even though the turn completed and persisted (the "second Ctrl+N shows no response" symptom). The pathname didn't change, so route-resume's normal gate skipped and the view stayed stuck. Resume whenever the routed session isn't the loaded one, gated on freshDraftReady so the /:sid -> /new transition (which also momentarily nulls selected/active a render before the pathname flips) is NOT treated as stranded. selectedStoredSessionIdRef is set synchronously at resume entry, so this can't loop, and the resume cached fast-path restores the already-streamed messages without a refetch. * fix(desktop): bypass smooth reveal on primary markdown stream Render main assistant text through deferred markdown directly instead of the smooth-reveal wrapper. This isolates the wrapper to reasoning surfaces and avoids the intermittent blank-response regression after consecutive new-session flows. --- apps/desktop/src/app/chat/sidebar/index.tsx | 298 ++++++++++-------- .../src/app/chat/sidebar/session-row.tsx | 17 + apps/desktop/src/app/desktop-controller.tsx | 73 ++++- apps/desktop/src/app/hooks/use-keybinds.ts | 5 + .../session/hooks/use-route-resume.test.tsx | 54 ++++ .../src/app/session/hooks/use-route-resume.ts | 23 +- .../session/hooks/use-session-state-cache.ts | 23 ++ .../components/assistant-ui/markdown-text.tsx | 8 +- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/session-source.ts | 64 ++++ apps/desktop/src/store/session.ts | 18 ++ apps/desktop/src/types/hermes.ts | 8 + 16 files changed, 460 insertions(+), 136 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 99f7f881372..6770234d853 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -76,6 +76,9 @@ import { } from '@/store/profile' import { $cronSessions, + $messagingPlatformTotals, + $messagingSessions, + $messagingTruncated, $selectedStoredSessionId, $sessionProfileTotals, $sessions, @@ -124,7 +127,6 @@ const WORKSPACE_PAGE = 5 // unified list scannable, then reveal/fetch more in N-sized steps on demand. const PROFILE_INITIAL_PAGE = 5 const GROUP_DND_ID_PREFIX = 'group:' -const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui']) const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}` @@ -141,24 +143,25 @@ function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[ const byId = new Map(items.map(item => [getId(item), item])) const seen = new Set() - const out: T[] = [] + const ordered: T[] = [] for (const id of orderIds) { const item = byId.get(id) if (item) { - out.push(item) + ordered.push(item) seen.add(id) } } - for (const item of items) { - if (!seen.has(getId(item))) { - out.push(item) - } - } + // Items missing from the persisted order are new since it was last + // reconciled. Callers pass recency-sorted lists (newest first), so surface + // these at the TOP instead of burying them beneath the saved order — + // otherwise a brand-new session sinks to the bottom of the sidebar and reads + // as "my latest session never showed up". + const fresh = items.filter(item => !seen.has(getId(item))) - return out + return fresh.length ? [...fresh, ...ordered] : ordered } function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { @@ -171,17 +174,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { } const current = new Set(currentIds) - const next = orderIds.filter(id => current.has(id)) - const known = new Set(next) + const retained = orderIds.filter(id => current.has(id)) + const retainedSet = new Set(retained) - for (const id of currentIds) { - if (!known.has(id)) { - next.push(id) - known.add(id) - } - } + // New ids (absent from the saved order) are the newest sessions/groups; keep + // them ahead of the persisted order so fresh activity surfaces at the top of + // the sidebar rather than being appended to the bottom. + const fresh = currentIds.filter(id => !retainedSet.has(id)) - return next + return [...fresh, ...retained] } function sameIds(left: string[], right: string[]) { @@ -251,43 +252,6 @@ function workspaceGroupsFor( return [...groups.values()] } -function sourceSessionGroupsFor(sessions: SessionInfo[]): { - localSessions: SessionInfo[] - sourceGroups: SidebarSessionGroup[] -} { - const groups = new Map() - const localSessions: SessionInfo[] = [] - - for (const session of sessions) { - const sourceId = normalizeSessionSource(session.source) - - if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) { - localSessions.push(session) - - continue - } - - const label = sessionSourceLabel(sourceId) ?? sourceId - - const group = groups.get(sourceId) ?? { - id: `source:${sourceId}`, - label, - mode: 'source', - path: null, - sessions: [], - sourceId - } - - group.sessions.push(session) - groups.set(sourceId, group) - } - - return { - localSessions, - sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) - } -} - function useSortableBindings(id: string) { const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) @@ -309,6 +273,7 @@ interface ChatSidebarProps extends React.ComponentProps { onNavigate: (item: SidebarNavItem) => void onLoadMoreSessions: () => void onLoadMoreProfileSessions?: (profile: string) => Promise | void + onLoadMoreMessaging?: (platform: string) => Promise | void onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void @@ -322,6 +287,7 @@ export function ChatSidebar({ onNavigate, onLoadMoreSessions, onLoadMoreProfileSessions, + onLoadMoreMessaging, onResumeSession, onDeleteSession, onArchiveSession, @@ -345,6 +311,9 @@ export function ChatSidebar({ const sessions = useStore($sessions) const cronSessions = useStore($cronSessions) const cronJobs = useStore($cronJobs) + const messagingSessions = useStore($messagingSessions) + const messagingPlatformTotals = useStore($messagingPlatformTotals) + const messagingTruncated = useStore($messagingTruncated) const sessionsLoading = useStore($sessionsLoading) const sessionsTotal = useStore($sessionsTotal) const sessionProfileTotals = useStore($sessionProfileTotals) @@ -364,6 +333,8 @@ export function ChatSidebar({ const [serverMatches, setServerMatches] = useState([]) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) + const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({}) + const [messagingOpen, setMessagingOpen] = useState>({}) const searchInputRef = useRef(null) const trimmedQuery = searchQuery.trim() @@ -529,24 +500,12 @@ export function ChatSidebar({ [unpinnedAgentSessions, agentOrderIds] ) - const { localSessions: localAgentSessions, sourceGroups } = useMemo( - () => sourceSessionGroupsFor(agentSessions), - [agentSessions] - ) - - const orderedSourceGroups = useMemo( - () => orderByIds(sourceGroups, g => g.id, workspaceOrderIds), - [sourceGroups, workspaceOrderIds] - ) - + // Recents are local-only: messaging-platform sessions are fetched as their + // own slice ($messagingSessions) and rendered in self-managed per-platform + // sections below, so there is no source-grouping magic to untangle here. const agentGroups = useMemo( - () => - orderByIds( - workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }), - g => g.id, - workspaceOrderIds - ), - [localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds] + () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds), + [agentSessions, s.noWorkspace, workspaceOrderIds] ) const loadMoreForProfileGroup = useCallback( @@ -564,6 +523,64 @@ export function ChatSidebar({ [onLoadMoreProfileSessions] ) + const loadMoreForMessaging = useCallback( + (platform: string) => { + if (!onLoadMoreMessaging) { + return + } + + setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true })) + + void Promise.resolve(onLoadMoreMessaging(platform)) + .catch(() => undefined) + .finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest)) + }, + [onLoadMoreMessaging] + ) + + // Each messaging platform is its own self-managed section: split the + // separately-fetched messaging slice by source, newest platform first, rows + // within a platform by recency. Per-platform totals (when a "load more" has + // resolved them) drive the count + whether more remain on disk. + const messagingGroups = useMemo(() => { + if (!messagingSessions.length) { + return [] + } + + const bySource = new Map() + + for (const session of messagingSessions) { + const sourceId = normalizeSessionSource(session.source) + + if (!sourceId) { + continue + } + + const list = bySource.get(sourceId) ?? [] + list.push(session) + bySource.set(sourceId, list) + } + + return [...bySource.entries()] + .map(([sourceId, list]) => { + const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a)) + const known = messagingPlatformTotals[sourceId] + const total = Math.max(ordered.length, known ?? 0) + + return { + // Known exact total → more exist iff total exceeds loaded; otherwise + // the seed fetch was capped, so assume more until a per-platform load + // resolves the count. + hasMore: known != null ? known > ordered.length : messagingTruncated, + label: sessionSourceLabel(sourceId) ?? sourceId, + sessions: ordered, + sourceId, + total + } + }) + .sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0])) + }, [messagingSessions, messagingPlatformTotals, messagingTruncated]) + // ALL-profiles view: one collapsible group per profile, color on the header // (not on every row). Default profile floats to the top, the rest alpha. const profileGroups = useMemo(() => { @@ -610,56 +627,7 @@ export function ChatSidebar({ sessionProfileTotals ]) - const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions - - const displayAgentGroups = useMemo(() => { - if (orderedSourceGroups.length) { - const localGroups = agentsGrouped - ? agentGroups - : localAgentSessions.length - ? [ - { - id: 'local-sessions', - label: 'Local', - mode: 'workspace' as const, - path: null, - sessions: localAgentSessions - } - ] - : [] - - return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds) - } - - return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined - }, [ - agentGroups, - agentsGrouped, - localAgentSessions, - orderedSourceGroups, - profileGroups, - showAllProfiles, - workspaceOrderIds - ]) - - useEffect(() => { - if (!displayAgentGroups?.length || showAllProfiles) { - return - } - - const next = reconcileOrderIds( - displayAgentGroups.map(g => g.id), - workspaceOrderIds - ) - - if (!sameIds(next, workspaceOrderIds)) { - setSidebarWorkspaceOrderIds(next) - } - }, [displayAgentGroups, showAllProfiles, workspaceOrderIds]) - - const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const displayAgentSessions = agentSessions // Pagination is scope-aware. In "All profiles" mode it tracks the global // unified set. When scoped to one profile it must compare that profile's own @@ -680,6 +648,27 @@ export function ChatSidebar({ const recentsMeta = countLabel(agentSessions.length, knownSessionTotal) + const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined + + useEffect(() => { + if (!displayAgentGroups?.length || showAllProfiles) { + return + } + + const next = reconcileOrderIds( + displayAgentGroups.map(g => g.id), + workspaceOrderIds + ) + + if (!sameIds(next, workspaceOrderIds)) { + setSidebarWorkspaceOrderIds(next) + } + }, [displayAgentGroups, showAllProfiles, workspaceOrderIds]) + + const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 + + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) { return @@ -902,7 +891,7 @@ export function ChatSidebar({ // the toggle does nothing, and it's irrelevant in the ALL-profiles // view (always grouped by profile), so hide the button (not the slot).
- {!showAllProfiles && localAgentSessions.length > 0 ? ( + {!showAllProfiles && agentSessions.length > 0 ? (