From 3dfbe4a60224078c5cbaf896650e63123a5d0ed5 Mon Sep 17 00:00:00 2001 From: yustme Date: Mon, 29 Jun 2026 23:40:54 +0200 Subject: [PATCH 01/11] feat(reply-drafts): Reply Drafts section for prepared replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Reply Drafts sidebar section that surfaces the prepared replies Scout writes to drafts/.md — one card per open loop where the user owes an answer. Read the full drafted text, Copy it, Open the original thread, then Mark sent / Dismiss. The app NEVER sends and never creates a native draft; it only flips the file's status: field (draft -> sent | dismissed), mirroring the Proposals feature. New module Scout/ReplyDrafts/ (model, channel/status enums, parser, status writer via GuardedFileWrite + git, FSEvents-backed document service, cards). Wired into AppState + MainWindowView + SidebarView. Swift Testing coverage for status/channel parsing, draft parsing, and the status-rewrite writer (pure + end-to-end with git). Contract matches scout-plugin drafts/.md. --- Scout/ReplyDrafts/Models/DraftChannel.swift | 57 +++++ Scout/ReplyDrafts/Models/DraftStatus.swift | 60 ++++++ Scout/ReplyDrafts/Models/ReplyDraft.swift | 50 +++++ .../ReplyDraftsDocumentService.swift | 100 +++++++++ Scout/ReplyDrafts/ReplyDraftsParser.swift | 120 +++++++++++ Scout/ReplyDrafts/ReplyDraftsWriter.swift | 175 +++++++++++++++ Scout/ReplyDrafts/Views/ChannelBadge.swift | 34 +++ Scout/ReplyDrafts/Views/DraftStatusPill.swift | 29 +++ Scout/ReplyDrafts/Views/RepliesView.swift | 181 ++++++++++++++++ .../Views/ReplyDraftCardView.swift | 202 ++++++++++++++++++ Scout/Shell/AppState.swift | 29 +++ Scout/Shell/MainWindowView.swift | 8 +- Scout/Shell/SidebarView.swift | 4 + ScoutTests/ReplyDrafts/DraftStatusTests.swift | 67 ++++++ .../ReplyDrafts/ReplyDraftsParserTests.swift | 110 ++++++++++ .../ReplyDrafts/ReplyDraftsWriterTests.swift | 153 +++++++++++++ 16 files changed, 1378 insertions(+), 1 deletion(-) create mode 100644 Scout/ReplyDrafts/Models/DraftChannel.swift create mode 100644 Scout/ReplyDrafts/Models/DraftStatus.swift create mode 100644 Scout/ReplyDrafts/Models/ReplyDraft.swift create mode 100644 Scout/ReplyDrafts/ReplyDraftsDocumentService.swift create mode 100644 Scout/ReplyDrafts/ReplyDraftsParser.swift create mode 100644 Scout/ReplyDrafts/ReplyDraftsWriter.swift create mode 100644 Scout/ReplyDrafts/Views/ChannelBadge.swift create mode 100644 Scout/ReplyDrafts/Views/DraftStatusPill.swift create mode 100644 Scout/ReplyDrafts/Views/RepliesView.swift create mode 100644 Scout/ReplyDrafts/Views/ReplyDraftCardView.swift create mode 100644 ScoutTests/ReplyDrafts/DraftStatusTests.swift create mode 100644 ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift create mode 100644 ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift diff --git a/Scout/ReplyDrafts/Models/DraftChannel.swift b/Scout/ReplyDrafts/Models/DraftChannel.swift new file mode 100644 index 0000000..4e6d6f2 --- /dev/null +++ b/Scout/ReplyDrafts/Models/DraftChannel.swift @@ -0,0 +1,57 @@ +import Foundation + +/// The channel a reply is owed on, parsed from the `channel:` frontmatter field +/// of a `drafts/.md` file. Drives the small channel badge and decides +/// whether a `subject:` is shown (email/PR titles) versus hidden (chat). +nonisolated enum DraftChannel: Equatable, Sendable { + case email + case slack + case linear + case github + case whatsapp + /// Any channel string we don't recognize; preserved verbatim for display. + case other(String) + + static func parse(_ rawValue: String) -> DraftChannel { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "email": return .email + case "slack": return .slack + case "linear": return .linear + case "github": return .github + case "whatsapp": return .whatsapp + case let other: return .other(other) + } + } + + var displayName: String { + switch self { + case .email: return "Email" + case .slack: return "Slack" + case .linear: return "Linear" + case .github: return "GitHub" + case .whatsapp: return "WhatsApp" + case .other(let raw): return raw.isEmpty ? "—" : raw + } + } + + /// SF Symbol shown on the channel badge. + var systemImage: String { + switch self { + case .email: return "envelope" + case .slack: return "number" + case .linear: return "square.stack.3d.up" + case .github: return "chevron.left.forwardslash.chevron.right" + case .whatsapp: return "message" + case .other: return "bubble.left" + } + } + + /// True for channels whose drafts carry a meaningful `subject:` (email + /// subject, PR/issue title). Chat channels omit it. + var usesSubject: Bool { + switch self { + case .email, .linear, .github: return true + case .slack, .whatsapp, .other: return false + } + } +} diff --git a/Scout/ReplyDrafts/Models/DraftStatus.swift b/Scout/ReplyDrafts/Models/DraftStatus.swift new file mode 100644 index 0000000..e8a1099 --- /dev/null +++ b/Scout/ReplyDrafts/Models/DraftStatus.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Lifecycle status of a prepared reply draft, parsed from the `status:` field +/// of a `drafts/.md` file. +/// +/// The vocabulary is the strict, lowercase contract the scout-plugin writes and +/// re-reads: a draft moves `draft` → `sent` (the user sent it himself) or +/// `dismissed` (no longer needed). The app only ever flips this field — it +/// never sends anything. The canonical word is what the plugin keys on, so the +/// app must write exactly `draft` / `sent` / `dismissed` back. +nonisolated enum DraftStatus: Equatable, Sendable { + /// `draft` — prepared, awaiting the user to send it. + case draft + /// `sent` — the user has sent the reply himself. + case sent + /// `dismissed` — the reply is no longer needed. + case dismissed + /// Any status string we don't recognize; preserved verbatim for display. + case unknown(String) + + /// Classify a raw `status:` value, case-insensitively. + static func parse(_ rawValue: String) -> DraftStatus { + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "draft": return .draft + case "sent": return .sent + case "dismissed": return .dismissed + case let other: return other.isEmpty ? .draft : .unknown(other) + } + } + + /// The canonical lowercase word written back into the file. Must match the + /// plugin's `status:` contract exactly so a re-read by Scout round-trips. + var fileValue: String { + switch self { + case .draft: return "draft" + case .sent: return "sent" + case .dismissed: return "dismissed" + case .unknown(let raw): return raw + } + } + + /// True while the draft still needs the user's action — drives the sidebar + /// badge and the send/dismiss buttons. + var isAwaitingAction: Bool { + if case .draft = self { return true } + return false + } + + var isResolved: Bool { !isAwaitingAction } + + /// Short label for the status pill. + var displayName: String { + switch self { + case .draft: return "Draft" + case .sent: return "Sent" + case .dismissed: return "Dismissed" + case .unknown(let raw): return raw + } + } +} diff --git a/Scout/ReplyDrafts/Models/ReplyDraft.swift b/Scout/ReplyDrafts/Models/ReplyDraft.swift new file mode 100644 index 0000000..9d1b4fd --- /dev/null +++ b/Scout/ReplyDrafts/Models/ReplyDraft.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A single prepared reply draft, parsed from one file in the `drafts/` +/// directory. +/// +/// Scout (the plugin) detects an open conversational loop where the user owes +/// someone a reply, prepares the reply text, and writes it as `drafts/.md` +/// with YAML frontmatter (`tag`, `channel`, `loop_type`, `to`, `thread_ref`, +/// `subject`, `status`, `created`, `context_answer_ref`) followed by the draft +/// body. `fileURL` is the stable identity for SwiftUI and the file the writer +/// rewrites when flipping `status`. +/// +/// The app shows the draft so the user can read, copy, and **send it himself**; +/// it never sends and never creates a native draft. +nonisolated struct ReplyDraft: Identifiable, Equatable, Sendable { + /// Absolute URL of the draft markdown file — stable identity + the file the + /// writer rewrites. + let fileURL: URL + /// `tag:` — mirrors the action item `[#TAG]`; falls back to the filename stem. + let tag: String + /// `channel:` — where the reply is owed. + let channel: DraftChannel + /// `loop_type:` — `direct-debt` or `promise-answered` (verbatim). + let loopType: String + /// `to:` — recipient (name + address/handle when known). + let to: String + /// `thread_ref:` — link/permalink/thread id to the original conversation. + let threadRef: String + /// `subject:` — email subject or PR/issue title; nil for chat channels. + let subject: String? + /// Parsed lifecycle status (`status:`). + let status: DraftStatus + /// `created:` date, falling back to the filename's `YYYY-MM-DD` prefix. + let created: String + /// `context_answer_ref:` — for `promise-answered`, the answer that unblocked + /// the reply; nil otherwise. + let contextAnswerRef: String? + /// The drafted reply body — everything after the frontmatter, trimmed. + let bodyMarkdown: String + + var id: String { fileURL.path } + + var isAwaitingAction: Bool { status.isAwaitingAction } + + /// Header chip — the tag reads like a code label. + var code: String { tag } + + /// Whether a subject line should be shown (channel uses one and it's set). + var showsSubject: Bool { channel.usesSubject && (subject?.isEmpty == false) } +} diff --git a/Scout/ReplyDrafts/ReplyDraftsDocumentService.swift b/Scout/ReplyDrafts/ReplyDraftsDocumentService.swift new file mode 100644 index 0000000..862a630 --- /dev/null +++ b/Scout/ReplyDrafts/ReplyDraftsDocumentService.swift @@ -0,0 +1,100 @@ +import Combine +import Foundation +import SwiftUI + +/// Loads per-file reply drafts from the `drafts/` directory, keeps them in sync +/// via FSEvents, and publishes the parsed drafts plus a pending-count for the +/// sidebar badge. +/// +/// Each `*.md` file in the directory is one draft (YAML frontmatter + body); the +/// `drafts/README.md` doc is intentionally ignored — it has no frontmatter, so +/// the parser skips it. Loading begins at app launch so the badge is populated +/// before the user opens the Reply Drafts section. Mirrors +/// ``ProposalsDocumentService``. +@MainActor +final class ReplyDraftsDocumentService: ObservableObject { + enum State: Equatable { + case idle + case loading + case loaded + case missing(URL) + case failed(String) + } + + @Published private(set) var drafts: [ReplyDraft] = [] + @Published private(set) var state: State = .idle + + /// The directory scanned for draft files (e.g. `~/Scout/drafts`). + let directoryURL: URL + private let fileEvents: any FileSystemEventSource + private var watchTask: Task? + + /// Number of drafts still awaiting the user's action (`status: draft`) — the + /// value the sidebar badge shows. + var pendingCount: Int { drafts.filter(\.isAwaitingAction).count } + + init(directoryURL: URL, fileEvents: any FileSystemEventSource) { + self.directoryURL = directoryURL + self.fileEvents = fileEvents + } + + /// Load (or reload) the drafts directory and start watching it. + func load() { + state = .loading + reparse() + startWatching() + } + + /// Re-scan immediately. Called by the view after a write so the UI reflects + /// the change without waiting on FSEvents. + func reload() { reparse() } + + private func reparse() { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: directoryURL.path, isDirectory: &isDir), + isDir.boolValue else { + drafts = [] + state = .missing(directoryURL) + return + } + let files: [URL] + do { + files = try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + } catch { + state = .failed(error.localizedDescription) + return + } + let parsed = files + .filter { $0.pathExtension == "md" } + // Newest first by filename for a stable, predictable order. + .sorted { $0.lastPathComponent > $1.lastPathComponent } + .compactMap { url -> ReplyDraft? in + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } + return ReplyDraftsParser.parseFile(contents: text, fileURL: url) + } + drafts = parsed + state = .loaded + } + + private func startWatching() { + watchTask?.cancel() + let stream = fileEvents.events(for: directoryURL) + watchTask = Task { [weak self] in + var debounce: Task? + for await event in stream { + guard self != nil else { return } + guard event.url.pathExtension == "md" else { continue } + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + self?.reparse() + } + } + } + } + + deinit { watchTask?.cancel() } +} diff --git a/Scout/ReplyDrafts/ReplyDraftsParser.swift b/Scout/ReplyDrafts/ReplyDraftsParser.swift new file mode 100644 index 0000000..9948ae4 --- /dev/null +++ b/Scout/ReplyDrafts/ReplyDraftsParser.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Parses a single reply-draft file (one file in `drafts/`) into a ``ReplyDraft``. +/// +/// A draft file is YAML frontmatter between `---` fences, followed by the +/// drafted reply body: +/// +/// ``` +/// --- +/// tag: NAHSEND +/// channel: email +/// loop_type: direct-debt +/// to: "Jan Novák " +/// thread_ref: "https://mail.google.com/…" +/// subject: "Re: Rozpočet Q3" +/// status: draft +/// created: 2026-06-29 +/// context_answer_ref: "" +/// --- +/// +/// Ahoj Jane, … +/// ``` +/// +/// Pure functions — no I/O — so they are trivially unit-testable. `nonisolated` +/// so the parser is callable from background contexts and the `MainActor` +/// document service alike. Frontmatter helpers mirror `ProposalsParser` +/// (parsing is hand-rolled per feature in this app). +nonisolated enum ReplyDraftsParser { + + /// Parse one draft file's contents into a ``ReplyDraft``, or `nil` if the + /// text has no `---` frontmatter (e.g. the `drafts/README.md` doc, which has + /// none and is therefore skipped). + static func parseFile(contents: String, fileURL: URL) -> ReplyDraft? { + guard let (frontmatter, body) = splitFrontmatter(contents) else { return nil } + let fields = parseFrontmatterFields(frontmatter) + let stem = fileURL.deletingPathExtension().lastPathComponent + + let tag = fields["tag"]?.nonEmpty ?? stem + let channel = DraftChannel.parse(fields["channel"] ?? "") + let loopType = fields["loop_type"] ?? "" + let to = fields["to"] ?? "" + let threadRef = fields["thread_ref"] ?? "" + let subject = fields["subject"]?.nonEmpty + let status = DraftStatus.parse(fields["status"] ?? "") + let created = fields["date"]?.nonEmpty ?? fields["created"]?.nonEmpty ?? datePrefix(of: stem) ?? "" + let contextAnswerRef = fields["context_answer_ref"]?.nonEmpty + let cleanBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + + return ReplyDraft( + fileURL: fileURL, + tag: tag, + channel: channel, + loopType: loopType, + to: to, + threadRef: threadRef, + subject: subject, + status: status, + created: created, + contextAnswerRef: contextAnswerRef, + bodyMarkdown: cleanBody + ) + } + + // MARK: - Frontmatter + + /// Split `---\n\n---\n`. Returns `nil` when the text does + /// not open with a `---` fence or the closing fence is missing. + static func splitFrontmatter(_ text: String) -> (frontmatter: String, body: String)? { + let lines = text.components(separatedBy: "\n") + guard let first = lines.first, + first.trimmingCharacters(in: .whitespaces) == "---" else { return nil } + var frontmatter: [String] = [] + var i = 1 + while i < lines.count { + if lines[i].trimmingCharacters(in: .whitespaces) == "---" { + let body = i + 1 < lines.count + ? lines[(i + 1)...].joined(separator: "\n") + : "" + return (frontmatter.joined(separator: "\n"), body) + } + frontmatter.append(lines[i]) + i += 1 + } + return nil // no closing fence + } + + /// Parse simple `key: value` frontmatter lines. Keys are lowercased; the + /// value keeps everything after the first colon (so a value may itself + /// contain colons), with surrounding double quotes stripped. + static func parseFrontmatterFields(_ frontmatter: String) -> [String: String] { + var out: [String: String] = [:] + for line in frontmatter.components(separatedBy: "\n") { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[..= 2, value.hasPrefix("\""), value.hasSuffix("\"") { + value = String(value.dropFirst().dropLast()) + } + out[key] = value + } + return out + } + + /// First `yyyy-MM-dd` at the start of a filename stem, used when frontmatter + /// omits `created`. + static func datePrefix(of stem: String) -> String? { + guard let re = try? NSRegularExpression(pattern: #"^\d{4}-\d{2}-\d{2}"#) else { return nil } + let ns = stem as NSString + guard let m = re.firstMatch(in: stem, range: NSRange(location: 0, length: ns.length)) else { return nil } + return ns.substring(with: m.range) + } +} + +private extension String { + /// `nil` when the string is empty after trimming, else self. + var nonEmpty: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Scout/ReplyDrafts/ReplyDraftsWriter.swift b/Scout/ReplyDrafts/ReplyDraftsWriter.swift new file mode 100644 index 0000000..defd049 --- /dev/null +++ b/Scout/ReplyDrafts/ReplyDraftsWriter.swift @@ -0,0 +1,175 @@ +import Combine +import Foundation + +/// A status change the app can write back to a reply-draft file. The app never +/// sends — it only flips `status:` in the markdown. +enum DraftAction: Sendable, Equatable { + /// The user sent the reply himself. + case markSent + /// The reply is no longer needed. + case dismiss + /// Move a resolved draft back to `draft`. + case reopen + + /// Canonical lowercase status word written into the file — must match the + /// scout-plugin `status:` contract exactly so a re-read round-trips. + var status: DraftStatus { + switch self { + case .markSent: return .sent + case .dismiss: return .dismissed + case .reopen: return .draft + } + } + + /// Verb used in the git commit message. + var verb: String { + switch self { + case .markSent: return "mark-sent" + case .dismiss: return "dismiss" + case .reopen: return "reopen" + } + } +} + +enum ReplyDraftsWriterError: Error, Equatable { + /// The file had no `---` YAML frontmatter to carry a `status:` field. + case frontmatterNotFound(file: String) + /// Frontmatter was present but had no `status:` line to replace. + case statusFieldNotFound(file: String) + case readFailed(String) + case writeFailed(String) +} + +/// Serializes reply-draft status mutations to per-file drafts in `drafts/`. +/// +/// There is no `scoutctl` command for drafts — they are plain markdown that +/// Scout sessions read and write directly — so the app edits the file in place: +/// replace the `status:` value in the file's YAML frontmatter, write +/// atomically, then commit just that file to the vault's git. The status word +/// (`sent` / `dismissed` / `draft`) is what Scout keys on. Submissions are +/// strictly serialized so two quick clicks can't interleave. The app **never** +/// sends a message or creates a native draft — flipping the status field is the +/// only side effect. +actor ReplyDraftsWriter { + private let scoutDirectory: URL + private let gitService: GitServiceProtocol? + + /// Tail of the serial task chain (same pattern as `ProposalsWriter`): each + /// submission awaits the previous one before running. + private var tail: Task? + + init(scoutDirectory: URL, gitService: GitServiceProtocol?) { + self.scoutDirectory = scoutDirectory + self.gitService = gitService + } + + /// Apply a status change to the draft at `fileURL`. `label` is used only for + /// the git commit message. Returns after the file is written and the git + /// commit (best-effort) completes. + func apply(_ action: DraftAction, fileURL: URL, label: String) async throws { + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + return try await Self.perform( + action: action, + fileURL: fileURL, + label: label, + scoutDirectory: scoutDirectory, + gitService: gitService + ) + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + private static func perform( + action: DraftAction, + fileURL: URL, + label: String, + scoutDirectory: URL, + gitService: GitServiceProtocol? + ) async throws { + let newStatusValue = action.status.fileValue + + // Read-modify-write guarded against a concurrent Scout write clobbering + // the file in our read→write window (same guard as ProposalsWriter). + let didWrite: Bool + do { + didWrite = try GuardedFileWrite.apply(to: fileURL) { text in + try rewriteFrontmatterStatus( + text: text, + newStatusValue: newStatusValue, + file: fileURL.lastPathComponent + ) + } + } catch let e as GuardedFileWrite.Failure { + switch e { + case .read(let m): throw ReplyDraftsWriterError.readFailed(m) + case .write(let m): throw ReplyDraftsWriterError.writeFailed(m) + case .conflictPersisted: + throw ReplyDraftsWriterError.writeFailed("\(fileURL.lastPathComponent) changed repeatedly under concurrent writes") + } + } + + // Nothing to do if the status is already exactly what we'd write. + guard didWrite else { return } + + let relativePath = relativePathInRepo(fileURL: fileURL, repo: scoutDirectory) + try? await gitService?.commitPaths( + [relativePath], + message: "app: \(action.verb) reply draft \(label)" + ) + } + + // MARK: - Pure rewrite (unit-tested directly) + + /// Replace the `status:` value inside the file's YAML frontmatter. Only that + /// one line changes — the body and every other frontmatter field are left + /// byte-for-byte identical, and the line's leading indentation is preserved. + /// Throws if there is no frontmatter or no `status:` field within it. + static func rewriteFrontmatterStatus( + text: String, + newStatusValue: String, + file: String + ) throws -> String { + var lines = text.components(separatedBy: "\n") + guard let first = lines.first, + first.trimmingCharacters(in: .whitespaces) == "---" else { + throw ReplyDraftsWriterError.frontmatterNotFound(file: file) + } + + var i = 1 + while i < lines.count { + // Closing fence ends the frontmatter without finding `status:`. + if lines[i].trimmingCharacters(in: .whitespaces) == "---" { break } + if let colon = lines[i].firstIndex(of: ":") { + let key = lines[i][.. String { + let filePath = fileURL.standardizedFileURL.path + let repoPath = repo.standardizedFileURL.path + if filePath.hasPrefix(repoPath + "/") { + return String(filePath.dropFirst(repoPath.count + 1)) + } + return fileURL.lastPathComponent + } +} + +/// A boxed writer — actors can't be stored directly in `@EnvironmentObject`, +/// but a class holding the actor can. Mirrors `ProposalsWriterBox`. +final class ReplyDraftsWriterBox: ObservableObject { + let writer: ReplyDraftsWriter + init(writer: ReplyDraftsWriter) { self.writer = writer } +} diff --git a/Scout/ReplyDrafts/Views/ChannelBadge.swift b/Scout/ReplyDrafts/Views/ChannelBadge.swift new file mode 100644 index 0000000..40fb4cb --- /dev/null +++ b/Scout/ReplyDrafts/Views/ChannelBadge.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// Small icon+label badge showing which channel a reply is owed on. Reads as a +/// quiet metadata chip next to the status pill. +struct ChannelBadge: View { + let channel: DraftChannel + + var body: some View { + HStack(spacing: 4) { + Image(systemName: channel.systemImage) + .font(.system(size: 9, weight: .semibold)) + Text(channel.displayName.uppercased()) + .font(DS.sans(10, weight: .semibold)) + .tracking(0.06 * 10) + } + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(tint.opacity(0.12))) + .overlay(Capsule().strokeBorder(tint.opacity(0.30), lineWidth: 0.5)) + .fixedSize() + } + + private var tint: Color { + switch channel { + case .email: return DS.Accent.ink + case .slack: return DS.Priority.personal + case .linear: return DS.Priority.todo + case .github: return DS.Ink.p2 + case .whatsapp: return DS.Status.ok + case .other: return DS.Ink.p3 + } + } +} diff --git a/Scout/ReplyDrafts/Views/DraftStatusPill.swift b/Scout/ReplyDrafts/Views/DraftStatusPill.swift new file mode 100644 index 0000000..0bf19a9 --- /dev/null +++ b/Scout/ReplyDrafts/Views/DraftStatusPill.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// Small color-coded capsule for a reply draft's lifecycle status. Part of the +/// editorial chip family (matched-chroma hues, hairline-soft fills), mirroring +/// ``ProposalStatusPill``. +struct DraftStatusPill: View { + let status: DraftStatus + + var body: some View { + Text(status.displayName.uppercased()) + .font(DS.sans(10, weight: .semibold)) + .tracking(0.06 * 10) + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(tint.opacity(0.14))) + .overlay(Capsule().strokeBorder(tint.opacity(0.35), lineWidth: 0.5)) + .fixedSize() + } + + private var tint: Color { + switch status { + case .draft: return DS.Priority.todo + case .sent: return DS.Status.ok + case .dismissed: return DS.Priority.done + case .unknown: return DS.Ink.p3 + } + } +} diff --git a/Scout/ReplyDrafts/Views/RepliesView.swift b/Scout/ReplyDrafts/Views/RepliesView.swift new file mode 100644 index 0000000..ce1372c --- /dev/null +++ b/Scout/ReplyDrafts/Views/RepliesView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +/// The Reply Drafts section: prepared replies Scout owes (from the `drafts/` +/// directory), with Copy / Open thread / Mark sent / Dismiss on the ones still +/// awaiting action and a read-only archive of resolved ones. +/// +/// The app never sends — it presents the drafted text for the user to send +/// himself and only flips the draft's `status:` field. +struct RepliesView: View { + @EnvironmentObject var docService: ReplyDraftsDocumentService + @EnvironmentObject var writerBox: ReplyDraftsWriterBox + + @State private var resolvedExpanded = false + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + header + content + } + .frame(maxWidth: 920, alignment: .leading) + .padding(.horizontal, 42) + .padding(.top, 28) + .padding(.bottom, 64) + .frame(maxWidth: .infinity, alignment: .center) + } + .scrollIndicators(.visible) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(DS.Paper.base) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + NSWorkspace.shared.activateFileViewerSelecting([docService.directoryURL]) + } label: { + Image(systemName: "folder") + } + .help("Reveal the drafts folder in Finder") + } + } + .onAppear { docService.load() } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 14) { + Text("Reply Drafts") + .font(DS.serif(28, weight: .medium)) + .foregroundStyle(DS.Ink.p1) + Spacer(minLength: 0) + Text("repo ~/Scout") + .font(DS.mono(12)) + .foregroundStyle(DS.Ink.p4) + } + Text(subtitle) + .font(DS.sans(13)) + .foregroundStyle(DS.Ink.p3) + } + .padding(.bottom, 6) + .overlay(alignment: .bottom) { EditorialRule() } + } + + private var subtitle: String { + let pending = docService.pendingCount + switch pending { + case 0: return "Replies Scout prepared for conversations you owe an answer. Nothing waiting — you're caught up." + case 1: return "1 reply prepared. Read it, copy or open the thread, send it yourself, then mark it sent. Scout never sends." + default: return "\(pending) replies prepared. Read each, send it yourself, then mark it sent. Scout never sends." + } + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + switch docService.state { + case .idle, .loading: + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 60) + case .missing: + emptyState( + icon: "tray", + message: "No drafts folder found. Scout writes prepared replies into drafts/ during briefing and consolidation runs. You can point Scout at a different folder in Settings." + ) + case .failed(let err): + Text("Couldn't load reply drafts: \(err)") + .font(DS.sans(13)) + .foregroundStyle(DS.Status.err) + .padding(.top, 24) + case .loaded: + loadedContent + } + } + + @ViewBuilder + private var loadedContent: some View { + let awaiting = docService.drafts.filter(\.isAwaitingAction) + let resolved = docService.drafts.filter { !$0.isAwaitingAction } + + if docService.drafts.isEmpty { + emptyState( + icon: "tray", + message: "No reply drafts right now. They'll appear here after a briefing or consolidation run prepares one." + ) + } else { + if awaiting.isEmpty { + emptyState( + icon: "checkmark.circle", + message: "Nothing waiting to send. Resolved drafts are below." + ) + } + ForEach(awaiting) { draft in + ReplyDraftCardView(draft: draft) { action in + try await apply(draft, action) + } + } + if !resolved.isEmpty { + resolvedSection(resolved) + } + } + } + + private func resolvedSection(_ resolved: [ReplyDraft]) -> some View { + VStack(alignment: .leading, spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { resolvedExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: resolvedExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)) + Text("Resolved") + .font(DS.sans(11.5, weight: .semibold)) + .tracking(0.06 * 11.5) + Text("\(resolved.count)") + .font(DS.mono(11)) + .foregroundStyle(DS.Ink.p4) + } + .foregroundStyle(DS.Ink.p3) + .contentShape(Rectangle()) + } + .buttonStyle(.plainHit) + + if resolvedExpanded { + ForEach(resolved) { draft in + ReplyDraftCardView(draft: draft) { action in + try await apply(draft, action) + } + } + } + } + .padding(.top, 12) + } + + private func emptyState(icon: String, message: String) -> some View { + VStack(spacing: 14) { + Image(systemName: icon) + .font(.largeTitle) + .foregroundStyle(DS.Ink.p3) + Text(message) + .font(DS.serif(14)) + .foregroundStyle(DS.Ink.p2) + .multilineTextAlignment(.center) + .frame(maxWidth: 440) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + // MARK: - Actions + + private func apply(_ draft: ReplyDraft, _ action: DraftAction) async throws { + try await writerBox.writer.apply( + action, + fileURL: draft.fileURL, + label: draft.tag + ) + docService.reload() + } +} diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift new file mode 100644 index 0000000..867376f --- /dev/null +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -0,0 +1,202 @@ +import SwiftUI +import AppKit + +/// One reply draft rendered as an editorial card: heading (tag + recipient + +/// subject + channel/status chips), the prepared reply body (selectable, ready +/// to copy), and actions. The app **never sends** — Copy puts the text on the +/// pasteboard, Open thread opens the original conversation, and Mark sent / +/// Dismiss only flip the file's `status:`. +struct ReplyDraftCardView: View { + let draft: ReplyDraft + /// Performs a status write. Throws so the card can show an inline error. + let onAction: @MainActor (DraftAction) async throws -> Void + + @State private var inFlight: DraftAction? + @State private var errorText: String? + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + metaLine + bodyPanel + actions + if let errorText { + Label(errorText, systemImage: "exclamationmark.triangle.fill") + .font(DS.sans(11)) + .foregroundStyle(DS.Status.err) + } + } + .editorialCard(padding: 18) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + if !draft.code.isEmpty { + Text("#\(draft.code)") + .font(DS.mono(11)) + .foregroundStyle(DS.Ink.p4) + } + Text(recipientLine) + .font(DS.serif(17, weight: .medium)) + .foregroundStyle(DS.Ink.p1) + .fixedSize(horizontal: false, vertical: true) + if draft.showsSubject, let subject = draft.subject { + Text(subject) + .font(DS.sans(12.5)) + .foregroundStyle(DS.Ink.p3) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 6) { + DraftStatusPill(status: draft.status) + ChannelBadge(channel: draft.channel) + } + } + } + + private var recipientLine: String { + draft.to.isEmpty ? "Reply" : "To \(draft.to)" + } + + // MARK: - Meta + + @ViewBuilder + private var metaLine: some View { + let bits = [draft.loopType.isEmpty ? nil : loopTypeLabel, + draft.created.isEmpty ? nil : "prepared \(draft.created)"] + .compactMap { $0 } + if !bits.isEmpty { + Text(bits.joined(separator: " · ")) + .font(DS.sans(11)) + .foregroundStyle(DS.Ink.p4) + } + } + + private var loopTypeLabel: String { + switch draft.loopType { + case "direct-debt": return "you owe a reply" + case "promise-answered": return "promise now answerable" + default: return draft.loopType + } + } + + // MARK: - Body + + private var bodyPanel: some View { + Text(draft.bodyMarkdown.isEmpty ? "(empty draft)" : draft.bodyMarkdown) + .font(DS.serif(14)) + .foregroundStyle(DS.Ink.p1) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(14) + .background { + RoundedRectangle(cornerRadius: 7) + .fill(DS.Paper.sunk) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + } + } + + // MARK: - Actions + + private var actions: some View { + HStack(spacing: 6) { + actButton(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") { + copyBody() + } + if !draft.threadRef.isEmpty { + actButton("Open thread", systemImage: "arrow.up.right.square") { + openThread() + } + } + Spacer(minLength: 0) + if draft.isAwaitingAction { + statusButton("Mark sent", systemImage: "paperplane", action: .markSent, tint: DS.Status.ok) + statusButton("Dismiss", systemImage: "xmark", action: .dismiss, tint: DS.Ink.p3) + } else { + statusButton("Reopen", systemImage: "arrow.uturn.backward", action: .reopen, tint: DS.Ink.p3) + } + } + .padding(.top, 2) + } + + /// A local (non-writing) action button — Copy / Open thread. + @ViewBuilder + private func actButton(_ label: String, systemImage: String, perform: @escaping () -> Void) -> some View { + Button(action: perform) { + chrome(label: label, systemImage: systemImage, tint: DS.Ink.p3, busy: false) + } + .buttonStyle(.plainHit) + .onHover { hovering in + if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + + /// A status-writing action button — Mark sent / Dismiss / Reopen. + @ViewBuilder + private func statusButton(_ label: String, systemImage: String, action: DraftAction, tint: Color) -> some View { + let isBusy = inFlight == action + Button { perform(action) } label: { + chrome(label: label, systemImage: systemImage, tint: tint, busy: isBusy) + } + .buttonStyle(.plainHit) + .disabled(inFlight != nil) + .onHover { hovering in + if hovering, inFlight == nil { NSCursor.pointingHand.push() } else { NSCursor.pop() } + } + } + + private func chrome(label: String, systemImage: String, tint: Color, busy: Bool) -> some View { + HStack(spacing: 5) { + if busy { + ProgressView().controlSize(.small).frame(width: 12, height: 12) + } else { + Image(systemName: systemImage).font(.system(size: 10)) + } + Text(label).font(DS.sans(11.5, weight: .medium)) + } + .foregroundStyle(tint) + .padding(.horizontal, 12) + .frame(height: 26) + .background { + RoundedRectangle(cornerRadius: 5) + .fill(DS.Paper.raised) + .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(tint.opacity(0.4), lineWidth: 0.5)) + } + } + + // MARK: - Behavior + + private func copyBody() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(draft.bodyMarkdown, forType: .string) + copied = true + Task { + try? await Task.sleep(nanoseconds: 1_400_000_000) + copied = false + } + } + + private func openThread() { + guard let url = URL(string: draft.threadRef) else { return } + NSWorkspace.shared.open(url) + } + + private func perform(_ action: DraftAction) { + inFlight = action + errorText = nil + Task { + do { + try await onAction(action) + } catch { + errorText = "Couldn't update the file — \(error.localizedDescription)" + } + inFlight = nil + } + } +} diff --git a/Scout/Shell/AppState.swift b/Scout/Shell/AppState.swift index d3638b7..2737cc6 100644 --- a/Scout/Shell/AppState.swift +++ b/Scout/Shell/AppState.swift @@ -47,6 +47,10 @@ final class AppState: ObservableObject { let proposalsDocumentService: ProposalsDocumentService let proposalsWriterBox: ProposalsWriterBox + // Reply Drafts (drafts/ review — prepared replies the user owes) + let replyDraftsDocumentService: ReplyDraftsDocumentService + let replyDraftsWriterBox: ReplyDraftsWriterBox + // Per-file Wishlist + Research tabs let wishlistDocumentService: PerFileDocumentService let researchDocumentService: PerFileDocumentService @@ -154,6 +158,23 @@ final class AppState: ObservableObject { ) let proposalsWriterBox = ProposalsWriterBox(writer: proposalsWriter) + // Reply drafts live in `drafts/` (the sibling `drafts/README.md` is just + // a doc with no frontmatter, so the parser skips it). The folder is + // overridable via the `replyDraftsPath` setting; takes effect on next + // launch. Mirrors the dreamingProposalsPath pattern. + let replyDraftsDirURL: URL = { + let override = UserDefaults.standard + .string(forKey: "replyDraftsPath")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let override, !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return scoutDir.appendingPathComponent("drafts") + }() + let replyDraftsDoc = ReplyDraftsDocumentService(directoryURL: replyDraftsDirURL, fileEvents: watcher) + let replyDraftsWriter = ReplyDraftsWriter(scoutDirectory: scoutDir, gitService: git) + let replyDraftsWriterBox = ReplyDraftsWriterBox(writer: replyDraftsWriter) + // Per-file Wishlist + Research: resolve directory (override key or default // relative path under scoutDir), matching the dreamingProposalsPath pattern. func perFileDir(_ config: PerFileTabConfig) -> URL { @@ -186,6 +207,8 @@ final class AppState: ObservableObject { self.actionItemsEnvState = envState self.proposalsDocumentService = proposalsDoc self.proposalsWriterBox = proposalsWriterBox + self.replyDraftsDocumentService = replyDraftsDoc + self.replyDraftsWriterBox = replyDraftsWriterBox self.wishlistDocumentService = wishlistDoc self.researchDocumentService = researchDoc self.perFileWriterBox = perFileWriterBox @@ -207,6 +230,10 @@ final class AppState: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) + replyDraftsDoc.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) Task { [weak self] in _ = try? await tracker.loadInitial() @@ -222,6 +249,8 @@ final class AppState: ObservableObject { // Load wishlist + research so their badges are ready on launch. wishlistDoc.load() researchDoc.load() + // Load reply drafts so the badge is ready on launch. + replyDraftsDoc.load() } await self?.recomputeMenuStatus() diff --git a/Scout/Shell/MainWindowView.swift b/Scout/Shell/MainWindowView.swift index 06b5a06..13cfa2e 100644 --- a/Scout/Shell/MainWindowView.swift +++ b/Scout/Shell/MainWindowView.swift @@ -18,6 +18,7 @@ struct MainWindowView: View { NavigationSplitView { SidebarView(selection: $selection, proposalsBadge: proposalsService.pendingCount, + replyDraftsBadge: appState.replyDraftsDocumentService.pendingCount, wishlistBadge: appState.wishlistDocumentService.activeCount, researchBadge: appState.researchDocumentService.activeCount) .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 240) @@ -50,6 +51,10 @@ struct MainWindowView: View { ProposalsView() .environmentObject(appState.proposalsDocumentService) .environmentObject(appState.proposalsWriterBox) + case .replyDrafts: + RepliesView() + .environmentObject(appState.replyDraftsDocumentService) + .environmentObject(appState.replyDraftsWriterBox) case .wishlist: PerFileListView(config: .wishlist) .environmentObject(appState.wishlistDocumentService) @@ -65,7 +70,7 @@ struct MainWindowView: View { } enum SidebarItem: Hashable { - case controlCenter, actionItems, schedules, proposals, wishlist, research, settings + case controlCenter, actionItems, schedules, proposals, replyDrafts, wishlist, research, settings /// Short label shown in the bottom status bar's "view" cell. var statusLabel: String { @@ -74,6 +79,7 @@ enum SidebarItem: Hashable { case .actionItems: return "actions" case .schedules: return "schedules" case .proposals: return "proposals" + case .replyDrafts: return "replies" case .wishlist: return "wishlist" case .research: return "research" case .settings: return "settings" diff --git a/Scout/Shell/SidebarView.swift b/Scout/Shell/SidebarView.swift index 10b162b..aa96c1b 100644 --- a/Scout/Shell/SidebarView.swift +++ b/Scout/Shell/SidebarView.swift @@ -8,6 +8,9 @@ struct SidebarView: View { /// Count of proposals awaiting a decision — drives the badge on the /// Proposals row. Hidden when zero. var proposalsBadge: Int = 0 + /// Count of reply drafts awaiting send — drives the badge on the Reply + /// Drafts row. Hidden when zero. + var replyDraftsBadge: Int = 0 /// Count of active wishlist items — drives the badge on the Wishlist row. var wishlistBadge: Int = 0 /// Count of active research topics — drives the badge on the Research row. @@ -20,6 +23,7 @@ struct SidebarView: View { row(.actionItems, label: "Action Items", system: "checklist") row(.schedules, label: "Schedules", system: "calendar.badge.clock") row(.proposals, label: "Proposals", system: "lightbulb", badge: proposalsBadge) + row(.replyDrafts, label: "Reply Drafts", system: "envelope.badge", badge: replyDraftsBadge) row(.wishlist, label: "Wishlist", system: "star", badge: wishlistBadge) row(.research, label: "Research", system: "magnifyingglass", badge: researchBadge) Spacer().frame(height: 10) diff --git a/ScoutTests/ReplyDrafts/DraftStatusTests.swift b/ScoutTests/ReplyDrafts/DraftStatusTests.swift new file mode 100644 index 0000000..cd91e3d --- /dev/null +++ b/ScoutTests/ReplyDrafts/DraftStatusTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +@testable import Scout + +@Suite("DraftStatus") +struct DraftStatusTests { + + @Test func parsesKnownWordsCaseInsensitively() { + #expect(DraftStatus.parse("draft") == .draft) + #expect(DraftStatus.parse("Sent") == .sent) + #expect(DraftStatus.parse(" DISMISSED ") == .dismissed) + } + + @Test func emptyValueDefaultsToDraft() { + #expect(DraftStatus.parse("") == .draft) + #expect(DraftStatus.parse(" ") == .draft) + } + + @Test func unknownValuePreservedVerbatim() { + #expect(DraftStatus.parse("queued") == .unknown("queued")) + } + + @Test func onlyDraftIsAwaitingAction() { + #expect(DraftStatus.draft.isAwaitingAction) + #expect(!DraftStatus.sent.isAwaitingAction) + #expect(!DraftStatus.dismissed.isAwaitingAction) + #expect(!DraftStatus.unknown("x").isAwaitingAction) + } + + @Test func fileValueIsCanonicalLowercaseContract() { + // Round-trips with the plugin's `status:` vocabulary. + #expect(DraftStatus.draft.fileValue == "draft") + #expect(DraftStatus.sent.fileValue == "sent") + #expect(DraftStatus.dismissed.fileValue == "dismissed") + for s in [DraftStatus.draft, .sent, .dismissed] { + #expect(DraftStatus.parse(s.fileValue) == s) + } + } + + @Test func displayNameIsTitleCased() { + #expect(DraftStatus.draft.displayName == "Draft") + #expect(DraftStatus.sent.displayName == "Sent") + #expect(DraftStatus.dismissed.displayName == "Dismissed") + #expect(DraftStatus.unknown("queued").displayName == "queued") + } +} + +@Suite("DraftChannel") +struct DraftChannelTests { + + @Test func parsesKnownChannels() { + #expect(DraftChannel.parse("email") == .email) + #expect(DraftChannel.parse("Slack") == .slack) + #expect(DraftChannel.parse("LINEAR") == .linear) + #expect(DraftChannel.parse("github") == .github) + #expect(DraftChannel.parse("whatsapp") == .whatsapp) + #expect(DraftChannel.parse("signal") == .other("signal")) + } + + @Test func subjectChannelsUseSubject() { + #expect(DraftChannel.email.usesSubject) + #expect(DraftChannel.linear.usesSubject) + #expect(DraftChannel.github.usesSubject) + #expect(!DraftChannel.slack.usesSubject) + #expect(!DraftChannel.whatsapp.usesSubject) + } +} diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift new file mode 100644 index 0000000..3095736 --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift @@ -0,0 +1,110 @@ +import Testing +import Foundation +@testable import Scout + +private let fullFixture = """ +--- +tag: NAHSEND +channel: email +loop_type: direct-debt +to: "Jan Novák " +thread_ref: "https://mail.google.com/mail/u/0/#inbox/abc123" +subject: "Re: Rozpočet Q3" +status: draft +created: 2026-06-29 +context_answer_ref: "" +--- + +Ahoj Jane, + +posílám čísla k Q3 rozpočtu. [TBD: doplnit finální částku] + +Měj se, +Vojta +""" + +@Suite("ReplyDraftsParser") +struct ReplyDraftsParserTests { + + @Test func parsesAllFrontmatterFields() throws { + let url = URL(fileURLWithPath: "/x/drafts/NAHSEND.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: fullFixture, fileURL: url)) + #expect(d.tag == "NAHSEND") + #expect(d.channel == .email) + #expect(d.loopType == "direct-debt") + #expect(d.to == "Jan Novák ") + #expect(d.threadRef == "https://mail.google.com/mail/u/0/#inbox/abc123") + #expect(d.subject == "Re: Rozpočet Q3") + #expect(d.status == .draft) + #expect(d.created == "2026-06-29") + // Empty quoted value → nil. + #expect(d.contextAnswerRef == nil) + #expect(d.bodyMarkdown.hasPrefix("Ahoj Jane,")) + #expect(d.bodyMarkdown.contains("[TBD: doplnit finální částku]")) + #expect(d.showsSubject) + } + + @Test func noFrontmatterReturnsNil() { + // The drafts/README.md doc has no frontmatter and must be skipped. + let readme = "# Reply Drafts\n\nThis directory holds prepared replies.\n" + let url = URL(fileURLWithPath: "/x/drafts/README.md") + #expect(ReplyDraftsParser.parseFile(contents: readme, fileURL: url) == nil) + } + + @Test func chatChannelOmitsSubject() throws { + let text = """ + --- + tag: PINGAL + channel: slack + loop_type: direct-debt + to: "@alex" + thread_ref: "https://slack.com/archives/C1/p123" + status: draft + created: 2026-06-29 + --- + + Hey Alex, on it — will send the doc by EOD. + """ + let url = URL(fileURLWithPath: "/x/drafts/PINGAL.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: text, fileURL: url)) + #expect(d.channel == .slack) + #expect(d.subject == nil) + #expect(!d.showsSubject) + } + + @Test func promiseAnsweredCarriesContextRef() throws { + let text = """ + --- + tag: QBACK + channel: email + loop_type: promise-answered + to: "Petra " + thread_ref: "https://mail/thread/1" + subject: "Re: termín" + status: draft + created: 2026-06-29 + context_answer_ref: "https://slack.com/archives/C2/p999" + --- + + Ahoj Petro, ptal jsem se a termín je 15. července. + """ + let url = URL(fileURLWithPath: "/x/drafts/QBACK.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: text, fileURL: url)) + #expect(d.loopType == "promise-answered") + #expect(d.contextAnswerRef == "https://slack.com/archives/C2/p999") + } + + @Test func missingStatusDefaultsToDraft() throws { + let text = "---\ntag: T\nchannel: email\nto: x\nthread_ref: y\n---\n\nbody" + let url = URL(fileURLWithPath: "/x/drafts/T.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: text, fileURL: url)) + #expect(d.status == .draft) + } + + @Test func tagFallsBackToFilenameStem() throws { + let text = "---\nchannel: email\nstatus: draft\nto: x\nthread_ref: y\n---\n\nbody" + let url = URL(fileURLWithPath: "/x/drafts/FALLBACK.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: text, fileURL: url)) + #expect(d.tag == "FALLBACK") + } +} diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift new file mode 100644 index 0000000..d746682 --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift @@ -0,0 +1,153 @@ +import Testing +import Foundation +@testable import Scout + +private let writerFixture = """ +--- +tag: NAHSEND +channel: email +loop_type: direct-debt +to: "Jan Novák " +thread_ref: "https://mail.google.com/mail/u/0/#inbox/abc123" +subject: "Re: Rozpočet Q3" +status: draft +created: 2026-06-29 +context_answer_ref: "" +--- + +Ahoj Jane, + +posílám čísla. [TBD: částka] +""" + +@Suite("ReplyDraftsWriter.rewriteFrontmatterStatus (pure)") +struct ReplyDraftsWriterRewriteTests { + + @Test func replacesOnlyTheFrontmatterStatusValue() throws { + let out = try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: writerFixture, newStatusValue: "sent", file: "NAHSEND.md") + #expect(out.contains("status: sent")) + #expect(!out.contains("status: draft")) + // Other frontmatter fields untouched. + #expect(out.contains("tag: NAHSEND")) + #expect(out.contains("subject: \"Re: Rozpočet Q3\"")) + } + + @Test func leavesBodyByteIdentical() throws { + let out = try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: writerFixture, newStatusValue: "dismissed", file: "NAHSEND.md") + #expect(out.contains("Ahoj Jane,")) + #expect(out.contains("posílám čísla. [TBD: částka]")) + } + + @Test func reparsingTheRewriteReflectsTheNewStatus() throws { + let out = try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: writerFixture, newStatusValue: "sent", file: "NAHSEND.md") + let d = try #require(ReplyDraftsParser.parseFile( + contents: out, fileURL: URL(fileURLWithPath: "/x/NAHSEND.md"))) + #expect(d.status == .sent) + } + + @Test func noFrontmatterThrows() { + #expect(throws: ReplyDraftsWriterError.self) { + try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: "# Just a heading\n\nbody", newStatusValue: "sent", file: "p.md") + } + } + + @Test func frontmatterWithoutStatusFieldThrows() { + let text = "---\ntag: T\nchannel: email\n---\n\nbody" + #expect(throws: ReplyDraftsWriterError.self) { + try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: text, newStatusValue: "sent", file: "p.md") + } + } + + @Test func preservesIndentationOnStatusLine() throws { + let text = "---\n status: draft\n---\nbody" + let out = try ReplyDraftsWriter.rewriteFrontmatterStatus( + text: text, newStatusValue: "sent", file: "p.md") + #expect(out.contains(" status: sent")) + } +} + +@Suite("ReplyDraftsWriter end-to-end (file + git commit)") +struct ReplyDraftsWriterE2ETests { + + private func makeDraftsDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("drafts-test-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir.appendingPathComponent("drafts"), + withIntermediateDirectories: true) + return dir + } + + @Test func markSentWritesStatusAndCommitsScopedToFile() async throws { + let repo = try makeDraftsDir() + defer { try? FileManager.default.removeItem(at: repo) } + let fileURL = repo.appendingPathComponent("drafts/NAHSEND.md") + try writerFixture.write(to: fileURL, atomically: true, encoding: .utf8) + + // rev-parse(0) → add(0) → diff(1=dirty) → commit(0) + let runner = ScriptedRunner(scripted: [ + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 1, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ]) + let git = GitService(repoURL: repo, runner: runner) + let writer = ReplyDraftsWriter(scoutDirectory: repo, gitService: git) + + try await writer.apply(.markSent, fileURL: fileURL, label: "NAHSEND") + + let written = try String(contentsOf: fileURL, encoding: .utf8) + #expect(written.contains("status: sent")) + + let commit = try #require(runner.calls.last) + #expect(commit.arguments.contains("commit")) + #expect(commit.arguments.contains("app: mark-sent reply draft NAHSEND")) + #expect(commit.arguments.contains("drafts/NAHSEND.md")) + } + + @Test func dismissWritesDismissedStatus() async throws { + let repo = try makeDraftsDir() + defer { try? FileManager.default.removeItem(at: repo) } + let fileURL = repo.appendingPathComponent("drafts/NAHSEND.md") + try writerFixture.write(to: fileURL, atomically: true, encoding: .utf8) + + let runner = ScriptedRunner(scripted: [ + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 1, stdout: Data(), stderr: Data()), + ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()), + ]) + let git = GitService(repoURL: repo, runner: runner) + let writer = ReplyDraftsWriter(scoutDirectory: repo, gitService: git) + + try await writer.apply(.dismiss, fileURL: fileURL, label: "NAHSEND") + + let written = try String(contentsOf: fileURL, encoding: .utf8) + #expect(written.contains("status: dismissed")) + let d = ReplyDraftsParser.parseFile(contents: written, fileURL: fileURL) + #expect(d?.status == .dismissed) + } + + @Test func fileWithoutFrontmatterThrowsAndDoesNotCommit() async throws { + let repo = try makeDraftsDir() + defer { try? FileManager.default.removeItem(at: repo) } + let fileURL = repo.appendingPathComponent("drafts/README.md") + let original = "# Reply Drafts, no frontmatter\n" + try original.write(to: fileURL, atomically: true, encoding: .utf8) + + let runner = ScriptedRunner(scripted: []) + let git = GitService(repoURL: repo, runner: runner) + let writer = ReplyDraftsWriter(scoutDirectory: repo, gitService: git) + + await #expect(throws: ReplyDraftsWriterError.self) { + try await writer.apply(.markSent, fileURL: fileURL, label: "README") + } + #expect(runner.calls.isEmpty) + #expect(try String(contentsOf: fileURL, encoding: .utf8) == original) + } +} From d5e7e1ca6e9ca4d07f20be2cf76fb161a33bc712 Mon Sep 17 00:00:00 2001 From: yustme Date: Mon, 29 Jun 2026 23:56:56 +0200 Subject: [PATCH 02/11] ci: build and upload Scout.app artifact Adds an 'app' job that produces an unsigned Release Scout.app and uploads it as the Scout-app artifact, so the build (with reply-drafts changes) can be downloaded and run without a local Xcode. Mirrors the step on feat/knowledge-base. --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84fef96..b7dd756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,52 @@ jobs: TestResults.xcresult xcodebuild.log if-no-files-found: ignore + + app: + name: Build app artifact + runs-on: macos-15 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Select newest Xcode + run: | + newest="$(ls -d /Applications/Xcode_*.app 2>/dev/null \ + | grep -E '/Xcode_[0-9.]+\.app$' | sort -V | tail -1)" + if [ -n "$newest" ]; then + echo "Selecting $newest" + sudo xcode-select -s "$newest/Contents/Developer" + else + echo "No Xcode_.app found; using image default." + fi + + # Unsigned Release build. The app runs locally after the download's + # quarantine flag is cleared (see the PR notes for the xattr command). + - name: Build Scout.app + run: | + set -o pipefail + xcodebuild build \ + -project Scout.xcodeproj \ + -scheme Scout \ + -configuration Release \ + -destination 'platform=macOS' \ + -derivedDataPath build \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + | tee build-app.log + + # ditto preserves the bundle's symlinks/permissions; upload-artifact then + # wraps this zip (so the download unzips to Scout.zip → Scout.app). + - name: Package Scout.app + run: | + cd build/Build/Products/Release + ditto -c -k --sequesterRsrc --keepParent Scout.app "$GITHUB_WORKSPACE/Scout.zip" + + - name: Upload Scout.app + uses: actions/upload-artifact@v7 + with: + name: Scout-app + path: Scout.zip + if-no-files-found: error From 899f1f7192d0204dc60bb8694e517a1d023b8df8 Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 10:26:30 +0200 Subject: [PATCH 03/11] feat(reply-drafts): show cc on draft cards Adds the optional cc frontmatter field to ReplyDraft + parser and renders it under the recipient on the card, so an email reply keeps the thread's other recipients visible. Matches the scout-plugin drafts/.md contract. --- Scout/ReplyDrafts/Models/ReplyDraft.swift | 2 ++ Scout/ReplyDrafts/ReplyDraftsParser.swift | 2 ++ Scout/ReplyDrafts/Views/ReplyDraftCardView.swift | 6 ++++++ ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/Scout/ReplyDrafts/Models/ReplyDraft.swift b/Scout/ReplyDrafts/Models/ReplyDraft.swift index 9d1b4fd..13eca5d 100644 --- a/Scout/ReplyDrafts/Models/ReplyDraft.swift +++ b/Scout/ReplyDrafts/Models/ReplyDraft.swift @@ -24,6 +24,8 @@ nonisolated struct ReplyDraft: Identifiable, Equatable, Sendable { let loopType: String /// `to:` — recipient (name + address/handle when known). let to: String + /// `cc:` — other thread recipients to keep on the reply (email/PR); nil if none. + let cc: String? /// `thread_ref:` — link/permalink/thread id to the original conversation. let threadRef: String /// `subject:` — email subject or PR/issue title; nil for chat channels. diff --git a/Scout/ReplyDrafts/ReplyDraftsParser.swift b/Scout/ReplyDrafts/ReplyDraftsParser.swift index 9948ae4..62e8392 100644 --- a/Scout/ReplyDrafts/ReplyDraftsParser.swift +++ b/Scout/ReplyDrafts/ReplyDraftsParser.swift @@ -39,6 +39,7 @@ nonisolated enum ReplyDraftsParser { let channel = DraftChannel.parse(fields["channel"] ?? "") let loopType = fields["loop_type"] ?? "" let to = fields["to"] ?? "" + let cc = fields["cc"]?.nonEmpty let threadRef = fields["thread_ref"] ?? "" let subject = fields["subject"]?.nonEmpty let status = DraftStatus.parse(fields["status"] ?? "") @@ -52,6 +53,7 @@ nonisolated enum ReplyDraftsParser { channel: channel, loopType: loopType, to: to, + cc: cc, threadRef: threadRef, subject: subject, status: status, diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 867376f..37d5188 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -44,6 +44,12 @@ struct ReplyDraftCardView: View { .font(DS.serif(17, weight: .medium)) .foregroundStyle(DS.Ink.p1) .fixedSize(horizontal: false, vertical: true) + if let cc = draft.cc { + Text("Cc \(cc)") + .font(DS.sans(11.5)) + .foregroundStyle(DS.Ink.p4) + .fixedSize(horizontal: false, vertical: true) + } if draft.showsSubject, let subject = draft.subject { Text(subject) .font(DS.sans(12.5)) diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift index 3095736..066626e 100644 --- a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift +++ b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift @@ -8,6 +8,7 @@ tag: NAHSEND channel: email loop_type: direct-debt to: "Jan Novák " +cc: "Petra Malá " thread_ref: "https://mail.google.com/mail/u/0/#inbox/abc123" subject: "Re: Rozpočet Q3" status: draft @@ -33,6 +34,7 @@ struct ReplyDraftsParserTests { #expect(d.channel == .email) #expect(d.loopType == "direct-debt") #expect(d.to == "Jan Novák ") + #expect(d.cc == "Petra Malá ") #expect(d.threadRef == "https://mail.google.com/mail/u/0/#inbox/abc123") #expect(d.subject == "Re: Rozpočet Q3") #expect(d.status == .draft) @@ -70,6 +72,8 @@ struct ReplyDraftsParserTests { #expect(d.channel == .slack) #expect(d.subject == nil) #expect(!d.showsSubject) + // No cc: line → nil. + #expect(d.cc == nil) } @Test func promiseAnsweredCarriesContextRef() throws { From a1c2c60b32574329fd3d91f717f03b0953073880 Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:04:05 +0200 Subject: [PATCH 04/11] feat(reply-drafts): fill-in fields for [TBD: ...] markers Each [TBD: ...] in a draft body is now surfaced as a labeled input on the card. Typing a value and applying replaces the whole [TBD: ...] literal with the text in the body (via GuardedFileWrite + git commit) so the reply reads cleanly and re-renders. Adds DraftInput model + extraction, ReplyDraftsWriter.fill, the card inputs section, and tests. --- Scout/ReplyDrafts/Models/DraftInput.swift | 37 ++++++++++ Scout/ReplyDrafts/Models/ReplyDraft.swift | 3 + Scout/ReplyDrafts/ReplyDraftsWriter.swift | 36 +++++++++ Scout/ReplyDrafts/Views/RepliesView.swift | 26 +++++-- .../Views/ReplyDraftCardView.swift | 73 +++++++++++++++++++ ScoutTests/ReplyDrafts/DraftInputTests.swift | 44 +++++++++++ .../ReplyDrafts/ReplyDraftsWriterTests.swift | 26 +++++++ 7 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 Scout/ReplyDrafts/Models/DraftInput.swift create mode 100644 ScoutTests/ReplyDrafts/DraftInputTests.swift diff --git a/Scout/ReplyDrafts/Models/DraftInput.swift b/Scout/ReplyDrafts/Models/DraftInput.swift new file mode 100644 index 0000000..5cabb21 --- /dev/null +++ b/Scout/ReplyDrafts/Models/DraftInput.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A fill-in slot inside a draft body — one `[TBD: …]` marker that Scout left +/// for the user to resolve before sending. +/// +/// Scout writes unknowns into a draft body as `[TBD: ]`. The app +/// extracts each one into a labeled input field; when the user fills it, the +/// app substitutes the typed value for the whole `[TBD: …]` literal in the body +/// (see ``ReplyDraftsWriter/fill``) so the email reads cleanly. +nonisolated struct DraftInput: Identifiable, Equatable, Sendable { + /// The full literal to replace, e.g. `[TBD: confirm the meeting time]`. + let placeholder: String + /// The human prompt — the text after `TBD:`, used as the field label. + let prompt: String + + /// Stable identity. Placeholders are distinct by their full text; an index + /// suffix disambiguates two identical prompts in the same body. + let id: String + + /// Extract every `[TBD: …]` marker from a draft body, in order of + /// appearance. Identical markers each get their own entry (disambiguated id) + /// so two same-worded TBDs can be filled independently. + static func extract(from body: String) -> [DraftInput] { + guard let re = try? NSRegularExpression(pattern: #"\[TBD:\s*([^\]]*)\]"#) else { return [] } + let ns = body as NSString + let matches = re.matches(in: body, range: NSRange(location: 0, length: ns.length)) + var out: [DraftInput] = [] + for (i, m) in matches.enumerated() { + let full = ns.substring(with: m.range) + let prompt = m.numberOfRanges > 1 + ? ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) + : full + out.append(DraftInput(placeholder: full, prompt: prompt, id: "\(i):\(full)")) + } + return out + } +} diff --git a/Scout/ReplyDrafts/Models/ReplyDraft.swift b/Scout/ReplyDrafts/Models/ReplyDraft.swift index 13eca5d..9d5534a 100644 --- a/Scout/ReplyDrafts/Models/ReplyDraft.swift +++ b/Scout/ReplyDrafts/Models/ReplyDraft.swift @@ -44,6 +44,9 @@ nonisolated struct ReplyDraft: Identifiable, Equatable, Sendable { var isAwaitingAction: Bool { status.isAwaitingAction } + /// Fill-in slots (`[TBD: …]` markers) the user still needs to resolve. + var inputs: [DraftInput] { DraftInput.extract(from: bodyMarkdown) } + /// Header chip — the tag reads like a code label. var code: String { tag } diff --git a/Scout/ReplyDrafts/ReplyDraftsWriter.swift b/Scout/ReplyDrafts/ReplyDraftsWriter.swift index defd049..8e5df13 100644 --- a/Scout/ReplyDrafts/ReplyDraftsWriter.swift +++ b/Scout/ReplyDrafts/ReplyDraftsWriter.swift @@ -82,6 +82,42 @@ actor ReplyDraftsWriter { return try await task.value } + /// Fill one `[TBD: …]` placeholder in the draft body with the user's value, + /// writing it into the file so the reply reads cleanly. Status is untouched + /// (the draft stays a draft). `label` is used for the git commit message. + func fill(placeholder: String, value: String, fileURL: URL, label: String) async throws { + let previous = tail + let task = Task { [scoutDirectory, gitService] in + _ = await previous?.value + let didWrite: Bool + do { + didWrite = try GuardedFileWrite.apply(to: fileURL) { text in + Self.fillPlaceholder(text: text, placeholder: placeholder, value: value) + } + } catch let e as GuardedFileWrite.Failure { + switch e { + case .read(let m): throw ReplyDraftsWriterError.readFailed(m) + case .write(let m): throw ReplyDraftsWriterError.writeFailed(m) + case .conflictPersisted: + throw ReplyDraftsWriterError.writeFailed("\(fileURL.lastPathComponent) changed repeatedly under concurrent writes") + } + } + guard didWrite else { return } + let rel = Self.relativePathInRepo(fileURL: fileURL, repo: scoutDirectory) + try? await gitService?.commitPaths([rel], message: "app: fill input in reply draft \(label)") + } + tail = Task { _ = try? await task.value } + return try await task.value + } + + /// Replace the FIRST occurrence of `placeholder` with `value`. Returns the + /// text unchanged when the placeholder isn't present (idempotent no-op — + /// e.g. a concurrent fill already resolved it). + static func fillPlaceholder(text: String, placeholder: String, value: String) -> String { + guard let range = text.range(of: placeholder) else { return text } + return text.replacingCharacters(in: range, with: value) + } + private static func perform( action: DraftAction, fileURL: URL, diff --git a/Scout/ReplyDrafts/Views/RepliesView.swift b/Scout/ReplyDrafts/Views/RepliesView.swift index ce1372c..cda894e 100644 --- a/Scout/ReplyDrafts/Views/RepliesView.swift +++ b/Scout/ReplyDrafts/Views/RepliesView.swift @@ -112,9 +112,11 @@ struct RepliesView: View { ) } ForEach(awaiting) { draft in - ReplyDraftCardView(draft: draft) { action in - try await apply(draft, action) - } + ReplyDraftCardView( + draft: draft, + onAction: { action in try await apply(draft, action) }, + onFill: { placeholder, value in try await fill(draft, placeholder, value) } + ) } if !resolved.isEmpty { resolvedSection(resolved) @@ -144,9 +146,11 @@ struct RepliesView: View { if resolvedExpanded { ForEach(resolved) { draft in - ReplyDraftCardView(draft: draft) { action in - try await apply(draft, action) - } + ReplyDraftCardView( + draft: draft, + onAction: { action in try await apply(draft, action) }, + onFill: { placeholder, value in try await fill(draft, placeholder, value) } + ) } } } @@ -178,4 +182,14 @@ struct RepliesView: View { ) docService.reload() } + + private func fill(_ draft: ReplyDraft, _ placeholder: String, _ value: String) async throws { + try await writerBox.writer.fill( + placeholder: placeholder, + value: value, + fileURL: draft.fileURL, + label: draft.tag + ) + docService.reload() + } } diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 37d5188..1428934 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -10,16 +10,23 @@ struct ReplyDraftCardView: View { let draft: ReplyDraft /// Performs a status write. Throws so the card can show an inline error. let onAction: @MainActor (DraftAction) async throws -> Void + /// Fills a `[TBD: …]` placeholder with a value, writing it into the body. + let onFill: @MainActor (_ placeholder: String, _ value: String) async throws -> Void @State private var inFlight: DraftAction? @State private var errorText: String? @State private var copied = false + @State private var inputValues: [String: String] = [:] + @State private var fillingID: String? var body: some View { VStack(alignment: .leading, spacing: 12) { header metaLine bodyPanel + if draft.isAwaitingAction && !draft.inputs.isEmpty { + inputsSection + } actions if let errorText { Label(errorText, systemImage: "exclamationmark.triangle.fill") @@ -108,6 +115,72 @@ struct ReplyDraftCardView: View { } } + // MARK: - Fill-in inputs + + private var inputsSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Doplň před odesláním (\(draft.inputs.count))") + .font(DS.sans(11, weight: .semibold)) + .tracking(0.06 * 11) + .foregroundStyle(DS.Ink.p3) + ForEach(draft.inputs) { input in + VStack(alignment: .leading, spacing: 4) { + Text(input.prompt) + .font(DS.sans(12)) + .foregroundStyle(DS.Ink.p2) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + TextField("Tvoje doplnění…", text: binding(for: input.id), axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(DS.sans(12.5)) + .lineLimit(1...4) + .onSubmit { applyFill(input) } + Button { applyFill(input) } label: { + if fillingID == input.id { + ProgressView().controlSize(.small).frame(width: 12, height: 12) + } else { + Text("Doplnit").font(DS.sans(11.5, weight: .medium)) + } + } + .buttonStyle(.plainHit) + .disabled(trimmed(input.id).isEmpty || fillingID != nil) + } + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 7) + .fill(DS.Accent.wash) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(DS.Accent.ink.opacity(0.25), lineWidth: 0.5)) + } + } + + private func binding(for id: String) -> Binding { + Binding(get: { inputValues[id] ?? "" }, set: { inputValues[id] = $0 }) + } + + private func trimmed(_ id: String) -> String { + (inputValues[id] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func applyFill(_ input: DraftInput) { + let value = trimmed(input.id) + guard !value.isEmpty, fillingID == nil else { return } + fillingID = input.id + errorText = nil + Task { + do { + try await onFill(input.placeholder, value) + inputValues[input.id] = nil + } catch { + errorText = "Couldn't fill in — \(error.localizedDescription)" + } + fillingID = nil + } + } + // MARK: - Actions private var actions: some View { diff --git a/ScoutTests/ReplyDrafts/DraftInputTests.swift b/ScoutTests/ReplyDrafts/DraftInputTests.swift new file mode 100644 index 0000000..2ef1735 --- /dev/null +++ b/ScoutTests/ReplyDrafts/DraftInputTests.swift @@ -0,0 +1,44 @@ +import Testing +import Foundation +@testable import Scout + +@Suite("DraftInput.extract") +struct DraftInputTests { + + @Test func extractsEachTBDInOrderWithTrimmedPrompt() { + let body = """ + Ahoj, + + potvrdím termín [TBD: ověřit v kalendáři a potvrdit konkrétní čas — dopoledne / odpoledne.] a cenu [TBD: doplnit částku ]. + + Díky + """ + let inputs = DraftInput.extract(from: body) + #expect(inputs.count == 2) + #expect(inputs[0].prompt == "ověřit v kalendáři a potvrdit konkrétní čas — dopoledne / odpoledne.") + #expect(inputs[0].placeholder == "[TBD: ověřit v kalendáři a potvrdit konkrétní čas — dopoledne / odpoledne.]") + #expect(inputs[1].prompt == "doplnit částku") + } + + @Test func noTBDsYieldsEmpty() { + #expect(DraftInput.extract(from: "Plain reply, nothing to fill.").isEmpty) + } + + @Test func identicalMarkersGetDistinctIDs() { + let body = "[TBD: confirm address] ... [TBD: confirm address]" + let inputs = DraftInput.extract(from: body) + #expect(inputs.count == 2) + #expect(inputs[0].id != inputs[1].id) + } + + @Test func draftExposesInputsFromBody() { + let d = ReplyDraft( + fileURL: URL(fileURLWithPath: "/x/T.md"), tag: "T", channel: .email, + loopType: "direct-debt", to: "a@b.cz", cc: nil, threadRef: "u", + subject: "s", status: .draft, created: "2026-06-30", contextAnswerRef: nil, + bodyMarkdown: "Hi [TBD: pick a date] — thanks" + ) + #expect(d.inputs.count == 1) + #expect(d.inputs[0].prompt == "pick a date") + } +} diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift index d746682..f9b4f90 100644 --- a/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift +++ b/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift @@ -71,6 +71,32 @@ struct ReplyDraftsWriterRewriteTests { } } +@Suite("ReplyDraftsWriter.fillPlaceholder (pure)") +struct ReplyDraftsFillTests { + + @Test func replacesFirstOccurrenceWithValue() { + let text = "Potvrdím termín [TBD: ověřit čas] a ozvu se." + let out = ReplyDraftsWriter.fillPlaceholder( + text: text, placeholder: "[TBD: ověřit čas]", value: "ve čtvrtek 14:00") + #expect(out == "Potvrdím termín ve čtvrtek 14:00 a ozvu se.") + #expect(!out.contains("[TBD")) + } + + @Test func missingPlaceholderReturnsUnchanged() { + let text = "Nothing to fill here." + let out = ReplyDraftsWriter.fillPlaceholder( + text: text, placeholder: "[TBD: x]", value: "y") + #expect(out == text) + } + + @Test func onlyFirstOccurrenceReplaced() { + let text = "[TBD: a] then [TBD: a]" + let out = ReplyDraftsWriter.fillPlaceholder( + text: text, placeholder: "[TBD: a]", value: "X") + #expect(out == "X then [TBD: a]") + } +} + @Suite("ReplyDraftsWriter end-to-end (file + git commit)") struct ReplyDraftsWriterE2ETests { From 541c2836214deb45d37bae2ee716cef82329975e Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:10:56 +0200 Subject: [PATCH 05/11] fix(reply-drafts): English UI labels on fill-in fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI chrome must be English: 'Doplň před odesláním' -> 'Fill in before sending', placeholder 'Tvoje doplnění…' -> 'Your input…', button 'Doplnit' -> 'Fill in'. Field labels still come from the draft's [TBD: ...] text (the email's language). --- Scout/ReplyDrafts/Views/ReplyDraftCardView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 1428934..7c3fcc3 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -119,7 +119,7 @@ struct ReplyDraftCardView: View { private var inputsSection: some View { VStack(alignment: .leading, spacing: 10) { - Text("Doplň před odesláním (\(draft.inputs.count))") + Text("Fill in before sending (\(draft.inputs.count))") .font(DS.sans(11, weight: .semibold)) .tracking(0.06 * 11) .foregroundStyle(DS.Ink.p3) @@ -130,7 +130,7 @@ struct ReplyDraftCardView: View { .foregroundStyle(DS.Ink.p2) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { - TextField("Tvoje doplnění…", text: binding(for: input.id), axis: .vertical) + TextField("Your input…", text: binding(for: input.id), axis: .vertical) .textFieldStyle(.roundedBorder) .font(DS.sans(12.5)) .lineLimit(1...4) @@ -139,7 +139,7 @@ struct ReplyDraftCardView: View { if fillingID == input.id { ProgressView().controlSize(.small).frame(width: 12, height: 12) } else { - Text("Doplnit").font(DS.sans(11.5, weight: .medium)) + Text("Fill in").font(DS.sans(11.5, weight: .medium)) } } .buttonStyle(.plainHit) From 89b285e50093590bc18054c5b6494f62e6b5cab9 Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:40:50 +0200 Subject: [PATCH 06/11] feat(reply-drafts): collapsible Summary + Thread under each draft Parses a block (## Summary + ## Thread '- [date] Sender: line' messages) out of the draft file, keeping bodyMarkdown to the sendable reply only. Renders two collapsible DisclosureGroups under the draft: an AI summary of the topic and the related thread messages, so the user can see what they're replying to. Adds DraftMessage model, context parsing, and tests. --- Scout/ReplyDrafts/Models/DraftMessage.swift | 17 +++++ Scout/ReplyDrafts/Models/ReplyDraft.swift | 9 ++- Scout/ReplyDrafts/ReplyDraftsParser.swift | 58 +++++++++++++- .../Views/ReplyDraftCardView.swift | 76 +++++++++++++++++++ .../ReplyDrafts/ReplyDraftsParserTests.swift | 46 +++++++++++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 Scout/ReplyDrafts/Models/DraftMessage.swift diff --git a/Scout/ReplyDrafts/Models/DraftMessage.swift b/Scout/ReplyDrafts/Models/DraftMessage.swift new file mode 100644 index 0000000..6c5e185 --- /dev/null +++ b/Scout/ReplyDrafts/Models/DraftMessage.swift @@ -0,0 +1,17 @@ +import Foundation + +/// One message in a draft's thread-context block — the prior messages on the +/// topic Scout used to ground the reply. Rendered in the collapsible "Thread" +/// section under the draft so the user can see what the conversation is about. +/// +/// Parsed from a context line of the form `- [YYYY-MM-DD] Sender: text`. +nonisolated struct DraftMessage: Identifiable, Equatable, Sendable { + /// `YYYY-MM-DD` (or whatever date string Scout wrote); may be empty. + let date: String + /// Who sent it (e.g. "Lucia Hallonová" or "Vojta (you)"). + let sender: String + /// One-line paraphrase or quote of the message. + let text: String + + let id: String +} diff --git a/Scout/ReplyDrafts/Models/ReplyDraft.swift b/Scout/ReplyDrafts/Models/ReplyDraft.swift index 9d5534a..da60e9e 100644 --- a/Scout/ReplyDrafts/Models/ReplyDraft.swift +++ b/Scout/ReplyDrafts/Models/ReplyDraft.swift @@ -37,8 +37,15 @@ nonisolated struct ReplyDraft: Identifiable, Equatable, Sendable { /// `context_answer_ref:` — for `promise-answered`, the answer that unblocked /// the reply; nil otherwise. let contextAnswerRef: String? - /// The drafted reply body — everything after the frontmatter, trimmed. + /// The drafted reply body — the **sendable** message only (everything after + /// the frontmatter and before the `` marker). let bodyMarkdown: String + /// AI-generated one-paragraph summary of what the thread/topic is about, + /// from the context block. nil when the draft carries no context block. + let summary: String? + /// Prior messages on the topic, from the context block — shown in the + /// collapsible "Thread" section. Empty when none. + let relatedMessages: [DraftMessage] var id: String { fileURL.path } diff --git a/Scout/ReplyDrafts/ReplyDraftsParser.swift b/Scout/ReplyDrafts/ReplyDraftsParser.swift index 62e8392..3a5bb71 100644 --- a/Scout/ReplyDrafts/ReplyDraftsParser.swift +++ b/Scout/ReplyDrafts/ReplyDraftsParser.swift @@ -45,7 +45,11 @@ nonisolated enum ReplyDraftsParser { let status = DraftStatus.parse(fields["status"] ?? "") let created = fields["date"]?.nonEmpty ?? fields["created"]?.nonEmpty ?? datePrefix(of: stem) ?? "" let contextAnswerRef = fields["context_answer_ref"]?.nonEmpty - let cleanBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + + // Split the post-frontmatter text into the sendable reply (before the + // marker) and the context block (after it). The marker keeps the + // summary + thread out of what Copy/Mark-sent treat as the email. + let (sendable, context) = splitContext(body) return ReplyDraft( fileURL: fileURL, @@ -59,10 +63,60 @@ nonisolated enum ReplyDraftsParser { status: status, created: created, contextAnswerRef: contextAnswerRef, - bodyMarkdown: cleanBody + bodyMarkdown: sendable.trimmingCharacters(in: .whitespacesAndNewlines), + summary: context.flatMap { parseSummary($0)?.nonEmpty }, + relatedMessages: context.map { parseMessages($0) } ?? [] ) } + // MARK: - Context block (summary + thread) + + /// Marker separating the sendable reply from the thread-context block. + static let contextMarker = "" + + /// Split post-frontmatter text at ``contextMarker``. Returns (sendable body, + /// context block or nil if there is no marker). + static func splitContext(_ body: String) -> (sendable: String, context: String?) { + guard let r = body.range(of: contextMarker) else { return (body, nil) } + return (String(body[.. String? { + sectionBody(context, heading: "## Summary") + } + + /// Parse `- [YYYY-MM-DD] Sender: text` lines under a `## Thread` heading. + static func parseMessages(_ context: String) -> [DraftMessage] { + guard let section = sectionBody(context, heading: "## Thread") else { return [] } + guard let re = try? NSRegularExpression(pattern: #"^\s*-\s*\[([^\]]*)\]\s*([^:]+):\s*(.*)$"#) else { return [] } + var out: [DraftMessage] = [] + for (i, line) in section.components(separatedBy: "\n").enumerated() { + let ns = line as NSString + guard let m = re.firstMatch(in: line, range: NSRange(location: 0, length: ns.length)) else { continue } + out.append(DraftMessage( + date: ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces), + sender: ns.substring(with: m.range(at: 2)).trimmingCharacters(in: .whitespaces), + text: ns.substring(with: m.range(at: 3)).trimmingCharacters(in: .whitespaces), + id: "\(i)" + )) + } + return out + } + + /// Body of a `## ` section, up to the next `## ` heading or end. + private static func sectionBody(_ text: String, heading: String) -> String? { + let lines = text.components(separatedBy: "\n") + guard let start = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == heading }) else { return nil } + var collected: [String] = [] + for line in lines[(start + 1)...] { + if line.hasPrefix("## ") { break } + collected.append(line) + } + let body = collected.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return body.isEmpty ? nil : body + } + // MARK: - Frontmatter /// Split `---\n\n---\n`. Returns `nil` when the text does diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 7c3fcc3..17c184b 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -18,12 +18,15 @@ struct ReplyDraftCardView: View { @State private var copied = false @State private var inputValues: [String: String] = [:] @State private var fillingID: String? + @State private var summaryExpanded = false + @State private var threadExpanded = false var body: some View { VStack(alignment: .leading, spacing: 12) { header metaLine bodyPanel + contextSection if draft.isAwaitingAction && !draft.inputs.isEmpty { inputsSection } @@ -115,6 +118,79 @@ struct ReplyDraftCardView: View { } } + // MARK: - Context (summary + thread) + + @ViewBuilder + private var contextSection: some View { + if draft.summary != nil || !draft.relatedMessages.isEmpty { + VStack(alignment: .leading, spacing: 6) { + if let summary = draft.summary { + DisclosureGroup(isExpanded: $summaryExpanded) { + Text(summary) + .font(DS.serif(13)) + .foregroundStyle(DS.Ink.p2) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } label: { + disclosureLabel("Summary", systemImage: "sparkles") + } + } + if !draft.relatedMessages.isEmpty { + DisclosureGroup(isExpanded: $threadExpanded) { + VStack(alignment: .leading, spacing: 10) { + ForEach(draft.relatedMessages) { messageRow($0) } + } + .padding(.top, 8) + } label: { + disclosureLabel("Thread (\(draft.relatedMessages.count))", systemImage: "text.bubble") + } + } + } + .tint(DS.Ink.p3) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 7) + .fill(DS.Paper.sunk) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + } + } + } + + private func disclosureLabel(_ title: String, systemImage: String) -> some View { + HStack(spacing: 6) { + Image(systemName: systemImage).font(.system(size: 11)) + Text(title) + .font(DS.sans(11.5, weight: .semibold)) + .tracking(0.04 * 11.5) + } + .foregroundStyle(DS.Ink.p3) + .contentShape(Rectangle()) + } + + private func messageRow(_ msg: DraftMessage) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(msg.sender) + .font(DS.sans(11.5, weight: .semibold)) + .foregroundStyle(DS.Ink.p2) + if !msg.date.isEmpty { + Text(msg.date) + .font(DS.mono(10.5)) + .foregroundStyle(DS.Ink.p4) + } + } + Text(msg.text) + .font(DS.serif(12.5)) + .foregroundStyle(DS.Ink.p2) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Fill-in inputs private var inputsSection: some View { diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift index 066626e..c8b75c1 100644 --- a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift +++ b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift @@ -46,6 +46,52 @@ struct ReplyDraftsParserTests { #expect(d.showsSubject) } + @Test func contextBlockIsSplitFromSendableBody() throws { + let text = """ + --- + tag: CTX1 + channel: email + to: "a@b.cz" + thread_ref: "u" + status: draft + created: 2026-06-30 + --- + + Ahoj, posílám odpověď. + + + ## Summary + + Lucia se ptá na možnosti granulárních rolí; míč je na tobě. + + ## Thread + + - [2026-05-26] Lucia Hallonová: shrnula tři varianty řešení + - [2026-05-22] Vojta (you): feature není na roadmapě + """ + let d = try #require(ReplyDraftsParser.parseFile( + contents: text, fileURL: URL(fileURLWithPath: "/x/CTX1.md"))) + // Sendable body excludes the context block (Copy stays clean). + #expect(d.bodyMarkdown == "Ahoj, posílám odpověď.") + #expect(!d.bodyMarkdown.contains("scout:context")) + #expect(!d.bodyMarkdown.contains("Summary")) + // Summary parsed. + #expect(d.summary == "Lucia se ptá na možnosti granulárních rolí; míč je na tobě.") + // Messages parsed. + #expect(d.relatedMessages.count == 2) + #expect(d.relatedMessages[0].date == "2026-05-26") + #expect(d.relatedMessages[0].sender == "Lucia Hallonová") + #expect(d.relatedMessages[0].text == "shrnula tři varianty řešení") + #expect(d.relatedMessages[1].sender == "Vojta (you)") + } + + @Test func draftWithoutContextHasNoSummaryOrMessages() throws { + let url = URL(fileURLWithPath: "/x/drafts/NAHSEND.md") + let d = try #require(ReplyDraftsParser.parseFile(contents: fullFixture, fileURL: url)) + #expect(d.summary == nil) + #expect(d.relatedMessages.isEmpty) + } + @Test func noFrontmatterReturnsNil() { // The drafts/README.md doc has no frontmatter and must be skipped. let readme = "# Reply Drafts\n\nThis directory holds prepared replies.\n" From c9e4448deeb21e267daa5e0d7d87332969d9ae9a Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:46:14 +0200 Subject: [PATCH 07/11] fix(reply-drafts): update DraftInput test for new ReplyDraft fields ReplyDraft gained summary + relatedMessages; the test's direct initializer call was missing them, failing the test-target build. Pass summary: nil, relatedMessages: []. --- ScoutTests/ReplyDrafts/DraftInputTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ScoutTests/ReplyDrafts/DraftInputTests.swift b/ScoutTests/ReplyDrafts/DraftInputTests.swift index 2ef1735..ae44015 100644 --- a/ScoutTests/ReplyDrafts/DraftInputTests.swift +++ b/ScoutTests/ReplyDrafts/DraftInputTests.swift @@ -36,7 +36,7 @@ struct DraftInputTests { fileURL: URL(fileURLWithPath: "/x/T.md"), tag: "T", channel: .email, loopType: "direct-debt", to: "a@b.cz", cc: nil, threadRef: "u", subject: "s", status: .draft, created: "2026-06-30", contextAnswerRef: nil, - bodyMarkdown: "Hi [TBD: pick a date] — thanks" + bodyMarkdown: "Hi [TBD: pick a date] — thanks", summary: nil, relatedMessages: [] ) #expect(d.inputs.count == 1) #expect(d.inputs[0].prompt == "pick a date") From 6371f801d9781ce111fdd10561f6050eba2946d3 Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:57:29 +0200 Subject: [PATCH 08/11] feat(reply-drafts): per-draft AI assistant chat Adds an 'Ask AI about this topic' chat under each draft that shells out to the user's claude CLI (their own license), grounded in the draft's summary, thread, and current reply. Multi-turn, in-memory per draft tag; never sends or mutates the draft file. Adds ChatMessage model, ReplyChatService, AppState wiring + claude path resolver, the card chat section, and a buildPrompt test. --- Scout/ReplyDrafts/Models/ChatMessage.swift | 9 ++ Scout/ReplyDrafts/ReplyChatService.swift | 119 ++++++++++++++++++ .../Views/ReplyDraftCardView.swift | 72 +++++++++++ Scout/Shell/AppState.swift | 33 +++++ Scout/Shell/MainWindowView.swift | 1 + .../ReplyDrafts/ReplyChatServiceTests.swift | 44 +++++++ 6 files changed, 278 insertions(+) create mode 100644 Scout/ReplyDrafts/Models/ChatMessage.swift create mode 100644 Scout/ReplyDrafts/ReplyChatService.swift create mode 100644 ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift diff --git a/Scout/ReplyDrafts/Models/ChatMessage.swift b/Scout/ReplyDrafts/Models/ChatMessage.swift new file mode 100644 index 0000000..24b1b53 --- /dev/null +++ b/Scout/ReplyDrafts/Models/ChatMessage.swift @@ -0,0 +1,9 @@ +import Foundation + +/// One turn in the per-draft AI assistant chat. +nonisolated struct ChatMessage: Identifiable, Equatable, Sendable { + enum Role: Equatable, Sendable { case user, assistant, error } + let role: Role + let text: String + let id: String +} diff --git a/Scout/ReplyDrafts/ReplyChatService.swift b/Scout/ReplyDrafts/ReplyChatService.swift new file mode 100644 index 0000000..9abfbaa --- /dev/null +++ b/Scout/ReplyDrafts/ReplyChatService.swift @@ -0,0 +1,119 @@ +import Foundation +import SwiftUI + +/// Per-draft AI assistant chat. Each draft (keyed by its tag) has its own thread +/// of messages. Sending a message shells out to the user's `claude` CLI — the +/// same binary the scheduled runner uses, so it runs on the user's own Claude +/// license — passing the draft's context (summary, thread, current reply) plus +/// the conversation so far, and appends the model's reply. +/// +/// This never sends the email or mutates the draft file — it is a thinking aid. +/// Acting on the draft (fill/edit/mark) goes through `ReplyDraftsWriter`. +@MainActor +final class ReplyChatService: ObservableObject { + /// Conversation per draft tag. + @Published private(set) var threads: [String: [ChatMessage]] = [:] + /// The draft tag currently awaiting a model reply (drives the spinner). + @Published private(set) var busyTag: String? + + private let runner: any ProcessRunner + private let claude: URL + private let claudeArgsPrefix: [String] + private let workingDirectory: URL + + init(runner: any ProcessRunner, claude: URL, claudeArgsPrefix: [String], workingDirectory: URL) { + self.runner = runner + self.claude = claude + self.claudeArgsPrefix = claudeArgsPrefix + self.workingDirectory = workingDirectory + } + + func messages(for tag: String) -> [ChatMessage] { threads[tag] ?? [] } + + func isBusy(_ tag: String) -> Bool { busyTag == tag } + + /// Send a user message about `draft` and append the assistant's reply. + func send(text: String, about draft: ReplyDraft) async { + let tag = draft.tag + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, busyTag == nil else { return } + + var thread = threads[tag] ?? [] + thread.append(ChatMessage(role: .user, text: trimmed, id: UUID().uuidString)) + threads[tag] = thread + busyTag = tag + + let prompt = Self.buildPrompt(draft: draft, history: thread) + let args = claudeArgsPrefix + ["-p", prompt, "--model", "sonnet"] + + let reply: ChatMessage + do { + let result = try await runner.run( + executable: claude, + arguments: args, + environment: [:], + workingDirectory: workingDirectory + ) + if result.exitCode == 0 { + let out = String(data: result.stdout, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + reply = ChatMessage( + role: out.isEmpty ? .error : .assistant, + text: out.isEmpty ? "No response from claude." : out, + id: UUID().uuidString + ) + } else { + let err = String(data: result.stderr, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + reply = ChatMessage( + role: .error, + text: "claude exited \(result.exitCode)" + (err.isEmpty ? "" : ": \(err)"), + id: UUID().uuidString + ) + } + } catch { + reply = ChatMessage(role: .error, text: "Couldn't run claude — \(error.localizedDescription)", id: UUID().uuidString) + } + + var updated = threads[tag] ?? [] + updated.append(reply) + threads[tag] = updated + busyTag = nil + } + + /// Build the single-shot prompt: a context preamble grounded in the draft + + /// the conversation so far + the latest user turn. + nonisolated static func buildPrompt(draft: ReplyDraft, history: [ChatMessage]) -> String { + let threadLines = draft.relatedMessages + .map { "- [\($0.date)] \($0.sender): \($0.text)" } + .joined(separator: "\n") + let context = """ + You are my assistant for ONE specific reply I owe. Help me think it through and draft wording. \ + Be concise and concrete. Do NOT send anything — you are a thinking aid; I send the email myself. + + REPLY CONTEXT + Channel: \(draft.channel.displayName) + To: \(draft.to)\(draft.cc.map { "\nCc: \($0)" } ?? "") + Subject: \(draft.subject ?? "—") + + What this thread is about: + \(draft.summary ?? "(no summary captured)") + + Thread so far: + \(threadLines.isEmpty ? "(no thread captured)" : threadLines) + + My current prepared reply: + \(draft.bodyMarkdown.isEmpty ? "(empty)" : draft.bodyMarkdown) + """ + + let convo = history.map { msg -> String in + switch msg.role { + case .user: return "Me: \(msg.text)" + case .assistant: return "Assistant: \(msg.text)" + case .error: return "" + } + }.filter { !$0.isEmpty }.joined(separator: "\n\n") + + return context + "\n\n--- Conversation ---\n\n" + convo + "\n\nAssistant:" + } +} diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 17c184b..4b238be 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -7,6 +7,7 @@ import AppKit /// pasteboard, Open thread opens the original conversation, and Mark sent / /// Dismiss only flip the file's `status:`. struct ReplyDraftCardView: View { + @EnvironmentObject var chat: ReplyChatService let draft: ReplyDraft /// Performs a status write. Throws so the card can show an inline error. let onAction: @MainActor (DraftAction) async throws -> Void @@ -20,6 +21,8 @@ struct ReplyDraftCardView: View { @State private var fillingID: String? @State private var summaryExpanded = false @State private var threadExpanded = false + @State private var chatExpanded = false + @State private var chatInput = "" var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -30,6 +33,7 @@ struct ReplyDraftCardView: View { if draft.isAwaitingAction && !draft.inputs.isEmpty { inputsSection } + chatSection actions if let errorText { Label(errorText, systemImage: "exclamationmark.triangle.fill") @@ -257,6 +261,74 @@ struct ReplyDraftCardView: View { } } + // MARK: - AI assistant chat + + private var chatSection: some View { + DisclosureGroup(isExpanded: $chatExpanded) { + VStack(alignment: .leading, spacing: 8) { + ForEach(chat.messages(for: draft.tag)) { chatBubble($0) } + HStack(spacing: 6) { + TextField("Ask about this topic…", text: $chatInput, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(DS.sans(12.5)) + .lineLimit(1...5) + .onSubmit { sendChat() } + Button { sendChat() } label: { + if chat.isBusy(draft.tag) { + ProgressView().controlSize(.small).frame(width: 12, height: 12) + } else { + Image(systemName: "paperplane.fill").font(.system(size: 11)) + } + } + .buttonStyle(.plainHit) + .disabled(chatInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || chat.isBusy(draft.tag)) + } + .padding(.top, 4) + Text("Runs on your Claude license · won't send anything") + .font(DS.sans(10)) + .foregroundStyle(DS.Ink.p4) + } + .padding(.top, 8) + } label: { + disclosureLabel("Ask AI about this topic", systemImage: "bubble.left.and.bubble.right") + } + .tint(DS.Ink.p3) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 7) + .fill(DS.Paper.raised) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(DS.Rule.soft, lineWidth: 0.5)) + } + } + + @ViewBuilder + private func chatBubble(_ msg: ChatMessage) -> some View { + let isUser = msg.role == .user + HStack { + if isUser { Spacer(minLength: 24) } + Text(msg.text) + .font(DS.serif(12.5)) + .foregroundStyle(msg.role == .error ? DS.Status.err : DS.Ink.p1) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isUser ? DS.Accent.wash : DS.Paper.sunk) + } + if !isUser { Spacer(minLength: 24) } + } + } + + private func sendChat() { + let text = chatInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !chat.isBusy(draft.tag) else { return } + chatInput = "" + Task { await chat.send(text: text, about: draft) } + } + // MARK: - Actions private var actions: some View { diff --git a/Scout/Shell/AppState.swift b/Scout/Shell/AppState.swift index 2737cc6..27901d1 100644 --- a/Scout/Shell/AppState.swift +++ b/Scout/Shell/AppState.swift @@ -50,6 +50,8 @@ final class AppState: ObservableObject { // Reply Drafts (drafts/ review — prepared replies the user owes) let replyDraftsDocumentService: ReplyDraftsDocumentService let replyDraftsWriterBox: ReplyDraftsWriterBox + /// Per-draft AI assistant chat (shells out to the user's `claude` CLI). + let replyChatService: ReplyChatService // Per-file Wishlist + Research tabs let wishlistDocumentService: PerFileDocumentService @@ -175,6 +177,15 @@ final class AppState: ObservableObject { let replyDraftsWriter = ReplyDraftsWriter(scoutDirectory: scoutDir, gitService: git) let replyDraftsWriterBox = ReplyDraftsWriterBox(writer: replyDraftsWriter) + // Per-draft AI chat shells out to the user's claude CLI (their license). + let claudeResolved = AppState.resolveClaudePath() + let replyChat = ReplyChatService( + runner: runner, + claude: claudeResolved.executable, + claudeArgsPrefix: claudeResolved.argsPrefix, + workingDirectory: scoutDir + ) + // Per-file Wishlist + Research: resolve directory (override key or default // relative path under scoutDir), matching the dreamingProposalsPath pattern. func perFileDir(_ config: PerFileTabConfig) -> URL { @@ -209,6 +220,7 @@ final class AppState: ObservableObject { self.proposalsWriterBox = proposalsWriterBox self.replyDraftsDocumentService = replyDraftsDoc self.replyDraftsWriterBox = replyDraftsWriterBox + self.replyChatService = replyChat self.wishlistDocumentService = wishlistDoc self.researchDocumentService = researchDoc self.perFileWriterBox = perFileWriterBox @@ -365,6 +377,27 @@ final class AppState: ObservableObject { ) } + /// Locate the `claude` CLI the same way we locate scoutctl — the per-draft + /// chat shells out to it so it runs on the user's own Claude license. Tries + /// known install paths; falls back to `/usr/bin/env claude` if none exist. + static func resolveClaudePath() -> ScoutctlInvocation { + let home = FileManager.default.homeDirectoryForCurrentUser + let candidates: [URL] = [ + home.appendingPathComponent(".local/bin/claude"), + home.appendingPathComponent(".claude/local/claude"), + URL(fileURLWithPath: "/opt/homebrew/bin/claude"), + URL(fileURLWithPath: "/usr/local/bin/claude"), + ] + let fm = FileManager.default + for url in candidates where fm.isExecutableFile(atPath: url.path) { + return ScoutctlInvocation(executable: url, argsPrefix: []) + } + return ScoutctlInvocation( + executable: URL(fileURLWithPath: "/usr/bin/env"), + argsPrefix: ["claude"] + ) + } + func recomputeMenuStatus() async { let latest = sessionLogService.runs.first let next: MenuBarStatus = switch latest?.status { diff --git a/Scout/Shell/MainWindowView.swift b/Scout/Shell/MainWindowView.swift index 13cfa2e..49ba6b6 100644 --- a/Scout/Shell/MainWindowView.swift +++ b/Scout/Shell/MainWindowView.swift @@ -55,6 +55,7 @@ struct MainWindowView: View { RepliesView() .environmentObject(appState.replyDraftsDocumentService) .environmentObject(appState.replyDraftsWriterBox) + .environmentObject(appState.replyChatService) case .wishlist: PerFileListView(config: .wishlist) .environmentObject(appState.wishlistDocumentService) diff --git a/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift new file mode 100644 index 0000000..c346caf --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift @@ -0,0 +1,44 @@ +import Testing +import Foundation +@testable import Scout + +@Suite("ReplyChatService.buildPrompt") +struct ReplyChatServiceTests { + + private func draft() -> ReplyDraft { + ReplyDraft( + fileURL: URL(fileURLWithPath: "/x/S1.md"), tag: "S1", channel: .email, + loopType: "direct-debt", to: "Lucia ", cc: "Jakub ", + threadRef: "u", subject: "Re: roles", status: .draft, created: "2026-06-30", + contextAnswerRef: nil, bodyMarkdown: "Ahoj Lucio, ...", + summary: "GDPR role per use-case; ball is with us.", + relatedMessages: [DraftMessage(date: "2026-05-26", sender: "Lucia", text: "tři varianty", id: "0")] + ) + } + + @Test func promptGroundsInDraftContext() { + let p = ReplyChatService.buildPrompt( + draft: draft(), + history: [ChatMessage(role: .user, text: "What should I emphasize?", id: "u1")] + ) + #expect(p.contains("GDPR role per use-case")) // summary + #expect(p.contains("[2026-05-26] Lucia: tři varianty")) // thread + #expect(p.contains("To: Lucia ")) + #expect(p.contains("Cc: Jakub ")) + #expect(p.contains("Ahoj Lucio, ...")) // current reply + #expect(p.contains("Me: What should I emphasize?")) // user turn + #expect(p.contains("Do NOT send anything")) // safety framing + } + + @Test func errorTurnsAreOmittedFromConversation() { + let p = ReplyChatService.buildPrompt( + draft: draft(), + history: [ + ChatMessage(role: .user, text: "hi", id: "1"), + ChatMessage(role: .error, text: "claude exited 1", id: "2"), + ] + ) + #expect(p.contains("Me: hi")) + #expect(!p.contains("claude exited 1")) + } +} From 096d3f317112c61d79b534e5e8dd1e3c36275e5f Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 15:59:41 +0200 Subject: [PATCH 09/11] fix(reply-drafts): import Combine in ReplyChatService @Published / ObservableObject need Combine; SwiftUI doesn't re-export the property-wrapper resolution here. Matches the other document services. --- Scout/ReplyDrafts/ReplyChatService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Scout/ReplyDrafts/ReplyChatService.swift b/Scout/ReplyDrafts/ReplyChatService.swift index 9abfbaa..4a303ab 100644 --- a/Scout/ReplyDrafts/ReplyChatService.swift +++ b/Scout/ReplyDrafts/ReplyChatService.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import SwiftUI From df94ede407cad4006ca4d55d6e203db7570ac23c Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 16:27:33 +0200 Subject: [PATCH 10/11] feat(reply-drafts): Send via Slack / Create Gmail draft buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-conditional delivery on the card: slack drafts get 'Send via Slack' (with a confirmation dialog — it really sends), email drafts get 'Create Gmail draft' (never sends). Both shell out to the user's claude CLI to perform the action via Slack/Gmail MCP; a successful Slack send marks the draft sent. Adds ReplyChatService.deliver + deliveryPrompt and tests. --- Scout/ReplyDrafts/ReplyChatService.swift | 70 ++++++++++++++++ .../Views/ReplyDraftCardView.swift | 81 ++++++++++++++++--- .../ReplyDrafts/ReplyChatServiceTests.swift | 17 ++++ 3 files changed, 155 insertions(+), 13 deletions(-) diff --git a/Scout/ReplyDrafts/ReplyChatService.swift b/Scout/ReplyDrafts/ReplyChatService.swift index 4a303ab..069f7bc 100644 --- a/Scout/ReplyDrafts/ReplyChatService.swift +++ b/Scout/ReplyDrafts/ReplyChatService.swift @@ -29,10 +29,80 @@ final class ReplyChatService: ObservableObject { self.workingDirectory = workingDirectory } + /// The draft tag currently being delivered (Slack send / Gmail draft). + @Published private(set) var deliveringTag: String? + + /// A delivery action driven from the app — performed by shelling out to the + /// user's `claude` CLI, since only a Claude session can reach Slack/Gmail MCP. + enum DeliveryKind: Equatable, Sendable { case slackSend, gmailDraft } + func messages(for tag: String) -> [ChatMessage] { threads[tag] ?? [] } func isBusy(_ tag: String) -> Bool { busyTag == tag } + func isDelivering(_ tag: String) -> Bool { deliveringTag == tag } + + /// Perform a delivery for `draft` via the claude CLI. Slack actually sends + /// (confirm in the UI first); email only creates a Gmail draft. Returns + /// whether it succeeded and a short message to surface on the card. + func deliver(_ kind: DeliveryKind, draft: ReplyDraft) async -> (ok: Bool, message: String) { + guard deliveringTag == nil else { return (false, "Busy — try again in a moment.") } + deliveringTag = draft.tag + defer { deliveringTag = nil } + + let prompt = Self.deliveryPrompt(kind, draft: draft) + let args = claudeArgsPrefix + ["-p", prompt, "--permission-mode", "auto", "--model", "sonnet"] + do { + let result = try await runner.run( + executable: claude, arguments: args, environment: [:], workingDirectory: workingDirectory + ) + let out = (String(data: result.stdout, encoding: .utf8) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let ok = result.exitCode == 0 && out.uppercased().contains("OK ") + if ok { + return (true, kind == .slackSend + ? "Sent via Slack." + : "Gmail draft created — review and send it in Gmail.") + } + return (false, "Failed: " + (out.isEmpty ? "no output from claude" : out)) + } catch { + return (false, "Couldn't run claude — \(error.localizedDescription)") + } + } + + /// Build the precise single-shot instruction for a delivery action. + nonisolated static func deliveryPrompt(_ kind: DeliveryKind, draft: ReplyDraft) -> String { + switch kind { + case .slackSend: + return """ + Using the Slack MCP tools, SEND the following message EXACTLY as written (do not change a \ + single word) as a reply in the Slack conversation identified by this reference: \ + \(draft.threadRef). Intended recipient: \(draft.to). If you cannot resolve the channel/thread \ + from the reference, search Slack to find it. Send nothing else. + + MESSAGE: + \(draft.bodyMarkdown) + + After sending, output exactly "OK SENT" on success, or "FAILED: " if you could not \ + send. Do not perform any other action. + """ + case .gmailDraft: + return """ + Using the Gmail MCP tools, CREATE A DRAFT — do NOT send — replying within the email thread \ + referenced by: \(draft.threadRef). + To: \(draft.to) + Cc: \(draft.cc ?? "(none)") + Subject: \(draft.subject ?? "(reply)") + Use this body EXACTLY (do not change wording): + + \(draft.bodyMarkdown) + + After creating the draft, output exactly "OK DRAFT" on success, or "FAILED: ". \ + Never send the email — only create the draft. + """ + } + } + /// Send a user message about `draft` and append the assistant's reply. func send(text: String, about draft: ReplyDraft) async { let tag = draft.tag diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 4b238be..223c71b 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -23,6 +23,8 @@ struct ReplyDraftCardView: View { @State private var threadExpanded = false @State private var chatExpanded = false @State private var chatInput = "" + @State private var confirmingSlack = false + @State private var deliveryNote: String? var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -332,24 +334,77 @@ struct ReplyDraftCardView: View { // MARK: - Actions private var actions: some View { - HStack(spacing: 6) { - actButton(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") { - copyBody() - } - if !draft.threadRef.isEmpty { - actButton("Open thread", systemImage: "arrow.up.right.square") { - openThread() + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + actButton(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") { + copyBody() + } + if !draft.threadRef.isEmpty { + actButton("Open thread", systemImage: "arrow.up.right.square") { + openThread() + } + } + Spacer(minLength: 0) + if draft.isAwaitingAction { + deliveryButton + statusButton("Mark sent", systemImage: "checkmark", action: .markSent, tint: DS.Status.ok) + statusButton("Dismiss", systemImage: "xmark", action: .dismiss, tint: DS.Ink.p3) + } else { + statusButton("Reopen", systemImage: "arrow.uturn.backward", action: .reopen, tint: DS.Ink.p3) } } - Spacer(minLength: 0) - if draft.isAwaitingAction { - statusButton("Mark sent", systemImage: "paperplane", action: .markSent, tint: DS.Status.ok) - statusButton("Dismiss", systemImage: "xmark", action: .dismiss, tint: DS.Ink.p3) - } else { - statusButton("Reopen", systemImage: "arrow.uturn.backward", action: .reopen, tint: DS.Ink.p3) + if let deliveryNote { + Text(deliveryNote) + .font(DS.sans(11)) + .foregroundStyle(deliveryNote.hasPrefix("Failed") || deliveryNote.hasPrefix("Couldn't") ? DS.Status.err : DS.Status.ok) } } .padding(.top, 2) + .confirmationDialog( + "Send this reply to \(draft.to) via Slack?", + isPresented: $confirmingSlack, + titleVisibility: .visible + ) { + Button("Send via Slack", role: .destructive) { deliver(.slackSend) } + Button("Cancel", role: .cancel) {} + } message: { + Text("This sends the message now — it can't be unsent.") + } + } + + /// Channel-conditional delivery button: Slack actually sends (after a + /// confirm); email creates a Gmail draft to review and send from Gmail. + @ViewBuilder + private var deliveryButton: some View { + let busy = chat.isDelivering(draft.tag) + switch draft.channel { + case .slack: + Button { confirmingSlack = true } label: { + chrome(label: "Send via Slack", systemImage: "paperplane.fill", tint: DS.Accent.ink, busy: busy) + } + .buttonStyle(.plainHit) + .disabled(busy) + case .email: + Button { deliver(.gmailDraft) } label: { + chrome(label: "Create Gmail draft", systemImage: "envelope", tint: DS.Accent.ink, busy: busy) + } + .buttonStyle(.plainHit) + .disabled(busy) + default: + EmptyView() + } + } + + private func deliver(_ kind: ReplyChatService.DeliveryKind) { + deliveryNote = nil + Task { + let result = await chat.deliver(kind, draft: draft) + deliveryNote = result.message + // A successful Slack send closes the loop; mark the draft sent. + if result.ok && kind == .slackSend { + try? await onAction(.markSent) + } + } } /// A local (non-writing) action button — Copy / Open thread. diff --git a/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift index c346caf..9760348 100644 --- a/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift +++ b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift @@ -30,6 +30,23 @@ struct ReplyChatServiceTests { #expect(p.contains("Do NOT send anything")) // safety framing } + @Test func slackDeliveryPromptSendsVerbatimAndExpectsAck() { + let p = ReplyChatService.deliveryPrompt(.slackSend, draft: draft()) + #expect(p.contains("SEND")) + #expect(p.contains("Slack")) + #expect(p.contains(draft().threadRef)) + #expect(p.contains(draft().bodyMarkdown)) + #expect(p.contains("OK SENT")) + } + + @Test func gmailDeliveryPromptCreatesDraftNeverSends() { + let p = ReplyChatService.deliveryPrompt(.gmailDraft, draft: draft()) + #expect(p.contains("CREATE A DRAFT")) + #expect(p.contains("do NOT send") || p.contains("Never send")) + #expect(p.contains("Cc: Jakub ")) + #expect(p.contains("OK DRAFT")) + } + @Test func errorTurnsAreOmittedFromConversation() { let p = ReplyChatService.buildPrompt( draft: draft(), From 85343c03d73f7befb5477f643b7bc8ab47311e55 Mon Sep 17 00:00:00 2001 From: yustme Date: Tue, 30 Jun 2026 16:37:01 +0200 Subject: [PATCH 11/11] feat(reply-drafts): channel-specific Open button (Open in Gmail/Slack) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic 'Open thread' button is now labeled per channel — 'Open in Gmail' for email, 'Open in Slack' for slack, etc. — and opens the draft's thread_ref (the Gmail thread / Slack permalink) so it jumps to where it's discussed. --- Scout/ReplyDrafts/Models/DraftChannel.swift | 13 +++++++++++++ Scout/ReplyDrafts/Views/ReplyDraftCardView.swift | 2 +- ScoutTests/ReplyDrafts/DraftStatusTests.swift | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Scout/ReplyDrafts/Models/DraftChannel.swift b/Scout/ReplyDrafts/Models/DraftChannel.swift index 4e6d6f2..4827412 100644 --- a/Scout/ReplyDrafts/Models/DraftChannel.swift +++ b/Scout/ReplyDrafts/Models/DraftChannel.swift @@ -46,6 +46,19 @@ nonisolated enum DraftChannel: Equatable, Sendable { } } + /// Label for the "open the original thread" button — channel-specific so it + /// reads "Open in Gmail" / "Open in Slack" etc. + var openActionLabel: String { + switch self { + case .email: return "Open in Gmail" + case .slack: return "Open in Slack" + case .linear: return "Open in Linear" + case .github: return "Open in GitHub" + case .whatsapp: return "Open in WhatsApp" + case .other: return "Open thread" + } + } + /// True for channels whose drafts carry a meaningful `subject:` (email /// subject, PR/issue title). Chat channels omit it. var usesSubject: Bool { diff --git a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift index 223c71b..bdd9e3c 100644 --- a/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -340,7 +340,7 @@ struct ReplyDraftCardView: View { copyBody() } if !draft.threadRef.isEmpty { - actButton("Open thread", systemImage: "arrow.up.right.square") { + actButton(draft.channel.openActionLabel, systemImage: "arrow.up.right.square") { openThread() } } diff --git a/ScoutTests/ReplyDrafts/DraftStatusTests.swift b/ScoutTests/ReplyDrafts/DraftStatusTests.swift index cd91e3d..f53ab56 100644 --- a/ScoutTests/ReplyDrafts/DraftStatusTests.swift +++ b/ScoutTests/ReplyDrafts/DraftStatusTests.swift @@ -64,4 +64,11 @@ struct DraftChannelTests { #expect(!DraftChannel.slack.usesSubject) #expect(!DraftChannel.whatsapp.usesSubject) } + + @Test func openActionLabelIsChannelSpecific() { + #expect(DraftChannel.email.openActionLabel == "Open in Gmail") + #expect(DraftChannel.slack.openActionLabel == "Open in Slack") + #expect(DraftChannel.linear.openActionLabel == "Open in Linear") + #expect(DraftChannel.github.openActionLabel == "Open in GitHub") + } }