From 96be664c121d727a5b753136ed52ada5e7134418 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 20 Sep 2026 11:27:29 -0700 Subject: [PATCH 1/2] Carry image attachments on a draft and resolve them into the prompt Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: scgopi --- GraphcodeKit/Sources/Domain/GraphImport.swift | 4 + GraphcodeKit/Sources/Domain/LoopNode.swift | 41 ++++++-- GraphcodeKit/Sources/Domain/NodeDraft.swift | 12 ++- .../Sources/Domain/PromptAttachment.swift | 93 +++++++++++++++++++ .../Sources/Sessions/NodeMemory.swift | 14 +++ .../Sources/Sessions/ZmxSessionLauncher.swift | 8 ++ 6 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 GraphcodeKit/Sources/Domain/PromptAttachment.swift diff --git a/GraphcodeKit/Sources/Domain/GraphImport.swift b/GraphcodeKit/Sources/Domain/GraphImport.swift index fbda372d..d57f5875 100644 --- a/GraphcodeKit/Sources/Domain/GraphImport.swift +++ b/GraphcodeKit/Sources/Domain/GraphImport.swift @@ -220,6 +220,10 @@ public enum GraphImportPlanner { heartbeatIntervalSeconds: node.heartbeatIntervalSeconds, firstInstruction: node.firstInstruction, pausesBeforeWritesOnly: node.pausesBeforeWritesOnly, + // Dropped for the same reason the worktree binding is, and one more: an + // attachment's file lives under the *exporting* node's id, which the remap above + // has just changed. Both halves of the path would be wrong. + attachments: [], goal: node.goal, backend: node.backend, modelTier: node.modelTier, diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 833e1b35..14f7da2d 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -78,6 +78,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// it agreed to — and so the prompt stays derivable from the node instead of being a /// sentence nobody can re-read. public var pausesBeforeWritesOnly: Bool + /// Images attached to whichever field holds this loop's brief (`PromptAttachment`). + /// Their paths replace the `[image #N]` placeholders in `sessionPrompt`. + public var attachments: [PromptAttachment] /// The stop condition a goal-based node was handed (`.goalBased`) — see /// docs/01-loop-taxonomy.md#goal-based--you-hand-off-the-stop-condition. public var goal: GoalSpec? @@ -217,6 +220,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { heartbeatIntervalSeconds: Double? = nil, firstInstruction: String? = nil, pausesBeforeWritesOnly: Bool = false, + attachments: [PromptAttachment] = [], goal: GoalSpec? = nil, backend: CLISessionBackendKind = .claudeCode, modelTier: ModelTier? = nil, @@ -246,6 +250,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.heartbeatIntervalSeconds = heartbeatIntervalSeconds self.firstInstruction = firstInstruction self.pausesBeforeWritesOnly = pausesBeforeWritesOnly + self.attachments = attachments self.goal = goal self.backend = backend self.modelTier = modelTier @@ -313,10 +318,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { switch loopType { case .sketch: // The starting note, when there is one. A blank note means the session opens - // quiet and waits — asking nothing up front is what the type is for. - let note = firstInstruction?.trimmingCharacters(in: .whitespaces) ?? "" + // quiet and waits — asking nothing up front is what the type is for. An attached + // image is itself something to say, so a note that is only a picture still opens. + let note = + PromptAttachments.resolving(firstInstruction, attachments: attachments)? + .trimmingCharacters(in: .whitespaces) ?? "" return note.isEmpty ? nil : note case .timeBased: + // Placeholders are swapped for paths before anything reads the prompt as a + // directive: `/loop ` takes the rest of the line as the task, so + // a path inside it travels into every scheduled pass. + let triggerPrompt = PromptAttachments.resolving( + self.triggerPrompt, attachments: attachments) // Copilot's `/every` submits its first prompt only after the interval elapses, so // a directive-led opening armed correctly and then sat idle — and the typed // first-pass workaround raced the composer. The reliable channel is the opening @@ -335,7 +348,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { interval.isFinite, interval > 0 { - let task = heartbeatTask ?? "" + // `heartbeatTask` reads the stored prompt, so it needs resolving of its own — + // the shadowed local above does not reach inside it. + let task = PromptAttachments.resolving(heartbeatTask, attachments: attachments) ?? "" return "Run one pass of this task now: \(task) Then stay in the session — every " + "\(Int(interval))s you will receive a [graphcode] heartbeat message, and each " + "one is your cue to run the next pass. Do not schedule your own /loop, wakeup, " @@ -352,16 +367,22 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { + "\(task) Do not schedule your own /loop, wakeup, or cron for it — the " + "orchestrator holds the timer. Stay in the session between heartbeats." case .goalBased: - guard let prompt = goal?.sessionPrompt(directive: backend.capabilities.goalDirective) - else { return nil } + // Resolved on the summary rather than on the composed prompt: `/goal` takes the + // rest of the line as its condition, and a path appended past the predicate and + // the metric would become part of what an evaluator judges. + guard var goal else { return nil } + goal.summary = + PromptAttachments.resolving(goal.summary, attachments: attachments) ?? goal.summary + let prompt = goal.sessionPrompt(directive: backend.capabilities.goalDirective) // A backend whose verdict the daemon cannot read resolves a goal with no predicate // only when its session reports it met. The briefing says so, but a session follows // its prompt first: OpenCode and pi loops finished their work and never reported. - guard !backend.recordsGoalVerdict, goal?.effectivePredicate == nil else { return prompt } + guard !backend.recordsGoalVerdict, goal.effectivePredicate == nil else { return prompt } return prompt + " " + Self.reportDoneSentence case .turnBased: return Self.turnBasedPrompt( - instruction: firstInstruction, check: checkDescription, + instruction: PromptAttachments.resolving(firstInstruction, attachments: attachments), + check: checkDescription, beforeWritesOnly: pausesBeforeWritesOnly) case .composite: return nil } @@ -564,7 +585,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy case lastMailroomRead, mailroomWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly - case summary, board, heartbeatIntervalSeconds, stallReason + case summary, board, heartbeatIntervalSeconds, stallReason, attachments case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure, resolution case pendingCompletion, goalSetAt } @@ -586,6 +607,10 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { // turn, which is what `false` says. pausesBeforeWritesOnly = try container.decodeIfPresent(Bool.self, forKey: .pausesBeforeWritesOnly) ?? false + // Absent from graphs saved before attachments existed, which is what an empty + // list says. + attachments = + try container.decodeIfPresent([PromptAttachment].self, forKey: .attachments) ?? [] goal = try container.decodeIfPresent(GoalSpec.self, forKey: .goal) backend = try container.decodeIfPresent(CLISessionBackendKind.self, forKey: .backend) ?? .claudeCode diff --git a/GraphcodeKit/Sources/Domain/NodeDraft.swift b/GraphcodeKit/Sources/Domain/NodeDraft.swift index d787b62c..a5f45d12 100644 --- a/GraphcodeKit/Sources/Domain/NodeDraft.swift +++ b/GraphcodeKit/Sources/Domain/NodeDraft.swift @@ -68,6 +68,9 @@ public struct NodeDraft: Codable, Equatable, Sendable { /// /// `nil` for anything a human created, which is the truth: the form is not a loop. public var createdBy: UUID? + /// Images the human attached to the brief — see `PromptAttachment`. The bytes are + /// already on disk by the time a draft carries one; this is only where they are. + public var attachments: [PromptAttachment] /// Which template the brief came from — attribution, carried to the node. public var createdFromTemplateID: UUID? /// The template a **timed or composite** draft follows — see @@ -90,6 +93,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { worktree: WorktreeRef? = nil, subGraph: LoopGraph? = nil, createdBy: UUID? = nil, + attachments: [PromptAttachment] = [], createdFromTemplateID: UUID? = nil, templateFollow: TemplateFollow? = nil ) { @@ -107,6 +111,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { self.worktree = worktree self.subGraph = subGraph self.createdBy = createdBy + self.attachments = attachments self.createdFromTemplateID = createdFromTemplateID self.templateFollow = templateFollow } @@ -202,6 +207,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { heartbeatIntervalSeconds: heartbeatIntervalSeconds, firstInstruction: firstInstruction, pausesBeforeWritesOnly: pausesBeforeWritesOnly, + attachments: attachments, goal: goal, backend: effectiveBackend, modelTier: modelTier, @@ -225,7 +231,7 @@ extension NodeDraft { private enum CodingKeys: String, CodingKey { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktree, subGraph, createdBy, firstInstruction, pausesBeforeWritesOnly - case heartbeatIntervalSeconds + case heartbeatIntervalSeconds, attachments case createdFromTemplateID, templateFollow } @@ -253,6 +259,10 @@ extension NodeDraft { worktree = try container.decodeIfPresent(WorktreeRef.self, forKey: .worktree) subGraph = try container.decodeIfPresent(LoopGraph.self, forKey: .subGraph) createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) + // Absent from every draft a CLI that predates attachments sends, which is what an + // empty list says. + attachments = + try container.decodeIfPresent([PromptAttachment].self, forKey: .attachments) ?? [] createdFromTemplateID = try container.decodeIfPresent(UUID.self, forKey: .createdFromTemplateID) templateFollow = diff --git a/GraphcodeKit/Sources/Domain/PromptAttachment.swift b/GraphcodeKit/Sources/Domain/PromptAttachment.swift new file mode 100644 index 00000000..71403eb0 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/PromptAttachment.swift @@ -0,0 +1,93 @@ +import Foundation + +/// An image a human dropped into the New Node dialog, and where it landed on disk. +/// +/// **The image itself can never travel.** `zmx` starts a session by *typing* its launch +/// command into a PTY (`SessionBriefing`), so everything a loop opens with is text on a +/// line — a canonical-mode tty at that, which drops whatever runs past `MAX_CANON`. What +/// does travel is the path, and every backend graphcode drives can open one: `codex` and +/// `pi` have a flag for it, and the rest read the file with their own tools once the +/// prompt names it. +/// +/// So the form writes the bytes down once, beside the node's memory +/// (`NodeMemory.attachmentsDirectory`), and the prompt carries the path. The node id is +/// chosen by the client (`NodeDraft.id`), which is what makes that possible before the +/// node exists. +/// +/// Local graphs only. A remote project's session runs on another machine, and the ensure +/// dial that delivers graphcode's files there carries text (`remoteDeliveryScript`) — a +/// path to a file that host has never seen would read to the agent as a missing file. +public struct PromptAttachment: Codable, Equatable, Sendable, Identifiable { + public var id: UUID + /// Absolute, on the machine that runs the loop. + public var path: String + + public init(id: UUID = UUID(), path: String) { + self.id = id + self.path = path + } + + public var fileName: String { URL(fileURLWithPath: path).lastPathComponent } +} + +/// How an attachment's path gets into the sentence a human wrote. +/// +/// The human never types or sees a path: `[image #1]` stands in its place in the field, +/// and the token is swapped for the path when the prompt is composed +/// (`LoopNode.sessionPrompt`). That keeps the picture where the sentence wanted it — +/// "compare `[image #1]` with the current header" — rather than in a list at the end +/// that the agent has to guess the intent of. +public enum PromptAttachments { + /// The placeholder for the `number`-th attachment, 1-based. ASCII and unmistakable: + /// it has to survive a round trip through a text field, argv, and a typed command + /// line, and it must not collide with anything a person would write by hand. + public static func token(_ number: Int) -> String { "[image #\(number)]" } + + /// `text` with every `[image #N]` replaced by the N-th attachment's path, and any + /// attachment the text never named stated at the end. + /// + /// The trailer keeps plain words on both sides of every path, for the reason + /// `NodeMemory.promptPointer` does: this string rides argv, `zmx`'s typed command + /// line and sometimes ssh, and punctuation touching a path has eaten a file extension + /// before. + public static func resolving( + _ text: String?, attachments: [PromptAttachment] + ) -> String? { + guard !attachments.isEmpty else { return text } + var resolved = text ?? "" + var unnamed: [String] = [] + for (offset, attachment) in attachments.enumerated() { + let placeholder = token(offset + 1) + if resolved.contains(placeholder) { + resolved = resolved.replacingOccurrences(of: placeholder, with: attachment.path) + } else { + unnamed.append(attachment.path) + } + } + guard !unnamed.isEmpty else { return resolved } + let trailer = + unnamed.count == 1 + ? "An image for this task is at \(unnamed[0]) - open it before you start." + : "Images for this task are at \(unnamed.joined(separator: " and ")) " + + "- open them before you start." + let body = resolved.trimmingCharacters(in: .whitespacesAndNewlines) + return body.isEmpty ? trailer : body + " " + trailer + } + + /// `text` with the `number`-th placeholder dropped and every later one renumbered, so + /// removing the middle chip of three doesn't leave `[image #3]` pointing at nothing. + public static func removing(attachment number: Int, from text: String, of count: Int) + -> String + { + var result = text.replacingOccurrences(of: token(number), with: "") + var later = number + 1 + while later <= count { + result = result.replacingOccurrences(of: token(later), with: token(later - 1)) + later += 1 + } + while result.contains(" ") { + result = result.replacingOccurrences(of: " ", with: " ") + } + return result.trimmingCharacters(in: .whitespaces) + } +} diff --git a/GraphcodeKit/Sources/Sessions/NodeMemory.swift b/GraphcodeKit/Sources/Sessions/NodeMemory.swift index d6d6a8ba..99999572 100644 --- a/GraphcodeKit/Sources/Sessions/NodeMemory.swift +++ b/GraphcodeKit/Sources/Sessions/NodeMemory.swift @@ -67,6 +67,20 @@ public enum NodeMemory { .appendingPathComponent(nodeID.uuidString, isDirectory: true) } + /// Where images attached to a node's brief are kept (`PromptAttachment`). + /// + /// Inside the node's memory directory deliberately: `remove` already wipes that when + /// the node is deleted, so an attachment cannot outlive the loop it was for, and a + /// path-verifying backend is granted one directory rather than two. + public static func attachmentsDirectory( + forProjectPath projectPath: String, nodeID: UUID, baseURL: URL = SupportDirectory.url + ) -> URL { + directory(forProjectPath: projectPath, nodeID: nodeID, baseURL: baseURL) + .appendingPathComponent(attachmentsDirectoryName, isDirectory: true) + } + + public static let attachmentsDirectoryName = "attachments" + public static func logURL( forProjectPath projectPath: String, nodeID: UUID, baseURL: URL = SupportDirectory.url ) -> URL { diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 0a69b81c..84159d7e 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1494,6 +1494,14 @@ public enum ZmxSessionLauncher { } } if let worktree = node.worktreeBinding?.worktreePath { paths.append(worktree) } + // Codex and Copilot verify paths, and a prompt naming an image the session is denied + // reads as the agent ignoring its instructions — the same failure the briefing's + // `--add-dir` exists to prevent. Granted from the paths themselves rather than from + // the memory directory, so an attachment that came from somewhere else still works. + for attachment in node.attachments { + let directory = URL(fileURLWithPath: attachment.path).deletingLastPathComponent().path + if !paths.contains(directory) { paths.append(directory) } + } return paths } From 42563d41b9ea7de1557e8232275bd80e41cfb869 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 20 Sep 2026 11:44:29 -0700 Subject: [PATCH 2/2] Take images into the New Node dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a screenshot into the New Node dialog did nothing at all: a SwiftUI `TextField`'s field editor owns ⌘V and can only accept text, so the keystroke landed nowhere and the dialog read as broken. ⌘V and drag-and-drop now take an image, write it beside the node's memory, and put `[image #1]` in the brief where the caret was heading. The picture itself cannot travel — `zmx` starts a session by typing its launch command into a PTY — so the placeholder is swapped for the file's path when the prompt is composed, which is one place both launchers already read. A board carrying words as well as pixels is read as words: copying out of a rich-text editor puts a rendering of the selection beside the text, and eating that paste would lose something the human meant to type. A copied image *file* is unambiguous and wins regardless. Not offered for a loop on another machine, whose ensure dial delivers text, or for a composite, which never opens a session. `ProjectFeature.swift` was at swiftlint's file-length and type-body budgets, so the New Node dialog's own reducer code moves to `ProjectFeature+NodeForm.swift` unchanged. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: scgopi --- .../Project/NodeDraftAttachments.swift | 181 ++++++++++ .../Project/NodeDraftBriefField.swift | 132 +++++++ .../Features/Project/NodeDraftForm.swift | 8 + .../Project/NodeDraftTypeFields.swift | 16 +- .../Project/ProjectFeature+Attachments.swift | 114 ++++++ .../Project/ProjectFeature+NodeForm.swift | 191 ++++++++++ .../Features/Project/ProjectFeature.swift | 189 +--------- .../Project/ProjectFeatureState.swift | 14 + graphcode/Tests/PromptAttachmentTests.swift | 337 ++++++++++++++++++ 9 files changed, 992 insertions(+), 190 deletions(-) create mode 100644 graphcode/Sources/Features/Project/NodeDraftAttachments.swift create mode 100644 graphcode/Sources/Features/Project/NodeDraftBriefField.swift create mode 100644 graphcode/Sources/Features/Project/ProjectFeature+Attachments.swift create mode 100644 graphcode/Sources/Features/Project/ProjectFeature+NodeForm.swift create mode 100644 graphcode/Tests/PromptAttachmentTests.swift diff --git a/graphcode/Sources/Features/Project/NodeDraftAttachments.swift b/graphcode/Sources/Features/Project/NodeDraftAttachments.swift new file mode 100644 index 00000000..75b9c8aa --- /dev/null +++ b/graphcode/Sources/Features/Project/NodeDraftAttachments.swift @@ -0,0 +1,181 @@ +import AppKit +import ComposableArchitecture +import GraphcodeKit +import SwiftUI +import UniformTypeIdentifiers + +/// Getting a picture out of the pasteboard, or off a drag, and onto disk where a loop's +/// prompt can name it (`PromptAttachment`). +/// +/// The bytes are read here, in the view layer, because that is the only place they exist: +/// a pasteboard is a live thing whose contents can change between the keystroke and any +/// effect that runs afterwards. +enum DraftImageImport { + /// What a paste or a drop yielded — already decoded, so the reducer never touches a + /// pasteboard and a test never needs one. + struct Payload: Equatable, Sendable { + var data: Data + var fileExtension: String + } + + /// Bigger than this is refused. An agent reads a screenshot, not a poster, and every + /// byte here is written synchronously while the dialog is open. + static let maximumBytes = 10 * 1024 * 1024 + + static let imageExtensions: Set = [ + "png", "jpg", "jpeg", "gif", "heic", "webp", "tiff", "tif", "bmp", + ] + + /// The image on `pasteboard`, or `nil` to let ⌘V mean what it has always meant. + /// + /// **A pasteboard carrying both a picture and text is read as text.** Copying a + /// selection out of a rich-text editor puts a TIFF rendering of it on the pasteboard + /// beside the words, and swallowing that paste would lose something the human meant to + /// type into the field. Missing an image paste costs nothing — the same picture can be + /// dragged in — where eating a text paste is a keystroke that silently did nothing. + /// A copied image *file* is unambiguous and wins regardless. + static func payload(on pasteboard: NSPasteboard) -> Payload? { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL], + let url = urls.first(where: { imageExtensions.contains($0.pathExtension.lowercased()) }) + { + return payload(ofFileAt: url) + } + guard pasteboard.string(forType: .string) == nil else { return nil } + if let png = pasteboard.data(forType: .png), png.count <= maximumBytes { + return Payload(data: png, fileExtension: "png") + } + if let tiff = pasteboard.data(forType: .tiff) { return pngPayload(fromTIFF: tiff) } + return nil + } + + static func payload(ofFileAt url: URL) -> Payload? { + guard imageExtensions.contains(url.pathExtension.lowercased()), + let data = try? Data(contentsOf: url), !data.isEmpty, data.count <= maximumBytes + else { return nil } + return Payload(data: data, fileExtension: url.pathExtension.lowercased()) + } + + private static func pngPayload(fromTIFF tiff: Data) -> Payload? { + guard let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]), + png.count <= maximumBytes + else { return nil } + return Payload(data: png, fileExtension: "png") + } + + /// Whatever the drag carried, as the same payload a paste produces. `nil` for a drag + /// of something that isn't an image graphcode can write down. + static func payload(from providers: [NSItemProvider]) async -> Payload? { + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier), + let url = await loadFileURL(from: provider), let payload = payload(ofFileAt: url) + { + return payload + } + for type in [UTType.png, UTType.jpeg, UTType.tiff, UTType.image] { + guard provider.hasItemConformingToTypeIdentifier(type.identifier), + let data = await loadData(from: provider, type: type) + else { continue } + if type == .tiff { return pngPayload(fromTIFF: data) } + guard data.count <= maximumBytes else { return nil } + return Payload(data: data, fileExtension: type == .jpeg ? "jpg" : "png") + } + } + return nil + } + + private static func loadFileURL(from provider: NSItemProvider) async -> URL? { + await withCheckedContinuation { continuation in + _ = provider.loadObject(ofClass: URL.self) { url, _ in + continuation.resume(returning: url) + } + } + } + + private static func loadData(from provider: NSItemProvider, type: UTType) async -> Data? { + await withCheckedContinuation { continuation in + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in + continuation.resume(returning: data) + } + } + } + + /// Where the `number`-th image of a draft lands. Named by position rather than by + /// whatever the source file was called: the name is what the agent sees in the path, + /// and `IMG_4821 (1).png` says less than `image-2.png` about which placeholder it is. + static func destination( + projectPath: String, nodeID: UUID, number: Int, fileExtension: String + ) -> URL { + NodeMemory.attachmentsDirectory(forProjectPath: projectPath, nodeID: nodeID) + .appendingPathComponent("image-\(number).\(fileExtension)") + } + + static func write(_ payload: Payload, to url: URL) -> Bool { + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try payload.data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } + + /// Drops a cancelled draft's images. The form's id is a node id nothing will ever + /// create, so its directory has no other owner to outlive. + static func discardAll(projectPath: String, nodeID: UUID) { + try? FileManager.default.removeItem( + at: NodeMemory.attachmentsDirectory(forProjectPath: projectPath, nodeID: nodeID)) + } +} + +/// ⌘V, caught before the focused field can spend it on nothing. +/// +/// A SwiftUI `TextField`'s field editor owns the keystroke and accepts only text, so a +/// pasted screenshot lands nowhere and the human sees the dialog do nothing at all. A +/// local monitor reads the pasteboard itself and decides: an image is taken, anything +/// else is handed straight back to the field. Installed only while the dialog is on +/// screen — and the dialog is a sheet, so nothing behind it can own the keyboard +/// meanwhile. +struct DraftImagePasteCatcher: ViewModifier { + var isEnabled: Bool + let onImage: (DraftImageImport.Payload) -> Void + + @State private var monitor: Any? + + func body(content: Content) -> some View { + content + .onAppear { install() } + .onDisappear { remove() } + .onChange(of: isEnabled) { _, _ in + remove() + install() + } + } + + private func install() { + guard isEnabled, monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + guard event.modifierFlags.contains(.command), + !event.modifierFlags.contains(.option), + event.charactersIgnoringModifiers?.lowercased() == "v", + let payload = DraftImageImport.payload(on: .general) + else { return event } + onImage(payload) + return nil + } + } + + private func remove() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + } +} + +extension View { + func catchingPastedImages( + isEnabled: Bool, onImage: @escaping (DraftImageImport.Payload) -> Void + ) -> some View { + modifier(DraftImagePasteCatcher(isEnabled: isEnabled, onImage: onImage)) + } +} diff --git a/graphcode/Sources/Features/Project/NodeDraftBriefField.swift b/graphcode/Sources/Features/Project/NodeDraftBriefField.swift new file mode 100644 index 00000000..e3015a26 --- /dev/null +++ b/graphcode/Sources/Features/Project/NodeDraftBriefField.swift @@ -0,0 +1,132 @@ +import ComposableArchitecture +import GraphcodeKit +import SwiftUI +import UniformTypeIdentifiers + +/// The brief field, plus the pictures attached to it. +/// +/// Every loop type has exactly one field that says what the loop is for — the starting +/// note, the goal, the task, the first instruction — and it is the one an image belongs +/// to. Wrapping it once is what keeps the drop target, the chips and the placeholder +/// numbering from being written four times and drifting three ways. +struct DraftBriefField: View { + @Bindable var store: StoreOf + let placeholder: String + @Binding var text: String + var takesFocusRequest: Binding? + var onTokenJump: (() -> Bool)? + + @State private var isTargeted = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + DraftProseField( + placeholder: placeholder, text: $text, takesFocusRequest: takesFocusRequest, + onTokenJump: onTokenJump + ) + .overlay { + if isTargeted { + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.paneFocusTint, style: StrokeStyle(lineWidth: 1.5, dash: [5, 3])) + } + } + .onDrop(of: [.fileURL, .image], isTargeted: $isTargeted) { providers in + Task { @MainActor in + guard let payload = await DraftImageImport.payload(from: providers) else { + store.send( + .draftAttachment(.rejected("That isn't an image GraphCode can attach."))) + return + } + store.send(.draftAttachment(.imageArrived(payload))) + } + return true + } + DraftAttachmentStrip(store: store) + } + } +} + +/// What is attached, under the field it is attached to: one chip per image, each +/// carrying the placeholder that stands for it in the text so the two can be read +/// against each other. Removing a chip takes the placeholder with it. +struct DraftAttachmentStrip: View { + @Bindable var store: StoreOf + + var body: some View { + if !store.draftAttachments.items.isEmpty || store.draftAttachments.notice != nil { + VStack(alignment: .leading, spacing: 6) { + if !store.draftAttachments.items.isEmpty { + HStack(spacing: 8) { + ForEach(Array(store.draftAttachments.items.enumerated()), id: \.element.id) { + number, attachment in + chip(attachment, number: number + 1) + } + Spacer(minLength: 0) + } + } + if let notice = store.draftAttachments.notice { + Text(notice) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + private func chip(_ attachment: PromptAttachment, number: Int) -> some View { + HStack(spacing: 6) { + AttachmentThumbnail(path: attachment.path) + Text(PromptAttachments.token(number)) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + Button { + store.send(.draftAttachment(.removed(attachment.id))) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white.opacity(0.55)) + } + .buttonStyle(.plain) + .help("Remove this image") + } + .padding(.leading, 4) + .padding(.trailing, 7) + .padding(.vertical, 4) + .background(Theme.draftField, in: RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).stroke(.white.opacity(0.12), lineWidth: 1) + } + } +} + +/// A 22pt look at what was attached. Loaded off the main actor and only when the path +/// changes — the dialog re-renders on every keystroke, and decoding a screenshot per +/// character would be felt. +private struct AttachmentThumbnail: View { + let path: String + + @State private var image: NSImage? + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .font(.system(size: 9)) + .foregroundStyle(.white.opacity(0.5)) + } + } + .frame(width: 22, height: 22) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .task(id: path) { + let loaded = await Task.detached(priority: .utility) { + NSImage(contentsOfFile: path) + }.value + image = loaded + } + } +} diff --git a/graphcode/Sources/Features/Project/NodeDraftForm.swift b/graphcode/Sources/Features/Project/NodeDraftForm.swift index 279742b4..a7d412a9 100644 --- a/graphcode/Sources/Features/Project/NodeDraftForm.swift +++ b/graphcode/Sources/Features/Project/NodeDraftForm.swift @@ -70,6 +70,14 @@ struct NodeDraftForm: View { .sheet(item: $store.templates.pendingSave) { _ in TemplateSaveSheet(store: store) } + // ⌘V with a picture on the pasteboard. Caught at the dialog rather than at the + // field: the focused `TextField`'s own editor takes the keystroke and can only + // accept text, so a pasted screenshot would land nowhere and read as the dialog + // ignoring it. Off while the template picker has the body — that sheet's ⌘V + // belongs to its search field. + .catchingPastedImages(isEnabled: !store.templates.isPickerOpen) { payload in + store.send(.draftAttachment(.imageArrived(payload))) + } } private var header: some View { diff --git a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift index 6f4ac396..a71fbd24 100644 --- a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift @@ -15,8 +15,8 @@ struct SketchDraftFields: View { help: "No done check, no cadence — it works with you until you promote it or close it.", fromTemplate: store.templateSetFields.contains(.brief) ) { - DraftProseField( - placeholder: "e.g. where does the usage cap get read from?", + DraftBriefField( + store: store, placeholder: "e.g. where does the usage cap get read from?", text: $store.draftSketchNote, takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } @@ -39,8 +39,8 @@ struct GoalDraftFields: View { help: "In your own words. The loop is told this, and works toward it.", fromTemplate: store.templateSetFields.contains(.brief) ) { - DraftProseField( - placeholder: "the crash rate is back under 1%", text: $store.draftGoal, + DraftBriefField( + store: store, placeholder: "the crash rate is back under 1%", text: $store.draftGoal, takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } @@ -205,8 +205,8 @@ struct TimedDraftFields: View { label: "What to do each time", fromTemplate: store.templateSetFields.contains(.brief) ) { - DraftProseField( - placeholder: "Check for new crash reports and triage anything new", + DraftBriefField( + store: store, placeholder: "Check for new crash reports and triage anything new", text: $store.draftTimedTask, takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } @@ -281,8 +281,8 @@ struct TurnDraftFields: View { label: "First instruction", fromTemplate: store.templateSetFields.contains(.brief) ) { - DraftProseField( - placeholder: "Port the settings screen to the new design system", + DraftBriefField( + store: store, placeholder: "Port the settings screen to the new design system", text: $store.draftFirstInstruction, takesFocusRequest: store.templateFocus(.brief), onTokenJump: store.tokenJump) } diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Attachments.swift b/graphcode/Sources/Features/Project/ProjectFeature+Attachments.swift new file mode 100644 index 00000000..53227562 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+Attachments.swift @@ -0,0 +1,114 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// The New Node dialog's images: what the draft holds, and what a paste or a drop does +/// to it. +/// +/// Its own file for the reason `AppFeature`'s helpers have one — `ProjectFeature.swift` +/// sits at swiftlint's file and type-body budgets, and a concern that arrives whole is +/// easier to read whole. +extension ProjectFeature { + /// Images attached to the brief field, already written to disk under the draft's own + /// id (`PromptAttachment`). Each one put an `[image #N]` placeholder into the brief; + /// the path replaces it when the prompt is composed (`LoopNode.sessionPrompt`). + struct DraftAttachments: Equatable { + var items: [PromptAttachment] = [] + /// How many this draft has ever taken, which is what names their files. Position + /// would re-use a name: remove the second of three and the next paste is number + /// three again, landing on a file that is still attached. + var taken = 0 + /// Why the last paste or drop did nothing — a remote graph, or a file too big. + /// Shown beside the field and cleared by the next one that works. + var notice: String? + } + + enum DraftAttachmentAction: Equatable { + /// An image arrived on the brief field — pasted over ⌘V or dropped onto it. Carries + /// the decoded bytes because a pasteboard cannot be read from an effect: its + /// contents belong to the moment of the keystroke. + case imageArrived(DraftImageImport.Payload) + case removed(UUID) + /// A drop that yielded nothing graphcode could write down. + case rejected(String) + } + + func draftAttachment( + _ state: inout State, _ action: DraftAttachmentAction + ) -> Effect { + switch action { + case .imageArrived(let payload): return attachDraftImage(&state, payload) + case .removed(let id): return removeDraftAttachment(&state, id) + case .rejected(let reason): + state.draftAttachments.notice = reason + return .none + } + } + + /// Writes a pasted or dropped image down and puts its placeholder in the brief. + /// + /// Synchronous, where most of this reducer's work is an effect: the number the file is + /// named after and the number the placeholder carries have to be decided in the same + /// breath, and an effect deciding them from a state that has since taken another paste + /// would hand two images the same name. The write is bounded at + /// `DraftImageImport.maximumBytes`, which is a few milliseconds of a dialog nobody is + /// typing into mid-paste. + func attachDraftImage( + _ state: inout State, _ payload: DraftImageImport.Payload + ) -> Effect { + // A composite never opens a session, so it has no prompt for a path to travel in — + // and no prose field for the placeholder to land in either. + guard state.draftLoopType != .composite else { return .none } + let projectPath = state.graph.project.path + // A remote loop runs on another machine, and the ensure dial that delivers + // graphcode's files there carries text (`ZmxSessionLauncher.remoteDeliveryScript`). + // A path to a file that host has never seen would read to the agent as a file that + // isn't there, which is worse than saying so here. + guard RemoteProjectLocation.parse(projectPath: projectPath) == nil else { + state.draftAttachments.notice = + "Images can't be attached to a loop on another machine yet." + return .none + } + let number = state.draftAttachments.taken + 1 + let url = DraftImageImport.destination( + projectPath: projectPath, nodeID: state.draftID, number: number, + fileExtension: payload.fileExtension) + guard DraftImageImport.write(payload, to: url) else { + state.draftAttachments.notice = "Couldn't save that image." + return .none + } + state.draftAttachments.taken = number + state.draftAttachments.items.append(PromptAttachment(path: url.path)) + let placeholder = PromptAttachments.token(state.draftAttachments.items.count) + let brief = state.currentBriefText + state.setBriefText(brief.isEmpty ? placeholder : brief + " " + placeholder) + state.draftAttachments.notice = nil + return .none + } + + /// Takes the chip, the file, and the placeholder — and renumbers the placeholders + /// after it, so removing the middle of three doesn't leave one pointing at nothing. + func removeDraftAttachment(_ state: inout State, _ id: UUID) -> Effect { + guard let index = state.draftAttachments.items.firstIndex(where: { $0.id == id }) + else { return .none } + let total = state.draftAttachments.items.count + let removed = state.draftAttachments.items.remove(at: index) + try? FileManager.default.removeItem(at: URL(fileURLWithPath: removed.path)) + state.setBriefText( + PromptAttachments.removing( + attachment: index + 1, from: state.currentBriefText, of: total)) + state.draftAttachments.notice = nil + return .none + } + + /// Closing the dialog without creating anything. The draft's id is a node id nothing + /// will now create, so its images have no other owner to outlive. A created node's are + /// left alone: `NodeMemory.remove` takes them when the node itself goes. + func cancelNodeForm(_ state: inout State) -> Effect { + state.showingNewNodeForm = false + DraftImageImport.discardAll( + projectPath: state.graph.project.path, nodeID: state.draftID) + state.draftAttachments = DraftAttachments() + return .cancel(id: CancelID.templateWatch) + } +} diff --git a/graphcode/Sources/Features/Project/ProjectFeature+NodeForm.swift b/graphcode/Sources/Features/Project/ProjectFeature+NodeForm.swift new file mode 100644 index 00000000..29288ee9 --- /dev/null +++ b/graphcode/Sources/Features/Project/ProjectFeature+NodeForm.swift @@ -0,0 +1,191 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// The New Node dialog's reducer half: opening the form with every field at its default, +/// and turning what was filled in into the commands that create the loop. +/// +/// Split out of `ProjectFeature.swift`, which sits at swiftlint's file-length budget, so +/// that the dialog's own code has somewhere to grow. A straight move — see git history +/// for what changed. +extension ProjectFeature { + /// The Create button's whole handler — in the trailing extension beside + /// `openNodeForm` and the rest of the form's helpers, and for the same reason. + func confirmCreateNode(_ state: inout State) -> Effect { + let draft = state.draft + // `isValid` carries the same rules the daemon enforces, so an incomplete form + // simply doesn't submit — the Create button is disabled on it too, and this is + // the backstop for the keyboard shortcut path. + guard draft.isValid else { return .none } + // Composite is deliberately not remembered: creating one is a rare, structural + // act, and the *next* loop is almost never another composite — remembering it + // made the heaviest type the default everywhere (see `loopType(remembered:)`). + if draft.loopType != .composite { + UserDefaults.standard.set(draft.loopType.rawValue, forKey: Self.lastLoopTypeKey) + } + let projectPath = state.graph.project.path + // A form opened from a node card's + handle also wires the new loop up: a + // default hand-off edge from the parent, created right after the node so the + // graph never broadcasts a child floating unconnected. + let parentNodeID = state.draftParentNodeID + let custodial = state.draftParentIsCustodial + // Asked for from the entry handle, so it is a beginning on purpose — without this it + // would land as `.unwired`, which the canvas draws dimmed and dashed and offers to + // fix. See `CardEntryRole`. + if state.draftDeclaresEntry { state.declaredEntryIDs.insert(draft.id) } + state.draftDeclaresEntry = false + state.draftParentNodeID = nil + state.draftParentIsCustodial = false + state.showingNewNodeForm = false + // The directory watch belongs to the open dialog, not to the store — creating a + // loop closes the form just as Cancel does, and leaving it running would keep + // re-reading the library for a form nobody is looking at. + let closedWatch = Effect.cancel(id: CancelID.templateWatch) + // Inside a composite, the same commands are addressed at its sub-graph. This is + // the app half of "add loops inside" — the step the dialog's own strip promises. + let insideComposite = state.openCompositeID + // **Create & open**, honoured: a composite made from the project canvas opens + // straight away, which is what its button has always said it would do. Only from + // the top level — a composite created inside another would otherwise take the + // canvas somewhere the human didn't ask to go. + if draft.loopType == .composite, insideComposite == nil { + state.openCompositeID = draft.id + } + // A loop created from the form switches to itself once its broadcast lands — + // see `pendingCreatedNodeID`. Not composites: opening their sub-graph canvas + // (above) already is the switch. Not inside a composite either: the drilled-in + // canvas the human is looking at is where the new card appears, and workspace + // opening (`AppFeature`'s `.nodeTapped`) only reaches top-level nodes anyway. + if draft.loopType != .composite, insideComposite == nil { + state.pendingCreatedNodeID = draft.id + } + + // Creating the worktree is the app's job, not the daemon's: `GitClient` lives + // here, and a failure needs somewhere to be shown. If it fails, the node is + // still created — unbound rather than not at all — since losing the loop over a + // branch that already exists would be the more annoying outcome. + let request = state.newWorktreeRequest + return .merge( + closedWatch, + .run { send in + var resolved = draft + // A custody child carries its parent on the draft; the daemon draws the + // fired-at-birth link and writes the report-back memo, exactly as it does + // for a CLI-created child. No separate edge command, so nothing blocks. + if custodial, let parentNodeID { resolved.createdBy = parentNodeID } + if let request { + do { + resolved.worktree = try await gitClient.createWorktree( + request.repositoryPath, request.worktreePath, request.branch) + } catch { + await send(.worktreeCreationFailed(String(describing: error))) + } + } + func addressed(_ command: GraphCommand) -> GraphCommand { + insideComposite.map { .subGraphCommand(nodeID: $0, command: command) } ?? command + } + try? await orchestratorClient.send( + .graphCommand(projectPath: projectPath, command: addressed(.createNode(resolved)))) + if let parentNodeID, !custodial { + try? await orchestratorClient.send( + .graphCommand( + projectPath: projectPath, + command: addressed( + .createEdge(from: parentNodeID, to: draft.id, spec: EdgeSpec())))) + } + + // A blank title creates the node as "NewNode" and asks the loop's own + // backend for a real one — after creation, so a slow (or absent) CLI never + // holds the node itself hostage. The rename can target the node because the + // draft's id *is* the node's id (see `NodeDraft.id`); no answer just means + // the fallback name stays. + guard draft.title.trimmingCharacters(in: .whitespaces).isEmpty, + let basis = [ + draft.checkDescription, draft.triggerPrompt, draft.goal?.summary, + draft.firstInstruction, + ] + .compactMap({ $0 }) + .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }), + let title = await titleSuggestionClient.suggest( + draft.effectiveBackend, basis, loopTitleDirectory.allTitles()) + else { return } + try? await orchestratorClient.send( + .graphCommand( + projectPath: projectPath, command: addressed(.renameNode(draft.id, title: title)))) + }) + } + + func openNodeForm( + _ state: inout State, backend: CLISessionBackendKind?, parentNodeID: UUID?, + custodial: Bool = false, declaresEntry: Bool = false + ) -> Effect { + state.draftID = UUID() + state.draftDeclaresEntry = declaresEntry + state.draftLoopType = Self.rememberedLoopType + state.draftTitle = "" + state.draftCheck = "" + state.draftPrompt = "" + state.draftGoal = "" + state.draftPredicate = "" + state.draftMetric = "" + state.draftMetricDirection = .maximize + state.isMetricExpanded = false + state.draftBudget = "" + state.isBudgetExpanded = false + state.doneCheckOutcome = nil + state.isTestingDoneCheck = false + state.draftFirstInstruction = "" + state.draftPausesBeforeWritesOnly = false + state.draftSketchNote = "" + state.draftAttachments = DraftAttachments() + state.draftInterval = .hourly + // While the experiment is on, the daemon heartbeat is the *default* for new timed + // loops — the /loop skill runs only when a person explicitly picks "Itself, with + // /loop" in the form. The toggle governing a default rather than mere availability + // is a deliberate, user-directed reversal of the earlier converts-nothing stance; + // existing loops are still never converted. Same settings read the defaultBackend + // line below already does. + state.draftUsesHeartbeat = GraphcodeSettingsStore.load().daemonHeartbeatEnabled + state.draftCustomInterval = "" + state.draftTimedTask = "" + state.draftStopAfter = "" + state.draftSchedule = .daily + state.draftScheduleTime = "09:00" + state.draftSubGraph = nil + // The parent's backend when there is one, then the open composite's — its workers + // run on what it runs on — and the human's default otherwise (Settings → Sessions), + // never a hardcoded one. + state.draftBackend = + backend ?? state.openCompositeID.flatMap { state.graph.nodes[id: $0]?.backend } + ?? GraphcodeSettingsStore.load().defaultBackend + let settings = GraphcodeSettingsStore.load() + state.draftModelTier = settings.autoSelectsModel ? nil : settings.defaultModelTier + state.draftWorktree = .none + state.draftBranch = "" + state.draftParentNodeID = parentNodeID + state.draftParentIsCustodial = custodial + state.templates = TemplateFormState() + state.showingNewNodeForm = true + let repositoryPath = state.graph.project.path + return .merge( + .run { send in + // A non-repo folder just yields nothing — a missing worktree list is not worth + // an error banner when the picker degrades to "None" on its own. + let worktrees = (try? await gitClient.listWorktrees(repositoryPath)) ?? [] + await send(.worktreesLoaded(worktrees)) + }, + // The template library rides in with the form: read once, then kept current by + // the directory watch, so an external edit or a `git pull` shows up without a + // relaunch (PROMPT_TEMPLATES.md § Storage). + .run { send in + await send(.templateLibraryChanged(await templateLibrary.load(repositoryPath))) + }, + .run { [projectPath = repositoryPath] send in + for await _ in templateLibrary.watch(projectPath) { + await send(.templateLibraryChanged(await templateLibrary.load(projectPath))) + } + } + .cancellable(id: CancelID.templateWatch, cancelInFlight: true) + ) + } +} diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index afeabd97..99b78542 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -90,6 +90,8 @@ struct ProjectFeature { /// creation, so it is a statement of intent until the thing is piloted and armed. var draftSchedule: CompositeSchedule = .daily var draftScheduleTime = "09:00" + /// Images pasted or dropped onto the brief field — see `DraftAttachments`. + var draftAttachments = DraftAttachments() var draftBackend: CLISessionBackendKind = .claudeCode var draftModelTier: ModelTier? var draftWorktree: WorktreeSelection = .none @@ -216,6 +218,8 @@ struct ProjectFeature { case canvasRowBudgetChanged(Int) case createNodeConfirmed case cancelNewNodeForm + /// What the brief field's images did — see `DraftAttachmentAction`. + case draftAttachment(DraftAttachmentAction) case nodeTapped(UUID) /// Drill into a composite — the canvas starts drawing its sub-graph instead. case compositeOpened(UUID) @@ -402,9 +406,9 @@ struct ProjectFeature { &state, backend: state.graph.nodes[id: parentID]?.backend, parentNodeID: parentID, custodial: true) - case .cancelNewNodeForm: - state.showingNewNodeForm = false - return .cancel(id: CancelID.templateWatch) + case .cancelNewNodeForm: return cancelNodeForm(&state) + + case .draftAttachment(let action): return draftAttachment(&state, action) case .createNodeConfirmed: return confirmCreateNode(&state) @@ -740,185 +744,6 @@ extension ProjectFeature { } } - /// The Create button's whole handler — in the trailing extension beside - /// `openNodeForm` and the rest of the form's helpers, and for the same reason. - func confirmCreateNode(_ state: inout State) -> Effect { - let draft = state.draft - // `isValid` carries the same rules the daemon enforces, so an incomplete form - // simply doesn't submit — the Create button is disabled on it too, and this is - // the backstop for the keyboard shortcut path. - guard draft.isValid else { return .none } - // Composite is deliberately not remembered: creating one is a rare, structural - // act, and the *next* loop is almost never another composite — remembering it - // made the heaviest type the default everywhere (see `loopType(remembered:)`). - if draft.loopType != .composite { - UserDefaults.standard.set(draft.loopType.rawValue, forKey: Self.lastLoopTypeKey) - } - let projectPath = state.graph.project.path - // A form opened from a node card's + handle also wires the new loop up: a - // default hand-off edge from the parent, created right after the node so the - // graph never broadcasts a child floating unconnected. - let parentNodeID = state.draftParentNodeID - let custodial = state.draftParentIsCustodial - // Asked for from the entry handle, so it is a beginning on purpose — without this it - // would land as `.unwired`, which the canvas draws dimmed and dashed and offers to - // fix. See `CardEntryRole`. - if state.draftDeclaresEntry { state.declaredEntryIDs.insert(draft.id) } - state.draftDeclaresEntry = false - state.draftParentNodeID = nil - state.draftParentIsCustodial = false - state.showingNewNodeForm = false - // The directory watch belongs to the open dialog, not to the store — creating a - // loop closes the form just as Cancel does, and leaving it running would keep - // re-reading the library for a form nobody is looking at. - let closedWatch = Effect.cancel(id: CancelID.templateWatch) - // Inside a composite, the same commands are addressed at its sub-graph. This is - // the app half of "add loops inside" — the step the dialog's own strip promises. - let insideComposite = state.openCompositeID - // **Create & open**, honoured: a composite made from the project canvas opens - // straight away, which is what its button has always said it would do. Only from - // the top level — a composite created inside another would otherwise take the - // canvas somewhere the human didn't ask to go. - if draft.loopType == .composite, insideComposite == nil { - state.openCompositeID = draft.id - } - // A loop created from the form switches to itself once its broadcast lands — - // see `pendingCreatedNodeID`. Not composites: opening their sub-graph canvas - // (above) already is the switch. Not inside a composite either: the drilled-in - // canvas the human is looking at is where the new card appears, and workspace - // opening (`AppFeature`'s `.nodeTapped`) only reaches top-level nodes anyway. - if draft.loopType != .composite, insideComposite == nil { - state.pendingCreatedNodeID = draft.id - } - - // Creating the worktree is the app's job, not the daemon's: `GitClient` lives - // here, and a failure needs somewhere to be shown. If it fails, the node is - // still created — unbound rather than not at all — since losing the loop over a - // branch that already exists would be the more annoying outcome. - let request = state.newWorktreeRequest - return .merge( - closedWatch, - .run { send in - var resolved = draft - // A custody child carries its parent on the draft; the daemon draws the - // fired-at-birth link and writes the report-back memo, exactly as it does - // for a CLI-created child. No separate edge command, so nothing blocks. - if custodial, let parentNodeID { resolved.createdBy = parentNodeID } - if let request { - do { - resolved.worktree = try await gitClient.createWorktree( - request.repositoryPath, request.worktreePath, request.branch) - } catch { - await send(.worktreeCreationFailed(String(describing: error))) - } - } - func addressed(_ command: GraphCommand) -> GraphCommand { - insideComposite.map { .subGraphCommand(nodeID: $0, command: command) } ?? command - } - try? await orchestratorClient.send( - .graphCommand(projectPath: projectPath, command: addressed(.createNode(resolved)))) - if let parentNodeID, !custodial { - try? await orchestratorClient.send( - .graphCommand( - projectPath: projectPath, - command: addressed( - .createEdge(from: parentNodeID, to: draft.id, spec: EdgeSpec())))) - } - - // A blank title creates the node as "NewNode" and asks the loop's own - // backend for a real one — after creation, so a slow (or absent) CLI never - // holds the node itself hostage. The rename can target the node because the - // draft's id *is* the node's id (see `NodeDraft.id`); no answer just means - // the fallback name stays. - guard draft.title.trimmingCharacters(in: .whitespaces).isEmpty, - let basis = [ - draft.checkDescription, draft.triggerPrompt, draft.goal?.summary, - draft.firstInstruction, - ] - .compactMap({ $0 }) - .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }), - let title = await titleSuggestionClient.suggest( - draft.effectiveBackend, basis, loopTitleDirectory.allTitles()) - else { return } - try? await orchestratorClient.send( - .graphCommand( - projectPath: projectPath, command: addressed(.renameNode(draft.id, title: title)))) - }) - } - - func openNodeForm( - _ state: inout State, backend: CLISessionBackendKind?, parentNodeID: UUID?, - custodial: Bool = false, declaresEntry: Bool = false - ) -> Effect { - state.draftID = UUID() - state.draftDeclaresEntry = declaresEntry - state.draftLoopType = Self.rememberedLoopType - state.draftTitle = "" - state.draftCheck = "" - state.draftPrompt = "" - state.draftGoal = "" - state.draftPredicate = "" - state.draftMetric = "" - state.draftMetricDirection = .maximize - state.isMetricExpanded = false - state.draftBudget = "" - state.isBudgetExpanded = false - state.doneCheckOutcome = nil - state.isTestingDoneCheck = false - state.draftFirstInstruction = "" - state.draftPausesBeforeWritesOnly = false - state.draftSketchNote = "" - state.draftInterval = .hourly - // While the experiment is on, the daemon heartbeat is the *default* for new timed - // loops — the /loop skill runs only when a person explicitly picks "Itself, with - // /loop" in the form. The toggle governing a default rather than mere availability - // is a deliberate, user-directed reversal of the earlier converts-nothing stance; - // existing loops are still never converted. Same settings read the defaultBackend - // line below already does. - state.draftUsesHeartbeat = GraphcodeSettingsStore.load().daemonHeartbeatEnabled - state.draftCustomInterval = "" - state.draftTimedTask = "" - state.draftStopAfter = "" - state.draftSchedule = .daily - state.draftScheduleTime = "09:00" - state.draftSubGraph = nil - // The parent's backend when there is one, then the open composite's — its workers - // run on what it runs on — and the human's default otherwise (Settings → Sessions), - // never a hardcoded one. - state.draftBackend = - backend ?? state.openCompositeID.flatMap { state.graph.nodes[id: $0]?.backend } - ?? GraphcodeSettingsStore.load().defaultBackend - let settings = GraphcodeSettingsStore.load() - state.draftModelTier = settings.autoSelectsModel ? nil : settings.defaultModelTier - state.draftWorktree = .none - state.draftBranch = "" - state.draftParentNodeID = parentNodeID - state.draftParentIsCustodial = custodial - state.templates = TemplateFormState() - state.showingNewNodeForm = true - let repositoryPath = state.graph.project.path - return .merge( - .run { send in - // A non-repo folder just yields nothing — a missing worktree list is not worth - // an error banner when the picker degrades to "None" on its own. - let worktrees = (try? await gitClient.listWorktrees(repositoryPath)) ?? [] - await send(.worktreesLoaded(worktrees)) - }, - // The template library rides in with the form: read once, then kept current by - // the directory watch, so an external edit or a `git pull` shows up without a - // relaunch (PROMPT_TEMPLATES.md § Storage). - .run { send in - await send(.templateLibraryChanged(await templateLibrary.load(repositoryPath))) - }, - .run { [projectPath = repositoryPath] send in - for await _ in templateLibrary.watch(projectPath) { - await send(.templateLibraryChanged(await templateLibrary.load(projectPath))) - } - } - .cancellable(id: CancelID.templateWatch, cancelInFlight: true) - ) - } - /// One-liner for the several actions that are just "route this straight to the /// daemon and wait for the broadcast". private func send(_ state: State, _ command: GraphCommand) -> Effect { diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index 531d91ff..244522a5 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -78,6 +78,7 @@ extension ProjectFeature.State { // Attribution only — the card can say where the brief came from. The follow // travels too, for the two types that follow: timed and composite re-read the // template on their next run; goal, turn and main snapshot at creation. + attachments: draftAttachments.items, createdFromTemplateID: templates.applied?.id, templateFollow: { guard let applied = templates.applied, @@ -110,6 +111,19 @@ extension ProjectFeature.State { } } + /// `currentBriefText`'s counterpart — where an image's `[image #N]` placeholder is + /// written, so it lands in the field the human is actually filling in. A composite + /// has no prose field, and nothing writes one. + mutating func setBriefText(_ text: String) { + switch draftLoopType { + case .sketch: draftSketchNote = text + case .goalBased: draftGoal = text + case .timeBased: draftTimedTask = text + case .turnBased: draftFirstInstruction = text + case .composite: break + } + } + /// Which of the applied template's `{token}`s is still a hole. A token the human /// has typed over is gone as text and so is the hole. /// diff --git a/graphcode/Tests/PromptAttachmentTests.swift b/graphcode/Tests/PromptAttachmentTests.swift new file mode 100644 index 00000000..e914b066 --- /dev/null +++ b/graphcode/Tests/PromptAttachmentTests.swift @@ -0,0 +1,337 @@ +import AppKit +import ComposableArchitecture +import Foundation +import Testing + +@testable import GraphcodeKit +@testable import graphcode + +/// What happens to a picture between the New Node dialog and the agent. +/// +/// The image itself never moves: `zmx` types a session's launch command into a PTY, so +/// the prompt is text on a line and a path is the only thing that can ride it. These are +/// the rules that make that path land where the human put the picture. +@Suite +struct PromptAttachmentTests { + private static func attachment(_ path: String) -> PromptAttachment { + PromptAttachment(id: UUID(), path: path) + } + + @Test + func aPlaceholderBecomesThePathWhereItStood() { + let resolved = PromptAttachments.resolving( + "compare [image #1] with the current header", + attachments: [Self.attachment("/tmp/a.png")]) + #expect(resolved == "compare /tmp/a.png with the current header") + } + + @Test + func eachPlaceholderTakesItsOwnPath() { + let resolved = PromptAttachments.resolving( + "[image #2] is what [image #1] should look like", + attachments: [Self.attachment("/tmp/a.png"), Self.attachment("/tmp/b.png")]) + #expect(resolved == "/tmp/b.png is what /tmp/a.png should look like") + } + + @Test + func animageTheTextNeverNamedIsStatedAtTheEnd() throws { + // A human can delete the placeholder and keep the chip. The picture is still + // attached, so the prompt still has to say where it is. + let resolved = try #require( + PromptAttachments.resolving( + "make the header match", attachments: [Self.attachment("/tmp/a.png")])) + #expect(resolved.hasPrefix("make the header match ")) + #expect(resolved.contains("An image for this task is at /tmp/a.png")) + } + + @Test + func aPromptThatIsOnlyAnImageIsStillAPrompt() throws { + let resolved = try #require( + PromptAttachments.resolving("", attachments: [Self.attachment("/tmp/a.png")])) + #expect(resolved.hasPrefix("An image for this task is at /tmp/a.png")) + } + + @Test + func noAttachmentsLeavesTheTextExactlyAsWritten() { + #expect(PromptAttachments.resolving("plain", attachments: []) == "plain") + #expect(PromptAttachments.resolving(nil, attachments: []) == nil) + } + + @Test + func removingTheMiddleImageRenumbersTheOnesBehindIt() { + // Otherwise `[image #3]` survives a two-image draft and resolves to nothing. + let text = PromptAttachments.removing( + attachment: 2, from: "a [image #1] b [image #2] c [image #3]", of: 3) + #expect(text == "a [image #1] b c [image #2]") + } + + @Test + func aGoalCarriesThePathInsideTheConditionRatherThanAfterIt() throws { + // `/goal` takes the rest of the line as its condition, so a path appended past the + // predicate and the metric would become part of what an evaluator judges. + let node = LoopNode( + title: "Header", loopType: .goalBased, + attachments: [Self.attachment("/tmp/a.png")], + goal: GoalSpec(summary: "the header matches [image #1]", predicate: "swift test"), + backend: .claudeCode) + let prompt = try #require(node.sessionPrompt) + let path = try #require(prompt.range(of: "/tmp/a.png")) + let predicate = try #require(prompt.range(of: "The goal counts as met when")) + #expect(path.lowerBound < predicate.lowerBound) + } + + @Test + func aSketchWhoseNoteIsOnlyAnImageOpensAnyway() throws { + // A blank note means the session opens quiet — but an attached picture is itself + // something to say, so this one has a prompt. + let node = LoopNode( + title: "Look", loopType: .sketch, firstInstruction: nil, + attachments: [Self.attachment("/tmp/a.png")]) + let prompt = try #require(node.sessionPrompt) + #expect(prompt.contains("/tmp/a.png")) + #expect(LoopNode(title: "Look", loopType: .sketch).sessionPrompt == nil) + } + + @Test + func aTimedLoopKeepsItsDirectiveAndCarriesThePathIntoTheTask() throws { + // The directive has to stay the first thing on the line or no schedule is armed + // (issue #179); the path belongs inside the task it repeats. + let node = LoopNode( + title: "Watch", loopType: .timeBased, + triggerPrompt: "/loop 1h check the banner against [image #1]", + attachments: [Self.attachment("/tmp/a.png")]) + let prompt = try #require(node.sessionPrompt) + #expect(prompt.hasPrefix("/loop 1h ")) + #expect(prompt.contains("/tmp/a.png")) + } + + @Test + func aPathVerifyingBackendIsGrantedTheDirectoryTheImageIsIn() { + // Codex and Copilot check paths, and a prompt naming a file the session is denied + // reads as the agent ignoring its instructions. + let node = LoopNode( + title: "Header", loopType: .goalBased, + attachments: [Self.attachment("/tmp/shots/a.png")], + goal: GoalSpec(summary: "match [image #1]"), backend: .codex) + let paths = ZmxSessionLauncher.workspacePaths(forNode: node, projectPath: "/tmp/project") + #expect(paths.contains("/tmp/shots")) + } + + @Test + func aDraftCarriesItsAttachmentsOntoTheNodeAndOverTheWire() throws { + let draft = NodeDraft( + title: "Header", loopType: .sketch, firstInstruction: "look at [image #1]", + attachments: [Self.attachment("/tmp/a.png")]) + #expect(draft.makeNode().attachments.map(\.path) == ["/tmp/a.png"]) + let decoded = try JSONDecoder().decode( + NodeDraft.self, from: try JSONEncoder().encode(draft)) + #expect(decoded.attachments == draft.attachments) + } + + @Test + func aDraftFromACLIThatPredatesAttachmentsDecodesWithNone() throws { + // Loops keep creating nodes with whatever `graphcode` binary they already have. + let json = #"{"title":"Header","loopType":"sketch"}"# + let decoded = try JSONDecoder().decode(NodeDraft.self, from: Data(json.utf8)) + #expect(decoded.attachments.isEmpty) + } +} + +/// Reading a pasteboard, and the one judgement call in it. +@Suite +struct DraftImageImportTests { + private static func pasteboard() -> NSPasteboard { + let board = NSPasteboard(name: NSPasteboard.Name("graphcode.test.\(UUID().uuidString)")) + board.clearContents() + return board + } + + private static let onePixelPNG = Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + )! + + @Test + func aScreenshotOnTheBoardIsTakenAsAnImage() throws { + let board = Self.pasteboard() + board.setData(Self.onePixelPNG, forType: .png) + let payload = try #require(DraftImageImport.payload(on: board)) + #expect(payload.fileExtension == "png") + #expect(payload.data == Self.onePixelPNG) + } + + @Test + func aBoardCarryingWordsAsWellAsPixelsIsReadAsWords() { + // Copying out of a rich-text editor puts a rendering of the selection on the board + // beside the text. Swallowing that ⌘V would lose a paste the human meant; missing + // an image paste costs nothing, because the same picture can be dragged in. + let board = Self.pasteboard() + board.setData(Self.onePixelPNG, forType: .png) + board.setString("some words", forType: .string) + #expect(DraftImageImport.payload(on: board) == nil) + } + + @Test + func aCopiedImageFileWinsEvenWithTextBesideIt() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-test-\(UUID().uuidString).png") + try Self.onePixelPNG.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let board = Self.pasteboard() + board.writeObjects([url as NSURL]) + board.setString(url.path, forType: .string) + let payload = try #require(DraftImageImport.payload(on: board)) + #expect(payload.data == Self.onePixelPNG) + } + + @Test + func aCopiedTextFileIsNotAnImage() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-test-\(UUID().uuidString).txt") + try Data("hello".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + let board = Self.pasteboard() + board.writeObjects([url as NSURL]) + #expect(DraftImageImport.payload(on: board) == nil) + } + + @Test + func anEmptyBoardLeavesPasteAlone() { + #expect(DraftImageImport.payload(on: Self.pasteboard()) == nil) + } +} + +/// The dialog's half: a pasted image is written down, its placeholder lands in the field +/// the human is filling in, and the draft carries the path. +@Suite +struct DraftAttachmentReducerTests { + private static let project = ProjectRef(path: "/tmp/graphcode-attachment-test", name: "t") + + private static let payload = DraftImageImport.Payload( + data: Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + )!, fileExtension: "png") + + private static func store( + _ project: ProjectRef = project, loopType: LoopType = .sketch, note: String = "" + ) -> TestStore { + var state = ProjectFeature.State(graph: LoopGraph(project: project)) + state.draftLoopType = loopType + state.draftSketchNote = note + let store = TestStore(initialState: state) { ProjectFeature() } + store.exhaustivity = .off + return store + } + + private static func cleanUp(_ store: TestStore) { + NodeMemory.remove(projectPath: store.state.graph.project.path, nodeID: store.state.draftID) + } + + @Test + @MainActor + func apastedImageIsWrittenDownAndStandsInTheBriefAsAPlaceholder() async throws { + let store = Self.store(note: "match this") + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) { + $0.draftSketchNote = "match this [image #1]" + $0.draftAttachments.taken = 1 + } + let path = try #require(store.state.draftAttachments.items.first?.path) + #expect(FileManager.default.fileExists(atPath: path)) + // The draft is what crosses the wire, and the node's prompt is composed from it. + #expect(store.state.draft.attachments.map(\.path) == [path]) + #expect(store.state.draft.makeNode().sessionPrompt?.contains(path) == true) + } + + @Test + @MainActor + func aSecondImageNeverLandsOnTheFirstOnesFile() async throws { + let store = Self.store() + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) + await store.send(.draftAttachment(.imageArrived(Self.payload))) + let paths = store.state.draftAttachments.items.map(\.path) + #expect(paths.count == 2) + #expect(Set(paths).count == 2) + #expect(store.state.draftSketchNote == "[image #1] [image #2]") + } + + @Test + @MainActor + func removingAChipTakesItsFileAndItsPlaceholder() async throws { + let store = Self.store() + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) + await store.send(.draftAttachment(.imageArrived(Self.payload))) + let first = try #require(store.state.draftAttachments.items.first) + await store.send(.draftAttachment(.removed(first.id))) + + #expect(!FileManager.default.fileExists(atPath: first.path)) + #expect(store.state.draftAttachments.items.count == 1) + // Renumbered, so the placeholder left behind still resolves. + #expect(store.state.draftSketchNote == "[image #1]") + } + + @Test + @MainActor + func theNextPasteAfterARemovalStillGetsAFileOfItsOwn() async throws { + // Numbering files by position would hand this one the name the surviving image + // already has. + let store = Self.store() + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) + await store.send(.draftAttachment(.imageArrived(Self.payload))) + let first = try #require(store.state.draftAttachments.items.first) + await store.send(.draftAttachment(.removed(first.id))) + let survivor = try #require(store.state.draftAttachments.items.first?.path) + await store.send(.draftAttachment(.imageArrived(Self.payload))) + + #expect(store.state.draftAttachments.items.map(\.path).contains(survivor)) + #expect(Set(store.state.draftAttachments.items.map(\.path)).count == 2) + #expect(FileManager.default.fileExists(atPath: survivor)) + } + + @Test + @MainActor + func cancellingTheDialogTakesThePicturesWithIt() async throws { + let store = Self.store() + await store.send(.draftAttachment(.imageArrived(Self.payload))) + let path = try #require(store.state.draftAttachments.items.first?.path) + + await store.send(.cancelNewNodeForm) { + $0.showingNewNodeForm = false + $0.draftAttachments = ProjectFeature.DraftAttachments() + } + #expect(!FileManager.default.fileExists(atPath: path)) + } + + @Test + @MainActor + func aLoopOnAnotherMachineSaysSoRatherThanNamingAFileThatHostNeverSaw() async { + // The ensure dial that delivers graphcode's files to a remote host carries text. + let remote = ProjectRef(path: "ssh://box/~/work/repo", name: "repo") + let store = Self.store(remote) + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) { + $0.draftAttachments.notice = + "Images can't be attached to a loop on another machine yet." + } + #expect(store.state.draftAttachments.items.isEmpty) + } + + @Test + @MainActor + func aCompositeHasNoPromptForAPathToTravelIn() async { + let store = Self.store(loopType: .composite) + defer { Self.cleanUp(store) } + + await store.send(.draftAttachment(.imageArrived(Self.payload))) + #expect(store.state.draftAttachments.items.isEmpty) + } +}