Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 89 additions & 30 deletions assistant/src/daemon/handlers/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,21 @@ export function handleHistoryRequest(
socket: net.Socket,
ctx: HandlerContext,
): void {
const dbMessages = conversationStore.getMessages(msg.sessionId);
const limit = msg.limit ?? 50;
Comment thread
ashleeradka marked this conversation as resolved.

// Resolve include flags: explicit flags override mode, mode provides defaults.
// Default mode is 'light' when no mode and no include flags are specified.
const isFullMode = msg.mode === 'full';
const includeAttachments = msg.includeAttachments ?? isFullMode;
const includeToolImages = msg.includeToolImages ?? isFullMode;
const includeSurfaceData = msg.includeSurfaceData ?? isFullMode;

const { messages: dbMessages, hasMore } = conversationStore.getMessagesPaginated(
msg.sessionId,
limit,
msg.beforeTimestamp,
);

const parsed: ParsedHistoryMessage[] = dbMessages.map((m) => {
let text = '';
let toolCalls: HistoryToolCall[] = [];
Expand Down Expand Up @@ -597,52 +611,97 @@ export function handleHistoryRequest(
if (m.role === 'assistant' && m.id) {
const linked = getAttachmentsForMessage(m.id);
if (linked.length > 0) {
// Skip embedding base64 data for large video attachments to keep the
// history_response payload small. Only videos have a lazy-fetch path on
// the client, so non-video attachments always keep their inline data.
const MAX_INLINE_B64_SIZE = 512 * 1024;
attachments = linked.map((a) => {
const isFileBacked = !a.dataBase64; // empty string = file-backed attachment
const omit = isFileBacked || (a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE);

// Lazily generate thumbnails for existing video attachments on first history load.
// Skip for file-backed attachments — there is no in-memory base64 to generate from.
if (a.mimeType.startsWith('video/') && !a.thumbnailBase64 && a.dataBase64) {
const attachmentId = a.id;
const base64 = a.dataBase64;
silentlyWithLog(
generateVideoThumbnail(base64).then((thumb) => {
if (thumb) setAttachmentThumbnail(attachmentId, thumb);
}),
'video thumbnail generation',
);
}

return {
if (includeAttachments) {
// Full attachment data: same behavior as before
const MAX_INLINE_B64_SIZE = 512 * 1024;
attachments = linked.map((a) => {
const isFileBacked = !a.dataBase64;
const omit = isFileBacked || (a.mimeType.startsWith('video/') && a.dataBase64.length > MAX_INLINE_B64_SIZE);

if (a.mimeType.startsWith('video/') && !a.thumbnailBase64 && a.dataBase64) {
const attachmentId = a.id;
const base64 = a.dataBase64;
silentlyWithLog(
generateVideoThumbnail(base64).then((thumb) => {
if (thumb) setAttachmentThumbnail(attachmentId, thumb);
}),
'video thumbnail generation',
);
}

return {
id: a.id,
filename: a.originalFilename,
mimeType: a.mimeType,
data: omit ? '' : a.dataBase64,
...(omit ? { sizeBytes: a.sizeBytes } : {}),
...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}),
};
});
} else {
// Light mode: metadata only, strip base64 data
attachments = linked.map((a) => ({
id: a.id,
filename: a.originalFilename,
mimeType: a.mimeType,
data: omit ? '' : a.dataBase64,
...(omit ? { sizeBytes: a.sizeBytes } : {}),
data: '',
sizeBytes: a.sizeBytes,
...(a.thumbnailBase64 ? { thumbnailData: a.thumbnailBase64 } : {}),
};
});
}));
}
}
}

// In light mode, strip imageData from tool calls
const filteredToolCalls = m.toolCalls.length > 0
? (includeToolImages
? m.toolCalls
: m.toolCalls.map((tc) => {
if (tc.imageData) {
const { imageData: _, ...rest } = tc;
return rest;
}
return tc;
}))
: m.toolCalls;

// In light mode, strip full data from surfaces (keep metadata)
const filteredSurfaces = m.surfaces.length > 0
? (includeSurfaceData
? m.surfaces
: m.surfaces.map((s) => ({
surfaceId: s.surfaceId,
surfaceType: s.surfaceType,
title: s.title,
data: {} as Record<string, unknown>,
...(s.actions ? { actions: s.actions } : {}),
...(s.display ? { display: s.display } : {}),
})))
: m.surfaces;

return {
...(m.id ? { id: m.id } : {}),
role: m.role,
text: m.text,
timestamp: m.timestamp,
...(m.toolCalls.length > 0 ? { toolCalls: m.toolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}),
...(filteredToolCalls.length > 0 ? { toolCalls: filteredToolCalls, toolCallsBeforeText: m.toolCallsBeforeText } : {}),
...(attachments ? { attachments } : {}),
...(m.textSegments.length > 0 ? { textSegments: m.textSegments } : {}),
...(m.contentOrder.length > 0 ? { contentOrder: m.contentOrder } : {}),
...(m.surfaces.length > 0 ? { surfaces: m.surfaces } : {}),
...(filteredSurfaces.length > 0 ? { surfaces: filteredSurfaces } : {}),
...(m.subagentNotification ? { subagentNotification: m.subagentNotification } : {}),
};
});
ctx.send(socket, { type: 'history_response', sessionId: msg.sessionId, messages: historyMessages });

