diff --git a/docs/plans/2026-03-15-attachment-ui.md b/docs/plans/2026-03-15-attachment-ui.md new file mode 100644 index 0000000..ebf628d --- /dev/null +++ b/docs/plans/2026-03-15-attachment-ui.md @@ -0,0 +1,903 @@ +# Attachment UI Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire attachment UI for compose (send) and thread detail (receive), add list badges. + +**Architecture:** Extends existing ComposeViewModel/ComposeView with attachment state, file picker, drag-drop, paste. MessageView gains lazy-loaded attachment chips with download + Quick Look. ThreadRow/ItemRow show paperclip badges via a new `attachmentCount` subquery on ThreadSummary. + +**Tech Stack:** SwiftUI (macOS), GRDB, NIO-IMAP, NIO-SSL, WKWebView, Quick Look, UniformTypeIdentifiers + +**Spec:** `docs/specs/2026-03-15-attachment-ui-design.md` + +--- + +## Chunk 1: Data Model + Send Path + +### Task 1: Add attachments field to OutgoingMessage + +**Files:** +- Modify: `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift` +- Test: `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift` + +- [ ] **Step 1: Add optional attachments field to OutgoingMessage** + +In `Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift`, add a new field. Since `OutgoingMessage` is `Codable` and attachments contain `Data`, we need a nested `Codable` struct: + +```swift +public struct OutgoingAttachment: Sendable, Codable, Equatable { + public var filename: String + public var mimeType: String + public var data: Data + + public init(filename: String, mimeType: String, data: Data) { + self.filename = filename + self.mimeType = mimeType + self.data = data + } +} +``` + +Add to `OutgoingMessage`: +```swift +public var attachments: [OutgoingAttachment] +``` + +Update the `init` to include `attachments: [OutgoingAttachment] = []`. + +- [ ] **Step 2: Build and verify no regressions** + +Run: `cd Packages/MagnumOpusCore && swift build` +Expected: Build succeeds (default `[]` means all existing call sites compile) + +- [ ] **Step 3: Commit** + +``` +git add Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift +git commit -m "add OutgoingAttachment model, attachments field on OutgoingMessage" +``` + +--- + +### Task 2: Update SMTPClient.send to handle attachments + +**Files:** +- Modify: `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift:51` (the `send` method) +- Test: `Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift` + +- [ ] **Step 1: Write test for multipart send formatting** + +In `MessageFormatterTests.swift`, add a test that verifies `MessageFormatter.formatMultipart` is called when attachments are present: + +```swift +@Test func formatMultipartWithAttachments() throws { + let msg = OutgoingMessage( + from: EmailAddress(name: "Test", address: "test@example.com"), + to: [EmailAddress(name: "To", address: "to@example.com")], + subject: "With attachment", + bodyText: "Hello", + messageId: "test-123", + attachments: [ + OutgoingAttachment(filename: "test.txt", mimeType: "text/plain", data: Data("file content".utf8)) + ] + ) + let formatted = try MessageFormatter.formatMultipart( + msg, + attachments: msg.attachments.map { ($0.filename, $0.mimeType, $0.data) } + ) + #expect(formatted.contains("multipart/mixed")) + #expect(formatted.contains("test.txt")) + #expect(formatted.contains("Hello")) +} +``` + +- [ ] **Step 2: Run test to verify it compiles and passes** + +Run: `cd Packages/MagnumOpusCore && swift test --filter MessageFormatterTests/formatMultipartWithAttachments` + +- [ ] **Step 3: Update SMTPClient.send to use formatMultipart when attachments present** + +In `Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift`, change line 51: + +```swift +// Before: +let formattedMessage = MessageFormatter.format(message) + +// After: +let formattedMessage: String +if message.attachments.isEmpty { + formattedMessage = MessageFormatter.format(message) +} else { + formattedMessage = try MessageFormatter.formatMultipart( + message, + attachments: message.attachments.map { ($0.filename, $0.mimeType, $0.data) } + ) +} +``` + +- [ ] **Step 4: Build and run all tests** + +Run: `swift test` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +``` +git add Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift Packages/MagnumOpusCore/Tests/SMTPClientTests/ +git commit -m "update SMTPClient.send to use formatMultipart when attachments present" +``` + +--- + +### Task 3: Update ComposeViewModel send path for attachments + +**Files:** +- Modify: `Apps/MagnumOpus/ViewModels/ComposeViewModel.swift` + +- [ ] **Step 1: Add ComposeAttachment model and state** + +Add at the top of `ComposeViewModel.swift` (below imports): + +```swift +struct ComposeAttachment: Identifiable { + let id = UUID() + let url: URL + let filename: String + let mimeType: String + let size: Int + let data: Data +} +``` + +Add to `ComposeViewModel` properties: + +```swift +var attachments: [ComposeAttachment] = [] +var sizeWarningMessage: String? +``` + +- [ ] **Step 2: Add addAttachment / removeAttachment methods** + +```swift +import UniformTypeIdentifiers + +func addAttachment(url: URL) { + do { + let data = try Data(contentsOf: url) + let size = data.count + let maxSize = 25 * 1024 * 1024 + let warnSize = 10 * 1024 * 1024 + + if size > maxSize { + errorMessage = "\(url.lastPathComponent) is too large (\(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))). Maximum is 25 MB." + return + } + + if size > warnSize { + sizeWarningMessage = "\(url.lastPathComponent) is \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)). Large attachments may slow delivery." + } + + let ext = url.pathExtension + let mimeType = UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream" + + attachments.append(ComposeAttachment( + url: url, + filename: url.lastPathComponent, + mimeType: mimeType, + size: size, + data: data + )) + } catch { + errorMessage = "Could not read file: \(error.localizedDescription)" + } +} + +func removeAttachment(id: UUID) { + attachments.removeAll { $0.id == id } +} +``` + +- [ ] **Step 3: Update send() to pass attachments** + +In the `send()` method, after building `outgoing`, change: + +```swift +// Before: +let outgoing = OutgoingMessage( + from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses, + subject: subject, bodyText: bodyText, + inReplyTo: inReplyTo, references: references, messageId: messageId +) +let formatted = MessageFormatter.format(outgoing) + +// After: +let outgoing = OutgoingMessage( + from: from, to: toAddresses, cc: ccAddresses, bcc: bccAddresses, + subject: subject, bodyText: bodyText, + inReplyTo: inReplyTo, references: references, messageId: messageId, + attachments: attachments.map { OutgoingAttachment(filename: $0.filename, mimeType: $0.mimeType, data: $0.data) } +) +let formatted: String +if attachments.isEmpty { + formatted = MessageFormatter.format(outgoing) +} else { + formatted = try MessageFormatter.formatMultipart( + outgoing, + attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) } + ) +} +``` + +The rest of `send()` stays the same — both `.send(message: outgoing)` and `.append(mailbox: "Sent", messageData: formatted, ...)` use the updated values. + +- [ ] **Step 4: Build** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"` +Expected: No errors + +- [ ] **Step 5: Commit** + +``` +git add Apps/MagnumOpus/ViewModels/ComposeViewModel.swift +git commit -m "add ComposeAttachment model, wire attachment send path with formatMultipart" +``` + +--- + +## Chunk 2: Compose UI + +### Task 4: Add attachment chip strip and paperclip button to ComposeView + +**Files:** +- Modify: `Apps/MagnumOpus/Views/ComposeView.swift` + +- [ ] **Step 1: Add file importer state and paperclip toolbar button** + +Add to `ComposeView`: +```swift +@State private var showFileImporter = false +``` + +Add a paperclip toolbar item alongside the BCC toggle: +```swift +ToolbarItem(placement: .automatic) { + Button { + showFileImporter = true + } label: { + Label("Attach", systemImage: "paperclip") + } + .keyboardShortcut("a", modifiers: [.shift, .command]) + .help("Attach File (⇧⌘A)") +} +``` + +Add the `.fileImporter` modifier to `content`: +```swift +.fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.item], + allowsMultipleSelection: true +) { result in + switch result { + case .success(let urls): + for url in urls { + guard url.startAccessingSecurityScopedResource() else { continue } + defer { url.stopAccessingSecurityScopedResource() } + viewModel.addAttachment(url: url) + } + case .failure(let error): + viewModel.errorMessage = error.localizedDescription + } +} +``` + +- [ ] **Step 2: Add attachment chip strip below the TextEditor section** + +After the `TextEditor` section, add: +```swift +if !viewModel.attachments.isEmpty { + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.attachments) { attachment in + AttachmentChip(attachment: attachment) { + viewModel.removeAttachment(id: attachment.id) + } + } + } + .padding(.vertical, 4) + } + } +} + +if let warning = viewModel.sizeWarningMessage { + Section { + Text(warning) + .foregroundStyle(.orange) + .font(.caption) + } +} +``` + +- [ ] **Step 3: Create AttachmentChip view** + +Add at the bottom of `ComposeView.swift`: +```swift +struct AttachmentChip: View { + let attachment: ComposeAttachment + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 4) { + Image(systemName: iconName) + .foregroundStyle(.secondary) + Text(attachment.filename) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150) + Text("·") + .foregroundStyle(.tertiary) + Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file)) + .foregroundStyle(.secondary) + Button { + onRemove() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + + private var iconName: String { + let ext = (attachment.filename as NSString).pathExtension.lowercased() + switch ext { + case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo" + case "pdf": return "doc.richtext" + case "mp4", "mov", "avi": return "film" + case "mp3", "wav", "aac", "m4a": return "music.note" + case "zip", "gz", "tar", "7z": return "doc.zipper" + default: return "paperclip" + } + } +} +``` + +- [ ] **Step 4: Add drag-and-drop to the TextEditor** + +Wrap the `TextEditor` section body with `.onDrop`: +```swift +TextEditor(text: $viewModel.bodyText) + .frame(minHeight: 200) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + if let url { + Task { @MainActor in + viewModel.addAttachment(url: url) + } + } + } + } + return true + } +``` + +- [ ] **Step 5: Build and test manually** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"` +Expected: No errors + +- [ ] **Step 6: Commit** + +``` +git add Apps/MagnumOpus/Views/ComposeView.swift +git commit -m "add attachment chip strip, paperclip button, file importer, drag-drop to compose view" +``` + +--- + +### Task 5: Add paste support for images + +**Files:** +- Modify: `Apps/MagnumOpus/Views/ComposeView.swift` + +- [ ] **Step 1: Create PastableTextEditor NSViewRepresentable** + +Add to `ComposeView.swift` (or a new helper file if preferred): + +```swift +#if os(macOS) +struct PastableTextEditor: NSViewRepresentable { + @Binding var text: String + var onPasteFile: (URL) -> Void + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + textView.delegate = context.coordinator + textView.font = .systemFont(ofSize: 14) + textView.isRichText = false + textView.allowsUndo = true + textView.string = text + context.coordinator.textView = textView + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + let textView = nsView.documentView as! NSTextView + if textView.string != text { + textView.string = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, onPasteFile: onPasteFile) + } + + class Coordinator: NSObject, NSTextViewDelegate { + @Binding var text: String + var onPasteFile: (URL) -> Void + weak var textView: NSTextView? + + init(text: Binding, onPasteFile: @escaping (URL) -> Void) { + _text = text + self.onPasteFile = onPasteFile + } + + func textDidChange(_ notification: Notification) { + guard let textView else { return } + text = textView.string + } + + // Override paste to detect images + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.paste(_:)) { + let pasteboard = NSPasteboard.general + // Check for file URLs first + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] { + for url in urls where url.isFileURL { + onPasteFile(url) + } + if !urls.isEmpty { return true } + } + // Check for images + if let images = pasteboard.readObjects(forClasses: [NSImage.self]) as? [NSImage], let image = images.first { + if let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("pasted-image-\(UUID().uuidString).png") + try? pngData.write(to: tempFile) + onPasteFile(tempFile) + return true + } + } + } + return false + } + } +} +#endif +``` + +- [ ] **Step 2: Replace TextEditor with PastableTextEditor in ComposeView** + +Replace: +```swift +TextEditor(text: $viewModel.bodyText) + .frame(minHeight: 200) + .onDrop(...) +``` + +With: +```swift +#if os(macOS) +PastableTextEditor(text: $viewModel.bodyText) { url in + viewModel.addAttachment(url: url) +} +.frame(minHeight: 200) +.onDrop(of: [.fileURL], isTargeted: nil) { providers in + // ... same drag-drop handler +} +#else +TextEditor(text: $viewModel.bodyText) + .frame(minHeight: 200) +#endif +``` + +- [ ] **Step 3: Build** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"` +Expected: No errors + +- [ ] **Step 4: Commit** + +``` +git add Apps/MagnumOpus/Views/ComposeView.swift +git commit -m "add PastableTextEditor with image paste support for macOS compose" +``` + +--- + +## Chunk 3: Received Attachments Display + +### Task 6: Add attachment chips to MessageView + +**Files:** +- Modify: `Apps/MagnumOpus/Views/ThreadDetailView.swift` +- Modify: `Apps/MagnumOpus/ViewModels/MailViewModel.swift` + +- [ ] **Step 1: Add download wrapper to MailViewModel** + +In `MailViewModel.swift`, add: +```swift +func downloadAttachment(attachmentId: String, messageId: String) async throws -> URL { + guard let store, let coordinator else { + throw NSError(domain: "MagnumOpus", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not configured"]) + } + guard let record = try store.message(id: messageId) else { + throw NSError(domain: "MagnumOpus", code: 2, userInfo: [NSLocalizedDescriptionKey: "Message not found"]) + } + let mailboxName = mailboxName(for: record.mailboxId) ?? "INBOX" + return try await coordinator.downloadAttachment( + attachmentId: attachmentId, + messageUid: record.uid, + mailboxName: mailboxName + ) +} +``` + +Also expose `attachments(messageId:)`: +```swift +func attachments(messageId: String) -> [AttachmentRecord] { + guard let store else { return [] } + return (try? store.attachments(messageId: messageId)) ?? [] +} +``` + +- [ ] **Step 2: Add attachment section to MessageView** + +In `ThreadDetailView.swift`, update `MessageView` to accept `viewModel` and show attachments: + +Update `MessageView` struct: +```swift +struct MessageView: View { + let message: MessageSummary + let viewModel: MailViewModel + @State private var showSource = false + @State private var attachments: [AttachmentRecord] = [] + @State private var downloadingIds: Set = [] + @State private var previewURL: URL? +``` + +Add `.onAppear` to load attachments: +```swift +.onAppear { + if message.hasAttachments { + attachments = viewModel.attachments(messageId: message.id) + } +} +``` + +Add attachment section after the body content (before the closing `VStack`'s `.padding()`): +```swift +if !attachments.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 4) { + Text("Attachments (\(attachments.count))") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(attachments, id: \.id) { attachment in + ReceivedAttachmentChip( + attachment: attachment, + isDownloading: downloadingIds.contains(attachment.id) + ) { + downloadAndPreview(attachment) + } + } + } + } + } +} +``` + +- [ ] **Step 3: Add ReceivedAttachmentChip and download helper** + +```swift +struct ReceivedAttachmentChip: View { + let attachment: AttachmentRecord + let isDownloading: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + if isDownloading { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: iconName) + .foregroundStyle(.blue) + } + Text(attachment.filename ?? "attachment") + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150) + if attachment.size > 0 { + Text("·") + .foregroundStyle(.tertiary) + Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file)) + .foregroundStyle(.secondary) + } + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + .buttonStyle(.plain) + } + + private var iconName: String { + let ext = ((attachment.filename ?? "") as NSString).pathExtension.lowercased() + switch ext { + case "jpg", "jpeg", "png", "gif", "heic", "webp": return "photo" + case "pdf": return "doc.richtext" + case "mp4", "mov", "avi": return "film" + case "mp3", "wav", "aac", "m4a": return "music.note" + case "zip", "gz", "tar", "7z": return "doc.zipper" + default: return "paperclip" + } + } +} +``` + +Add download helper and error state tracking to `MessageView`: +```swift +@State private var failedIds: Set = [] + +private func downloadAndPreview(_ attachment: AttachmentRecord) { + // If cached, open directly via Quick Look + if let cachePath = attachment.cachePath, + FileManager.default.fileExists(atPath: cachePath) { + previewURL = URL(fileURLWithPath: cachePath) + return + } + failedIds.remove(attachment.id) + downloadingIds.insert(attachment.id) + Task { + do { + let url = try await viewModel.downloadAttachment( + attachmentId: attachment.id, + messageId: message.id + ) + downloadingIds.remove(attachment.id) + previewURL = url + } catch { + downloadingIds.remove(attachment.id) + failedIds.insert(attachment.id) + } + } +} +``` + +Add Quick Look sheet to the `MessageView` body (inside the VStack): +```swift +.quickLookPreview($previewURL) +``` + +Note: `previewURL` is already declared as `@State private var previewURL: URL?` in Step 2. + +Update `ReceivedAttachmentChip` to accept `hasFailed` parameter and show red tint: +```swift +struct ReceivedAttachmentChip: View { + let attachment: AttachmentRecord + let isDownloading: Bool + let hasFailed: Bool + let onTap: () -> Void + // ... existing body, but add: + // .background(hasFailed ? Color.red.opacity(0.15) : Color.quaternary, in: Capsule()) +} +``` + +Update the chip creation to pass `hasFailed`: +```swift +ReceivedAttachmentChip( + attachment: attachment, + isDownloading: downloadingIds.contains(attachment.id), + hasFailed: failedIds.contains(attachment.id) +) { + downloadAndPreview(attachment) +} +``` + +- [ ] **Step 4: Update MessageView call sites to pass viewModel** + +In `ThreadDetailView`, update the `ForEach`: +```swift +case .message(let message): + MessageView(message: message, viewModel: viewModel) +``` + +This requires adding `viewModel` to `ThreadDetailView`. Update the struct: +```swift +struct ThreadDetailView: View { + let thread: ThreadSummary? + let messages: [MessageSummary] + let linkedTasks: [TaskSummary] + let viewModel: MailViewModel + var onComposeRequest: (ComposeMode) -> Void +``` + +Update the call site in `ContentView.swift`: +```swift +ThreadDetailView( + thread: viewModel.selectedThread, + messages: viewModel.messages, + linkedTasks: ..., + viewModel: viewModel, + onComposeRequest: ... +) +``` + +- [ ] **Step 5: Build** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"` +Expected: No errors + +- [ ] **Step 6: Commit** + +``` +git add Apps/MagnumOpus/Views/ThreadDetailView.swift Apps/MagnumOpus/ViewModels/MailViewModel.swift Apps/MagnumOpus/ContentView.swift +git commit -m "add attachment chips to message view with download + open" +``` + +--- + +## Chunk 4: Email List Badges + +### Task 7: Add attachmentCount to ThreadSummary and thread query + +**Files:** +- Modify: `Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift` +- Modify: `Packages/MagnumOpusCore/Sources/MailStore/Queries.swift` + +- [ ] **Step 1: Add attachmentCount to ThreadSummary** + +In `ThreadSummary.swift`, add `public var attachmentCount: Int` and update `init`: + +```swift +public struct ThreadSummary: Sendable, Identifiable, Equatable { + public var id: String + public var accountId: String + public var subject: String? + public var lastDate: Date + public var messageCount: Int + public var unreadCount: Int + public var senders: String + public var snippet: String? + public var attachmentCount: Int + + public init( + id: String, accountId: String, subject: String?, lastDate: Date, + messageCount: Int, unreadCount: Int, senders: String, snippet: String?, + attachmentCount: Int = 0 + ) { + // ... existing assignments ... + self.attachmentCount = attachmentCount + } +} +``` + +- [ ] **Step 2: Add attachmentCount subquery to Queries.swift** + +In `threadSummariesFromDB`, add to the SQL: +```sql +(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount +``` + +Update the row mapping: +```swift +ThreadSummary( + // ... existing fields ... + snippet: row["snippet"], + attachmentCount: row["attachmentCount"] +) +``` + +- [ ] **Step 3: Build and run tests** + +Run: `cd Packages/MagnumOpusCore && swift test` +Expected: All 142+ tests pass + +- [ ] **Step 4: Commit** + +``` +git add Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift Packages/MagnumOpusCore/Sources/MailStore/Queries.swift +git commit -m "add attachmentCount to ThreadSummary via subquery" +``` + +--- + +### Task 8: Add paperclip badges to ThreadRow and ItemRow + +**Files:** +- Modify: `Apps/MagnumOpus/Views/ThreadListView.swift` + +- [ ] **Step 1: Add paperclip badge to ThreadRow** + +In `ThreadRow`, after the message count badge, add: + +```swift +if thread.attachmentCount > 0 { + HStack(spacing: 2) { + Image(systemName: "paperclip") + Text("\(thread.attachmentCount)") + } + .font(.caption2) + .foregroundStyle(.secondary) +} +``` + +- [ ] **Step 2: Add paperclip icon to ItemRow for emails with attachments** + +In `ItemRow`, within the `.email(let msg)` case, after the snippet, add: + +```swift +if msg.hasAttachments { + Image(systemName: "paperclip") + .font(.caption2) + .foregroundStyle(.secondary) +} +``` + +Place it in the trailing area, next to the relative date. + +- [ ] **Step 3: Build** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep "error:" | grep -v "/.build/"` +Expected: No errors + +- [ ] **Step 4: Commit** + +``` +git add Apps/MagnumOpus/Views/ThreadListView.swift +git commit -m "add paperclip badges to thread list and item list" +``` + +--- + +## Chunk 5: Final Integration + Verification + +### Task 9: Full build + test verification + +- [ ] **Step 1: Run all package tests** + +Run: `cd Packages/MagnumOpusCore && swift test` +Expected: All tests pass + +- [ ] **Step 2: Build macOS app** + +Run: `cd Apps && xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -configuration Debug build 2>&1 | grep -E "error:|warning:" | grep -v "/.build/" | grep -v "appintentsmetadataprocessor"` +Expected: No errors, no new warnings + +- [ ] **Step 3: Verify no regressions in existing functionality** + +Check: +- Compose without attachments still works (plain text path unchanged) +- Thread list loads correctly +- GTD perspectives load correctly +- Thread detail shows messages + +- [ ] **Step 4: Final commit if any integration fixes needed** + +``` +git commit -m "fix attachment UI integration issues" +```