619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
import type { Letta } from "@letta-ai/letta-client";
|
|
import type { Message } from "@letta-ai/letta-client/resources/agents/messages";
|
|
import type { Conversation } from "@letta-ai/letta-client/resources/conversations/conversations";
|
|
import { Box, useInput } from "ink";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { getClient } from "../../agent/client";
|
|
import { SYSTEM_ALERT_OPEN, SYSTEM_REMINDER_OPEN } from "../../constants";
|
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
|
import { colors } from "./colors";
|
|
import { MarkdownDisplay } from "./MarkdownDisplay";
|
|
import { Text } from "./Text";
|
|
|
|
// Horizontal line character (matches approval dialogs)
|
|
const SOLID_LINE = "─";
|
|
|
|
interface ConversationSelectorProps {
|
|
agentId: string;
|
|
agentName?: string;
|
|
currentConversationId: string;
|
|
onSelect: (
|
|
conversationId: string,
|
|
context?: {
|
|
summary?: string;
|
|
messageCount: number;
|
|
},
|
|
) => void;
|
|
onNewConversation: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
// Preview line with role prefix
|
|
interface PreviewLine {
|
|
role: "user" | "assistant";
|
|
text: string;
|
|
}
|
|
|
|
// Enriched conversation with message data
|
|
interface EnrichedConversation {
|
|
conversation: Conversation;
|
|
previewLines: PreviewLine[]; // Last 1-3 user/assistant messages
|
|
lastActiveAt: string | null;
|
|
messageCount: number;
|
|
}
|
|
|
|
const DISPLAY_PAGE_SIZE = 3;
|
|
const FETCH_PAGE_SIZE = 20;
|
|
|
|
/**
|
|
* Format a relative time string from a date
|
|
*/
|
|
function formatRelativeTime(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return "Never";
|
|
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
|
|
if (diffMins < 1) return "Just now";
|
|
if (diffMins < 60)
|
|
return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`;
|
|
if (diffHours < 24)
|
|
return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
|
|
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
|
|
return `${diffWeeks} week${diffWeeks === 1 ? "" : "s"} ago`;
|
|
}
|
|
|
|
/**
|
|
* Extract preview text from a user message
|
|
* Content can be a string or an array of content parts like [{ type: "text", text: "..." }]
|
|
*/
|
|
function extractUserMessagePreview(message: Message): string | null {
|
|
// User messages have a 'content' field
|
|
const content = (
|
|
message as Message & {
|
|
content?: string | Array<{ type?: string; text?: string }>;
|
|
}
|
|
).content;
|
|
|
|
if (!content) return null;
|
|
|
|
let textToShow: string | null = null;
|
|
|
|
if (typeof content === "string") {
|
|
textToShow = content;
|
|
} else if (Array.isArray(content)) {
|
|
// Find the last text part that isn't a system-reminder
|
|
// (system-reminders are auto-injected context, not user text)
|
|
for (let i = content.length - 1; i >= 0; i--) {
|
|
const part = content[i];
|
|
if (part?.type === "text" && part.text) {
|
|
// Skip system-reminder blocks
|
|
if (
|
|
part.text.startsWith(SYSTEM_REMINDER_OPEN) ||
|
|
part.text.startsWith(SYSTEM_ALERT_OPEN)
|
|
) {
|
|
continue;
|
|
}
|
|
textToShow = part.text;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!textToShow) return null;
|
|
|
|
// Strip newlines and collapse whitespace
|
|
textToShow = textToShow.replace(/\s+/g, " ").trim();
|
|
|
|
// Truncate to a reasonable preview length
|
|
const maxLen = 60;
|
|
if (textToShow.length > maxLen) {
|
|
return `${textToShow.slice(0, maxLen - 3)}...`;
|
|
}
|
|
return textToShow;
|
|
}
|
|
|
|
/**
|
|
* Extract preview text from an assistant message
|
|
* Content can be a string or array of content parts (text, images, etc.)
|
|
*/
|
|
function extractAssistantMessagePreview(message: Message): string | null {
|
|
// Assistant messages have content field directly on message
|
|
const content = (
|
|
message as Message & {
|
|
content?: string | Array<{ type?: string; text?: string }>;
|
|
}
|
|
).content;
|
|
|
|
if (!content) return null;
|
|
|
|
let textToShow: string | null = null;
|
|
|
|
if (typeof content === "string") {
|
|
textToShow = content.trim();
|
|
} else if (Array.isArray(content)) {
|
|
// Find the first text part
|
|
for (const part of content) {
|
|
if (part?.type === "text" && part.text) {
|
|
textToShow = part.text.trim();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!textToShow) return null;
|
|
|
|
// Strip newlines and collapse whitespace
|
|
textToShow = textToShow.replace(/\s+/g, " ").trim();
|
|
|
|
// Truncate to a reasonable preview length
|
|
const maxLen = 60;
|
|
if (textToShow.length > maxLen) {
|
|
return `${textToShow.slice(0, maxLen - 3)}...`;
|
|
}
|
|
return textToShow;
|
|
}
|
|
|
|
/**
|
|
* Get preview lines and stats from messages
|
|
*/
|
|
function getMessageStats(messages: Message[]): {
|
|
previewLines: PreviewLine[];
|
|
lastActiveAt: string | null;
|
|
messageCount: number;
|
|
} {
|
|
if (messages.length === 0) {
|
|
return { previewLines: [], lastActiveAt: null, messageCount: 0 };
|
|
}
|
|
|
|
// Find last 3 user/assistant messages with actual content (searching from end)
|
|
const previewLines: PreviewLine[] = [];
|
|
for (let i = messages.length - 1; i >= 0 && previewLines.length < 3; i--) {
|
|
const msg = messages[i];
|
|
if (!msg) continue;
|
|
|
|
if (msg.message_type === "user_message") {
|
|
const text = extractUserMessagePreview(msg);
|
|
if (text) {
|
|
previewLines.unshift({ role: "user", text });
|
|
}
|
|
} else if (msg.message_type === "assistant_message") {
|
|
const text = extractAssistantMessagePreview(msg);
|
|
if (text) {
|
|
previewLines.unshift({ role: "assistant", text });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last activity is the timestamp of the last message
|
|
const lastMessage = messages[messages.length - 1];
|
|
const lastActiveAt =
|
|
(lastMessage as Message & { date?: string }).date ?? null;
|
|
|
|
return { previewLines, lastActiveAt, messageCount: messages.length };
|
|
}
|
|
|
|
export function ConversationSelector({
|
|
agentId,
|
|
agentName,
|
|
currentConversationId,
|
|
onSelect,
|
|
onNewConversation,
|
|
onCancel,
|
|
}: ConversationSelectorProps) {
|
|
const clientRef = useRef<Letta | null>(null);
|
|
|
|
// Conversation list state (enriched with message data)
|
|
const [conversations, setConversations] = useState<EnrichedConversation[]>(
|
|
[],
|
|
);
|
|
const [cursor, setCursor] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Selection state
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [page, setPage] = useState(0);
|
|
|
|
// Load conversations and enrich with message data
|
|
const loadConversations = useCallback(
|
|
async (afterCursor?: string | null) => {
|
|
const isLoadingMore = !!afterCursor;
|
|
if (isLoadingMore) {
|
|
setLoadingMore(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
setError(null);
|
|
|
|
try {
|
|
const client = clientRef.current || (await getClient());
|
|
clientRef.current = client;
|
|
|
|
// Fetch default conversation data (agent's primary message history)
|
|
// Only fetch on initial load (not when paginating)
|
|
let defaultConversation: EnrichedConversation | null = null;
|
|
if (!afterCursor) {
|
|
try {
|
|
const defaultMessages = await client.conversations.messages.list(
|
|
"default",
|
|
{
|
|
agent_id: agentId,
|
|
limit: 20,
|
|
order: "desc",
|
|
},
|
|
);
|
|
const defaultMsgItems = defaultMessages.getPaginatedItems();
|
|
if (defaultMsgItems.length > 0) {
|
|
const defaultStats = getMessageStats(
|
|
[...defaultMsgItems].reverse(),
|
|
);
|
|
defaultConversation = {
|
|
conversation: {
|
|
id: "default",
|
|
agent_id: agentId,
|
|
created_at: new Date().toISOString(),
|
|
} as Conversation,
|
|
previewLines: defaultStats.previewLines,
|
|
lastActiveAt: defaultStats.lastActiveAt,
|
|
messageCount: defaultStats.messageCount,
|
|
};
|
|
}
|
|
} catch {
|
|
// If we can't fetch default messages, just skip showing it
|
|
}
|
|
}
|
|
|
|
const result = await client.conversations.list({
|
|
agent_id: agentId,
|
|
limit: FETCH_PAGE_SIZE,
|
|
...(afterCursor && { after: afterCursor }),
|
|
});
|
|
|
|
// Enrich conversations with message data in parallel
|
|
const enrichedConversations = await Promise.all(
|
|
result.map(async (conv) => {
|
|
try {
|
|
// Fetch recent messages to get stats (desc order = newest first)
|
|
const messages = await client.conversations.messages.list(
|
|
conv.id,
|
|
{ limit: 20, order: "desc" },
|
|
);
|
|
// Reverse to chronological for getMessageStats (expects oldest-first)
|
|
const chronologicalMessages = [
|
|
...messages.getPaginatedItems(),
|
|
].reverse();
|
|
const stats = getMessageStats(chronologicalMessages);
|
|
return {
|
|
conversation: conv,
|
|
previewLines: stats.previewLines,
|
|
lastActiveAt: stats.lastActiveAt,
|
|
messageCount: stats.messageCount,
|
|
};
|
|
} catch {
|
|
// If we fail to fetch messages, show conversation anyway with -1 to indicate error
|
|
return {
|
|
conversation: conv,
|
|
previewLines: [],
|
|
lastActiveAt: null,
|
|
messageCount: -1, // Unknown, don't filter out
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Filter out empty conversations (messageCount === 0)
|
|
// Keep conversations with messageCount > 0 or -1 (error/unknown)
|
|
const nonEmptyConversations = enrichedConversations.filter(
|
|
(c) => c.messageCount !== 0,
|
|
);
|
|
|
|
const newCursor =
|
|
result.length === FETCH_PAGE_SIZE
|
|
? (result[result.length - 1]?.id ?? null)
|
|
: null;
|
|
|
|
if (isLoadingMore) {
|
|
setConversations((prev) => [...prev, ...nonEmptyConversations]);
|
|
} else {
|
|
// Prepend default conversation to the list (if it has messages)
|
|
const allConversations = defaultConversation
|
|
? [defaultConversation, ...nonEmptyConversations]
|
|
: nonEmptyConversations;
|
|
setConversations(allConversations);
|
|
setPage(0);
|
|
setSelectedIndex(0);
|
|
}
|
|
setCursor(newCursor);
|
|
setHasMore(newCursor !== null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
if (isLoadingMore) {
|
|
setLoadingMore(false);
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[agentId],
|
|
);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
loadConversations();
|
|
}, [loadConversations]);
|
|
|
|
// Pagination calculations
|
|
const totalPages = Math.ceil(conversations.length / DISPLAY_PAGE_SIZE);
|
|
const startIndex = page * DISPLAY_PAGE_SIZE;
|
|
const pageConversations = conversations.slice(
|
|
startIndex,
|
|
startIndex + DISPLAY_PAGE_SIZE,
|
|
);
|
|
const canGoNext = page < totalPages - 1 || hasMore;
|
|
|
|
// Fetch more when needed
|
|
const fetchMore = useCallback(async () => {
|
|
if (loadingMore || !hasMore || !cursor) return;
|
|
await loadConversations(cursor);
|
|
}, [loadingMore, hasMore, cursor, loadConversations]);
|
|
|
|
useInput((input, key) => {
|
|
// CTRL-C: immediately cancel
|
|
if (key.ctrl && input === "c") {
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
if (loading) return;
|
|
|
|
if (key.upArrow) {
|
|
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
} else if (key.downArrow) {
|
|
setSelectedIndex((prev) =>
|
|
Math.min(pageConversations.length - 1, prev + 1),
|
|
);
|
|
} else if (key.return) {
|
|
const selected = pageConversations[selectedIndex];
|
|
if (selected?.conversation.id) {
|
|
onSelect(selected.conversation.id, {
|
|
summary: selected.conversation.summary ?? undefined,
|
|
messageCount: selected.messageCount,
|
|
});
|
|
}
|
|
} else if (key.escape) {
|
|
onCancel();
|
|
} else if (input === "n" || input === "N") {
|
|
// New conversation
|
|
onNewConversation();
|
|
} else if (key.leftArrow) {
|
|
// Previous page
|
|
if (page > 0) {
|
|
setPage((prev) => prev - 1);
|
|
setSelectedIndex(0);
|
|
}
|
|
} else if (key.rightArrow) {
|
|
// Next page
|
|
if (canGoNext) {
|
|
const nextPageIndex = page + 1;
|
|
const nextStartIndex = nextPageIndex * DISPLAY_PAGE_SIZE;
|
|
|
|
if (nextStartIndex >= conversations.length && hasMore) {
|
|
fetchMore();
|
|
}
|
|
|
|
if (nextStartIndex < conversations.length) {
|
|
setPage(nextPageIndex);
|
|
setSelectedIndex(0);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Render conversation item
|
|
const renderConversationItem = (
|
|
enrichedConv: EnrichedConversation,
|
|
_index: number,
|
|
isSelected: boolean,
|
|
) => {
|
|
const {
|
|
conversation: conv,
|
|
previewLines,
|
|
lastActiveAt,
|
|
messageCount,
|
|
} = enrichedConv;
|
|
const isCurrent = conv.id === currentConversationId;
|
|
|
|
// Format timestamps
|
|
const activeTime = formatRelativeTime(lastActiveAt);
|
|
const createdTime = formatRelativeTime(conv.created_at);
|
|
|
|
// Build preview content: (1) summary if exists, (2) preview lines, (3) message count fallback
|
|
// Uses L-bracket indentation style for visual hierarchy
|
|
const renderPreview = () => {
|
|
const bracket = <Text dimColor>{"⎿ "}</Text>;
|
|
const indent = " "; // Same width as "⎿ " for alignment
|
|
|
|
// Priority 1: Summary
|
|
if (conv.summary) {
|
|
return (
|
|
<Box flexDirection="row" marginLeft={2}>
|
|
{bracket}
|
|
<Text dimColor italic>
|
|
{conv.summary.length > 57
|
|
? `${conv.summary.slice(0, 54)}...`
|
|
: conv.summary}
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Priority 2: Preview lines with emoji prefixes
|
|
if (previewLines.length > 0) {
|
|
return (
|
|
<>
|
|
{previewLines.map((line, idx) => (
|
|
<Box
|
|
key={`${line.role}-${idx}`}
|
|
flexDirection="row"
|
|
marginLeft={2}
|
|
>
|
|
{idx === 0 ? bracket : <Text>{indent}</Text>}
|
|
<Text dimColor>
|
|
{line.role === "assistant" ? "👾 " : "👤 "}
|
|
</Text>
|
|
<Text dimColor italic>
|
|
{line.text}
|
|
</Text>
|
|
</Box>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Priority 3: Message count fallback
|
|
if (messageCount > 0) {
|
|
return (
|
|
<Box flexDirection="row" marginLeft={2}>
|
|
{bracket}
|
|
<Text dimColor italic>
|
|
{messageCount} message{messageCount === 1 ? "" : "s"} (no
|
|
in-context user/agent messages)
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="row" marginLeft={2}>
|
|
{bracket}
|
|
<Text dimColor italic>
|
|
No in-context messages
|
|
</Text>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const isDefault = conv.id === "default";
|
|
|
|
return (
|
|
<Box key={conv.id} flexDirection="column" marginBottom={1}>
|
|
<Box flexDirection="row">
|
|
<Text
|
|
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
|
>
|
|
{isSelected ? ">" : " "}
|
|
</Text>
|
|
<Text> </Text>
|
|
<Text
|
|
bold={isSelected}
|
|
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
|
>
|
|
{isDefault ? "default" : conv.id}
|
|
</Text>
|
|
{isDefault && <Text dimColor> (agent's default conversation)</Text>}
|
|
{isCurrent && (
|
|
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
|
)}
|
|
</Box>
|
|
{renderPreview()}
|
|
<Box flexDirection="row" marginLeft={2}>
|
|
<Text dimColor>
|
|
Active {activeTime} · Created {createdTime}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const terminalWidth = useTerminalWidth();
|
|
const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10));
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
{/* Command header */}
|
|
<Text dimColor>{"> /resume"}</Text>
|
|
<Text dimColor>{solidLine}</Text>
|
|
|
|
<Box height={1} />
|
|
|
|
{/* Title */}
|
|
<Box marginBottom={1}>
|
|
<Text bold color={colors.selector.title}>
|
|
Resume a previous conversation
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<Box flexDirection="column">
|
|
<Text color="red">Error: {error}</Text>
|
|
<Text dimColor>Press ESC to cancel</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{loading && (
|
|
<Box>
|
|
<Text dimColor>Loading conversations...</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!loading && !error && conversations.length === 0 && (
|
|
<Box flexDirection="column">
|
|
<Text dimColor>
|
|
No conversations for {agentName || agentId.slice(0, 12)}
|
|
</Text>
|
|
<Text dimColor>Press N to start a new conversation</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Conversation list */}
|
|
{!loading && !error && conversations.length > 0 && (
|
|
<Box flexDirection="column">
|
|
{pageConversations.map((conv, index) =>
|
|
renderConversationItem(conv, index, index === selectedIndex),
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
{!loading &&
|
|
!error &&
|
|
conversations.length > 0 &&
|
|
(() => {
|
|
const footerWidth = Math.max(0, terminalWidth - 2);
|
|
const pageText = `Page ${page + 1}${hasMore ? "+" : `/${totalPages || 1}`}${loadingMore ? " (loading...)" : ""}`;
|
|
const hintsText =
|
|
"Enter select · ↑↓ navigate · ←→ page · N new · Esc cancel";
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Box flexDirection="row">
|
|
<Box width={2} flexShrink={0} />
|
|
<Box flexGrow={1} width={footerWidth}>
|
|
<MarkdownDisplay text={pageText} dimColor />
|
|
</Box>
|
|
</Box>
|
|
<Box flexDirection="row">
|
|
<Box width={2} flexShrink={0} />
|
|
<Box flexGrow={1} width={footerWidth}>
|
|
<MarkdownDisplay text={hintsText} dimColor />
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
})()}
|
|
</Box>
|
|
);
|
|
}
|