Show first user message in resume picker

This commit is contained in:
weishu
2026-05-20 20:31:40 +08:00
parent 2ef90f84fb
commit 856af6d8b2
9 changed files with 137 additions and 6 deletions
+6
View File
@@ -152,6 +152,7 @@ export const ResumeSessionPicker: React.FC<ResumeSessionPickerProps> = ({
const width = Math.max(40, terminalWidth - 4)
const shownStart = filteredSessions.length === 0 ? 0 : scrollOffset + 1
const shownEnd = Math.min(filteredSessions.length, scrollOffset + visibleSessions.length)
const selectedTitle = selectedSession?.name ?? selectedSession?.summary ?? selectedSession?.sessionId
return (
<Box flexDirection="column" width={terminalWidth}>
@@ -181,6 +182,11 @@ export const ResumeSessionPicker: React.FC<ResumeSessionPickerProps> = ({
})}
</Box>
<Box marginTop={1}>
<Text color="gray">
Title: {selectedTitle ? truncateText(selectedTitle, Math.max(10, terminalWidth - 11)) : '-'}
</Text>
</Box>
<Box>
<Text color="gray">
Directory: {selectedSession ? truncateText(selectedSession.directory, Math.max(10, terminalWidth - 15)) : '-'}
</Text>
@@ -3,6 +3,7 @@ import type { ResumableSession } from '@hapi/protocol'
import {
filterResumeSessions,
formatResumeSessionRelativeTime,
getResumeSessionName,
reducePickerState,
type PickerState
} from './resumeSessionPickerState'
@@ -23,6 +24,15 @@ function session(overrides: Partial<ResumableSession>): ResumableSession {
}
describe('resumeSessionPickerState', () => {
it('uses the first user message as the list label before title or summary', () => {
expect(getResumeSessionName(session({
sessionId: 'session-title',
name: 'Generated title',
summary: 'Generated summary',
firstUserMessage: 'First prompt'
}))).toBe('First prompt')
})
it('formats updatedAt as relative time', () => {
const now = 1_700_000_000_000
@@ -39,6 +49,7 @@ describe('resumeSessionPickerState', () => {
session({
sessionId: 'alpha',
name: 'Payment Refactor',
firstUserMessage: 'Implement billing flow',
directory: '/repo/api',
agentSessionId: 'thread-a'
}),
@@ -58,7 +69,7 @@ describe('resumeSessionPickerState', () => {
})
]
expect(filterResumeSessions(sessions, 'payment').map((item) => item.sessionId)).toEqual(['alpha'])
expect(filterResumeSessions(sessions, 'billing').map((item) => item.sessionId)).toEqual(['alpha'])
expect(filterResumeSessions(sessions, 'MOBILE').map((item) => item.sessionId)).toEqual(['beta'])
expect(filterResumeSessions(sessions, 'thread-c').map((item) => item.sessionId)).toEqual(['gamma'])
expect(filterResumeSessions(sessions, 'remote').map((item) => item.sessionId)).toEqual(['gamma'])
+2 -1
View File
@@ -17,7 +17,7 @@ export type PickerKey =
| 'escape'
export function getResumeSessionName(session: ResumableSession): string {
return session.name ?? session.summary ?? session.sessionId
return session.firstUserMessage ?? session.summary ?? session.sessionId
}
export function getResumeSessionState(session: ResumableSession): string {
@@ -55,6 +55,7 @@ export function filterResumeSessions(
const fields = [
session.name,
session.summary,
session.firstUserMessage,
session.sessionId,
session.agentSessionId,
session.directory,
+5 -1
View File
@@ -1,7 +1,7 @@
import type { Database } from 'bun:sqlite'
import type { StoredMessage } from './types'
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages'
export class MessageStore {
private readonly db: Database
@@ -18,6 +18,10 @@ export class MessageStore {
return getMessages(this.db, sessionId, limit)
}
getFirstMessages(sessionId: string, limit: number = 50): StoredMessage[] {
return getFirstMessages(this.db, sessionId, limit)
}
getDeliverableMessagesAfter(sessionId: string, afterSeq: number, now: number, limit: number = 200): StoredMessage[] {
return getDeliverableMessagesAfter(this.db, sessionId, afterSeq, now, limit)
}
+14
View File
@@ -106,6 +106,20 @@ export function getMessages(
return rows.reverse().map(toStoredMessage)
}
export function getFirstMessages(
db: Database,
sessionId: string,
limit: number = 50
): StoredMessage[] {
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, limit)) : 50
const rows = db.prepare(
'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC LIMIT ?'
).all(sessionId, safeLimit) as DbMessageRow[]
return rows.map(toStoredMessage)
}
/** CLI reconnect backfill: returns messages above the seq cursor that are
* deliverable now, i.e. excludes future-scheduled rows (scheduled_at > now).
* Without this filter, a CLI reconnect between schedule time and release time
+43
View File
@@ -991,6 +991,49 @@ describe('session model', () => {
}
})
it('includes first user message in local resumable sessions', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-first-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
name: 'Generated title'
},
null,
'default'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: { type: 'text', text: 'agent warmup' }
})
store.messages.addMessage(session.id, {
role: 'user',
content: { type: 'text', text: ' Build the picker\nwith search ' }
})
const sessions = engine.listLocalResumableSessions('default', { machineId: 'machine-1' })
expect(sessions.find((item) => item.sessionId === session.id)).toMatchObject({
name: 'Generated title',
firstUserMessage: 'Build the picker with search'
})
} finally {
engine.stop()
}
})
it('local handoff succeeds immediately for inactive sessions', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+51 -1
View File
@@ -9,6 +9,7 @@
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import type { Server } from 'socket.io'
import type { Store, CancelQueuedMessageResult } from '../store'
import type { RpcRegistry } from '../socket/rpcRegistry'
@@ -61,6 +62,42 @@ export type LocalHandoffResult =
| { type: 'success' }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' }
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function normalizeUserMessageText(value: string): string | undefined {
const text = value.trim().replace(/\s+/g, ' ')
return text.length > 0 ? text : undefined
}
function extractUserMessageText(content: unknown): string | undefined {
if (typeof content === 'string') {
return normalizeUserMessageText(content)
}
if (Array.isArray(content)) {
const parts = content
.map((block) => {
const record = asRecord(block)
return record?.type === 'text' && typeof record.text === 'string'
? record.text
: null
})
.filter((text): text is string => text !== null)
return normalizeUserMessageText(parts.join(' '))
}
const record = asRecord(content)
if (record?.type === 'text' && typeof record.text === 'string') {
return normalizeUserMessageText(record.text)
}
return undefined
}
export class SyncEngine {
private readonly eventPublisher: EventPublisher
private readonly sessionCache: SessionCache
@@ -521,13 +558,26 @@ export class SyncEngine {
collaborationMode: target.collaborationMode,
updatedAt: session?.updatedAt ?? 0,
name: session?.metadata?.name,
summary: session?.metadata?.summary?.text
summary: session?.metadata?.summary?.text,
firstUserMessage: this.resolveFirstUserMessage(target.sessionId)
}
})
.filter((session) => !opts?.machineId || session.machineId === opts.machineId)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
private resolveFirstUserMessage(sessionId: string): string | undefined {
for (const message of this.store.messages.getFirstMessages(sessionId, 50)) {
const roleWrapped = unwrapRoleWrappedRecordEnvelope(message.content)
if (roleWrapped?.role !== 'user') continue
const text = extractUserMessageText(roleWrapped.content)
if (text) return text
}
return undefined
}
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
+2 -1
View File
@@ -36,7 +36,8 @@ describe('resume schemas', () => {
agentSessionId: '11111111-1111-4111-8111-111111111111',
updatedAt: 123,
name: 'project work',
summary: 'finish docs'
summary: 'finish docs',
firstUserMessage: 'implement resume picker'
})
expect(parsed.success).toBe(true)
+2 -1
View File
@@ -25,7 +25,8 @@ export type LocalResumeTarget = z.infer<typeof LocalResumeTargetSchema>
export const ResumableSessionSchema = LocalResumeTargetSchema.extend({
updatedAt: z.number(),
name: z.string().optional(),
summary: z.string().optional()
summary: z.string().optional(),
firstUserMessage: z.string().optional()
})
export type ResumableSession = z.infer<typeof ResumableSessionSchema>