Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
855 changes: 855 additions & 0 deletions docs/superpowers/plans/2026-04-05-session-sidebar-title-hardening.md

Large diffs are not rendered by default.

93 changes: 72 additions & 21 deletions server/coding-cli/session-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import chokidar from 'chokidar'
import { logger } from '../logger.js'
import { getPerfConfig, startPerfTimer } from '../perf-logger.js'
import { configStore, SessionOverride } from '../config-store.js'
import { extractTitleFromMessage } from '../title-utils.js'
import type { CodingCliProvider } from './provider.js'
import { makeSessionKey, type CodingCliSession, type CodingCliProviderName, type ProjectGroup } from './types.js'
import { sanitizeCodexTaskEventsForTruncatedSnippet } from './providers/codex.js'
import { resolveGitCheckoutRoot, resolveGitRepoRoot } from './utils.js'
import { extractFromIdeContext, isSystemContext, resolveGitCheckoutRoot, resolveGitRepoRoot } from './utils.js'
import { diffProjects } from '../sessions-sync/diff.js'
import type { SessionMetadataStore, SessionMetadataEntry } from '../session-metadata-store.js'

Expand Down Expand Up @@ -47,6 +48,19 @@ function maxDefined(a: number | undefined, b: number | undefined): number | unde
return Math.max(a, b)
}

function normalizeTitle(title: string | undefined): string | undefined {
const trimmed = title?.trim()
return trimmed ? trimmed : undefined
}

function resolveSessionTitle(
parsedTitle: string | undefined,
previousTitle: string | undefined,
storedTitle: string | undefined,
): string | undefined {
return normalizeTitle(parsedTitle) || normalizeTitle(previousTitle) || normalizeTitle(storedTitle)
}

// Byte pattern for a text user message (content is a string, not a tool_result array).
const USER_TEXT_PATTERN = Buffer.from('"role":"user","content":"')

Expand Down Expand Up @@ -247,15 +261,35 @@ async function readLightweightMeta(filePath: string): Promise<LightweightFileMet
if (Number.isFinite(parsed)) createdAt = parsed
}
if (!title) {
const isUser = obj?.role === 'user' || obj?.type === 'user' || obj?.message?.role === 'user'
const nestedMessagePayload =
obj?.type === 'response_item' && obj?.payload?.type === 'message'
? obj.payload
: undefined
const rawContent =
nestedMessagePayload?.content ??
obj?.message?.content ??
obj?.content
const isUser =
nestedMessagePayload?.role === 'user' ||
obj?.role === 'user' ||
obj?.type === 'user' ||
obj?.message?.role === 'user'
if (isUser) {
const content = obj?.message?.content || obj?.content
const text = typeof content === 'string'
? content
: Array.isArray(content)
? content.filter((b: any) => typeof b?.text === 'string').map((b: any) => b.text).join(' ')
const rawText = typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent
.filter((part: any) => typeof part?.text === 'string')
.map((part: any) => part.text)
.join('\n')
: undefined
if (typeof text === 'string' && text.trim()) title = text.trim().slice(0, 200)

const ideRequest = rawText ? extractFromIdeContext(rawText) : undefined
const candidate = ideRequest
|| (!isSystemContext(rawText ?? '') ? rawText?.replace(/<\/?image[^>]*>/g, '').trim() : '')
if (candidate) {
title = extractTitleFromMessage(candidate, 200)
}
}
}
if (sessionId && cwd && title && createdAt) break
Expand Down Expand Up @@ -718,7 +752,12 @@ export class CodingCliSessionIndexer {
}
}

