Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>.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
9 changes: 9 additions & 0 deletions Scout/ReplyDrafts/Models/ChatMessage.swift
Original file line number Diff line number Diff line change
@@ -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
}
70 changes: 70 additions & 0 deletions Scout/ReplyDrafts/Models/DraftChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// The channel a reply is owed on, parsed from the `channel:` frontmatter field
/// of a `drafts/<TAG>.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
}
}
}
37 changes: 37 additions & 0 deletions Scout/ReplyDrafts/Models/DraftInput.swift
Original file line number Diff line number Diff line change
@@ -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: <what to supply>]`. 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
}
}
17 changes: 17 additions & 0 deletions Scout/ReplyDrafts/Models/DraftMessage.swift
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions Scout/ReplyDrafts/Models/DraftStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

/// Lifecycle status of a prepared reply draft, parsed from the `status:` field
/// of a `drafts/<TAG>.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
}
}
}
62 changes: 62 additions & 0 deletions Scout/ReplyDrafts/Models/ReplyDraft.swift
Original file line number Diff line number Diff line change
@@ -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/<TAG>.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 `<!-- scout:context -->` 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) }
}
Loading