Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions leanring-buddy/CompanionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions leanring-buddy/CompanionPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions leanring-buddy/ConversationEntry.swift
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions leanring-buddyTests/leanring_buddyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Testing
import Foundation
@testable import leanring_buddy

struct leanring_buddyTests {
Expand Down Expand Up @@ -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)
}
}

}