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:
committed by
Peter Steinberger
parent
14f56a4e18
commit
1d9d5b30ce
@@ -9,6 +9,7 @@ export const SessionsListParamsSchema = Type.Object(
|
|||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||||
|
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||||
label: Type.Optional(SessionLabelString),
|
label: Type.Optional(SessionLabelString),
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
agentId: Type.Optional(NonEmptyString),
|
agentId: Type.Optional(NonEmptyString),
|
||||||
|
|||||||
@@ -137,3 +137,53 @@ export function readFirstUserMessageFromTranscript(
|
|||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import {
|
|||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.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 {
|
import type {
|
||||||
GatewayAgentRow,
|
GatewayAgentRow,
|
||||||
GatewaySessionRow,
|
GatewaySessionRow,
|
||||||
@@ -34,6 +37,7 @@ export {
|
|||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
|
readLastMessagePreviewFromTranscript,
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
@@ -389,6 +393,7 @@ export function listSessionsFromStore(params: {
|
|||||||
const includeGlobal = opts.includeGlobal === true;
|
const includeGlobal = opts.includeGlobal === true;
|
||||||
const includeUnknown = opts.includeUnknown === true;
|
const includeUnknown = opts.includeUnknown === true;
|
||||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||||
|
const includeLastMessage = opts.includeLastMessage === true;
|
||||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||||
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||||
@@ -503,15 +508,26 @@ export function listSessionsFromStore(params: {
|
|||||||
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
|
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
|
||||||
const { entry, ...rest } = s;
|
const { entry, ...rest } = s;
|
||||||
let derivedTitle: string | undefined;
|
let derivedTitle: string | undefined;
|
||||||
if (includeDerivedTitles && entry?.sessionId) {
|
let lastMessagePreview: string | undefined;
|
||||||
const firstUserMsg = readFirstUserMessageFromTranscript(
|
if (entry?.sessionId) {
|
||||||
entry.sessionId,
|
if (includeDerivedTitles) {
|
||||||
storePath,
|
const firstUserMsg = readFirstUserMessageFromTranscript(
|
||||||
entry.sessionFile,
|
entry.sessionId,
|
||||||
);
|
storePath,
|
||||||
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
|
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 {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type GatewaySessionRow = {
|
|||||||
label?: string;
|
label?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
derivedTitle?: string;
|
derivedTitle?: string;
|
||||||
|
lastMessagePreview?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
groupChannel?: string;
|
groupChannel?: string;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type GatewaySessionList = {
|
|||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
lastAccountId?: string;
|
lastAccountId?: string;
|
||||||
derivedTitle?: string;
|
derivedTitle?: string;
|
||||||
|
lastMessagePreview?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ export class GatewayChatClient {
|
|||||||
includeGlobal: opts?.includeGlobal,
|
includeGlobal: opts?.includeGlobal,
|
||||||
includeUnknown: opts?.includeUnknown,
|
includeUnknown: opts?.includeUnknown,
|
||||||
includeDerivedTitles: opts?.includeDerivedTitles,
|
includeDerivedTitles: opts?.includeDerivedTitles,
|
||||||
|
includeLastMessage: opts?.includeLastMessage,
|
||||||
agentId: opts?.agentId,
|
agentId: opts?.agentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,21 +155,30 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
includeGlobal: false,
|
includeGlobal: false,
|
||||||
includeUnknown: false,
|
includeUnknown: false,
|
||||||
includeDerivedTitles: true,
|
includeDerivedTitles: true,
|
||||||
|
includeLastMessage: true,
|
||||||
agentId: state.currentAgentId,
|
agentId: state.currentAgentId,
|
||||||
});
|
});
|
||||||
const items = result.sessions.map((session) => {
|
const items = result.sessions.map((session) => {
|
||||||
const title = session.derivedTitle ?? session.displayName;
|
const title = session.derivedTitle ?? session.displayName;
|
||||||
const formattedKey = formatSessionKey(session.key);
|
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 {
|
return {
|
||||||
value: session.key,
|
value: session.key,
|
||||||
label: title ? `${title} (${formattedKey})` : formattedKey,
|
label,
|
||||||
description: session.updatedAt ? formatRelativeTime(session.updatedAt) : "",
|
description,
|
||||||
searchText: [
|
searchText: [
|
||||||
session.displayName,
|
session.displayName,
|
||||||
session.label,
|
session.label,
|
||||||
session.subject,
|
session.subject,
|
||||||
session.sessionId,
|
session.sessionId,
|
||||||
session.key,
|
session.key,
|
||||||
|
session.lastMessagePreview,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" "),
|
.join(" "),
|
||||||
|
|||||||
Reference in New Issue
Block a user