From 93aca7b6c99791711ac62c6c91814a26c8b34a66 Mon Sep 17 00:00:00 2001 From: Satoru Yamamoto Date: Sun, 3 May 2026 15:59:20 -0700 Subject: [PATCH 1/4] Show session transcript in menu bar panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a scrollable THIS SESSION section to the companion panel that displays each voice exchange as it happens. User transcript appears in dim text with a timestamp; Claude's response appears below with a copy-to-clipboard button. Auto-scrolls to the latest entry. History is in-memory only — nothing is written to disk. --- leanring-buddy/CompanionPanelView.swift | 82 +++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..153a4768 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -25,6 +25,14 @@ struct CompanionPanelView: View { .padding(.top, 16) .padding(.horizontal, 16) + if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted && !companionManager.conversationHistory.isEmpty { + Spacer() + .frame(height: 12) + + sessionTranscriptSection + .padding(.horizontal, 16) + } + if companionManager.hasCompletedOnboarding && companionManager.allPermissionsGranted { Spacer() .frame(height: 12) @@ -716,6 +724,80 @@ struct CompanionPanelView: View { } } + // MARK: - Session Transcript + + private var sessionTranscriptSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("THIS SESSION") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundColor(DS.Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + + ScrollViewReader { scrollProxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 6) { + ForEach(companionManager.conversationHistory) { entry in + conversationEntryRow(entry: entry) + .id(entry.id) + } + } + } + .frame(maxHeight: 180) + .onChange(of: companionManager.conversationHistory.count) { _, _ in + if let latestEntry = companionManager.conversationHistory.last { + withAnimation { + scrollProxy.scrollTo(latestEntry.id, anchor: .bottom) + } + } + } + } + } + } + + private func conversationEntryRow(entry: ConversationEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(entry.userTranscript) + .font(.system(size: 11)) + .foregroundColor(DS.Colors.textTertiary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(entry.timestamp, style: .time) + .font(.system(size: 10)) + .foregroundColor(DS.Colors.textTertiary) + } + + HStack(alignment: .top, spacing: 6) { + Text(entry.assistantResponse) + .font(.system(size: 11)) + .foregroundColor(DS.Colors.textSecondary) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entry.assistantResponse, forType: .string) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(DS.Colors.textTertiary) + } + .buttonStyle(.plain) + .pointerCursor() + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous) + .fill(Color.white.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: DS.CornerRadius.medium, style: .continuous) + .stroke(DS.Colors.borderSubtle, lineWidth: 0.5) + ) + } + // MARK: - Visual Helpers private var panelBackground: some View { From 5e9e829f0460e5baa9fb201f677f343fe8c0992e Mon Sep 17 00:00:00 2001 From: Satoru Yamamoto Date: Sun, 3 May 2026 16:02:22 -0700 Subject: [PATCH 2/4] Extract ConversationEntry and publish conversationHistory Moves ConversationEntry into its own file so it can be imported by both CompanionManager and CompanionPanelView. Makes conversationHistory @Published so the panel can observe and display it in real time. Adds a timestamp to each entry for the session transcript UI. --- leanring-buddy/CompanionManager.swift | 9 +++++---- leanring-buddy/ConversationEntry.swift | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 leanring-buddy/ConversationEntry.swift diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..530a08b0 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -81,8 +81,8 @@ final class CompanionManager: ObservableObject { }() /// Conversation history so Claude remembers prior exchanges within a session. - /// Each entry is the user's transcript and Claude's response. - private var conversationHistory: [(userTranscript: String, assistantResponse: String)] = [] + /// Published so the panel UI can display the transcript. Not persisted to disk. + @Published private(set) var conversationHistory: [ConversationEntry] = [] /// The currently running AI response task, if any. Cancelled when the user /// speaks again so a new response can begin immediately. @@ -683,9 +683,10 @@ final class CompanionManager: ObservableObject { // Save this exchange to conversation history (with the point tag // stripped so it doesn't confuse future context) - conversationHistory.append(( + conversationHistory.append(ConversationEntry( userTranscript: transcript, - assistantResponse: spokenText + assistantResponse: spokenText, + timestamp: Date() )) // Keep only the last 10 exchanges to avoid unbounded context growth diff --git a/leanring-buddy/ConversationEntry.swift b/leanring-buddy/ConversationEntry.swift new file mode 100644 index 00000000..c72c7ea5 --- /dev/null +++ b/leanring-buddy/ConversationEntry.swift @@ -0,0 +1,19 @@ +// +// ConversationEntry.swift +// leanring-buddy +// +// A single completed exchange between the user and Claude within the current +// app session. Used by CompanionManager to track history and by +// CompanionPanelView to render the session transcript. +// + +import Foundation + +/// A single completed exchange between the user and Claude within the current app session. +/// Not persisted to disk — lives only as long as the app is running. +struct ConversationEntry: Identifiable { + let id = UUID() + let userTranscript: String + let assistantResponse: String + let timestamp: Date +} From 81ccb87fb2d71b52e18a82b67c4dd46f0e39c291 Mon Sep 17 00:00:00 2001 From: Satoru Yamamoto Date: Sun, 3 May 2026 16:31:13 -0700 Subject: [PATCH 3/4] Add unit tests for ConversationEntry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers field storage, UUID uniqueness across instances, and ID stability on repeated access — following the existing Swift Testing pattern. --- leanring-buddyTests/leanring_buddyTests.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/leanring-buddyTests/leanring_buddyTests.swift b/leanring-buddyTests/leanring_buddyTests.swift index 188fe7ae..904c419f 100644 --- a/leanring-buddyTests/leanring_buddyTests.swift +++ b/leanring-buddyTests/leanring_buddyTests.swift @@ -6,6 +6,7 @@ // import Testing +import Foundation @testable import leanring_buddy struct leanring_buddyTests { @@ -37,4 +38,42 @@ struct leanring_buddyTests { #expect(shouldTreatPermissionAsGranted) } + @Test func conversationEntryStoresAllFields() { + let timestamp = Date() + let entry = ConversationEntry( + userTranscript: "What is SwiftUI?", + assistantResponse: "SwiftUI is Apple's declarative UI framework.", + timestamp: timestamp + ) + + #expect(entry.userTranscript == "What is SwiftUI?") + #expect(entry.assistantResponse == "SwiftUI is Apple's declarative UI framework.") + #expect(entry.timestamp == timestamp) + } + + @Test func conversationEntryHasUniqueIDs() { + let firstEntry = ConversationEntry( + userTranscript: "First question", + assistantResponse: "First answer", + timestamp: Date() + ) + let secondEntry = ConversationEntry( + userTranscript: "Second question", + assistantResponse: "Second answer", + timestamp: Date() + ) + + #expect(firstEntry.id != secondEntry.id) + } + + @Test func conversationEntryIDIsStable() { + let entry = ConversationEntry( + userTranscript: "Hello", + assistantResponse: "Hi there", + timestamp: Date() + ) + + #expect(entry.id == entry.id) + } + } From bbb27c87057e5200268dd2c31b2a7c5b8f3f4e59 Mon Sep 17 00:00:00 2001 From: Satoru Yamamoto Date: Mon, 4 May 2026 19:02:33 -0700 Subject: [PATCH 4/4] Fix auto-scroll and add tests for capped conversation history Watching .count stops triggering onChange once history stabilizes at 10 entries; switching to .last?.id fires on every append since each new ConversationEntry gets a fresh UUID. Also adds unit tests covering ConversationEntry field storage, UUID uniqueness, ID stability, and the append+truncate scroll trigger contract. --- leanring-buddy/CompanionPanelView.swift | 2 +- leanring-buddyTests/leanring_buddyTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/leanring-buddy/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 153a4768..1fe1a0b4 100644 --- a/leanring-buddy/CompanionPanelView.swift +++ b/leanring-buddy/CompanionPanelView.swift @@ -743,7 +743,7 @@ struct CompanionPanelView: View { } } .frame(maxHeight: 180) - .onChange(of: companionManager.conversationHistory.count) { _, _ in + .onChange(of: companionManager.conversationHistory.last?.id) { _, _ in if let latestEntry = companionManager.conversationHistory.last { withAnimation { scrollProxy.scrollTo(latestEntry.id, anchor: .bottom) diff --git a/leanring-buddyTests/leanring_buddyTests.swift b/leanring-buddyTests/leanring_buddyTests.swift index 904c419f..decb1279 100644 --- a/leanring-buddyTests/leanring_buddyTests.swift +++ b/leanring-buddyTests/leanring_buddyTests.swift @@ -76,4 +76,26 @@ struct leanring_buddyTests { #expect(entry.id == entry.id) } + @Test func lastConversationEntryIDChangesWhenHistoryIsCappedAtTen() { + var history: [ConversationEntry] = (1...10).map { i in + ConversationEntry(userTranscript: "Q\(i)", assistantResponse: "A\(i)", timestamp: Date()) + } + + for i in 11...12 { + let idBeforeAppend = history.last?.id + + history.append(ConversationEntry( + userTranscript: "Q\(i)", + assistantResponse: "A\(i)", + timestamp: Date() + )) + if history.count > 10 { + history.removeFirst(history.count - 10) + } + + #expect(history.count == 10) + #expect(history.last?.id != idBeforeAppend) + } + } + }