add attachment UI implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 11:24:07 +01:00
parent a3e3618d34
commit 1c9e36a970

View File

@@ -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<String>, 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<String> = []
@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<String> = []
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"
```