refactor: use conversations (#475)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
615
src/cli/App.tsx
615
src/cli/App.tsx
@@ -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} />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
438
src/cli/components/ConversationSelector.tsx
Normal file
438
src/cli/components/ConversationSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user