Files
MagnumOpus/docs/plans/2026-03-15-attachment-ui.md
Felix Förtsch 1c9e36a970 add attachment UI implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 11:24:07 +01:00

24 KiB

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:

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:

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:

@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:

// 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):

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:

var attachments: [ComposeAttachment] = []
var sizeWarningMessage: String?
  • Step 2: Add addAttachment / removeAttachment methods
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:

// 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:

@State private var showFileImporter = false

Add a paperclip toolbar item alongside the BCC toggle:

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:

.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:

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:

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:

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):

#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:

TextEditor(text: $viewModel.bodyText)
	.frame(minHeight: 200)
	.onDrop(...)

With:

#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:

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:):

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:

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:

.onAppear {
	if message.hasAttachments {
		attachments = viewModel.attachments(messageId: message.id)
	}
}

Add attachment section after the body content (before the closing VStack's .padding()):

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
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:

@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):

.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:

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:

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:

case .message(let message):
	MessageView(message: message, viewModel: viewModel)

This requires adding viewModel to ThreadDetailView. Update the struct:

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:

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:

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:

(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount

Update the row mapping:

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:

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:

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"