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 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/Models/DraftChannel.swift b/Scout/ReplyDrafts/Models/DraftChannel.swift new file mode 100644 index 0000000..4827412 --- /dev/null +++ b/Scout/ReplyDrafts/Models/DraftChannel.swift @@ -0,0 +1,70 @@ +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" + } + } + + /// 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 { + switch self { + case .email, .linear, .github: return true + case .slack, .whatsapp, .other: return false + } + } +} 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/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/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..da60e9e --- /dev/null +++ b/Scout/ReplyDrafts/Models/ReplyDraft.swift @@ -0,0 +1,62 @@ +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 + /// `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. + 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 — 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 } + + 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 } + + /// 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/ReplyChatService.swift b/Scout/ReplyDrafts/ReplyChatService.swift new file mode 100644 index 0000000..069f7bc --- /dev/null +++ b/Scout/ReplyDrafts/ReplyChatService.swift @@ -0,0 +1,190 @@ +import Combine +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 + } + + /// 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 + 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/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..3a5bb71 --- /dev/null +++ b/Scout/ReplyDrafts/ReplyDraftsParser.swift @@ -0,0 +1,176 @@ +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 cc = fields["cc"]?.nonEmpty + 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 + + // 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, + tag: tag, + channel: channel, + loopType: loopType, + to: to, + cc: cc, + threadRef: threadRef, + subject: subject, + status: status, + created: created, + contextAnswerRef: contextAnswerRef, + 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 + /// 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..8e5df13 --- /dev/null +++ b/Scout/ReplyDrafts/ReplyDraftsWriter.swift @@ -0,0 +1,211 @@ +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 + } + + /// 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, + 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..cda894e --- /dev/null +++ b/Scout/ReplyDrafts/Views/RepliesView.swift @@ -0,0 +1,195 @@ +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, + onAction: { action in try await apply(draft, action) }, + onFill: { placeholder, value in try await fill(draft, placeholder, value) } + ) + } + 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, + onAction: { action in try await apply(draft, action) }, + onFill: { placeholder, value in try await fill(draft, placeholder, value) } + ) + } + } + } + .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() + } + + 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 new file mode 100644 index 0000000..bdd9e3c --- /dev/null +++ b/Scout/ReplyDrafts/Views/ReplyDraftCardView.swift @@ -0,0 +1,484 @@ +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 { + @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 + /// 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? + @State private var summaryExpanded = false + @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) { + header + metaLine + bodyPanel + contextSection + if draft.isAwaitingAction && !draft.inputs.isEmpty { + inputsSection + } + chatSection + 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 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)) + .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: - 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 { + VStack(alignment: .leading, spacing: 10) { + Text("Fill in before sending (\(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("Your input…", 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("Fill in").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: - 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 { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + actButton(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") { + copyBody() + } + if !draft.threadRef.isEmpty { + actButton(draft.channel.openActionLabel, 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) + } + } + 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. + @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..27901d1 100644 --- a/Scout/Shell/AppState.swift +++ b/Scout/Shell/AppState.swift @@ -47,6 +47,12 @@ 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-draft AI assistant chat (shells out to the user's `claude` CLI). + let replyChatService: ReplyChatService + // Per-file Wishlist + Research tabs let wishlistDocumentService: PerFileDocumentService let researchDocumentService: PerFileDocumentService @@ -154,6 +160,32 @@ 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-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 { @@ -186,6 +218,9 @@ final class AppState: ObservableObject { self.actionItemsEnvState = envState self.proposalsDocumentService = proposalsDoc self.proposalsWriterBox = proposalsWriterBox + self.replyDraftsDocumentService = replyDraftsDoc + self.replyDraftsWriterBox = replyDraftsWriterBox + self.replyChatService = replyChat self.wishlistDocumentService = wishlistDoc self.researchDocumentService = researchDoc self.perFileWriterBox = perFileWriterBox @@ -207,6 +242,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 +261,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() @@ -336,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 06b5a06..49ba6b6 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,11 @@ struct MainWindowView: View { ProposalsView() .environmentObject(appState.proposalsDocumentService) .environmentObject(appState.proposalsWriterBox) + case .replyDrafts: + RepliesView() + .environmentObject(appState.replyDraftsDocumentService) + .environmentObject(appState.replyDraftsWriterBox) + .environmentObject(appState.replyChatService) case .wishlist: PerFileListView(config: .wishlist) .environmentObject(appState.wishlistDocumentService) @@ -65,7 +71,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 +80,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/DraftInputTests.swift b/ScoutTests/ReplyDrafts/DraftInputTests.swift new file mode 100644 index 0000000..ae44015 --- /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", summary: nil, relatedMessages: [] + ) + #expect(d.inputs.count == 1) + #expect(d.inputs[0].prompt == "pick a date") + } +} diff --git a/ScoutTests/ReplyDrafts/DraftStatusTests.swift b/ScoutTests/ReplyDrafts/DraftStatusTests.swift new file mode 100644 index 0000000..f53ab56 --- /dev/null +++ b/ScoutTests/ReplyDrafts/DraftStatusTests.swift @@ -0,0 +1,74 @@ +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) + } + + @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") + } +} diff --git a/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift new file mode 100644 index 0000000..9760348 --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyChatServiceTests.swift @@ -0,0 +1,61 @@ +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 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(), + 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")) + } +} diff --git a/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift new file mode 100644 index 0000000..c8b75c1 --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyDraftsParserTests.swift @@ -0,0 +1,160 @@ +import Testing +import Foundation +@testable import Scout + +private let fullFixture = """ +--- +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 +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.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) + #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 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" + 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) + // No cc: line → nil. + #expect(d.cc == nil) + } + + @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..f9b4f90 --- /dev/null +++ b/ScoutTests/ReplyDrafts/ReplyDraftsWriterTests.swift @@ -0,0 +1,179 @@ +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.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 { + + 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) + } +}