feat: add last message preview to session picker

Read the final user/assistant message from session transcripts and display
it in the picker alongside the session update time. Allows quick previews
of what's in each session without opening it.
This commit is contained in:
CJ Winslow
2026-01-18 18:14:09 -08:00
committed by Peter Steinberger
parent 14f56a4e18
commit 1d9d5b30ce
6 changed files with 90 additions and 11 deletions

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -14,6 +14,7 @@ export type GatewaySessionRow = {
label?: string;
displayName?: string;
derivedTitle?: string;
lastMessagePreview?: string;
channel?: string;
subject?: string;
groupChannel?: string;

View File

@@ -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,
});
}

View File

@@ -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(" "),