mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Show first user message in resume picker
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user