private async updateCacheEntry(provider: CodingCliProvider, filePath: string, cacheKey: string) {
private async updateCacheEntry(
provider: CodingCliProvider,
filePath: string,
cacheKey: string,
sessionMetadata: Record<string, SessionMetadataEntry>,
) {
let stat: Stats
try {
stat = await fsp.stat(filePath)
Expand Down Expand Up @@ -772,6 +811,10 @@ export class CodingCliSessionIndexer {
const sessionId = meta.sessionId || provider.extractSessionId(filePath, meta)
const previous = cached?.lightweight ? undefined : cached?.baseSession
const sameSession = previous?.provider === provider.name && previous?.sessionId === sessionId
const metaKey = makeSessionKey(provider.name, sessionId)
const storedTitle = normalizeTitle(sessionMetadata[metaKey]?.derivedTitle)
const parsedTitle = normalizeTitle(meta.title)
const resolvedTitle = resolveSessionTitle(parsedTitle, sameSession ? previous?.title : undefined, storedTitle)
const appendOnlyReparse = sameSession && size >= (cached?.size ?? 0)
const createdAt = appendOnlyReparse
? minDefined(previous?.createdAt, meta.createdAt)
Expand All @@ -793,7 +836,7 @@ export class CodingCliSessionIndexer {
lastActivityAt,
createdAt,
messageCount: meta.messageCount,
title: meta.title,
title: resolvedTitle,
summary: meta.summary,
...(meta.firstUserMessage ? { firstUserMessage: meta.firstUserMessage } : {}),
cwd: meta.cwd,
Expand All @@ -806,6 +849,10 @@ export class CodingCliSessionIndexer {
codexTaskEvents: meta.codexTaskEvents,
}

if (this.sessionMetadataStore && parsedTitle && parsedTitle !== storedTitle) {
await this.sessionMetadataStore.set(provider.name, sessionId, { derivedTitle: parsedTitle })
Comment on lines +852 to +853
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle derived-title persistence failures as non-fatal

If sessionMetadataStore.set(...) fails (for example ENOSPC, read-only config dir, or transient I/O errors), updateCacheEntry throws before updating fileCache, and refresh() aborts the whole indexing pass. That means a metadata write failure can blank or stale the sidebar session list even though title persistence should be best-effort. Catch and log this write error so session indexing continues.

Useful? React with 👍 / 👎.

}

this.fileCache.set(cacheKey, {
provider: provider.name,
mtimeMs,
Expand Down Expand Up @@ -1035,14 +1082,17 @@ export class CodingCliSessionIndexer {
if (existing && existing.baseSession) continue

const sessionId = meta.sessionId || provider.extractSessionId(meta.filePath)
const metaKey = makeSessionKey(provider.name, sessionId)
const storedTitle = normalizeTitle(sessionMetadata[metaKey]?.derivedTitle)
const resolvedTitle = resolveSessionTitle(meta.title, existing?.baseSession?.title, storedTitle)
const projectPath = meta.cwd ? await resolveGitRepoRoot(meta.cwd) : meta.cwd
const baseSession: CodingCliSession = {
provider: provider.name,
sessionId,
projectPath,
lastActivityAt: meta.lastActivityAt || meta.mtimeMs,
createdAt: meta.createdAt,
title: meta.title,
title: resolvedTitle,
cwd: meta.cwd,
sourceFile: meta.filePath,
isSubagent: isSubagentSession(meta.filePath) || undefined,
Expand Down Expand Up @@ -1081,6 +1131,7 @@ export class CodingCliSessionIndexer {
filesByProvider: Map<CodingCliProvider, string[]>,
enabledSet: Set<string>,
seenCacheKeys: Set<string>,
sessionMetadata: Record<string, SessionMetadataEntry>,
): Promise<void> {
// Collect all file-based entries for enrichment. Files the lightweight scan couldn't
// parse (e.g. Codex with 14KB first lines) use file mtime as the recency estimate.
Expand Down Expand Up @@ -1128,7 +1179,7 @@ export class CodingCliSessionIndexer {
if (INDEXER_DELAY_MS > 0) {
await new Promise((r) => setTimeout(r, INDEXER_DELAY_MS))
}
await this.updateCacheEntry(provider, filePath, cacheKey)
await this.updateCacheEntry(provider, filePath, cacheKey, sessionMetadata)
enriched += 1
if (enriched % REFRESH_YIELD_EVERY === 0) {
await yieldToEventLoop()
Expand Down Expand Up @@ -1202,13 +1253,13 @@ export class CodingCliSessionIndexer {
})
fileCount += files.length

if (isColdStart) {
// Selective enrichment: only the most recent non-subagent sessions.
await this.enrichRecentSessions(
new Map([[provider, files]]), enabledSet, seenCacheKeys,
)
for (const f of files) seenCacheKeys.add(normalizeFilePath(f))
} else {
if (isColdStart) {
// Selective enrichment: only the most recent non-subagent sessions.
await this.enrichRecentSessions(
new Map([[provider, files]]), enabledSet, seenCacheKeys, sessionMetadata,
)
for (const f of files) seenCacheKeys.add(normalizeFilePath(f))
} else {
// Warm rescan: process all files (cache hits skip unchanged files).
for (const file of files) {
processedEntries += 1
Expand All @@ -1217,7 +1268,7 @@ export class CodingCliSessionIndexer {
}
const cacheKey = normalizeFilePath(file)
seenCacheKeys.add(cacheKey)
await this.updateCacheEntry(provider, file, cacheKey)
await this.updateCacheEntry(provider, file, cacheKey, sessionMetadata)
}
}
}
Expand Down Expand Up @@ -1274,7 +1325,7 @@ export class CodingCliSessionIndexer {
this.deleteCacheEntry(file)
continue
}
await this.updateCacheEntry(provider, file, file)
await this.updateCacheEntry(provider, file, file, sessionMetadata)
}
}

Expand Down
6 changes: 5 additions & 1 deletion server/session-metadata-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { logger } from './logger.js'

export interface SessionMetadataEntry {
sessionType?: string
derivedTitle?: string
}

interface MetadataFile {
Expand Down Expand Up @@ -126,7 +127,10 @@ export class SessionMetadataStore {
if (!sessions[provider]) {
sessions[provider] = safeRecord()
}
sessions[provider][sessionId] = { ...entry }
sessions[provider][sessionId] = {
...(sessions[provider][sessionId] ?? {}),
...entry,
}
await this.save({ version: 1, sessions })
})
}
Expand Down
46 changes: 38 additions & 8 deletions src/store/selectors/sidebarSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function buildSessionItems(
worktreeGrouping: WorktreeGrouping = 'repo',
): SidebarSessionItem[] {
const items: SidebarSessionItem[] = []
const itemsByKey = new Map<string, SidebarSessionItem>()
const runningSessionMap = new Map<string, { terminalId: string; createdAt: number; allTerminalIds: string[] }>()
const tabSessionMap = new Map<string, { hasTab: boolean }>()

Expand Down Expand Up @@ -108,7 +109,7 @@ export function buildSessionItems(
const effectivePath = worktreeGrouping === 'worktree'
? (session.checkoutPath || project.projectPath)
: project.projectPath
items.push({
const item: SidebarSessionItem = {
id: `session-${provider}-${session.sessionId}`,
sessionId: session.sessionId,
provider,
Expand All @@ -129,11 +130,13 @@ export function buildSessionItems(
isSubagent: session.isSubagent,
isNonInteractive: session.isNonInteractive,
firstUserMessage: session.firstUserMessage,
})
isFallback: undefined,
}
items.push(item)
itemsByKey.set(key, item)
}
}

const knownKeys = new Set(items.map((item) => `${item.provider}:${item.sessionId}`))
const paneTitles = panes?.paneTitles ?? {}

const pushFallbackItem = (input: {
Expand All @@ -146,14 +149,39 @@ export function buildSessionItems(
metadata?: SessionListMetadata
}) => {
const key = `${input.provider}:${input.sessionId}`
if (knownKeys.has(key)) return
knownKeys.add(key)
const existing = itemsByKey.get(key)
if (existing) {
existing.hasTab = true
existing.timestamp = Math.max(existing.timestamp, input.timestamp ?? 0)
const fallbackTitle = input.title?.trim()
if (!existing.hasTitle && fallbackTitle) {
existing.title = fallbackTitle
existing.hasTitle = true
}
const fallbackSessionType = input.metadata?.sessionType || input.sessionType
if (fallbackSessionType && (!existing.sessionType || existing.sessionType === existing.provider)) {
existing.sessionType = fallbackSessionType
}
if (!existing.cwd && input.cwd) {
existing.cwd = input.cwd
}
if (!existing.firstUserMessage && input.metadata?.firstUserMessage) {
existing.firstUserMessage = input.metadata.firstUserMessage
}
if (existing.isSubagent === undefined && input.metadata?.isSubagent !== undefined) {
existing.isSubagent = input.metadata.isSubagent
}
if (existing.isNonInteractive === undefined && input.metadata?.isNonInteractive !== undefined) {
existing.isNonInteractive = input.metadata.isNonInteractive
}
return
}

const fallbackTitle = input.title?.trim() || input.sessionId.slice(0, 8)
const runningTerminal = runningSessionMap.get(key)
const runningTerminalId = runningTerminal?.terminalId
const runningTerminalIds = runningTerminal?.allTerminalIds
items.push({
const item: SidebarSessionItem = {
id: `session-${input.provider}-${input.sessionId}`,
sessionId: input.sessionId,
provider: input.provider,
Expand All @@ -173,7 +201,9 @@ export function buildSessionItems(
isNonInteractive: input.metadata?.isNonInteractive,
firstUserMessage: input.metadata?.firstUserMessage,
isFallback: true,
})
}
items.push(item)
itemsByKey.set(key, item)
}

const collectFallbackItemsFromNode = (
Expand Down Expand Up @@ -323,7 +353,7 @@ export function filterSessionItemsByVisibility(
if (!settings.showSubagents && item.isSubagent) return false
if (settings.ignoreCodexSubagents && item.isSubagent && item.provider === 'codex') return false
if (shouldHideAsNonInteractive(item, settings.showNoninteractiveSessions)) return false
if (settings.hideEmptySessions && !item.hasTitle) return false
if (settings.hideEmptySessions && !item.hasTitle && !item.hasTab && !item.isRunning) return false
if (isExcludedByFirstUserMessage(item.firstUserMessage, exclusions, settings.excludeFirstChatMustStart)) return false
return true
})
Expand Down
76 changes: 75 additions & 1 deletion test/e2e/open-tab-session-sidebar-visibility.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import App from '@/App'
Expand Down Expand Up @@ -1150,4 +1150,78 @@ describe('open tab session sidebar visibility (e2e)', () => {
expect(screen.getAllByText('137 tour').length).toBeGreaterThan(0)
})
})

it('keeps an open Codex session visible when the indexed sidebar row is titleless', async () => {
const sessionId = 'codex-current'
fetchSidebarSessionsSnapshot.mockResolvedValue({
projects: [
{
projectPath: '/repo',
sessions: [
{
provider: 'codex',
sessionId,
projectPath: '/repo',
lastActivityAt: 40,
title: undefined,
cwd: '/repo',
},
],
},
],
totalSessions: 1,
oldestIncludedTimestamp: 40,
oldestIncludedSessionId: `codex:${sessionId}`,
hasMore: false,
})

const store = createStore({
tabs: [{
id: 'tab-1',
title: 'Investigate sidebar visibility',
mode: 'codex',
resumeSessionId: sessionId,
createdAt: Date.now(),
}],
panes: {
layouts: {
'tab-1': {
type: 'leaf',
id: 'pane-1',
content: {
kind: 'terminal',
mode: 'codex',
createRequestId: 'req-1',
status: 'running',
resumeSessionId: sessionId,
initialCwd: '/repo',
},
},
},
activePane: { 'tab-1': 'pane-1' },
paneTitles: { 'tab-1': { 'pane-1': 'Investigate sidebar visibility' } },
},
})

render(
<Provider store={store}>
<App />
</Provider>,
)

const sidebarList = await screen.findByTestId('sidebar-session-list')

await waitFor(() => {
expect(within(sidebarList).getAllByText('Investigate sidebar visibility').length).toBeGreaterThan(0)
})

act(() => {
broadcastWs({ type: 'sessions.changed', revision: 1 })
})

await waitFor(() => {
expect(fetchSidebarSessionsSnapshot).toHaveBeenCalled()
expect(within(sidebarList).getAllByText('Investigate sidebar visibility').length).toBeGreaterThan(0)
})
})
})
Loading
Loading