From 2d6d3baa5e92c0bd095965705062bfb168b101b7 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 16 Jan 2026 20:30:47 -0800 Subject: [PATCH] feat: add release notes display system (#573) Co-authored-by: Letta --- src/agent/check-approval.ts | 12 +++-- src/cli/App.tsx | 29 ++++++++++++- src/index.ts | 15 +++++++ src/release-notes.ts | 87 +++++++++++++++++++++++++++++++++++++ src/settings-manager.ts | 2 + 5 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 src/release-notes.ts diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index f478f47..b2e684e 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -161,8 +161,10 @@ export async function getResumeData( // Fetch the last in-context message directly by ID // (We already checked inContextMessageIds.length > 0 above) - const lastInContextId = - inContextMessageIds[inContextMessageIds.length - 1]!; + const lastInContextId = inContextMessageIds.at(-1); + if (!lastInContextId) { + throw new Error("Expected at least one in-context message"); + } const retrievedMessages = await client.messages.retrieve(lastInContextId); // Fetch message history separately for backfill (desc then reverse for last N chronological) @@ -243,8 +245,10 @@ export async function getResumeData( // Fetch the last in-context message directly by ID // (We already checked inContextMessageIds.length > 0 above) - const lastInContextId = - inContextMessageIds[inContextMessageIds.length - 1]!; + const lastInContextId = inContextMessageIds.at(-1); + if (!lastInContextId) { + throw new Error("Expected at least one in-context message"); + } const retrievedMessages = await client.messages.retrieve(lastInContextId); // Fetch message history separately for backfill (desc then reverse for last N chronological) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index c3e5a3f..cc3aba5 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -537,6 +537,7 @@ export default function App({ resumedExistingConversation = false, tokenStreaming = false, agentProvenance = null, + releaseNotes = null, }: { agentId: string; agentState?: AgentState | null; @@ -554,6 +555,7 @@ export default function App({ resumedExistingConversation?: boolean; // True if we explicitly resumed via --resume tokenStreaming?: boolean; agentProvenance?: AgentProvenance | null; + releaseNotes?: string | null; // Markdown release notes to display above header }) { // Warm the model-access cache in the background so /model is fast on first open. useEffect(() => { @@ -1434,7 +1436,18 @@ export default function App({ "→ **/remember** teach your agent", ]; - const statusLines = [headerMessage, ...commandHints]; + // Build status lines with optional release notes above header + const statusLines: string[] = []; + + // Add release notes first (above everything) - same styling as rest of status block + if (releaseNotes) { + statusLines.push(releaseNotes); + statusLines.push(""); // blank line separator + } + + statusLines.push(headerMessage); + statusLines.push(...commandHints); + buffersRef.current.byId.set(statusId, { kind: "status", id: statusId, @@ -1455,6 +1468,7 @@ export default function App({ agentState, agentProvenance, resumedExistingConversation, + releaseNotes, ]); // Fetch llmConfig when agent is ready @@ -7184,7 +7198,17 @@ Plan file path: ${planFilePath}`; "→ **/remember** teach your agent", ]; - const statusLines = [headerMessage, ...commandHints]; + // Build status lines with optional release notes above header + const statusLines: string[] = []; + + // Add release notes first (above everything) - same styling as rest of status block + if (releaseNotes) { + statusLines.push(releaseNotes); + statusLines.push(""); // blank line separator + } + + statusLines.push(headerMessage); + statusLines.push(...commandHints); buffersRef.current.byId.set(statusId, { kind: "status", @@ -7205,6 +7229,7 @@ Plan file path: ${planFilePath}`; agentProvenance, agentState, refreshDerived, + releaseNotes, ]); return ( diff --git a/src/index.ts b/src/index.ts index f2ec2f8..987ca36 100755 --- a/src/index.ts +++ b/src/index.ts @@ -916,6 +916,9 @@ async function main(): Promise { // Track when user explicitly requested new agent from selector (not via --new flag) const [userRequestedNewAgent, setUserRequestedNewAgent] = useState(false); + // Release notes to display (checked once on mount) + const [releaseNotes, setReleaseNotes] = useState(null); + // Auto-install Shift+Enter keybinding for VS Code/Cursor/Windsurf (silent, no prompt) useEffect(() => { async function autoInstallKeybinding() { @@ -989,6 +992,16 @@ async function main(): Promise { autoInstallWezTermFix(); }, []); + // Check for release notes to display (runs once on mount) + useEffect(() => { + async function checkNotes() { + const { checkReleaseNotes } = await import("./release-notes"); + const notes = await checkReleaseNotes(); + setReleaseNotes(notes); + } + checkNotes(); + }, []); + // Initialize on mount - check if we should show global agent selector useEffect(() => { async function checkAndStart() { @@ -1764,6 +1777,7 @@ async function main(): Promise { resumedExistingConversation, tokenStreaming: settings.tokenStreaming, agentProvenance, + releaseNotes, }); } @@ -1779,6 +1793,7 @@ async function main(): Promise { resumedExistingConversation, tokenStreaming: settings.tokenStreaming, agentProvenance, + releaseNotes, }); } diff --git a/src/release-notes.ts b/src/release-notes.ts new file mode 100644 index 0000000..dbd3a57 --- /dev/null +++ b/src/release-notes.ts @@ -0,0 +1,87 @@ +/** + * Release notes displayed to users once per version when they upgrade Letta Code. + * Notes appear above the "Starting new conversation with..." line in the transcript. + * + * To add release notes for a new version: + * 1. Add an entry keyed by the base version (e.g., "0.13.0", not "0.13.0-next.5") + * 2. Use markdown formatting (rendered with MarkdownDisplay) + * 3. Keep notes concise - 2-4 bullet points max + */ + +import { settingsManager } from "./settings-manager"; +import { getVersion } from "./version"; + +// Map of base version → markdown string +// Notes are looked up by base version (pre-release suffix stripped) +export const releaseNotes: Record = { + // Add release notes for new versions here. + // Keep concise - 3-4 bullet points max. + // Use → for bullets to match the command hints below. + "0.13.0": `🎁 **Letta Code 0.13.0: Introducing Conversations!** +→ Letta Code now starts a new conversation on each startup (memory is shared across all conversations) +→ Use **/resume** to switch conversations, or run **letta --continue** to continue an existing conversation +→ Read more: https://docs.letta.com/letta-code/changelog#0130`, +}; + +/** + * Get release notes for a specific base version (or null if none exist). + */ +export function getReleaseNotes(baseVersion: string): string | null { + return releaseNotes[baseVersion] ?? null; +} + +/** + * Strip pre-release suffix from version string. + * "0.13.0-next.5" → "0.13.0" + */ +function getBaseVersion(version: string): string { + return version.split("-")[0] ?? version; +} + +/** + * Check if there are release notes to display for the current version. + * Returns the notes markdown string if: + * - Notes exist for the current base version + * - User hasn't seen them yet (tracked in settings) + * + * Also updates settings to mark notes as seen. + * + * Debug: Set LETTA_SHOW_RELEASE_NOTES=1 to force display. + */ +export async function checkReleaseNotes(): Promise { + // Skip for subagents (background processes) + if (process.env.LETTA_CODE_AGENT_ROLE === "subagent") { + return null; + } + + const currentVersion = getVersion(); + const baseVersion = getBaseVersion(currentVersion); + + // Debug flag to force show (still respects whether notes exist) + if (process.env.LETTA_SHOW_RELEASE_NOTES === "1") { + return getReleaseNotes(baseVersion); + } + + const settings = settingsManager.getSettings(); + + // Compare BASE versions so 0.13.0-next.5 → 0.13.0-next.6 doesn't re-show notes + // This ensures users on `next` channel only see notes once per major version + const lastSeenBase = settings.lastSeenReleaseNotesVersion + ? getBaseVersion(settings.lastSeenReleaseNotesVersion) + : undefined; + + if (lastSeenBase === baseVersion) { + return null; + } + + // Look up notes by base version (so 0.13.0-next.5 finds 0.13.0 notes) + const notes = getReleaseNotes(baseVersion); + if (notes) { + // Store BASE version so future pre-releases of same version don't re-show + await settingsManager.updateSettings({ + lastSeenReleaseNotesVersion: baseVersion, + }); + } + + return notes; +} diff --git a/src/settings-manager.ts b/src/settings-manager.ts index 2ee32f6..dd3f9b6 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -40,6 +40,8 @@ export interface Settings { refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets tokenExpiresAt?: number; // Unix timestamp in milliseconds deviceId?: string; + // Release notes tracking + lastSeenReleaseNotesVersion?: string; // Base version of last seen release notes (e.g., "0.13.0") // Pending OAuth state (for PKCE flow) oauthState?: { state: string;