diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 12ac2fe57..7191ffd28 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -9,6 +9,7 @@ export const SessionsListParamsSchema = Type.Object( includeGlobal: Type.Optional(Type.Boolean()), includeUnknown: Type.Optional(Type.Boolean()), includeDerivedTitles: Type.Optional(Type.Boolean()), + includeLastMessage: Type.Optional(Type.Boolean()), label: Type.Optional(SessionLabelString), spawnedBy: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString), diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index d624632cb..73e43a69c 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -137,3 +137,53 @@ export function readFirstUserMessageFromTranscript( } return null; } + +const LAST_MSG_MAX_BYTES = 16384; +const LAST_MSG_MAX_LINES = 20; + +export function readLastMessagePreviewFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string | null { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return null; + + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const stat = fs.fstatSync(fd); + const size = stat.size; + if (size === 0) return null; + + const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES); + const readLen = Math.min(size, LAST_MSG_MAX_BYTES); + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, readStart); + + const chunk = buf.toString("utf-8"); + const lines = chunk.split(/\r?\n/).filter((l) => l.trim()); + const tailLines = lines.slice(-LAST_MSG_MAX_LINES); + + for (let i = tailLines.length - 1; i >= 0; i--) { + const line = tailLines[i]; + try { + const parsed = JSON.parse(line); + const msg = parsed?.message as TranscriptMessage | undefined; + if (msg?.role === "user" || msg?.role === "assistant") { + const text = extractTextFromContent(msg.content); + if (text) return text; + } + } catch { + // skip malformed + } + } + } catch { + // file error + } finally { + if (fd !== null) fs.closeSync(fd); + } + return null; +} diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 26ef11aec..79dc3f9a6 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -22,7 +22,10 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; -import { readFirstUserMessageFromTranscript } from "./session-utils.fs.js"; +import { + readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, +} from "./session-utils.fs.js"; import type { GatewayAgentRow, GatewaySessionRow, @@ -34,6 +37,7 @@ export { archiveFileOnDisk, capArrayByJsonBytes, readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; @@ -389,6 +393,7 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; const includeDerivedTitles = opts.includeDerivedTitles === true; + const includeLastMessage = opts.includeLastMessage === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const label = typeof opts.label === "string" ? opts.label.trim() : ""; const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : ""; @@ -503,15 +508,26 @@ export function listSessionsFromStore(params: { const finalSessions: GatewaySessionRow[] = sessions.map((s) => { const { entry, ...rest } = s; let derivedTitle: string | undefined; - if (includeDerivedTitles && entry?.sessionId) { - const firstUserMsg = readFirstUserMessageFromTranscript( - entry.sessionId, - storePath, - entry.sessionFile, - ); - derivedTitle = deriveSessionTitle(entry, firstUserMsg); + let lastMessagePreview: string | undefined; + if (entry?.sessionId) { + if (includeDerivedTitles) { + const firstUserMsg = readFirstUserMessageFromTranscript( + entry.sessionId, + storePath, + entry.sessionFile, + ); + derivedTitle = deriveSessionTitle(entry, firstUserMsg); + } + if (includeLastMessage) { + const lastMsg = readLastMessagePreviewFromTranscript( + entry.sessionId, + storePath, + entry.sessionFile, + ); + if (lastMsg) lastMessagePreview = lastMsg; + } } - return { ...rest, derivedTitle } satisfies GatewaySessionRow; + return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow; }); return { diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 08d1eeb3a..77774d6d7 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -14,6 +14,7 @@ export type GatewaySessionRow = { label?: string; displayName?: string; derivedTitle?: string; + lastMessagePreview?: string; channel?: string; subject?: string; groupChannel?: string; diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 2cd924179..580550347 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -65,6 +65,7 @@ export type GatewaySessionList = { lastTo?: string; lastAccountId?: string; derivedTitle?: string; + lastMessagePreview?: string; }>; }; @@ -185,6 +186,7 @@ export class GatewayChatClient { includeGlobal: opts?.includeGlobal, includeUnknown: opts?.includeUnknown, includeDerivedTitles: opts?.includeDerivedTitles, + includeLastMessage: opts?.includeLastMessage, agentId: opts?.agentId, }); } diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 88eea609f..f0749959a 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -155,21 +155,30 @@ export function createCommandHandlers(context: CommandHandlerContext) { includeGlobal: false, includeUnknown: false, includeDerivedTitles: true, + includeLastMessage: true, agentId: state.currentAgentId, }); const items = result.sessions.map((session) => { const title = session.derivedTitle ?? session.displayName; const formattedKey = formatSessionKey(session.key); + // Avoid redundant "title (key)" when title matches key + const label = + title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; + // Build description: time + message preview + const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; + const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); + const description = preview ? `${timePart} ยท ${preview}` : timePart; return { value: session.key, - label: title ? `${title} (${formattedKey})` : formattedKey, - description: session.updatedAt ? formatRelativeTime(session.updatedAt) : "", + label, + description, searchText: [ session.displayName, session.label, session.subject, session.sessionId, session.key, + session.lastMessagePreview, ] .filter(Boolean) .join(" "),