From 5c3172005ddc1913b6d7338e94fa0ee5a8156977 Mon Sep 17 00:00:00 2001 From: teamster22 Date: Thu, 3 Sep 2026 05:00:00 +0000 Subject: [PATCH] fix(desktop): resolve tab titles from the cron and messaging slices (#95096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvage of PR #102346 by teamster22, rebased onto current main. The sidebar fetch splits its rows into three mutually exclusive source-scoped slices ($sessions recents / $cronSessions / $messagingSessions), and recents excludes every messaging and cron source. All three tab-title resolution sites (tileStoredRow, syncWorkspaceTitle, workspaceDragPayload) searched recents only, so a telegram/matrix/discord-origin session tab read "New session" forever even while its sidebar row showed the real title — a partition miss, not a timing miss, so no activity could ever fix it. Route the three resolvers through ownerLookupSessionRows() (the three-slice concat added for this bug class in #95633, which preserves $sessions' array identity when the other slices are empty) and add $cronSessions / $messagingSessions to watchSessionTiles' `also` array and syncWorkspaceTitle's listeners so the late-arriving slices re-sync the label. Closes #95096. --- .../app/chat/session-tile-slice-title.test.ts | 76 +++++++++++++++++++ apps/desktop/src/app/chat/session-tile.tsx | 31 +++++--- apps/desktop/src/app/contrib/controller.tsx | 25 +++++- 3 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-tile-slice-title.test.ts diff --git a/apps/desktop/src/app/chat/session-tile-slice-title.test.ts b/apps/desktop/src/app/chat/session-tile-slice-title.test.ts new file mode 100644 index 0000000000..9881c1aa7c --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-slice-title.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { $projectTree } from '@/store/projects' +import { $cronSessions, $messagingSessions, $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { tileStoredRow } from './session-tile' + +const STORED = 'tg-chat-1' +const TITLE = 'Debug the office sensor' + +function row(overrides: Partial = {}): SessionInfo { + return { + cwd: null, + ended_at: null, + id: STORED, + input_tokens: 0, + is_active: true, + last_active: 1, + message_count: 466, + model: null, + output_tokens: 0, + parent_session_id: null, + preview: null, + source: 'telegram', + started_at: 1, + title: TITLE, + tool_call_count: 0, + ...overrides + } +} + +/** A tab for a gateway conversation used to read "New session" forever. + * The sidebar fetch splits its rows into three source-scoped slices, and + * recents EXCLUDES every messaging/cron source — so a telegram tab's row is + * only ever in `$messagingSessions`. `tileStoredRow` searched recents alone, + * missed it, and `tileTitle` fell through to NEW_SESSION_TITLE. No amount of + * activity could fix it: the row is never eligible for recents. */ +describe('tileStoredRow resolves across every sidebar slice', () => { + afterEach(() => { + $sessions.set([]) + $cronSessions.set([]) + $messagingSessions.set([]) + $projectTree.set([]) + }) + + it('resolves a local recents row', () => { + $sessions.set([row({ source: 'desktop' })]) + + expect(tileStoredRow(STORED)?.title).toBe(TITLE) + }) + + it('resolves a telegram row listed only in the messaging slice', () => { + $messagingSessions.set([row()]) + + expect(tileStoredRow(STORED)?.title).toBe(TITLE) + }) + + it('resolves a cron row listed only in the cron slice', () => { + $cronSessions.set([row({ source: 'cron', title: 'nightly-report' })]) + + expect(tileStoredRow(STORED)?.title).toBe('nightly-report') + }) + + it('matches a messaging row across compression by lineage root', () => { + $messagingSessions.set([row({ _lineage_root_id: STORED, id: 'tg-chat-1-compressed-2' })]) + + expect(tileStoredRow(STORED)?.title).toBe(TITLE) + }) + + it('still misses an id no slice holds, so the placeholder path survives', () => { + $messagingSessions.set([row({ id: 'someone-else' })]) + + expect(tileStoredRow(STORED)).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 1ba8f08ef4..ea927f4a7a 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -49,6 +49,7 @@ import { $messagingSessions, $selectedStoredSessionId, $sessions, + ownerLookupSessionRows, sessionMatchesStoredId, sessionPinId } from '@/store/session' @@ -588,16 +589,23 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } // Tile -> pane contribution sync (call once from the app root). // --------------------------------------------------------------------------- -/** Resolve a tile's stored row: the recents list first, then the project - * tree. A session opened as a tab from a project group is often older than - * the paginated recents page, so it has no `$sessions` row at all until new - * activity lands it there — resolving through the tree keeps its tab titled - * and tinted instead of a grey "Session" placeholder. */ +/** Resolve a tile's stored row: every loaded sidebar slice first, then the + * project tree. A session opened as a tab from a project group is often older + * than the paginated recents page, so it has no `$sessions` row at all until + * new activity lands it there — resolving through the tree keeps its tab + * titled and tinted instead of a grey "Session" placeholder. + * + * The slice scan is `ownerLookupSessionRows`, not `$sessions`: the sidebar + * fetch splits its rows three ways, and a telegram/discord/cron conversation + * is listed ONLY in `$messagingSessions` / `$cronSessions` — recents excludes + * those sources outright. Searching recents alone made every such tab read + * "New session" forever, since the row it needed was one atom over and no + * amount of activity would ever move it into recents. */ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined { const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId) return ( - $sessions.get().find(match) ?? + ownerLookupSessionRows().find(match) ?? $projectTree .get() .flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])]) @@ -868,9 +876,14 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) export const watchSessionTiles = paneMirror({ source: $sessionTiles, // $projectTree: a tile whose session is older than the recents page resolves - // its title through the tree, which loads after the tiles register. (The tab's - // status dot subscribes to color/state itself, so it needs no `also` entry.) - also: [$sessions, $projectTree, $workspaceOwnerLabels], + // its title through the tree, which loads after the tiles register. + // $cronSessions/$messagingSessions: `tileStoredRow` reads every sidebar + // slice, so the strip must re-sync when the slice that owns a gateway + // conversation lands — it arrives on its own fetch, after the tiles register, + // and without it the tab stays stuck on its "New session" placeholder. + // (The tab's status dot subscribes to color/state itself, so it needs no + // `also` entry.) + also: [$sessions, $cronSessions, $messagingSessions, $projectTree, $workspaceOwnerLabels], key: t => t.storedSessionId, prefix: 'session-tile', dir: t => t.dir, diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 63515cfded..7ce186c26b 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -83,7 +83,16 @@ import { openReview, REVIEW_PANE_ID } from '@/store/review' -import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' +import { + $cronSessions, + $currentCwd, + $messagingSessions, + $selectedStoredSessionId, + $sessions, + $yoloActive, + ownerLookupSessionRows, + sessionMatchesStoredId +} from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' import { $botChatScopes } from '@/store/session-states' import { watchUnreadWriteGuard } from '@/store/session-unread-remote' @@ -153,7 +162,7 @@ const workspaceDragPayload = (): SessionDragPayload | null => { return null } - const stored = $sessions.get().find(s => sessionMatchesStoredId(s, selected)) + const stored = ownerLookupSessionRows().find(s => sessionMatchesStoredId(s, selected)) return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' } } @@ -487,7 +496,12 @@ watchUnreadWriteGuard() // above, so the pane content never remounts. const syncWorkspaceTitle = () => { const selected = $selectedStoredSessionId.get() - const stored = selected ? $sessions.get().find(s => sessionMatchesStoredId(s, selected)) : null + // Every loaded slice, not just recents: a telegram/discord/cron conversation + // is listed ONLY in $messagingSessions / $cronSessions (recents excludes + // those sources), so a recents-only scan missed the row and fell through to + // the NEW_SESSION_TITLE placeholder — a loaded gateway chat titled + // "New session" in the tab while its sidebar row read correctly. + const stored = selected ? ownerLookupSessionRows().find(s => sessionMatchesStoredId(s, selected)) : null registry.register({ id: 'workspace', @@ -531,6 +545,11 @@ const syncWorkspaceTitle = () => { $selectedStoredSessionId.listen(syncWorkspaceTitle) $sessions.listen(syncWorkspaceTitle) +// The cron and messaging slices arrive on their OWN fetch, after a restored +// tab has already registered. Without these listens the workspace tab keeps +// whatever it resolved at register time — "New session" for a gateway chat. +$cronSessions.listen(syncWorkspaceTitle) +$messagingSessions.listen(syncWorkspaceTitle) $botChatScopes.listen(syncWorkspaceTitle) $workspaceOwnerLabels.listen(syncWorkspaceTitle) $workspaceIsPage.listen(syncWorkspaceTitle)