Kaynağa Gözat

Add AI history and speed up post/title generation.

Persist AI tool outputs for restore from a new History sidebar, and cut optimize/generate latency with a faster model, slimmer API payloads, local title analysis, and async network/history work.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ay önce
ebeveyn
işleme
f32d63d06f

+ 10 - 2
Reddit App/Managers/AIConfiguration.swift

@@ -4,14 +4,22 @@ enum AIConfiguration {
     /// OpenAI API key used by Post Generator, Title Optimizer, and Comment Writer.
     /// Priority: `OPENAI_API_KEY` environment variable (dev builds), then bundled encrypted key.
     static var apiKey: String? {
+        if let cachedAPIKey {
+            return cachedAPIKey
+        }
+
         if let envKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"],
            !envKey.trimmingCharacters(in: .whitespaces).isEmpty {
-            return envKey.trimmingCharacters(in: .whitespaces)
+            cachedAPIKey = envKey.trimmingCharacters(in: .whitespaces)
+            return cachedAPIKey
         }
 
-        return EncryptedOpenAIKey.openAIAPIKey()
+        cachedAPIKey = EncryptedOpenAIKey.openAIAPIKey()
+        return cachedAPIKey
     }
 
+    private static var cachedAPIKey: String?
+
     static func usesLiveAI(hasPremiumAccess: Bool) -> Bool {
         hasPremiumAccess && apiKey != nil
     }

+ 83 - 0
Reddit App/Managers/AIHistoryManager.swift

@@ -0,0 +1,83 @@
+import Foundation
+
+@MainActor
+@Observable
+final class AIHistoryManager {
+    static let shared = AIHistoryManager()
+
+    private(set) var entries: [AIHistoryEntry] = []
+
+    private let defaults: UserDefaults
+    private let storageKey = "reddora.ai.history.entries"
+    private let maxEntries = 100
+    private let persistQueue = DispatchQueue(label: "com.reddora.ai-history.persist", qos: .utility)
+
+    init(defaults: UserDefaults = .standard) {
+        self.defaults = defaults
+        load()
+    }
+
+    var isEmpty: Bool {
+        entries.isEmpty
+    }
+
+    func entries(matching filter: AIHistoryFilter) -> [AIHistoryEntry] {
+        guard let toolKind = filter.toolKind else { return entries }
+        return entries.filter { $0.toolKind == toolKind }
+    }
+
+    func save(_ entry: AIHistoryEntry) {
+        entries.removeAll { $0.id == entry.id }
+        entries.insert(entry, at: 0)
+
+        if entries.count > maxEntries {
+            entries = Array(entries.prefix(maxEntries))
+        }
+
+        persist()
+    }
+
+    func delete(id: UUID) {
+        guard entries.contains(where: { $0.id == id }) else { return }
+        entries.removeAll { $0.id == id }
+        persist()
+    }
+
+    func deleteAll() {
+        guard !entries.isEmpty else { return }
+        entries.removeAll()
+        persist()
+    }
+
+    private func load() {
+        guard let data = defaults.data(forKey: storageKey) else {
+            entries = []
+            return
+        }
+
+        do {
+            let decoder = JSONDecoder()
+            decoder.dateDecodingStrategy = .iso8601
+            entries = try decoder.decode([AIHistoryEntry].self, from: data)
+        } catch {
+            entries = []
+        }
+    }
+
+    private func persist() {
+        let snapshot = entries
+        let storageKey = storageKey
+        let defaults = defaults
+
+        persistQueue.async {
+            do {
+                let encoder = JSONEncoder()
+                encoder.dateEncodingStrategy = .iso8601
+                let data = try encoder.encode(snapshot)
+                defaults.set(data, forKey: storageKey)
+            } catch {
+                // Keep in-memory entries if persistence fails.
+            }
+        }
+    }
+}

+ 412 - 0
Reddit App/Models/AIHistoryModels.swift

@@ -0,0 +1,412 @@
+import Foundation
+
+enum AIHistoryToolKind: String, Codable, CaseIterable, Identifiable {
+    case postGenerator
+    case titleOptimizer
+    case commentWriter
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .postGenerator: "Post Generator"
+        case .titleOptimizer: "Title Optimizer"
+        case .commentWriter: "Comment Writer"
+        }
+    }
+
+    var iconKind: SidebarIconKind {
+        switch self {
+        case .postGenerator: .postGenerator
+        case .titleOptimizer: .titleOptimizer
+        case .commentWriter: .commentWriter
+        }
+    }
+}
+
+enum AIHistoryFilter: String, CaseIterable, Identifiable {
+    case all
+    case postGenerator
+    case titleOptimizer
+    case commentWriter
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .all: "All"
+        case .postGenerator: "Posts"
+        case .titleOptimizer: "Titles"
+        case .commentWriter: "Comments"
+        }
+    }
+
+    var toolKind: AIHistoryToolKind? {
+        switch self {
+        case .all: nil
+        case .postGenerator: .postGenerator
+        case .titleOptimizer: .titleOptimizer
+        case .commentWriter: .commentWriter
+        }
+    }
+}
+
+struct StoredPollOption: Codable, Equatable {
+    let id: UUID
+    var text: String
+
+    init(id: UUID = UUID(), text: String = "") {
+        self.id = id
+        self.text = text
+    }
+
+    init(from option: PollOption) {
+        id = option.id
+        text = option.text
+    }
+
+    var pollOption: PollOption {
+        PollOption(id: id, text: text)
+    }
+}
+
+struct StoredPostDraft: Codable, Equatable {
+    var postType: RedditPostType
+    var subreddit: String
+    var topic: String
+    var tone: PostTone
+    var title: String
+    var body: String
+    var linkURL: String
+    var videoURL: String
+    var pollOptions: [StoredPollOption]
+    var pollDuration: PollDuration
+    var isNSFW: Bool
+    var isSpoiler: Bool
+    var isOC: Bool
+    var flair: String
+
+    init(from draft: PostDraft) {
+        postType = draft.postType
+        subreddit = draft.subreddit
+        topic = draft.topic
+        tone = draft.tone
+        title = draft.title
+        body = draft.body
+        linkURL = draft.linkURL
+        videoURL = draft.videoURL
+        pollOptions = draft.pollOptions.map(StoredPollOption.init)
+        pollDuration = draft.pollDuration
+        isNSFW = draft.isNSFW
+        isSpoiler = draft.isSpoiler
+        isOC = draft.isOC
+        flair = draft.flair
+    }
+
+    var postDraft: PostDraft {
+        PostDraft(
+            postType: postType,
+            subreddit: subreddit,
+            topic: topic,
+            tone: tone,
+            title: title,
+            body: body,
+            linkURL: linkURL,
+            videoURL: videoURL,
+            imageFileURL: nil,
+            pollOptions: pollOptions.map(\.pollOption),
+            pollDuration: pollDuration,
+            isNSFW: isNSFW,
+            isSpoiler: isSpoiler,
+            isOC: isOC,
+            flair: flair
+        )
+    }
+}
+
+struct StoredTitleDraft: Codable, Equatable {
+    var subreddit: String
+    var topic: String
+    var tone: PostTone
+    var titleGoal: TitleGoal
+    var originalTitle: String
+    var postType: RedditPostType
+    var isNSFW: Bool
+    var isSpoiler: Bool
+    var isOC: Bool
+    var flair: String
+    var variantCount: TitleVariantCount
+
+    init(from draft: TitleDraft) {
+        subreddit = draft.subreddit
+        topic = draft.topic
+        tone = draft.tone
+        titleGoal = draft.titleGoal
+        originalTitle = draft.originalTitle
+        postType = draft.postType
+        isNSFW = draft.isNSFW
+        isSpoiler = draft.isSpoiler
+        isOC = draft.isOC
+        flair = draft.flair
+        variantCount = draft.variantCount
+    }
+
+    var titleDraft: TitleDraft {
+        TitleDraft(
+            subreddit: subreddit,
+            topic: topic,
+            tone: tone,
+            titleGoal: titleGoal,
+            originalTitle: originalTitle,
+            postType: postType,
+            isNSFW: isNSFW,
+            isSpoiler: isSpoiler,
+            isOC: isOC,
+            flair: flair,
+            variantCount: variantCount
+        )
+    }
+}
+
+struct StoredTitleVariant: Codable, Equatable {
+    let id: UUID
+    var title: String
+    var score: Int
+    var reasoning: String
+
+    init(from variant: TitleVariant) {
+        id = variant.id
+        title = variant.title
+        score = variant.score
+        reasoning = variant.reasoning
+    }
+
+    var titleVariant: TitleVariant {
+        TitleVariant(id: id, title: title, score: score, reasoning: reasoning)
+    }
+}
+
+struct StoredTitleAnalysis: Codable, Equatable {
+    var overallScore: Int
+    var lengthScore: Int
+    var engagementScore: Int
+    var clarityScore: Int
+    var suggestions: [String]
+
+    init(from analysis: TitleAnalysis) {
+        overallScore = analysis.overallScore
+        lengthScore = analysis.lengthScore
+        engagementScore = analysis.engagementScore
+        clarityScore = analysis.clarityScore
+        suggestions = analysis.suggestions
+    }
+
+    var titleAnalysis: TitleAnalysis {
+        TitleAnalysis(
+            overallScore: overallScore,
+            lengthScore: lengthScore,
+            engagementScore: engagementScore,
+            clarityScore: clarityScore,
+            suggestions: suggestions
+        )
+    }
+}
+
+struct StoredCommentDraft: Codable, Equatable {
+    var commentType: CommentType
+    var subreddit: String
+    var postTitle: String
+    var postBody: String
+    var postURL: String
+    var parentComment: String
+    var topic: String
+    var tone: PostTone
+    var intent: CommentIntent
+    var length: CommentLength
+    var body: String
+    var includeQuote: Bool
+    var useMarkdown: Bool
+    var variantCount: CommentVariantCount
+
+    init(from draft: CommentDraft) {
+        commentType = draft.commentType
+        subreddit = draft.subreddit
+        postTitle = draft.postTitle
+        postBody = draft.postBody
+        postURL = draft.postURL
+        parentComment = draft.parentComment
+        topic = draft.topic
+        tone = draft.tone
+        intent = draft.intent
+        length = draft.length
+        body = draft.body
+        includeQuote = draft.includeQuote
+        useMarkdown = draft.useMarkdown
+        variantCount = draft.variantCount
+    }
+
+    var commentDraft: CommentDraft {
+        CommentDraft(
+            commentType: commentType,
+            subreddit: subreddit,
+            postTitle: postTitle,
+            postBody: postBody,
+            postURL: postURL,
+            parentComment: parentComment,
+            topic: topic,
+            tone: tone,
+            intent: intent,
+            length: length,
+            body: body,
+            includeQuote: includeQuote,
+            useMarkdown: useMarkdown,
+            variantCount: variantCount
+        )
+    }
+}
+
+struct StoredCommentVariant: Codable, Equatable {
+    let id: UUID
+    var body: String
+    var score: Int
+    var reasoning: String
+
+    init(from variant: CommentVariant) {
+        id = variant.id
+        body = variant.body
+        score = variant.score
+        reasoning = variant.reasoning
+    }
+
+    var commentVariant: CommentVariant {
+        CommentVariant(id: id, body: body, score: score, reasoning: reasoning)
+    }
+}
+
+enum AIHistoryPayload: Codable, Equatable {
+    case postGenerator(StoredPostDraft)
+    case titleOptimizer(
+        draft: StoredTitleDraft,
+        analysis: StoredTitleAnalysis?,
+        variants: [StoredTitleVariant],
+        selectedVariantID: UUID?
+    )
+    case commentWriter(
+        draft: StoredCommentDraft,
+        variants: [StoredCommentVariant],
+        selectedVariantID: UUID?
+    )
+}
+
+struct AIHistoryEntry: Identifiable, Codable, Equatable {
+    let id: UUID
+    let toolKind: AIHistoryToolKind
+    let createdAt: Date
+    let displayTitle: String
+    let displaySubtitle: String
+    let payload: AIHistoryPayload
+
+    init(
+        id: UUID = UUID(),
+        toolKind: AIHistoryToolKind,
+        createdAt: Date = .now,
+        displayTitle: String,
+        displaySubtitle: String,
+        payload: AIHistoryPayload
+    ) {
+        self.id = id
+        self.toolKind = toolKind
+        self.createdAt = createdAt
+        self.displayTitle = displayTitle
+        self.displaySubtitle = displaySubtitle
+        self.payload = payload
+    }
+}
+
+extension AIHistoryEntry {
+    static func fromPostGenerator(draft: PostDraft) -> AIHistoryEntry {
+        let title = draft.title.trimmingCharacters(in: .whitespaces)
+        let displayTitle = title.isEmpty ? "Untitled post" : title
+        let subreddit = formattedSubredditName(draft.subreddit)
+
+        return AIHistoryEntry(
+            toolKind: .postGenerator,
+            displayTitle: displayTitle,
+            displaySubtitle: subreddit,
+            payload: .postGenerator(StoredPostDraft(from: draft))
+        )
+    }
+
+    static func fromTitleOptimizer(
+        draft: TitleDraft,
+        analysis: TitleAnalysis?,
+        variants: [TitleVariant],
+        selectedVariantID: UUID?
+    ) -> AIHistoryEntry {
+        let activeTitle: String
+        if let selectedVariantID,
+           let variant = variants.first(where: { $0.id == selectedVariantID }) {
+            activeTitle = variant.title
+        } else {
+            activeTitle = draft.originalTitle
+        }
+
+        let title = activeTitle.trimmingCharacters(in: .whitespaces)
+        let displayTitle = title.isEmpty ? "Untitled optimization" : title
+        let subreddit = formattedSubredditName(draft.subreddit)
+
+        return AIHistoryEntry(
+            toolKind: .titleOptimizer,
+            displayTitle: displayTitle,
+            displaySubtitle: subreddit,
+            payload: .titleOptimizer(
+                draft: StoredTitleDraft(from: draft),
+                analysis: analysis.map(StoredTitleAnalysis.init),
+                variants: variants.map(StoredTitleVariant.init),
+                selectedVariantID: selectedVariantID
+            )
+        )
+    }
+
+    static func fromCommentWriter(
+        draft: CommentDraft,
+        variants: [CommentVariant],
+        selectedVariantID: UUID?
+    ) -> AIHistoryEntry {
+        let activeBody: String
+        if let selectedVariantID,
+           let variant = variants.first(where: { $0.id == selectedVariantID }) {
+            activeBody = variant.body
+        } else {
+            activeBody = draft.body
+        }
+
+        let trimmed = activeBody.trimmingCharacters(in: .whitespacesAndNewlines)
+        let displayTitle: String
+        if trimmed.isEmpty {
+            displayTitle = "Untitled comment"
+        } else {
+            let firstLine = trimmed.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? trimmed
+            displayTitle = firstLine.count > 80 ? String(firstLine.prefix(77)) + "…" : firstLine
+        }
+
+        let subreddit = formattedSubredditName(draft.subreddit)
+
+        return AIHistoryEntry(
+            toolKind: .commentWriter,
+            displayTitle: displayTitle,
+            displaySubtitle: subreddit,
+            payload: .commentWriter(
+                draft: StoredCommentDraft(from: draft),
+                variants: variants.map(StoredCommentVariant.init),
+                selectedVariantID: selectedVariantID
+            )
+        )
+    }
+
+    private static func formattedSubredditName(_ subreddit: String) -> String {
+        let name = PostDraftValidator.normalizedSubreddit(subreddit)
+        guard !name.isEmpty else { return "r/subreddit" }
+        return "r/\(name)"
+    }
+}

