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/CompanionPanelView.swift b/leanring-buddy/CompanionPanelView.swift index 76789b4c..1fe1a0b4 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.last?.id) { _, _ 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 { 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 +} diff --git a/leanring-buddyTests/leanring_buddyTests.swift b/leanring-buddyTests/leanring_buddyTests.swift index 188fe7ae..decb1279 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,64 @@ 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) + } + + @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) + } + } + }