refactor: use conversations (#475)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-01-13 16:40:59 -08:00
committed by GitHub
parent 3615247d14
commit ef7d8c98df
26 changed files with 1572 additions and 168 deletions

View File

@@ -81,6 +81,7 @@ import { ApprovalPreview } from "./components/ApprovalPreview";
import { AssistantMessage } from "./components/AssistantMessageRich";
import { BashCommandMessage } from "./components/BashCommandMessage";
import { CommandMessage } from "./components/CommandMessage";
import { ConversationSelector } from "./components/ConversationSelector";
import { colors } from "./components/colors";
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
import { ErrorMessage } from "./components/ErrorMessageRich";
@@ -530,16 +531,19 @@ type StaticItem =
export default function App({
agentId: initialAgentId,
agentState: initialAgentState,
conversationId: initialConversationId,
loadingState = "ready",
continueSession = false,
startupApproval = null,
startupApprovals = [],
messageHistory = [],
resumedExistingConversation = false,
tokenStreaming = false,
agentProvenance = null,
}: {
agentId: string;
agentState?: AgentState | null;
conversationId: string; // Required: created at startup
loadingState?:
| "assembling"
| "importing"
@@ -550,6 +554,7 @@ export default function App({
startupApproval?: ApprovalRequest | null; // Deprecated: use startupApprovals
startupApprovals?: ApprovalRequest[];
messageHistory?: Message[];
resumedExistingConversation?: boolean; // True if we explicitly resumed via --resume
tokenStreaming?: boolean;
agentProvenance?: AgentProvenance | null;
}) {
@@ -562,6 +567,9 @@ export default function App({
const [agentId, setAgentId] = useState(initialAgentId);
const [agentState, setAgentState] = useState(initialAgentState);
// Track current conversation (always created fresh on startup)
const [conversationId, setConversationId] = useState(initialConversationId);
// Keep a ref to the current agentId for use in callbacks that need the latest value
const agentIdRef = useRef(agentId);
useEffect(() => {
@@ -569,11 +577,18 @@ export default function App({
telemetry.setCurrentAgentId(agentId);
}, [agentId]);
// Keep a ref to the current conversationId for use in callbacks
const conversationIdRef = useRef(conversationId);
useEffect(() => {
conversationIdRef.current = conversationId;
}, [conversationId]);
const resumeKey = useSuspend();
// Track previous prop values to detect actual prop changes (not internal state changes)
const prevInitialAgentIdRef = useRef(initialAgentId);
const prevInitialAgentStateRef = useRef(initialAgentState);
const prevInitialConversationIdRef = useRef(initialConversationId);
// Sync with prop changes (e.g., when parent updates from "loading" to actual ID)
// Only sync when the PROP actually changes, not when internal state changes
@@ -592,6 +607,14 @@ export default function App({
}
}, [initialAgentState]);
useEffect(() => {
if (initialConversationId !== prevInitialConversationIdRef.current) {
prevInitialConversationIdRef.current = initialConversationId;
conversationIdRef.current = initialConversationId;
setConversationId(initialConversationId);
}
}, [initialConversationId]);
// Set agent context for tools (especially Task tool)
useEffect(() => {
if (agentId) {
@@ -793,6 +816,7 @@ export default function App({
| "system"
| "agent"
| "resume"
| "conversations"
| "search"
| "subagent"
| "feedback"
@@ -1299,10 +1323,6 @@ export default function App({
// Add combined status at the END so user sees it without scrolling
const statusId = `status-resumed-${Date.now().toString(36)}`;
const cwd = process.cwd();
const shortCwd = cwd.startsWith(process.env.HOME || "")
? `~${cwd.slice((process.env.HOME || "").length)}`
: cwd;
// Check if agent is pinned (locally or globally)
const isPinned = agentState?.id
@@ -1312,23 +1332,32 @@ export default function App({
// Build status message
const agentName = agentState?.name || "Unnamed Agent";
const headerMessage = `Connecting to **${agentName}** (last used in ${shortCwd})`;
const isResumingConversation =
resumedExistingConversation || messageHistory.length > 0;
if (process.env.DEBUG) {
console.log(
`[DEBUG] Header: resumedExistingConversation=${resumedExistingConversation}, messageHistory.length=${messageHistory.length}`,
);
}
const headerMessage = isResumingConversation
? `Resuming conversation with **${agentName}**`
: `Starting new conversation with **${agentName}**`;
// Command hints - for pinned agents show /memory, for unpinned show /pin
const commandHints = isPinned
? [
"→ **/agents** list all agents",
"→ **/resume** resume a previous conversation",
"→ **/memory** view your agent's memory blocks",
"→ **/init** initialize your agent's memory",
"→ **/remember** teach your agent",
"→ **/agents** list agents",
"→ **/ade** open in the browser (web UI)",
]
: [
"→ **/agents** list all agents",
"→ **/resume** resume a previous conversation",
"→ **/pin** save + name your agent",
"→ **/init** initialize your agent's memory",
"→ **/remember** teach your agent",
"→ **/agents** list agents",
"→ **/ade** open in the browser (web UI)",
];
const statusLines = [headerMessage, ...commandHints];
@@ -1351,6 +1380,7 @@ export default function App({
columns,
agentState,
agentProvenance,
resumedExistingConversation,
]);
// Fetch llmConfig when agent is ready
@@ -1595,13 +1625,16 @@ export default function App({
return;
}
// Stream one turn - use ref to always get the latest agentId
// Stream one turn - use ref to always get the latest conversationId
// Wrap in try-catch to handle pre-stream desync errors (when sendMessageStream
// throws before streaming begins, e.g., retry after LLM error when backend
// already cleared the approval)
let stream: Awaited<ReturnType<typeof sendMessageStream>>;
try {
stream = await sendMessageStream(agentIdRef.current, currentInput);
stream = await sendMessageStream(
conversationIdRef.current,
currentInput,
);
} catch (preStreamError) {
// Check if this is a pre-stream approval desync error
const hasApprovalInPayload = currentInput.some(
@@ -2829,7 +2862,7 @@ export default function App({
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: "/pinned",
input: "/agents",
output: `Already on "${label}"`,
phase: "finished",
success: true,
@@ -2842,7 +2875,7 @@ export default function App({
// Lock input for async operation (set before any await to prevent queue processing)
setCommandRunning(true);
const inputCmd = "/pinned";
const inputCmd = "/agents";
const cmdId = uid("cmd");
// Show loading indicator while switching
@@ -2861,13 +2894,26 @@ export default function App({
// Fetch new agent
const agent = await client.agents.retrieve(targetAgentId);
// Fetch agent's message history
const messagesPage = await client.agents.messages.list(targetAgentId);
const messages = messagesPage.items;
// Always create a new conversation when switching agents
// User can /resume to get back to a previous conversation if needed
const newConversation = await client.conversations.create({
agent_id: targetAgentId,
});
const targetConversationId = newConversation.id;
// Update project settings with new agent
await updateProjectSettings({ lastAgent: targetAgentId });
// Save the session (agent + conversation) to settings
settingsManager.setLocalLastSession(
{ agentId: targetAgentId, conversationId: targetConversationId },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId: targetAgentId,
conversationId: targetConversationId,
});
// Clear current transcript and static items
buffersRef.current.byId.clear();
buffersRef.current.order = [];
@@ -2885,10 +2931,14 @@ export default function App({
setAgentState(agent);
setAgentName(agent.name);
setLlmConfig(agent.llm_config);
setConversationId(targetConversationId);
// Build success command
const agentUrl = `https://app.letta.com/projects/default-project/agents/${targetAgentId}`;
const successOutput = `Resumed "${agent.name || targetAgentId}"\n⎿ ${agentUrl}`;
// Build success message - always a new conversation
const agentLabel = agent.name || targetAgentId;
const successOutput = [
`Started a new conversation with **${agentLabel}**.`,
`⎿ Type /resume to resume a previous conversation`,
].join("\n");
const successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
@@ -2898,30 +2948,13 @@ export default function App({
success: true,
};
// Backfill message history with visual separator, then success command at end
if (messages.length > 0) {
hasBackfilledRef.current = false;
backfillBuffers(buffersRef.current, messages);
// Collect backfilled items
const backfilledItems: StaticItem[] = [];
for (const id of buffersRef.current.order) {
const ln = buffersRef.current.byId.get(id);
if (!ln) continue;
emittedIdsRef.current.add(id);
backfilledItems.push({ ...ln } as StaticItem);
}
// Add separator before backfilled messages, then success at end
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, ...backfilledItems, successItem]);
setLines(toLines(buffersRef.current));
hasBackfilledRef.current = true;
} else {
setStaticItems([successItem]);
setLines(toLines(buffersRef.current));
}
// Add separator for visual spacing, then success message
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, successItem]);
setLines(toLines(buffersRef.current));
} catch (error) {
const errorDetails = formatErrorDetails(error, agentId);
const errorCmdId = uid("cmd");
@@ -3886,14 +3919,14 @@ export default function App({
return { submitted: true };
}
// Special handling for /clear command - reset conversation
// Special handling for /clear command - start new conversation
if (msg.trim() === "/clear") {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "Clearing conversation...",
output: "Starting new conversation...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
@@ -3903,16 +3936,24 @@ export default function App({
try {
const client = await getClient();
await client.agents.messages.reset(agentId, {
add_default_initial_messages: false,
// Create a new conversation for the current agent
const conversation = await client.conversations.create({
agent_id: agentId,
});
// Clear local buffers and static items
// buffersRef.current.byId.clear();
// buffersRef.current.order = [];
// buffersRef.current.tokenCount = 0;
// emittedIdsRef.current.clear();
// setStaticItems([]);
// Update conversationId state
setConversationId(conversation.id);
// Save the new session to settings
settingsManager.setLocalLastSession(
{ agentId, conversationId: conversation.id },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId,
conversationId: conversation.id,
});
// Reset turn counter for memory reminders
turnCountRef.current = 0;
@@ -3922,7 +3963,75 @@ export default function App({
kind: "command",
id: cmdId,
input: msg,
output: "Conversation cleared",
output: "Started new conversation",
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
} catch (error) {
const errorDetails = formatErrorDetails(error, agentId);
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: `Failed: ${errorDetails}`,
phase: "finished",
success: false,
});
refreshDerived();
} finally {
setCommandRunning(false);
}
return { submitted: true };
}
// Special handling for /clear-messages command - reset all agent messages (destructive)
if (msg.trim() === "/clear-messages") {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "Resetting agent messages...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
setCommandRunning(true);
try {
const client = await getClient();
// Reset all messages on the agent (destructive operation)
await client.agents.messages.reset(agentId, {
add_default_initial_messages: false,
});
// Also create a new conversation since messages were cleared
const conversation = await client.conversations.create({
agent_id: agentId,
});
setConversationId(conversation.id);
settingsManager.setLocalLastSession(
{ agentId, conversationId: conversation.id },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId,
conversationId: conversation.id,
});
// Reset turn counter for memory reminders
turnCountRef.current = 0;
// Update command with success
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "All agent messages reset",
phase: "finished",
success: true,
});
@@ -4177,10 +4286,9 @@ export default function App({
}
// Special handling for /agents command - show agent browser
// /resume, /pinned, /profiles are hidden aliases
// /pinned, /profiles are hidden aliases
if (
msg.trim() === "/agents" ||
msg.trim() === "/resume" ||
msg.trim() === "/pinned" ||
msg.trim() === "/profiles"
) {
@@ -4188,6 +4296,147 @@ export default function App({
return { submitted: true };
}
// Special handling for /resume command - show conversation selector or switch directly
if (msg.trim().startsWith("/resume")) {
const parts = msg.trim().split(/\s+/);
const targetConvId = parts[1]; // Optional conversation ID
if (targetConvId) {
// Direct switch to specified conversation
if (targetConvId === conversationId) {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg.trim(),
output: "Already on this conversation",
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
return { submitted: true };
}
// Lock input and show loading
setCommandRunning(true);
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg.trim(),
output: "Switching conversation...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
try {
// Update conversation ID and settings
setConversationId(targetConvId);
settingsManager.setLocalLastSession(
{ agentId, conversationId: targetConvId },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId,
conversationId: targetConvId,
});
// Fetch message history for the selected conversation
if (agentState) {
const client = await getClient();
const resumeData = await getResumeData(
client,
agentState,
targetConvId,
);
// Clear current transcript and static items
buffersRef.current.byId.clear();
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
// Build success message
const currentAgentName = agentState.name || "Unnamed Agent";
const successLines =
resumeData.messageHistory.length > 0
? [
`Resumed conversation with "${currentAgentName}"`,
`⎿ Agent: ${agentId}`,
`⎿ Conversation: ${targetConvId}`,
]
: [
`Switched to conversation with "${currentAgentName}"`,
`⎿ Agent: ${agentId}`,
`⎿ Conversation: ${targetConvId} (empty)`,
];
const successOutput = successLines.join("\n");
const successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
input: msg.trim(),
output: successOutput,
phase: "finished",
success: true,
};
// Backfill message history
if (resumeData.messageHistory.length > 0) {
hasBackfilledRef.current = false;
backfillBuffers(
buffersRef.current,
resumeData.messageHistory,
);
const backfilledItems: StaticItem[] = [];
for (const id of buffersRef.current.order) {
const ln = buffersRef.current.byId.get(id);
if (!ln) continue;
emittedIdsRef.current.add(id);
backfilledItems.push({ ...ln } as StaticItem);
}
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, ...backfilledItems, successItem]);
setLines(toLines(buffersRef.current));
hasBackfilledRef.current = true;
} else {
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, successItem]);
setLines(toLines(buffersRef.current));
}
}
} catch (error) {
const errorCmdId = uid("cmd");
buffersRef.current.byId.set(errorCmdId, {
kind: "command",
id: errorCmdId,
input: msg.trim(),
output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`,
phase: "finished",
success: false,
});
buffersRef.current.order.push(errorCmdId);
refreshDerived();
} finally {
setCommandRunning(false);
}
return { submitted: true };
}
// No conversation ID provided - show selector
setActiveOverlay("conversations");
return { submitted: true };
}
// Special handling for /search command - show message search
if (msg.trim() === "/search") {
setActiveOverlay("search");
@@ -6737,12 +6986,6 @@ Plan file path: ${planFilePath}`;
// Add status line showing agent info
const statusId = `status-agent-${Date.now().toString(36)}`;
// Get short path for display
const cwd = process.cwd();
const shortCwd = cwd.startsWith(process.env.HOME || "")
? `~${cwd.slice((process.env.HOME || "").length)}`
: cwd;
// Check if agent is pinned (locally or globally)
const isPinned = agentState?.id
? settingsManager.getLocalPinnedAgents().includes(agentState.id) ||
@@ -6751,25 +6994,27 @@ Plan file path: ${planFilePath}`;
// Build status message based on session type
const agentName = agentState?.name || "Unnamed Agent";
const headerMessage = continueSession
? `Connecting to **${agentName}** (last used in ${shortCwd})`
: "Creating a new agent";
const headerMessage = resumedExistingConversation
? `Resuming (empty) conversation with **${agentName}**`
: continueSession
? `Starting new conversation with **${agentName}**`
: "Creating a new agent";
// Command hints - for pinned agents show /memory, for unpinned show /pin
const commandHints = isPinned
? [
"→ **/agents** list all agents",
"→ **/resume** resume a previous conversation",
"→ **/memory** view your agent's memory blocks",
"→ **/init** initialize your agent's memory",
"→ **/remember** teach your agent",
"→ **/agents** list agents",
"→ **/ade** open in the browser (web UI)",
]
: [
"→ **/agents** list all agents",
"→ **/resume** resume a previous conversation",
"→ **/pin** save + name your agent",
"→ **/init** initialize your agent's memory",
"→ **/remember** teach your agent",
"→ **/agents** list agents",
"→ **/ade** open in the browser (web UI)",
];
const statusLines = [headerMessage, ...commandHints];
@@ -6786,6 +7031,7 @@ Plan file path: ${planFilePath}`;
}, [
loadingState,
continueSession,
resumedExistingConversation,
messageHistory.length,
commitEligibleLines,
columns,
@@ -7345,6 +7591,237 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Conversation Selector - for resuming conversations */}
{activeOverlay === "conversations" && (
<ConversationSelector
agentId={agentId}
currentConversationId={conversationId}
onSelect={async (convId) => {
closeOverlay();
// Skip if already on this conversation
if (convId === conversationId) {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: "/resume",
output: "Already on this conversation",
phase: "finished",
success: true,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
return;
}
// Lock input for async operation
setCommandRunning(true);
const inputCmd = "/resume";
const cmdId = uid("cmd");
// Show loading indicator while switching
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: inputCmd,
output: "Switching conversation...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
try {
// Update conversation ID and settings
setConversationId(convId);
settingsManager.setLocalLastSession(
{ agentId, conversationId: convId },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId,
conversationId: convId,
});
// Fetch message history for the selected conversation
if (agentState) {
const client = await getClient();
const resumeData = await getResumeData(
client,
agentState,
convId,
);
// Clear current transcript and static items
buffersRef.current.byId.clear();
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
// Build success command with agent + conversation info
const currentAgentName =
agentState.name || "Unnamed Agent";
const successLines =
resumeData.messageHistory.length > 0
? [
`Resumed conversation with "${currentAgentName}"`,
`⎿ Agent: ${agentId}`,
`⎿ Conversation: ${convId}`,
]
: [
`Switched to conversation with "${currentAgentName}"`,
`⎿ Agent: ${agentId}`,
`⎿ Conversation: ${convId} (empty)`,
];
const successOutput = successLines.join("\n");
const successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
input: inputCmd,
output: successOutput,
phase: "finished",
success: true,
};
// Backfill message history with visual separator
if (resumeData.messageHistory.length > 0) {
hasBackfilledRef.current = false;
backfillBuffers(
buffersRef.current,
resumeData.messageHistory,
);
// Collect backfilled items
const backfilledItems: StaticItem[] = [];
for (const id of buffersRef.current.order) {
const ln = buffersRef.current.byId.get(id);
if (!ln) continue;
emittedIdsRef.current.add(id);
backfilledItems.push({ ...ln } as StaticItem);
}
// Add separator before backfilled messages, then success at end
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([
separator,
...backfilledItems,
successItem,
]);
setLines(toLines(buffersRef.current));
hasBackfilledRef.current = true;
} else {
// Add separator for visual spacing even without backfill
const separator = {
kind: "separator" as const,
id: uid("sep"),
};
setStaticItems([separator, successItem]);
setLines(toLines(buffersRef.current));
}
}
} catch (error) {
const errorCmdId = uid("cmd");
buffersRef.current.byId.set(errorCmdId, {
kind: "command",
id: errorCmdId,
input: inputCmd,
output: `Failed to switch conversation: ${error instanceof Error ? error.message : String(error)}`,
phase: "finished",
success: false,
});
buffersRef.current.order.push(errorCmdId);
refreshDerived();
} finally {
setCommandRunning(false);
}
}}
onNewConversation={async () => {
closeOverlay();
// Lock input for async operation
setCommandRunning(true);
const inputCmd = "/resume";
const cmdId = uid("cmd");
// Show loading indicator
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: inputCmd,
output: "Creating new conversation...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
try {
// Create a new conversation
const client = await getClient();
const conversation = await client.conversations.create({
agent_id: agentId,
});
setConversationId(conversation.id);
settingsManager.setLocalLastSession(
{ agentId, conversationId: conversation.id },
process.cwd(),
);
settingsManager.setGlobalLastSession({
agentId,
conversationId: conversation.id,
});
// Clear current transcript and static items
buffersRef.current.byId.clear();
buffersRef.current.order = [];
buffersRef.current.tokenCount = 0;
emittedIdsRef.current.clear();
setStaticItems([]);
setStaticRenderEpoch((e) => e + 1);
// Build success command with agent + conversation info
const currentAgentName =
agentState?.name || "Unnamed Agent";
const shortConvId = conversation.id.slice(0, 20);
const successLines = [
`Started new conversation with "${currentAgentName}"`,
`⎿ Agent: ${agentId}`,
`⎿ Conversation: ${shortConvId}... (new)`,
];
const successItem: StaticItem = {
kind: "command",
id: uid("cmd"),
input: inputCmd,
output: successLines.join("\n"),
phase: "finished",
success: true,
};
setStaticItems([successItem]);
setLines(toLines(buffersRef.current));
} catch (error) {
const errorCmdId = uid("cmd");
buffersRef.current.byId.set(errorCmdId, {
kind: "command",
id: errorCmdId,
input: inputCmd,
output: `Failed to create conversation: ${error instanceof Error ? error.message : String(error)}`,
phase: "finished",
success: false,
});
buffersRef.current.order.push(errorCmdId);
refreshDerived();
} finally {
setCommandRunning(false);
}
}}
onCancel={closeOverlay}
/>
)}
{/* Message Search - conditionally mounted as overlay */}
{activeOverlay === "search" && (
<MessageSearch onClose={closeOverlay} />

View File

@@ -6,6 +6,7 @@ type CommandHandler = (args: string[]) => Promise<string> | string;
interface Command {
desc: string;
handler: CommandHandler;
args?: string; // Optional argument syntax hint (e.g., "[conversation_id]", "<name>")
hidden?: boolean; // Hidden commands don't show in autocomplete but still work
order?: number; // Lower numbers appear first in autocomplete (default: 100)
}
@@ -68,11 +69,20 @@ export const commands: Record<string, Command> = {
},
},
"/clear": {
desc: "Clear conversation history (keep memory)",
desc: "Start a new conversation (keep agent memory)",
order: 17,
handler: () => {
// Handled specially in App.tsx to access client and agent ID
return "Clearing messages...";
// Handled specially in App.tsx to create new conversation
return "Starting new conversation...";
},
},
"/clear-messages": {
desc: "Reset all agent messages (destructive)",
order: 18,
hidden: true, // Advanced command, not shown in autocomplete
handler: () => {
// Handled specially in App.tsx to reset agent messages
return "Resetting agent messages...";
},
},
@@ -339,11 +349,12 @@ export const commands: Record<string, Command> = {
},
},
"/resume": {
desc: "Browse and switch to another agent",
hidden: true, // Backwards compatibility alias for /agents
desc: "Resume a previous conversation",
args: "[conversation_id]",
order: 19,
handler: () => {
// Handled specially in App.tsx to show agent selector
return "Opening agent selector...";
// Handled specially in App.tsx to show conversation selector or switch directly
return "Opening conversation selector...";
},
},
"/pinned": {

View File

@@ -0,0 +1,438 @@
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, Text, useInput } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient } from "../../agent/client";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
interface ConversationSelectorProps {
agentId: string;
currentConversationId: string;
onSelect: (conversationId: string) => void;
onNewConversation: () => void;
onCancel: () => void;
}
// Enriched conversation with message data
interface EnrichedConversation {
conversation: Conversation;
lastUserMessage: string | null;
lastActiveAt: string | null;
messageCount: number;
}
const DISPLAY_PAGE_SIZE = 5;
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>")) continue;
textToShow = part.text;
break;
}
}
}
if (!textToShow) return null;
// Truncate to a reasonable preview length
const maxLen = 60;
if (textToShow.length > maxLen) {
return `${textToShow.slice(0, maxLen - 3)}...`;
}
return textToShow;
}
/**
* Get the last user message and last activity time from messages
*/
function getMessageStats(messages: Message[]): {
lastUserMessage: string | null;
lastActiveAt: string | null;
messageCount: number;
} {
if (messages.length === 0) {
return { lastUserMessage: null, lastActiveAt: null, messageCount: 0 };
}
// Find last user message with actual content (searching from end)
let lastUserMessage: string | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (!msg) continue;
// Check for user_message type
if (msg.message_type === "user_message") {
lastUserMessage = extractUserMessagePreview(msg);
if (lastUserMessage) break;
}
}
// Last activity is the timestamp of the last message
// Most message types have a 'date' field for the timestamp
const lastMessage = messages[messages.length - 1];
const lastActiveAt =
(lastMessage as Message & { date?: string }).date ?? null;
return { lastUserMessage, lastActiveAt, messageCount: messages.length };
}
/**
* Truncate ID with middle ellipsis if it exceeds available width
*/
function truncateId(id: string, availableWidth: number): string {
if (id.length <= availableWidth) return id;
if (availableWidth < 15) return id.slice(0, availableWidth);
const prefixLen = Math.floor((availableWidth - 3) / 2);
const suffixLen = availableWidth - 3 - prefixLen;
return `${id.slice(0, prefixLen)}...${id.slice(-suffixLen)}`;
}
export function ConversationSelector({
agentId,
currentConversationId,
onSelect,
onNewConversation,
onCancel,
}: ConversationSelectorProps) {
const terminalWidth = useTerminalWidth();
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;
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 messages to get stats
const messages = await client.conversations.messages.list(
conv.id,
);
const stats = getMessageStats(messages);
return {
conversation: conv,
lastUserMessage: stats.lastUserMessage,
lastActiveAt: stats.lastActiveAt,
messageCount: stats.messageCount,
};
} catch {
// If we fail to fetch messages, show conversation anyway with -1 to indicate error
return {
conversation: conv,
lastUserMessage: null,
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 {
setConversations(nonEmptyConversations);
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);
}
} else if (key.escape) {
onCancel();
} else if (input === "n" || input === "N") {
// New conversation
onNewConversation();
} else if (input === "j" || input === "J") {
// Previous page
if (page > 0) {
setPage((prev) => prev - 1);
setSelectedIndex(0);
}
} else if (input === "k" || input === "K") {
// 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,
lastUserMessage,
lastActiveAt,
messageCount,
} = enrichedConv;
const isCurrent = conv.id === currentConversationId;
const displayId = truncateId(conv.id, Math.min(40, terminalWidth - 30));
// Format timestamps
const activeTime = formatRelativeTime(lastActiveAt);
const createdTime = formatRelativeTime(conv.created_at);
// Preview text: prefer last user message, fall back to summary or message count
let previewText: string;
if (lastUserMessage) {
previewText = lastUserMessage;
} else if (conv.summary) {
previewText = conv.summary;
} else if (messageCount > 0) {
previewText = `${messageCount} message${messageCount === 1 ? "" : "s"}`;
} else {
previewText = "No preview";
}
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}
>
{displayId}
</Text>
{isCurrent && (
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Box>
<Box flexDirection="row" marginLeft={2}>
<Text dimColor italic>
{previewText}
</Text>
</Box>
<Box flexDirection="row" marginLeft={2}>
<Text dimColor>
Active {activeTime} · Created {createdTime}
</Text>
</Box>
</Box>
);
};
return (
<Box flexDirection="column">
{/* Header */}
<Box flexDirection="column" gap={1} marginBottom={1}>
<Text bold color={colors.selector.title}>
Resume Conversation
</Text>
<Text dimColor>Select a conversation to resume or start a new one</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 found</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 && (
<Box flexDirection="column">
<Box>
<Text dimColor>
Page {page + 1}
{hasMore ? "+" : `/${totalPages || 1}`}
{loadingMore ? " (loading...)" : ""}
</Text>
</Box>
<Box>
<Text dimColor>
navigate · Enter select · J/K page · N new · ESC cancel
</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -431,12 +431,17 @@ export function ResumeSelector({
if (currentLoading) return;
// For pinned tab, use pinnedPageAgents.length to include "not found" entries
// For other tabs, use currentAgents.length
const maxIndex =
activeTab === "pinned"
? pinnedPageAgents.length - 1
: (currentAgents as AgentState[]).length - 1;
if (key.upArrow) {
setCurrentSelectedIndex((prev: number) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setCurrentSelectedIndex((prev: number) =>
Math.min((currentAgents as AgentState[]).length - 1, prev + 1),
);
setCurrentSelectedIndex((prev: number) => Math.min(maxIndex, prev + 1));
} else if (key.return) {
// If typing a search query (list tabs only), submit it
if (
@@ -526,13 +531,14 @@ export function ResumeSelector({
setAllSelectedIndex(0);
}
}
} else if (activeTab === "pinned" && (input === "d" || input === "D")) {
// Unpin from all (pinned tab only)
const selected = pinnedPageAgents[pinnedSelectedIndex];
if (selected) {
settingsManager.unpinBoth(selected.agentId);
loadPinnedAgents();
}
// NOTE: "D" for unpin all disabled - too destructive without confirmation
// } else if (activeTab === "pinned" && (input === "d" || input === "D")) {
// const selected = pinnedPageAgents[pinnedSelectedIndex];
// if (selected) {
// settingsManager.unpinBoth(selected.agentId);
// loadPinnedAgents();
// }
// }
} else if (activeTab === "pinned" && (input === "p" || input === "P")) {
// Unpin from current scope (pinned tab only)
const selected = pinnedPageAgents[pinnedSelectedIndex];
@@ -773,9 +779,7 @@ export function ResumeSelector({
<Box>
<Text dimColor>
Tab switch · navigate · Enter select · J/K page
{activeTab === "pinned"
? " · P unpin · D unpin all"
: " · Type to search"}
{activeTab === "pinned" ? " · P unpin" : " · Type to search"}
</Text>
</Box>
</Box>