+ 4 - 4
Reddit App/Models/CommentModels.swift

@@ -16,7 +16,7 @@ enum CommentWriterTab: String, CaseIterable, Identifiable {
     }
 }
 
-enum CommentType: String, CaseIterable, Identifiable {
+enum CommentType: String, CaseIterable, Identifiable, Codable {
     case topLevel
     case reply
 
@@ -44,7 +44,7 @@ enum CommentType: String, CaseIterable, Identifiable {
     }
 }
 
-enum CommentIntent: String, CaseIterable, Identifiable {
+enum CommentIntent: String, CaseIterable, Identifiable, Codable {
     case addValue
     case agree
     case disagree
@@ -96,7 +96,7 @@ enum CommentIntent: String, CaseIterable, Identifiable {
     }
 }
 
-enum CommentLength: String, CaseIterable, Identifiable {
+enum CommentLength: String, CaseIterable, Identifiable, Codable {
     case short
     case medium
     case long
@@ -128,7 +128,7 @@ enum CommentLength: String, CaseIterable, Identifiable {
     }
 }
 
-enum CommentVariantCount: Int, CaseIterable, Identifiable {
+enum CommentVariantCount: Int, CaseIterable, Identifiable, Codable {
     case three = 3
     case five = 5
 

+ 1 - 0
Reddit App/Models/NavigationModels.swift

@@ -10,6 +10,7 @@ struct SidebarNavItem: Identifiable, Hashable {
 enum SidebarSection: String, CaseIterable, Identifiable {
     case quickAccess = "QUICK ACCESS"
     case create = "CREATE"
+    case history = "HISTORY"
     case settings = "SETTINGS"
 
     var id: String { rawValue }

+ 3 - 3
Reddit App/Models/RedditPostModels.swift

@@ -1,6 +1,6 @@
 import Foundation
 
-enum RedditPostType: String, CaseIterable, Identifiable {
+enum RedditPostType: String, CaseIterable, Identifiable, Codable {
     case text
     case image
     case link
@@ -40,7 +40,7 @@ enum RedditPostType: String, CaseIterable, Identifiable {
     }
 }
 
-enum PostTone: String, CaseIterable, Identifiable {
+enum PostTone: String, CaseIterable, Identifiable, Codable {
     case casual
     case professional
     case humorous
@@ -62,7 +62,7 @@ enum PostTone: String, CaseIterable, Identifiable {
     }
 }
 
-enum PollDuration: Int, CaseIterable, Identifiable {
+enum PollDuration: Int, CaseIterable, Identifiable, Codable {
     case oneDay = 1
     case threeDays = 3
     case sevenDays = 7

+ 3 - 0
Reddit App/Models/SidebarIconKind.swift

@@ -4,6 +4,7 @@ enum SidebarIconKind: Hashable {
     case postGenerator
     case titleOptimizer
     case commentWriter
+    case history
     case settings
 
     var systemImage: String {
@@ -11,6 +12,7 @@ enum SidebarIconKind: Hashable {
         case .postGenerator: "square.and.pencil"
         case .titleOptimizer: "textformat.size"
         case .commentWriter: "bubble.left.and.bubble.right.fill"
+        case .history: "clock.arrow.circlepath"
         case .settings: "gearshape.fill"
         }
     }
@@ -20,6 +22,7 @@ enum SidebarIconKind: Hashable {
         case .postGenerator: [Color(hex: 0xC4B5FD), Color(hex: 0x7C3AED)]
         case .titleOptimizer: [Color(hex: 0x93C5FD), Color(hex: 0x2563EB)]
         case .commentWriter: [Color(hex: 0x86EFAC), Color(hex: 0x16A34A)]
+        case .history: [Color(hex: 0xFCD34D), Color(hex: 0xD97706)]
         case .settings: [Color(hex: 0x9CA3AF), Color(hex: 0x4B5563)]
         }
     }

+ 2 - 2
Reddit App/Models/TitleOptimizerModels.swift

@@ -16,7 +16,7 @@ enum TitleOptimizerTab: String, CaseIterable, Identifiable {
     }
 }
 
-enum TitleGoal: String, CaseIterable, Identifiable {
+enum TitleGoal: String, CaseIterable, Identifiable, Codable {
     case curiosity
     case question
     case listicle
@@ -49,7 +49,7 @@ enum TitleGoal: String, CaseIterable, Identifiable {
     }
 }
 
-enum TitleVariantCount: Int, CaseIterable, Identifiable {
+enum TitleVariantCount: Int, CaseIterable, Identifiable, Codable {
     case three = 3
     case five = 5
     case eight = 8

+ 13 - 15
Reddit App/Services/PostGenerationService.swift

@@ -40,7 +40,7 @@ enum PostGenerationError: LocalizedError {
 
 struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
     let apiKey: String
-    var model: String = "gpt-4o-mini"
+    var model: String = AIRequestConfiguration.model
 
     func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
         try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
@@ -51,22 +51,22 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
-        request.timeoutInterval = 60
+        request.timeoutInterval = AIRequestConfiguration.requestTimeout
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
         let body: [String: Any] = [
             "model": model,
-            "temperature": 0.8,
+            "temperature": 0.6,
+            "max_tokens": AIRequestConfiguration.postGeneratorMaxTokens(for: draft.postType),
             "response_format": ["type": "json_object"],
             "messages": [
                 [
                     "role": "system",
                     "content": """
-                    You write Reddit posts. Respond with JSON only using keys:
-                    title (string, max 300 chars), body (string), suggestedFlair (string or null), \
-                    pollOptions (array of 2-6 strings when post type is poll, otherwise null).
-                    Respect NSFW, spoiler, and OC tags. Match the requested tone and post type.
+                    Write Reddit posts. JSON only: title, body, suggestedFlair (string or null), \
+                    pollOptions (2-6 strings for polls, else null).
+                    Keep body concise (under 250 words). Match tone and post type.
                     """,
                 ],
                 ["role": "user", "content": prompt],
@@ -78,7 +78,7 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
         let response: URLResponse
 
         do {
-            (data, response) = try await NetworkClient.data(for: request)
+            (data, response) = try await NetworkClient.aiData(for: request)
         } catch let error as URLError {
             throw PostGenerationError.apiUnavailable(UserFacingError.networkMessage(for: error))
         }
@@ -107,15 +107,15 @@ struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
 
         switch draft.postType {
         case .text:
-            lines.append("Write a full text post with markdown formatting.")
+            lines.append("Write a concise text post with light markdown. Under 250 words.")
         case .image:
-            lines.append("Write a short image caption and optional supporting context in body.")
+            lines.append("Write a short image caption. Body under 80 words.")
         case .link:
-            lines.append("Write a link post description explaining why r/\(subreddit) should care.")
+            lines.append("Write a brief link post description. Under 100 words.")
         case .video:
-            lines.append("Write a video post description with a compelling hook.")
+            lines.append("Write a short video post hook. Under 100 words.")
         case .poll:
-            lines.append("Write poll context in body and provide \(draft.pollOptions.count) distinct poll options.")
+            lines.append("Write brief poll context and provide \(draft.pollOptions.count) poll options.")
         }
 
         return lines.joined(separator: "\n")
@@ -176,8 +176,6 @@ struct MockPostGenerationService: PostGenerationServiceProtocol {
     func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
         try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
 
-        try await Task.sleep(for: .milliseconds(900))
-
         let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
         let topic = draft.topic.trimmingCharacters(in: .whitespaces)
         let tone = draft.tone.title.lowercased()

+ 16 - 138
Reddit App/Services/TitleOptimizationService.swift

@@ -37,7 +37,7 @@ enum TitleOptimizationError: LocalizedError {
 
 struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
     let apiKey: String
-    var model: String = "gpt-4o-mini"
+    var model: String = AIRequestConfiguration.model
 
     func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult {
         guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
@@ -55,23 +55,22 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
 
         var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
         request.httpMethod = "POST"
-        request.timeoutInterval = 60
+        request.timeoutInterval = AIRequestConfiguration.requestTimeout
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
 
+        let variantCount = draft.variantCount.rawValue
         let body: [String: Any] = [
             "model": model,
-            "temperature": 0.85,
+            "temperature": 0.6,
+            "max_tokens": AIRequestConfiguration.titleOptimizerMaxTokens(variantCount: variantCount),
             "response_format": ["type": "json_object"],
             "messages": [
                 [
                     "role": "system",
                     "content": """
-                    You optimize Reddit post titles. Respond with JSON only using keys:
-                    analysis (object with overallScore, lengthScore, engagementScore, clarityScore as integers 0-100, \
-                    and suggestions as string array),
-                    variants (array of exactly \(draft.variantCount.rawValue) objects with title, score 0-100, reasoning strings).
-                    Titles must be under 300 characters. Match subreddit culture and the requested goal/tone.
+                    Optimize Reddit titles. JSON only: {"variants":[{"title":"...","score":85}]}.
+                    Return exactly \(variantCount) variants. Titles under 300 chars. Match subreddit, goal, and tone.
                     """,
                 ],
                 ["role": "user", "content": prompt],
@@ -83,7 +82,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         let response: URLResponse
 
         do {
-            (data, response) = try await NetworkClient.data(for: request)
+            (data, response) = try await NetworkClient.aiData(for: request)
         } catch let error as URLError {
             throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error))
         }
@@ -94,7 +93,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
             )
         }
 
-        return try parseResponse(data)
+        return try parseResponse(data, draft: draft)
     }
 
     private func buildPrompt(draft: TitleDraft, subreddit: String) -> String {
@@ -115,7 +114,7 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
         return lines.joined(separator: "\n")
     }
 
-    private func parseResponse(_ data: Data) throws -> TitleOptimizationResult {
+    private func parseResponse(_ data: Data, draft: TitleDraft) throws -> TitleOptimizationResult {
         struct APIEnvelope: Decodable {
             struct Choice: Decodable {
                 struct Message: Decodable {
@@ -126,46 +125,22 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
             let choices: [Choice]
         }
 
-        struct AnalysisPayload: Decodable {
-            let overallScore: Int
-            let lengthScore: Int
-            let engagementScore: Int
-            let clarityScore: Int
-            let suggestions: [String]
-
-            enum CodingKeys: String, CodingKey {
-                case overallScore, lengthScore, engagementScore, clarityScore, suggestions
-            }
-
-            init(from decoder: Decoder) throws {
-                let container = try decoder.container(keyedBy: CodingKeys.self)
-                overallScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .overallScore)?.value ?? 70
-                lengthScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .lengthScore)?.value ?? 70
-                engagementScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .engagementScore)?.value ?? 70
-                clarityScore = try container.decodeIfPresent(FlexibleInt.self, forKey: .clarityScore)?.value ?? 70
-                suggestions = try container.decodeIfPresent([String].self, forKey: .suggestions) ?? []
-            }
-        }
-
         struct VariantPayload: Decodable {
             let title: String
             let score: Int
-            let reasoning: String
 
             enum CodingKeys: String, CodingKey {
-                case title, score, reasoning
+                case title, score
             }
 
             init(from decoder: Decoder) throws {
                 let container = try decoder.container(keyedBy: CodingKeys.self)
                 title = try container.decode(String.self, forKey: .title)
                 score = try container.decode(FlexibleInt.self, forKey: .score).value
-                reasoning = try container.decode(FlexibleStringOrArray.self, forKey: .reasoning).value
             }
         }
 
         struct Payload: Decodable {
-            let analysis: AnalysisPayload
             let variants: [VariantPayload]
         }
 
@@ -185,19 +160,15 @@ struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol {
             throw TitleOptimizationError.invalidResponse
         }
 
-        let analysis = TitleAnalysis(
-            overallScore: clampScore(payload.analysis.overallScore),
-            lengthScore: clampScore(payload.analysis.lengthScore),
-            engagementScore: clampScore(payload.analysis.engagementScore),
-            clarityScore: clampScore(payload.analysis.clarityScore),
-            suggestions: payload.analysis.suggestions
-        )
+        let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
+        let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal)
+        let defaultReasoning = "\(draft.titleGoal.title) hook"
 
         let variants = payload.variants.map {
             TitleVariant(
                 title: String($0.title.prefix(PostDraftValidator.maxTitleLength)),
                 score: clampScore($0.score),
-                reasoning: $0.reasoning
+                reasoning: defaultReasoning
             )
         }
 
@@ -223,13 +194,11 @@ struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
             throw TitleOptimizationError.emptyTitle
         }
 
-        try await Task.sleep(for: .milliseconds(900))
-
         let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
         let topic = draft.topic.trimmingCharacters(in: .whitespaces)
         let original = draft.originalTitle.trimmingCharacters(in: .whitespaces)
 
-        let analysis = analyzeTitle(original, goal: draft.titleGoal)
+        let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal)
         let variants = makeVariants(
             original: original,
             topic: topic,
@@ -242,97 +211,6 @@ struct MockTitleOptimizationService: TitleOptimizationServiceProtocol {
         return TitleOptimizationResult(analysis: analysis, variants: variants)
     }
 
-    private func analyzeTitle(_ title: String, goal: TitleGoal) -> TitleAnalysis {
-        let length = title.count
-        let lengthScore = scoreLength(length)
-        let engagementScore = scoreEngagement(title, goal: goal)
-        let clarityScore = scoreClarity(title)
-        let overall = (lengthScore + engagementScore + clarityScore) / 3
-        let suggestions = makeSuggestions(title: title, length: length, goal: goal)
-
-        return TitleAnalysis(
-            overallScore: overall,
-            lengthScore: lengthScore,
-            engagementScore: engagementScore,
-            clarityScore: clarityScore,
-            suggestions: suggestions
-        )
-    }
-
-    private func scoreLength(_ length: Int) -> Int {
-        switch length {
-        case 0: 0
-        case 1...50: 95
-        case 51...80: 85
-        case 81...120: 70
-        case 121...200: 50
-        case 201...300: 30
-        default: 10
-        }
-    }
-
-    private func scoreEngagement(_ title: String, goal: TitleGoal) -> Int {
-        var score = 50
-        let lower = title.lowercased()
-
-        if title.contains("?") { score += 10 }
-        if title.range(of: #"\d+"#, options: .regularExpression) != nil { score += 8 }
-        if lower.contains("how") || lower.contains("why") || lower.contains("what") { score += 6 }
-        if lower.contains("you") || lower.contains("your") { score += 5 }
-        if title == title.uppercased() && title.count > 5 { score -= 15 }
-
-        switch goal {
-        case .question where !title.contains("?"): score -= 10
-        case .listicle where title.range(of: #"\d+"#, options: .regularExpression) == nil: score -= 8
-        case .curiosity where !lower.contains("secret") && !lower.contains("nobody") && !lower.contains("actually"): score -= 3
-        default: break
-        }
-
-        return min(100, max(0, score))
-    }
-
-    private func scoreClarity(_ title: String) -> Int {
-        var score = 70
-        let words = title.split(separator: " ").count
-
-        if words >= 4 && words <= 12 { score += 15 }
-        if words < 3 { score -= 20 }
-        if words > 20 { score -= 15 }
-        if title.contains("  ") { score -= 5 }
-        if title.hasPrefix(" ") || title.hasSuffix(" ") { score -= 10 }
-
-        return min(100, max(0, score))
-    }
-
-    private func makeSuggestions(title: String, length: Int, goal: TitleGoal) -> [String] {
-        var tips: [String] = []
-
-        if length > 120 {
-            tips.append("Shorten to under 80 characters for better mobile visibility.")
-        } else if length < 20 {
-            tips.append("Add more context — very short titles can feel vague in feeds.")
-        }
-
-        if !title.contains("?") && goal == .question {
-            tips.append("Reframe as a question to invite comments.")
-        }
-
-        if title.range(of: #"\d+"#, options: .regularExpression) == nil && goal == .listicle {
-            tips.append("Include a number (e.g. \"5 tips\") for listicle-style engagement.")
-        }
-
-        let lower = title.lowercased()
-        if goal == .curiosity && !lower.contains("?") {
-            tips.append("Add a curiosity hook — hint at a surprising outcome.")
-        }
-
-        if tips.isEmpty {
-            tips.append("Strong base title — try variants with different hooks below.")
-        }
-
-        return tips
-    }
-
     private func makeVariants(
         original: String,
         topic: String,

+ 19 - 0
Reddit App/Utilities/AIRequestConfiguration.swift

@@ -0,0 +1,19 @@
+import Foundation
+
+enum AIRequestConfiguration {
+    /// Fastest OpenAI model — optimized for low-latency structured output.
+    static let model = "gpt-4.1-nano"
+    static let requestTimeout: TimeInterval = 30
+
+    static func titleOptimizerMaxTokens(variantCount: Int) -> Int {
+        80 + variantCount * 55
+    }
+
+    static func postGeneratorMaxTokens(for postType: RedditPostType) -> Int {
+        switch postType {
+        case .text: 450
+        case .poll: 350
+        case .image, .link, .video: 200
+        }
+    }
+}

+ 22 - 3
Reddit App/Utilities/NetworkClient.swift

@@ -10,7 +10,22 @@ enum NetworkClient {
         return URLSession(configuration: configuration)
     }()
 
-    static func data(for request: URLRequest, retries: Int = 2) async throws -> (Data, URLResponse) {
+    /// Low-latency session for AI API calls — does not block waiting for connectivity.
+    static let aiSession: URLSession = {
+        let configuration = URLSessionConfiguration.default
+        configuration.waitsForConnectivity = false
+        configuration.timeoutIntervalForRequest = 45
+        configuration.timeoutIntervalForResource = 60
+        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
+        configuration.httpMaximumConnectionsPerHost = 4
+        return URLSession(configuration: configuration)
+    }()
+
+    static func data(
+        for request: URLRequest,
+        session: URLSession = session,
+        retries: Int = 2
+    ) async throws -> (Data, URLResponse) {
         var lastError: Error?
 
         for attempt in 0...retries {
@@ -30,13 +45,17 @@ enum NetworkClient {
         throw lastError ?? URLError(.unknown)
     }
 
+    static func aiData(for request: URLRequest) async throws -> (Data, URLResponse) {
+        try await data(for: request, session: aiSession, retries: 0)
+    }
+
     static func prepare() async {
         guard let url = URL(string: "https://api.openai.com") else { return }
 
         var request = URLRequest(url: url)
         request.httpMethod = "HEAD"
-        request.timeoutInterval = 15
-        _ = try? await session.data(for: request)
+        request.timeoutInterval = 10
+        _ = try? await aiSession.data(for: request)
     }
 }
 

+ 94 - 0
Reddit App/Utilities/TitleAnalysisEngine.swift

@@ -0,0 +1,94 @@
+import Foundation
+
+enum TitleAnalysisEngine {
+    static func analyze(title: String, goal: TitleGoal) -> TitleAnalysis {
+        let length = title.count
+        let lengthScore = scoreLength(length)
+        let engagementScore = scoreEngagement(title, goal: goal)
+        let clarityScore = scoreClarity(title)
+        let overall = (lengthScore + engagementScore + clarityScore) / 3
+        let suggestions = makeSuggestions(title: title, length: length, goal: goal)
+
+        return TitleAnalysis(
+            overallScore: overall,
+            lengthScore: lengthScore,
+            engagementScore: engagementScore,
+            clarityScore: clarityScore,
+            suggestions: suggestions
+        )
+    }
+
+    private static func scoreLength(_ length: Int) -> Int {
+        switch length {
+        case 0: 0
+        case 1...50: 95
+        case 51...80: 85
+        case 81...120: 70
+        case 121...200: 50
+        case 201...300: 30
+        default: 10
+        }
+    }
+
+    private static func scoreEngagement(_ title: String, goal: TitleGoal) -> Int {
+        var score = 50
+        let lower = title.lowercased()
+
+        if title.contains("?") { score += 10 }
+        if title.range(of: #"\d+"#, options: .regularExpression) != nil { score += 8 }
+        if lower.contains("how") || lower.contains("why") || lower.contains("what") { score += 6 }
+        if lower.contains("you") || lower.contains("your") { score += 5 }
+        if title == title.uppercased() && title.count > 5 { score -= 15 }
+
+        switch goal {
+        case .question where !title.contains("?"): score -= 10
+        case .listicle where title.range(of: #"\d+"#, options: .regularExpression) == nil: score -= 8
+        case .curiosity where !lower.contains("secret") && !lower.contains("nobody") && !lower.contains("actually"): score -= 3
+        default: break
+        }
+
+        return min(100, max(0, score))
+    }
+
+    private static func scoreClarity(_ title: String) -> Int {
+        var score = 70
+        let words = title.split(separator: " ").count
+
+        if words >= 4 && words <= 12 { score += 15 }
+        if words < 3 { score -= 20 }
+        if words > 20 { score -= 15 }
+        if title.contains("  ") { score -= 5 }
+        if title.hasPrefix(" ") || title.hasSuffix(" ") { score -= 10 }
+
+        return min(100, max(0, score))
+    }
+
+    private static func makeSuggestions(title: String, length: Int, goal: TitleGoal) -> [String] {
+        var tips: [String] = []
+
+        if length > 120 {
+            tips.append("Shorten to under 80 characters for better mobile visibility.")
+        } else if length < 20 {
+            tips.append("Add more context — very short titles can feel vague in feeds.")
+        }
+
+        if !title.contains("?") && goal == .question {
+            tips.append("Reframe as a question to invite comments.")
+        }
+
+        if title.range(of: #"\d+"#, options: .regularExpression) == nil && goal == .listicle {
+            tips.append("Include a number (e.g. \"5 tips\") for listicle-style engagement.")
+        }
+
+        let lower = title.lowercased()
+        if goal == .curiosity && !lower.contains("?") {
+            tips.append("Add a curiosity hook — hint at a surprising outcome.")
+        }
+
+        if tips.isEmpty {
+            tips.append("Strong base title — try variants with different hooks below.")
+        }
+
+        return tips
+    }
+}

+ 54 - 0
Reddit App/ViewModels/AIHistoryViewModel.swift

@@ -0,0 +1,54 @@
+import Foundation
+
+@MainActor
+@Observable
+final class AIHistoryViewModel {
+    var selectedFilter: AIHistoryFilter = .all
+    var showDeleteAllConfirmation = false
+    var entryPendingDeletion: AIHistoryEntry?
+
+    private let historyManager: AIHistoryManager
+
+    init(historyManager: AIHistoryManager = .shared) {
+        self.historyManager = historyManager
+    }
+
+    var filteredEntries: [AIHistoryEntry] {
+        historyManager.entries(matching: selectedFilter)
+    }
+
+    var isEmpty: Bool {
+        historyManager.isEmpty
+    }
+
+    var hasFilteredEntries: Bool {
+        !filteredEntries.isEmpty
+    }
+
+    func requestDelete(_ entry: AIHistoryEntry) {
+        entryPendingDeletion = entry
+    }
+
+    func confirmDelete() {
+        guard let entry = entryPendingDeletion else { return }
+        historyManager.delete(id: entry.id)
+        entryPendingDeletion = nil
+    }
+
+    func cancelDelete() {
+        entryPendingDeletion = nil
+    }
+
+    func requestDeleteAll() {
+        showDeleteAllConfirmation = true
+    }
+
+    func confirmDeleteAll() {
+        historyManager.deleteAll()
+        showDeleteAllConfirmation = false
+    }
+
+    func cancelDeleteAll() {
+        showDeleteAllConfirmation = false
+    }
+}

+ 21 - 1
Reddit App/ViewModels/CommentWriterViewModel.swift

@@ -15,14 +15,17 @@ final class CommentWriterViewModel {
 
     private var hasPremiumAccess = false
     private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
+    private let historyManager: AIHistoryManager
     private let emptyDraft = CommentDraft()
 
     init(
         hasPremiumAccess: Bool = false,
-        generationService: (any CommentGenerationServiceProtocol)? = nil
+        generationService: (any CommentGenerationServiceProtocol)? = nil,
+        historyManager: AIHistoryManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
+        self.historyManager = historyManager
     }
 
     func setPremiumAccess(_ hasPremiumAccess: Bool) {
@@ -140,6 +143,18 @@ final class CommentWriterViewModel {
         clearMessages()
     }
 
+    func restore(from entry: AIHistoryEntry) {
+        guard case .commentWriter(let storedDraft, let storedVariants, let selectedID) = entry.payload else {
+            return
+        }
+        draft = storedDraft.commentDraft
+        variants = storedVariants.map(\.commentVariant)
+        selectedVariantID = selectedID
+        selectedTab = variants.isEmpty ? .compose : .preview
+        clearMessages()
+        successMessage = "Restored from history."
+    }
+
     func clearMessages() {
         errorMessage = nil
         successMessage = nil
@@ -162,6 +177,11 @@ final class CommentWriterViewModel {
             variants = result.variants
             selectedVariantID = result.variants.first?.id
             selectedTab = .preview
+            historyManager.save(AIHistoryEntry.fromCommentWriter(
+                draft: draft,
+                variants: variants,
+                selectedVariantID: selectedVariantID
+            ))
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
         } catch {

+ 17 - 0
Reddit App/ViewModels/FrontPageViewModel.swift

@@ -9,12 +9,19 @@ final class FrontPageViewModel {
     var selectedNavItem: SidebarNavItem?
     var isRedditActive = true
     var isSettingsActive = false
+    var isHistoryActive = false
     var isPaywallPresented = false
     var authErrorMessage: String?
     private(set) var redditReloadTrigger = 0
     private(set) var redditGoBackTrigger = 0
     var redditCanGoBack = false
 
+    static let historyItem = SidebarNavItem(
+        title: "History",
+        subtitle: "Saved AI responses",
+        iconKind: .history
+    )
+
     static let settingsItem = SidebarNavItem(
         title: "Settings",
         subtitle: "Manage app preferences",
@@ -25,6 +32,7 @@ final class FrontPageViewModel {
         selectedNavItem = nil
         isRedditActive = true
         isSettingsActive = false
+        isHistoryActive = false
         let targetURL = url ?? Self.redditHomeURL
         redditLoadURL = targetURL
         if RedditWebAuthHelper.isRedditHomeURL(targetURL) {
@@ -54,12 +62,21 @@ final class FrontPageViewModel {
         selectedNavItem = nil
         isRedditActive = false
         isSettingsActive = true
+        isHistoryActive = false
+    }
+
+    func openHistory() {
+        selectedNavItem = nil
+        isRedditActive = false
+        isSettingsActive = false
+        isHistoryActive = true
     }
 
     func selectNavItem(_ item: SidebarNavItem) {
         selectedNavItem = item
         isRedditActive = false
         isSettingsActive = false
+        isHistoryActive = false
     }
 
     func showPaywall() {

+ 24 - 5
Reddit App/ViewModels/PostGeneratorViewModel.swift

@@ -13,25 +13,30 @@ final class PostGeneratorViewModel {
     var showOverwriteConfirmation = false
 
     private var hasPremiumAccess = false
+    private var generationService: any PostGenerationServiceProtocol
     private let injectedGenerationService: (any PostGenerationServiceProtocol)?
+    private let historyManager: AIHistoryManager
     private var imageAccessURL: URL?
     private var isAccessingImageResource = false
     private let emptyDraft = PostDraft()
 
     init(
         hasPremiumAccess: Bool = false,
-        generationService: (any PostGenerationServiceProtocol)? = nil
+        generationService: (any PostGenerationServiceProtocol)? = nil,
+        historyManager: AIHistoryManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedGenerationService = generationService
+        self.historyManager = historyManager
+        self.generationService = generationService
+            ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
     }
 
     func setPremiumAccess(_ hasPremiumAccess: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
-    }
-
-    private var generationService: any PostGenerationServiceProtocol {
-        injectedGenerationService ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        if injectedGenerationService == nil {
+            generationService = PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        }
     }
 
     var formattedSubreddit: String {
@@ -160,6 +165,15 @@ final class PostGeneratorViewModel {
         clearMessages()
     }
 
+    func restore(from entry: AIHistoryEntry) {
+        guard case .postGenerator(let storedDraft) = entry.payload else { return }
+        releaseImageAccess()
+        draft = storedDraft.postDraft
+        selectedTab = .preview
+        clearMessages()
+        successMessage = "Restored from history."
+    }
+
     func clearMessages() {
         errorMessage = nil
         successMessage = nil
@@ -215,6 +229,7 @@ final class PostGeneratorViewModel {
         isGenerating = true
         errorMessage = nil
         successMessage = nil
+        await Task.yield()
 
         do {
             let result = try await generationService.generatePost(from: draft)
@@ -222,6 +237,10 @@ final class PostGeneratorViewModel {
             selectedTab = .preview
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Post generated successfully using \(engine)."
+            let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
+            Task { @MainActor in
+                historyManager.save(entry)
+            }
         } catch {
             errorMessage = UserFacingError.message(for: error)
         }

+ 33 - 5
Reddit App/ViewModels/TitleOptimizerViewModel.swift

@@ -14,22 +14,27 @@ final class TitleOptimizerViewModel {
     var selectedVariantID: UUID?
 
     private var hasPremiumAccess = false
+    private var optimizationService: any TitleOptimizationServiceProtocol
     private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
+    private let historyManager: AIHistoryManager
 
     init(
         hasPremiumAccess: Bool = false,
-        optimizationService: (any TitleOptimizationServiceProtocol)? = nil
+        optimizationService: (any TitleOptimizationServiceProtocol)? = nil,
+        historyManager: AIHistoryManager = .shared
     ) {
         self.hasPremiumAccess = hasPremiumAccess
         self.injectedOptimizationService = optimizationService
+        self.historyManager = historyManager
+        self.optimizationService = optimizationService
+            ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
     }
 
     func setPremiumAccess(_ hasPremiumAccess: Bool) {
         self.hasPremiumAccess = hasPremiumAccess
-    }
-
-    private var optimizationService: any TitleOptimizationServiceProtocol {
-        injectedOptimizationService ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        if injectedOptimizationService == nil {
+            optimizationService = TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
+        }
     }
 
     var usesLiveAI: Bool {
@@ -114,6 +119,7 @@ final class TitleOptimizerViewModel {
         isOptimizing = true
         errorMessage = nil
         successMessage = nil
+        await Task.yield()
         defer { isOptimizing = false }
 
         do {
@@ -123,6 +129,15 @@ final class TitleOptimizerViewModel {
             selectedVariantID = result.variants.first?.id
             let engine = usesLiveAI ? "AI" : "local AI templates"
             successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
+            let entry = AIHistoryEntry.fromTitleOptimizer(
+                draft: draft,
+                analysis: analysis,
+                variants: variants,
+                selectedVariantID: selectedVariantID
+            )
+            Task { @MainActor in
+                historyManager.save(entry)
+            }
         } catch {
             errorMessage = UserFacingError.message(for: error)
         }
@@ -147,6 +162,19 @@ final class TitleOptimizerViewModel {
         clearMessages()
     }
 
+    func restore(from entry: AIHistoryEntry) {
+        guard case .titleOptimizer(let storedDraft, let storedAnalysis, let storedVariants, let selectedID) = entry.payload else {
+            return
+        }
+        draft = storedDraft.titleDraft
+        analysis = storedAnalysis?.titleAnalysis
+        variants = storedVariants.map(\.titleVariant)
+        selectedVariantID = selectedID
+        selectedTab = variants.isEmpty ? .optimize : .compare
+        clearMessages()
+        successMessage = "Restored from history."
+    }
+
     func exportText() -> String {
         var lines: [String] = []
         lines.append("Subreddit: \(formattedSubreddit)")

+ 254 - 0
Reddit App/Views/AIHistoryView.swift

@@ -0,0 +1,254 @@
+import SwiftUI
+
+struct AIHistoryView: View {
+    @Bindable var viewModel: AIHistoryViewModel
+    var onOpen: (AIHistoryEntry) -> Void
+
+    private enum Layout {
+        static let horizontalPadding: CGFloat = 24
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            header
+            filterBar
+
+            if viewModel.isEmpty {
+                emptyState
+            } else if !viewModel.hasFilteredEntries {
+                filteredEmptyState
+            } else {
+                historyList
+            }
+        }
+        .background(AppTheme.background)
+        .alert("Delete this item?", isPresented: deleteBinding) {
+            Button("Cancel", role: .cancel) {
+                viewModel.cancelDelete()
+            }
+            Button("Delete", role: .destructive) {
+                viewModel.confirmDelete()
+            }
+        } message: {
+            if let entry = viewModel.entryPendingDeletion {
+                Text("Remove \"\(entry.displayTitle)\" from history?")
+            }
+        }
+        .alert("Delete all history?", isPresented: $viewModel.showDeleteAllConfirmation) {
+            Button("Cancel", role: .cancel) {
+                viewModel.cancelDeleteAll()
+            }
+            Button("Delete All", role: .destructive) {
+                viewModel.confirmDeleteAll()
+            }
+        } message: {
+            Text("This permanently removes all saved posts, titles, and comments.")
+        }
+    }
+
+    private var deleteBinding: Binding<Bool> {
+        Binding(
+            get: { viewModel.entryPendingDeletion != nil },
+            set: { isPresented in
+                if !isPresented {
+                    viewModel.cancelDelete()
+                }
+            }
+        )
+    }
+
+    private var header: some View {
+        VStack(spacing: 0) {
+            HStack(spacing: 14) {
+                ModernSidebarIconView(kind: .history, size: 40)
+
+                VStack(alignment: .leading, spacing: 2) {
+                    Text("History")
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundStyle(AppTheme.textPrimary)
+                    Text("Saved AI responses from your tools")
+                        .font(.system(size: 11))
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+
+                Spacer()
+
+                if !viewModel.isEmpty {
+                    Button {
+                        viewModel.requestDeleteAll()
+                    } label: {
+                        Label("Delete All", systemImage: "trash")
+                            .font(.system(size: 11, weight: .medium))
+                    }
+                    .buttonStyle(AppSecondaryButtonStyle())
+                }
+            }
+            .padding(.horizontal, Layout.horizontalPadding)
+            .padding(.vertical, 16)
+
+            FullWidthDivider()
+        }
+    }
+
+    private var filterBar: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 6) {
+                ForEach(AIHistoryFilter.allCases) { filter in
+                    filterChip(filter)
+                }
+            }
+            .padding(.horizontal, Layout.horizontalPadding)
+            .padding(.vertical, 12)
+        }
+    }
+
+    private func filterChip(_ filter: AIHistoryFilter) -> some View {
+        let isSelected = viewModel.selectedFilter == filter
+
+        return Button {
+            withAnimation(.easeInOut(duration: 0.15)) {
+                viewModel.selectedFilter = filter
+            }
+        } label: {
+            Text(filter.title)
+                .font(.system(size: 11, weight: isSelected ? .bold : .medium))
+                .foregroundStyle(isSelected ? Color(hex: 0xD97706) : AppTheme.textSecondary)
+                .padding(.horizontal, 12)
+                .padding(.vertical, 7)
+                .background(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .fill(isSelected ? Color(hex: 0xD97706).opacity(0.12) : AppTheme.cardBackground)
+                )
+                .overlay(
+                    RoundedRectangle(cornerRadius: 8, style: .continuous)
+                        .stroke(
+                            isSelected ? Color(hex: 0xD97706).opacity(0.5) : AppTheme.cardBorder,
+                            lineWidth: isSelected ? 1.5 : 1
+                        )
+                )
+        }
+        .buttonStyle(.plain)
+        .hoverCursor()
+    }
+
+    private var historyList: some View {
+        ScrollView {
+            LazyVStack(spacing: 8) {
+                ForEach(viewModel.filteredEntries) { entry in
+                    AIHistoryRowView(
+                        entry: entry,
+                        onOpen: { onOpen(entry) },
+                        onDelete: { viewModel.requestDelete(entry) }
+                    )
+                }
+            }
+            .padding(.horizontal, Layout.horizontalPadding)
+            .padding(.bottom, 24)
+        }
+    }
+
+    private var emptyState: some View {
+        VStack(spacing: 12) {
+            Image(systemName: "clock.arrow.circlepath")
+                .font(.system(size: 32))
+                .foregroundStyle(Color(hex: 0xD97706).opacity(0.7))
+
+            Text("No history yet")
+                .font(.system(size: 15, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            Text("Generated posts, optimized titles, and comments\nwill appear here automatically.")
+                .font(.system(size: 12))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private var filteredEmptyState: some View {
+        VStack(spacing: 12) {
+            Image(systemName: "line.3.horizontal.decrease.circle")
+                .font(.system(size: 28))
+                .foregroundStyle(AppTheme.textTertiary)
+
+            Text("No \(viewModel.selectedFilter.title.lowercased()) in history")
+                .font(.system(size: 14, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            Text("Try another filter or generate content with the AI tools.")
+                .font(.system(size: 12))
+                .foregroundStyle(AppTheme.textSecondary)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+}
+
+private struct AIHistoryRowView: View {
+    let entry: AIHistoryEntry
+    var onOpen: () -> Void
+    var onDelete: () -> Void
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            ModernSidebarIconView(kind: entry.toolKind.iconKind, size: 36)
+
+            VStack(alignment: .leading, spacing: 4) {
+                HStack(spacing: 6) {
+                    Text(entry.toolKind.title)
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundStyle(AppTheme.textTertiary)
+                        .textCase(.uppercase)
+                        .tracking(0.3)
+
+                    Text("·")
+                        .foregroundStyle(AppTheme.textTertiary)
+
+                    Text(entry.createdAt, style: .relative)
+                        .font(.system(size: 9))
+                        .foregroundStyle(AppTheme.textTertiary)
+                }
+
+                Text(entry.displayTitle)
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .lineLimit(2)
+                    .multilineTextAlignment(.leading)
+
+                Text(entry.displaySubtitle)
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textSecondary)
+            }
+
+            Spacer(minLength: 8)
+
+            HStack(spacing: 6) {
+                Button(action: onOpen) {
+                    Label("Open", systemImage: "arrow.up.right.square")
+                        .font(.system(size: 10, weight: .medium))
+                        .labelStyle(.titleAndIcon)
+                }
+                .buttonStyle(AppSecondaryButtonStyle())
+
+                Button(action: onDelete) {
+                    Image(systemName: "trash")
+                        .font(.system(size: 10, weight: .medium))
+                }
+                .buttonStyle(AppSecondaryButtonStyle())
+            }
+        }
+        .padding(14)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                .fill(AppTheme.cardBackground)
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                        .stroke(AppTheme.cardBorder, lineWidth: 1)
+                )
+        )
+    }
+}
+
+#Preview {
+    AIHistoryView(viewModel: AIHistoryViewModel()) { _ in }
+        .frame(width: 880, height: 720)
+}

+ 25 - 0
Reddit App/Views/FrontPageView.swift

@@ -10,6 +10,7 @@ struct FrontPageView: View {
     @State private var titleOptimizerViewModel = TitleOptimizerViewModel()
     @State private var commentWriterViewModel = CommentWriterViewModel()
     @State private var settingsViewModel = SettingsViewModel()
+    @State private var historyViewModel = AIHistoryViewModel()
 
     var body: some View {
         let _ = appearanceManager.isDarkMode
@@ -126,6 +127,10 @@ struct FrontPageView: View {
 
                 if viewModel.isSettingsActive {
                     SettingsView(viewModel: settingsViewModel)
+                } else if viewModel.isHistoryActive {
+                    AIHistoryView(viewModel: historyViewModel) { entry in
+                        openHistoryEntry(entry)
+                    }
                 } else if !viewModel.isRedditActive, let item = viewModel.selectedNavItem {
                     toolContent(for: item)
                 }
@@ -154,6 +159,26 @@ struct FrontPageView: View {
         titleOptimizerViewModel.setPremiumAccess(hasPremium)
         commentWriterViewModel.setPremiumAccess(hasPremium)
     }
+
+    private func openHistoryEntry(_ entry: AIHistoryEntry) {
+        switch entry.toolKind {
+        case .postGenerator:
+            if let item = viewModel.createItems.first(where: { $0.iconKind == .postGenerator }) {
+                postGeneratorViewModel.restore(from: entry)
+                viewModel.selectNavItem(item)
+            }
+        case .titleOptimizer:
+            if let item = viewModel.createItems.first(where: { $0.iconKind == .titleOptimizer }) {
+                titleOptimizerViewModel.restore(from: entry)
+                viewModel.selectNavItem(item)
+            }
+        case .commentWriter:
+            if let item = viewModel.createItems.first(where: { $0.iconKind == .commentWriter }) {
+                commentWriterViewModel.restore(from: entry)
+                viewModel.selectNavItem(item)
+            }
+        }
+    }
 }
 
 private struct ToolPlaceholderView: View {

+ 10 - 1
Reddit App/Views/SidebarView.swift

@@ -62,13 +62,22 @@ struct SidebarView: View {
                     ForEach(viewModel.createItems) { item in
                         SidebarNavItemView(
                             item: item,
-                            isSelected: !viewModel.isSettingsActive && !viewModel.isRedditActive && viewModel.selectedNavItem == item
+                            isSelected: !viewModel.isSettingsActive && !viewModel.isHistoryActive && !viewModel.isRedditActive && viewModel.selectedNavItem == item
                         ) {
                             viewModel.selectNavItem(item)
                         }
                     }
                 }
 
+                sidebarSection(title: SidebarSection.history.rawValue) {
+                    SidebarNavItemView(
+                        item: FrontPageViewModel.historyItem,
+                        isSelected: viewModel.isHistoryActive
+                    ) {
+                        viewModel.openHistory()
+                    }
+                }
+
                 sidebarSection(title: SidebarSection.settings.rawValue) {
                     SidebarNavItemView(
                         item: FrontPageViewModel.settingsItem,