fix(desktop): resolve tab titles from the cron and messaging slices (#95096)

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.
This commit is contained in:
teamster22
2026-09-03 05:00:00 +00:00
committed by brooklyn!
parent 627bf49baa
commit 5c3172005d
3 changed files with 120 additions and 12 deletions

View File

@@ -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> = {}): 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()
})
})

View File

@@ -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<SessionTile>({
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,

View File

@@ -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)