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:
76
apps/desktop/src/app/chat/session-tile-slice-title.test.ts
Normal file
76
apps/desktop/src/app/chat/session-tile-slice-title.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user