const oldestTimestamp = historyMessages.length > 0 ? historyMessages[0].timestamp : undefined;

ctx.send(socket, {
type: 'history_response',
sessionId: msg.sessionId,
messages: historyMessages,
hasMore,
...(oldestTimestamp !== undefined ? { oldestTimestamp } : {}),
});

// Surfaces are now included directly in the history_response message (in the surfaces array),
// so we no longer emit separate ui_surface_show messages during history loading.
Expand Down
16 changes: 16 additions & 0 deletions assistant/src/daemon/ipc-contract/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ export interface ImageGenModelSetRequest {
export interface HistoryRequest {
type: 'history_request';
sessionId: string;
/** Max messages to return. Defaults to 50. */
limit?: number;
/** Pagination cursor: return messages with timestamp before this value. */
beforeTimestamp?: number;
/** Include attachment base64 data. Defaults to false in light mode. */
includeAttachments?: boolean;
/** Include tool screenshot base64 data. Defaults to false in light mode. */
includeToolImages?: boolean;
/** Include surface HTML payloads. Defaults to false in light mode. */
includeSurfaceData?: boolean;
/** Shorthand: 'light' = all include flags false (default), 'full' = all include flags true. */
mode?: 'light' | 'full';
}

export interface UndoRequest {
Expand Down Expand Up @@ -263,6 +275,10 @@ export interface HistoryResponse {
conversationId?: string;
};
}>;
/** Whether older messages exist beyond the returned page. */
hasMore: boolean;
/** Timestamp of the oldest message in the response (client uses as next pagination cursor). */
oldestTimestamp?: number;
}

export interface UndoComplete {
Expand Down
42 changes: 41 additions & 1 deletion assistant/src/memory/conversation-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { and, asc, count, desc, eq, inArray, isNull, lt, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { z } from 'zod';

Expand Down Expand Up @@ -323,6 +323,46 @@ export function getMessages(conversationId: string): MessageRow[] {
.map(parseMessage);
}

export interface PaginatedMessagesResult {
messages: MessageRow[];
/** Whether older messages exist beyond the returned page. */
hasMore: boolean;
}

/**
* Paginated variant of getMessages. Returns the most recent `limit` messages
* (optionally before a cursor timestamp), in chronological order.
*/
export function getMessagesPaginated(
conversationId: string,
limit: number,
beforeTimestamp?: number,
): PaginatedMessagesResult {
const db = getDb();
const conditions = [eq(messages.conversationId, conversationId)];
if (beforeTimestamp !== undefined) {
conditions.push(lt(messages.createdAt, beforeTimestamp));
Comment thread
ashleeradka marked this conversation as resolved.
}

// Fetch limit+1 rows ordered newest-first so we can detect hasMore
const rows = db
.select()
.from(messages)
.where(and(...conditions))
.orderBy(desc(messages.createdAt))
.limit(limit + 1)
.all()
.map(parseMessage);

const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;

// Return in chronological order (oldest first) for the client
page.reverse();

return { messages: page, hasMore };
}

export function updateConversationTitle(id: string, title: string, isAutoTitle?: number): void {
const db = getDb();
const set: Record<string, unknown> = { title, updatedAt: Date.now() };
Expand Down
28 changes: 26 additions & 2 deletions clients/shared/IPC/Generated/IPCContractGenerated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1940,22 +1940,46 @@ public struct IPCHeartbeatRunsListResponseRun: Codable, Sendable {
public struct IPCHistoryRequest: Codable, Sendable {
public let type: String
public let sessionId: String
/// Max messages to return. Defaults to 50.
public let limit: Double?
/// Pagination cursor: return messages with timestamp before this value.
public let beforeTimestamp: Double?
/// Include attachment base64 data. Defaults to false in light mode.
public let includeAttachments: Bool?
/// Include tool screenshot base64 data. Defaults to false in light mode.
public let includeToolImages: Bool?
/// Include surface HTML payloads. Defaults to false in light mode.
public let includeSurfaceData: Bool?
/// Shorthand: 'light' = all include flags false (default), 'full' = all include flags true.
public let mode: String?

public init(type: String, sessionId: String) {
public init(type: String, sessionId: String, limit: Double? = nil, beforeTimestamp: Double? = nil, includeAttachments: Bool? = nil, includeToolImages: Bool? = nil, includeSurfaceData: Bool? = nil, mode: String? = nil) {
self.type = type
self.sessionId = sessionId
self.limit = limit
self.beforeTimestamp = beforeTimestamp
self.includeAttachments = includeAttachments
self.includeToolImages = includeToolImages
self.includeSurfaceData = includeSurfaceData
self.mode = mode
}
}

public struct IPCHistoryResponse: Codable, Sendable {
public let type: String
public let sessionId: String
public let messages: [IPCHistoryResponseMessage]
/// Whether older messages exist beyond the returned page.
public let hasMore: Bool
Comment thread
ashleeradka marked this conversation as resolved.
/// Timestamp of the oldest message in the response (client uses as next pagination cursor).
public let oldestTimestamp: Double?

public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage]) {
public init(type: String, sessionId: String, messages: [IPCHistoryResponseMessage], hasMore: Bool, oldestTimestamp: Double? = nil) {
self.type = type
self.sessionId = sessionId
self.messages = messages
self.hasMore = hasMore
self.oldestTimestamp = oldestTimestamp
}
}

Expand Down
Loading