From 856af6d8b2f1f919b8213171ecd303a3ca693e2a Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 20 May 2026 20:31:40 +0800 Subject: [PATCH] Show first user message in resume picker --- cli/src/ui/ink/ResumeSessionPicker.tsx | 6 +++ .../ui/ink/resumeSessionPickerState.test.ts | 13 ++++- cli/src/ui/ink/resumeSessionPickerState.ts | 3 +- hub/src/store/messageStore.ts | 6 ++- hub/src/store/messages.ts | 14 +++++ hub/src/sync/sessionModel.test.ts | 43 +++++++++++++++ hub/src/sync/syncEngine.ts | 52 ++++++++++++++++++- shared/src/resume.test.ts | 3 +- shared/src/resume.ts | 3 +- 9 files changed, 137 insertions(+), 6 deletions(-) diff --git a/cli/src/ui/ink/ResumeSessionPicker.tsx b/cli/src/ui/ink/ResumeSessionPicker.tsx index 15d50d06..1fed864b 100644 --- a/cli/src/ui/ink/ResumeSessionPicker.tsx +++ b/cli/src/ui/ink/ResumeSessionPicker.tsx @@ -152,6 +152,7 @@ export const ResumeSessionPicker: React.FC = ({ 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 ( @@ -181,6 +182,11 @@ export const ResumeSessionPicker: React.FC = ({ })} + + Title: {selectedTitle ? truncateText(selectedTitle, Math.max(10, terminalWidth - 11)) : '-'} + + + Directory: {selectedSession ? truncateText(selectedSession.directory, Math.max(10, terminalWidth - 15)) : '-'} diff --git a/cli/src/ui/ink/resumeSessionPickerState.test.ts b/cli/src/ui/ink/resumeSessionPickerState.test.ts index e943179e..2c8d2272 100644 --- a/cli/src/ui/ink/resumeSessionPickerState.test.ts +++ b/cli/src/ui/ink/resumeSessionPickerState.test.ts @@ -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 { } 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']) diff --git a/cli/src/ui/ink/resumeSessionPickerState.ts b/cli/src/ui/ink/resumeSessionPickerState.ts index 1ebde16f..ca72ef37 100644 --- a/cli/src/ui/ink/resumeSessionPickerState.ts +++ b/cli/src/ui/ink/resumeSessionPickerState.ts @@ -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, diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 20af6725..90fe0b16 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -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) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 9bef82bb..019c403f 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -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 diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index adafda42..49ef20bf 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -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( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index afa864fa..c4bff8b7 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : 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 { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { diff --git a/shared/src/resume.test.ts b/shared/src/resume.test.ts index 02362160..471be6d6 100644 --- a/shared/src/resume.test.ts +++ b/shared/src/resume.test.ts @@ -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) diff --git a/shared/src/resume.ts b/shared/src/resume.ts index 4bd388b9..d5cbe8eb 100644 --- a/shared/src/resume.ts +++ b/shared/src/resume.ts @@ -25,7 +25,8 @@ export type LocalResumeTarget = z.infer 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