From f6c2a1b17e0288a2030fc339940c8bbf20066cd3 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 09:15:14 -0700 Subject: [PATCH] Ship ten starter templates, so a fresh install teaches the loop types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new library is empty, and an empty ⌘T picker teaches nothing: the person who most needs to know what a Goal loop is for is exactly the person who has never written one. Templates are the natural place to explain the taxonomy, because a template can *demonstrate* a type rather than describe it. `MAIN_LOOP.md` orders the five types by how much you have to decide before the loop can start; the tour subtitles them by what makes them stop. The starter set makes that axis concrete by being it — the lesson is in each template's settings, not its prose: - **Main** ×2 — no done check and no cadence, because "I understood it" is not checkable by a command. *Where does this live?* · *Why did this break?* - **Goal** ×3 — its three separate cases: a check that decides (*Get the build green*, whose token sits in the done check so filling it is what teaches that exit 0 stops the loop), no check at all (*Review the diff on this branch*, which resolves when the work is finished), and a metric (*Raise test coverage*). - **Timed** ×2 — a brief worth *repeating*. *Nightly dependency review* · *Watch the build*. These are also the two that follow their file. - **Turn** ×2 — the two pause rhythms side by side, which is the only way the difference reads. *Port {area} to {target}* pauses before writes; *Pair on this* pauses every turn. - **Composite** ×1 — *Review, fix, verify* carries three children and the two hand-offs between them, which is what a composite template is for. Shipped as real markdown files seeded into `~/.graphcode/templates`, not as constants, so the format teaches itself: open one and the front matter is there. Seeding runs **once**, guarded by a dotfile marker, and never writes over a name somebody already used — a starter you delete stays deleted. Surfaced in two places: - **An empty canvas** offers three of them — one Main, one Goal, one Timed, so the row climbs the commitment ladder — each labelled with what makes that type stop. One click opens the New loop dialog with the brief already applied. - **The ⌘T picker** groups them under `Starters`, above All projects and below a project's own committed templates, *until* somebody saves a brief of their own. After that the scaffolding stops outranking their work and sorts in with everything else. `PromptTemplate.isStarter` rides in the file as `starter: true`, so the mark survives an edit and is visible to anyone reading the folder. 1482 tests (up from 1467), 22 of them new. swiftlint 0 errors — `AppFeature` was one line over its type-body budget, so `onboardingDismissed`'s body moved to the trailing extension the way that file's convention says to. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MJR8Wuwc4qnaSBUMcYf7Af --- .../Sources/Templates/PromptTemplate.swift | 14 +- .../Sources/Templates/StarterTemplates.swift | 226 ++++++++++++++++++ .../Sources/Templates/TemplateStorage.swift | 40 ++++ .../Clients/TemplateLibraryClient.swift | 11 +- .../Sources/Features/App/AppFeature.swift | 18 +- .../Features/Project/CanvasEmptyState.swift | 92 ++++++- .../Features/Project/ProjectCanvasView.swift | 8 +- .../Project/ProjectFeature+Templates.swift | 22 ++ .../Features/Project/ProjectFeature.swift | 7 +- .../Project/ProjectFeatureState.swift | 44 +++- .../Features/Project/TemplatePickerView.swift | 9 +- graphcode/Tests/StarterTemplateTests.swift | 173 ++++++++++++++ graphcode/Tests/TemplatePickerTests.swift | 78 ++++++ 13 files changed, 712 insertions(+), 30 deletions(-) create mode 100644 GraphcodeKit/Sources/Templates/StarterTemplates.swift create mode 100644 graphcode/Tests/StarterTemplateTests.swift diff --git a/GraphcodeKit/Sources/Templates/PromptTemplate.swift b/GraphcodeKit/Sources/Templates/PromptTemplate.swift index 30ef1ce6..75f5f4e0 100644 --- a/GraphcodeKit/Sources/Templates/PromptTemplate.swift +++ b/GraphcodeKit/Sources/Templates/PromptTemplate.swift @@ -26,6 +26,11 @@ public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { /// either names itself inside. Fresh templates (not yet written) derive it from /// their name. public var fileName: String + /// Whether this is one of the briefs the app ships with (`StarterTemplates`). + /// Written into the file as `starter: true`, so it survives an edit and is visible + /// to anyone reading the file — and so the picker can group the scaffolding apart + /// from a library somebody has actually built. + public var isStarter: Bool /// How many times this template has been applied, as this app has counted it. /// **Never written back into the file** — applying a template must not dirty a /// repository's working tree, and a project folder may not even be writable. It is @@ -48,6 +53,7 @@ public struct PromptTemplate: Codable, Equatable, Identifiable, Sendable { self.settings = settings self.origin = origin self.fileName = Self.fileName(for: name) + self.isStarter = false self.useCount = useCount } @@ -352,6 +358,7 @@ public enum TemplateFileCodec { var shape: LoopType? var settings = TemplateSettings() var hadSettings = false + var isStarter = false for rawLine in header.split(separator: "\n", omittingEmptySubsequences: false) { let line = rawLine.trimmingCharacters(in: .whitespaces) guard !line.isEmpty, !line.hasPrefix("#"), @@ -390,6 +397,8 @@ public enum TemplateFileCodec { case "graph", "subgraph": settings.graphJSON = unwrapped.isEmpty ? nil : unwrapped hadSettings = true + case "starter": + isStarter = unwrapped.lowercased() == "true" default: break } @@ -406,13 +415,15 @@ public enum TemplateFileCodec { .first.map { String($0.prefix(64)).trimmingCharacters(in: .whitespaces) } ?? "" let resolvedName = (name?.isEmpty == false ? name : fallbackName) ?? "" guard !bodyText.isEmpty || shape == .composite else { return nil } - return PromptTemplate( + var template = PromptTemplate( id: id, name: resolvedName, body: bodyText, shape: shape, settings: hadSettings ? settings : nil, origin: origin) + template.isStarter = isStarter + return template } /// Writes the whole file. The `id` line is what lets a following loop find its @@ -423,6 +434,7 @@ public enum TemplateFileCodec { lines.append("id: \(template.id.uuidString)") lines.append("name: \(quoteIfNeeded(template.name))") lines.append("shape: \(TemplateShapeWord.word(for: template.shape))") + if template.isStarter { lines.append("starter: true") } if let settings = template.settings, !settings.isEmpty { if let backend = settings.backend { lines.append("backend: \(backend.rawValue)") diff --git a/GraphcodeKit/Sources/Templates/StarterTemplates.swift b/GraphcodeKit/Sources/Templates/StarterTemplates.swift new file mode 100644 index 00000000..8cbe497e --- /dev/null +++ b/GraphcodeKit/Sources/Templates/StarterTemplates.swift @@ -0,0 +1,226 @@ +import Foundation + +/// The templates a fresh install starts with. +/// +/// A brand-new library is empty, and an empty ⌘T picker teaches nothing: the person +/// who most needs to know what a Goal loop is for is exactly the person who has never +/// written one. So the app ships ten briefs, and they are chosen to teach the +/// taxonomy rather than merely to be useful. +/// +/// `MAIN_LOOP.md` orders the five types by **how much you have to decide before the +/// loop can start**, and the onboarding tour subtitles them by **what makes them +/// stop**. The starter set makes that axis concrete by *being* it — the lesson is in +/// each template's settings, not in its prose: +/// +/// | Type | What its starters demonstrate | +/// | --- | --- | +/// | Main | No done check and no cadence, because "I understood it" is not checkable. | +/// | Goal | Exit 0 means done; the check is optional; a metric draws progress. | +/// | Timed | A brief worth *repeating*, and the two that follow their file. | +/// | Turn | The two pause rhythms, side by side. | +/// | Composite | Work handed along edges, carried inside one shareable file. | +/// +/// They are seeded as **real markdown files** rather than held as constants, which +/// teaches the format too: open one and the front matter is right there. Seeding runs +/// once (see `TemplateStorage.seedStartersIfNeeded`), so a starter you delete stays +/// deleted. +public enum StarterTemplates { + /// Ids are fixed rather than freshly generated so that a loop following a starter + /// keeps following it across a reinstall, and so a starter is recognisable as the + /// same template on two machines. + private static func id(_ suffix: String) -> UUID { + UUID(uuidString: "5747A57E-0000-4000-8000-\(suffix)") ?? UUID() + } + + public static var all: [PromptTemplate] { + [ + whereDoesThisLive, whyDidThisBreak, + getTheBuildGreen, reviewTheDiff, raiseTestCoverage, + nightlyDependencyReview, watchTheBuild, + portWithReview, pairOnThis, + reviewFixVerify, + ] + } + + /// The three offered on an empty canvas — one from each of the first three rungs of + /// the commitment ladder, so the pick itself shows the axis. Deliberately not five: + /// a row of every type is a taxonomy lesson, and this is a "get started" row. + public static var firstLaunchPicks: [PromptTemplate] { + [whereDoesThisLive, getTheBuildGreen, nightlyDependencyReview] + } + + // MARK: - Main + // Nothing to fill in, nothing to decide. Both of these end when you close them, + // which is the whole type. + + static var whereDoesThisLive: PromptTemplate { + starter( + id("100000000001"), "Where does this live?", + """ + Trace {symbol} through this codebase. Show me where it's defined, everything \ + that reads it, and everything that writes it. Don't change anything — I'm \ + trying to understand the shape before I touch it. + """) + } + + static var whyDidThisBreak: PromptTemplate { + starter( + id("100000000002"), "Why did this break?", + """ + Reproduce {symptom} and explain what causes it. Work from the failure back to \ + the line responsible. Stop when you can tell me the cause — I'll decide what to \ + do about it. + """) + } + + // MARK: - Goal + // Three, because Goal is the workhorse, and because its three lessons are separate: + // a check that decides, no check at all, and a metric. + + static var getTheBuildGreen: PromptTemplate { + starter( + id("200000000001"), "Get the build green", + """ + The build is failing. Find out why and fix it — the smallest change that works. \ + Don't refactor anything you weren't asked to. + """, + shape: .goalBased, + // The token sits in the *done check*, not the brief: filling it is what teaches + // that a Goal loop stops itself when a command says so, and that ⇥ walks every + // field a template left a hole in. + settings: TemplateSettings(doneCheck: "{test_command}")) + } + + static var reviewTheDiff: PromptTemplate { + starter( + id("200000000002"), "Review the diff on this branch", + """ + Review every file changed on {branch} against the conventions already in this \ + codebase. List what must change before merge, most important first, with \ + file:line. Don't fix anything — the list is the deliverable. + """, + // No done check on purpose: "a good review exists" is not a shell command, and + // a Goal loop without one resolves when it has finished the work. The pair of + // this and `getTheBuildGreen` is the lesson. + shape: .goalBased) + } + + static var raiseTestCoverage: PromptTemplate { + starter( + id("200000000003"), "Raise test coverage", + """ + Add tests for the least-covered code in {area}. Cover the behaviour that would \ + actually break, not the lines that are cheapest to hit. Keep every existing \ + test passing. + """, + shape: .goalBased, + settings: TemplateSettings(metric: "{coverage_command}")) + } + + // MARK: - Timed + // Both of these also demonstrate following: edit either file and the next run picks + // the change up, which is the half of the design a card has to state. + + static var nightlyDependencyReview: PromptTemplate { + starter( + id("300000000001"), "Nightly dependency review", + """ + Check for dependency updates worth taking. For each one: what changed, what it \ + would break here, and whether it's worth doing now. Say "nothing worth taking" \ + if that's the answer — a quiet night is a valid report. + """, + shape: .timeBased, + settings: TemplateSettings(cadence: "daily")) + } + + static var watchTheBuild: PromptTemplate { + starter( + id("300000000002"), "Watch the build", + """ + Check whether the build on {branch} is passing. If it broke since last time, \ + find the commit responsible and say what it changed. If it's still green, say \ + so in one line and stop. + """, + shape: .timeBased, + settings: TemplateSettings(cadence: "1h")) + } + + // MARK: - Turn + // The two pause rhythms, side by side — which is the only way the difference reads. + + static var portWithReview: PromptTemplate { + starter( + id("400000000001"), "Port {area} to {target}", + """ + Port {area} to {target}. Work file by file. Before each file you change, tell me \ + what you're about to do and why. + """, + shape: .turnBased, + settings: TemplateSettings(pausesBeforeWritesOnly: true)) + } + + static var pairOnThis: PromptTemplate { + starter( + id("400000000002"), "Pair on this", + """ + Work through {task} with me one step at a time. After each step, stop and tell \ + me what you did and what you think comes next. I'll steer. + """, + shape: .turnBased, + settings: TemplateSettings(pausesBeforeWritesOnly: false)) + } + + // MARK: - Composite + + /// Three loops and the two hand-offs between them, carried inside one file. This is + /// the template that shows what a composite template is *for*: an orchestration + /// somebody else can start from without drawing the graph. + static var reviewFixVerify: PromptTemplate { + let reviewer = LoopNode( + title: "Reviewer", loopType: .goalBased, + goal: GoalSpec( + summary: """ + Review every file changed on {branch} and list what must change, most \ + important first, with file:line. + """)) + let fixer = LoopNode( + title: "Fixer", loopType: .goalBased, + goal: GoalSpec( + summary: """ + Work through the findings you were handed, most important first. Make the \ + smallest change that resolves each one. + """)) + let verifier = LoopNode( + title: "Verifier", loopType: .goalBased, + goal: GoalSpec( + summary: "Confirm nothing the fixer changed broke anything else.", + predicate: "{test_command}")) + var graph = LoopGraph( + project: ProjectRef(path: "review-fix-verify", name: "Review, fix, verify"), + nodes: [reviewer, fixer, verifier]) + graph.edges = [ + LoopEdge(from: reviewer.id, to: fixer.id, spec: EdgeSpec()), + LoopEdge(from: fixer.id, to: verifier.id, spec: EdgeSpec()), + ] + return starter( + id("500000000001"), "Review, fix, verify", + """ + A reviewer hands its findings to a fixer, which hands the build to a verifier. \ + Nothing runs until you pilot it once. + """, + shape: .composite, + settings: TemplateSettings(graphJSON: TemplateSettings.graphJSON(for: graph))) + } + + // MARK: - Building + + private static func starter( + _ id: UUID, _ name: String, _ body: String, + shape: LoopType? = nil, settings: TemplateSettings? = nil + ) -> PromptTemplate { + var template = PromptTemplate( + id: id, name: name, body: body, shape: shape, settings: settings, origin: .home) + template.isStarter = true + return template + } +} diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift index ba75ea1c..9baaff25 100644 --- a/GraphcodeKit/Sources/Templates/TemplateStorage.swift +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -87,6 +87,46 @@ public struct TemplateStorage: Sendable { } } + // MARK: - Starters + + /// Writes the templates the app ships with into the home folder, **once**. + /// + /// A fresh install has an empty library, and an empty ⌘T picker teaches nothing — + /// see `StarterTemplates` for what the ten briefs are chosen to demonstrate. They + /// are written as real files so they read, diff and edit like any other template. + /// + /// Two rules keep this from being annoying: + /// - **Once.** Guarded by `seededMarker` in the home folder, so a starter you + /// deleted stays deleted rather than reappearing at every launch. + /// - **Never over anything.** A file already at that name is somebody's, and is + /// left exactly as it is even on the first run. + /// + /// Returns what it actually wrote, which is empty on every launch after the first. + @discardableResult + public func seedStartersIfNeeded(_ starters: [PromptTemplate] = StarterTemplates.all) throws + -> [PromptTemplate] + { + let marker = homeDirectory.appendingPathComponent(Self.seededMarker) + guard !FileManager.default.fileExists(atPath: marker.path) else { return [] } + try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) + var written: [PromptTemplate] = [] + for starter in starters { + let url = homeDirectory.appendingPathComponent(starter.fileName) + guard !FileManager.default.fileExists(atPath: url.path) else { continue } + var seeded = starter + seeded.origin = .home + try TemplateFileCodec.encode(seeded).write(to: url, atomically: true, encoding: .utf8) + written.append(seeded) + } + // The marker is written last and on its own: a run that threw half way through + // should try again, not leave someone with four of the ten. + try Data().write(to: marker, options: .atomic) + return written + } + + /// A dotfile, so it never shows up as a template — `read` skips hidden files. + static let seededMarker = ".starters-seeded" + // MARK: - Writing /// Saves a template. Returns where it actually landed: **home unless the caller diff --git a/graphcode/Sources/Clients/TemplateLibraryClient.swift b/graphcode/Sources/Clients/TemplateLibraryClient.swift index a29b2a8a..efac2a00 100644 --- a/graphcode/Sources/Clients/TemplateLibraryClient.swift +++ b/graphcode/Sources/Clients/TemplateLibraryClient.swift @@ -35,6 +35,9 @@ struct TemplateLibraryClient: Sendable { /// One more use of this template, as the picker counts them. App-local: applying a /// template must never write to the file, which may live in a repository. var recordUse: @Sendable (_ template: PromptTemplate) -> Void + /// Writes the briefs the app ships with, once, on a library that has never been + /// seeded — an empty ⌘T picker teaches nothing. See `StarterTemplates`. + var seedStarters: @Sendable () async -> Void } extension TemplateLibraryClient: DependencyKey { @@ -75,6 +78,11 @@ extension TemplateLibraryClient: DependencyKey { }, recordUse: { template in bumpUseCount(for: template) + }, + seedStarters: { + await Task.detached(priority: .utility) { + _ = try? TemplateStorage.shared.seedStartersIfNeeded() + }.value } ) @@ -86,7 +94,8 @@ extension TemplateLibraryClient: DependencyKey { watch: { _ in AsyncStream { $0.finish() } }, template: { _, _ in nil }, projectIsWritable: { _ in false }, - recordUse: { _ in } + recordUse: { _ in }, + seedStarters: {} ) /// The use count is app-local (UserDefaults, keyed on filename + origin) and is diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index aabcfe4a..4e7e7bb2 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -281,6 +281,7 @@ struct AppFeature { private enum CancelID { case daemonSubscription } @Dependency(\.orchestratorClient) var orchestratorClient + @Dependency(\.templateLibrary) var templateLibrary @Dependency(\.terminalLayoutStore) var terminalLayoutStore @Dependency(\.quickChatStore) var quickChatStore @Dependency(\.updateClient) var updateClient @@ -487,9 +488,7 @@ struct AppFeature { return .none case .onboardingDismissed: - state.showingOnboarding = false - UserDefaults.standard.set(true, forKey: "hasSeenOnboarding") - return .none + return finishOnboarding(&state) // Both handled by `historyReducer`, in `AppFeature+History.swift` — listed here // only so this switch stays exhaustive. @@ -624,6 +623,15 @@ struct AppFeature { // the state and actions above keep growing. extension AppFeature { + /// The tour is over — and stays over. Written down rather than held in state so a + /// relaunch doesn't start it again; in the extension for the same reason `start` is, + /// the type body being at swiftlint's limit. + private func finishOnboarding(_ state: inout State) -> Effect { + state.showingOnboarding = false + UserDefaults.standard.set(true, forKey: "hasSeenOnboarding") + return .none + } + /// Everything a launch has to do: restore the app-local lists, decide whether the /// primer is due, and open the daemon subscription the whole app hangs off. /// @@ -663,6 +671,10 @@ extension AppFeature { // Ramps refresh once per launch, silently — the cached copy answers reads // until this lands, and a failure keeps the last good configuration. .run { _ in await FeatureRamps.refresh() }, + // A fresh install has an empty template library, and an empty ⌘T picker teaches + // nothing about what the five loop types are for. Writes the shipped briefs + // once and never again, so a starter somebody deleted stays deleted. + .run { _ in await templateLibrary.seedStarters() }, .send(.checkForUpdatesInBackground) ) } diff --git a/graphcode/Sources/Features/Project/CanvasEmptyState.swift b/graphcode/Sources/Features/Project/CanvasEmptyState.swift index 6018b919..6a85f92f 100644 --- a/graphcode/Sources/Features/Project/CanvasEmptyState.swift +++ b/graphcode/Sources/Features/Project/CanvasEmptyState.swift @@ -1,3 +1,4 @@ +import GraphcodeKit import SwiftUI /// What a graph canvas shows when it has nothing on it yet. @@ -17,6 +18,10 @@ struct CanvasEmptyState: View { let message: String let actionTitle: String let action: () -> Void + /// A few of the briefs the app ships with, offered as one-click starts. Empty + /// everywhere but a folder's own empty canvas — see `StarterTemplates`. + var starters: [PromptTemplate] = [] + var onStart: ((PromptTemplate) -> Void)? init( symbol: String, title: String, message: String, actionTitle: String, @@ -30,7 +35,15 @@ struct CanvasEmptyState: View { } /// A folder's canvas with no loops in it. - init(projectName: String, onCreateLoop: @escaping () -> Void) { + /// + /// The starter row is the point of this state, not decoration: somebody who has + /// never made a loop is being asked to pick one of five types, and three briefs + /// they can actually start answer that better than any sentence about taxonomy. + init( + projectName: String, starters: [PromptTemplate] = [], + onStart: ((PromptTemplate) -> Void)? = nil, + onCreateLoop: @escaping () -> Void + ) { self.init( symbol: "point.3.connected.trianglepath.dotted", title: "No loops in \(projectName) yet", @@ -38,6 +51,8 @@ struct CanvasEmptyState: View { "Create a loop to run an AI coding session in this folder, then drag between loops to hand work off.", actionTitle: "Create Loop", action: onCreateLoop) + self.starters = starters + self.onStart = onStart } var body: some View { @@ -52,13 +67,82 @@ struct CanvasEmptyState: View { .multilineTextAlignment(.center) .frame(maxWidth: 340) Button(actionTitle, action: action) + if !starters.isEmpty, let onStart { + starterRow(onStart) + } } .padding(24) } + + /// Three briefs, each labelled with the type it makes — the row is a taxonomy + /// lesson that happens to also be a way to start working. + private func starterRow(_ onStart: @escaping (PromptTemplate) -> Void) -> some View { + VStack(spacing: 8) { + Text("OR START FROM A TEMPLATE") + .font(.system(size: 10, weight: .bold)) + .tracking(0.7) + .foregroundStyle(.secondary.opacity(0.7)) + .padding(.top, 10) + HStack(alignment: .top, spacing: 8) { + ForEach(starters) { starter in + Button { + onStart(starter) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 1.5) + .fill((starter.shape ?? .sketch).accent) + .frame(width: 3, height: 22) + VStack(alignment: .leading, spacing: 1) { + Text(starter.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(typeLabel(starter)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + Text(starter.summaryLine) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .lineLimit(2, reservesSpace: true) + .multilineTextAlignment(.leading) + } + .padding(9) + .frame(width: 168, alignment: .leading) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(starter.body) + } + } + } + } + + private func typeLabel(_ template: PromptTemplate) -> String { + switch template.shape { + case .sketch, nil: return "Main — stops when you close it" + case .goalBased: return "Goal — stops when it's done" + case .timeBased: + let cadence = template.settings?.cadence?.lowercased() ?? "" + return cadence.isEmpty ? "Timed — runs again" : "Timed · \(cadence)" + case .turnBased: return "Turn — pauses for you" + case .composite: return "Composite — a group of loops" + } + } } #Preview { - CanvasEmptyState(projectName: "preview", onCreateLoop: {}) - .frame(width: 560, height: 420) - .background(Theme.canvasBackground) + CanvasEmptyState( + projectName: "preview", starters: StarterTemplates.firstLaunchPicks, onStart: { _ in }, + onCreateLoop: {} + ) + .frame(width: 560, height: 420) + .background(Theme.canvasBackground) } diff --git a/graphcode/Sources/Features/Project/ProjectCanvasView.swift b/graphcode/Sources/Features/Project/ProjectCanvasView.swift index 6949afba..9b00f349 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasView.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasView.swift @@ -308,9 +308,15 @@ struct ProjectCanvasView: View { @ViewBuilder private var emptyState: some View { if store.canvasGraph.nodes.isEmpty { - CanvasEmptyState(projectName: store.openComposite?.title ?? store.graph.project.name) { + CanvasEmptyState( + projectName: store.openComposite?.title ?? store.graph.project.name, + starters: store.firstLaunchStarters, + onStart: { store.send(.startFromTemplateTapped($0.id)) } + ) { store.send(.addNodeButtonTapped(parentBackend: nil)) } + // Only an empty canvas reads the library, and only to fill the starter row. + .onAppear { store.send(.templateLibraryRequested) } } } diff --git a/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift index b36e1c80..57a5529e 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature+Templates.swift @@ -146,6 +146,28 @@ extension ProjectFeature { state.templates.focusRequest = fields[(index + 1) % fields.count] return .none + case .templateLibraryRequested: + // The empty canvas offers starters, and it is the one surface that needs the + // library before anybody has opened the New loop dialog. + let projectPath = state.graph.project.path + let library = templateLibrary + return .run { send in + await send(.templateLibraryChanged(await library.load(projectPath))) + } + + case .startFromTemplateTapped(let id): + guard let template = state.templates.library.first(where: { $0.id == id }) else { + return .none + } + // Opening the form resets the template state, so the library it just loaded is + // put back before the template lands in it. + let library = state.templates.library + let opened = openNodeForm(&state, backend: nil, parentNodeID: nil) + state.templates.library = library + applyTemplate(&state, template) + state.templates.focusRequest = .brief + return .merge(opened, countUse(of: template, in: state.graph.project.path)) + case .templateFocusConsumed: state.templates.focusRequest = nil return .none diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 431dfaf0..a1baf1a6 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -285,6 +285,8 @@ struct ProjectFeature { case templateQueryChanged(String) case templateSelectionMoved(Int) case templateTokenJumpRequested + case templateLibraryRequested + case startFromTemplateTapped(UUID) case templateFocusConsumed case templateChosen(UUID) case templateLaunched(UUID) @@ -398,7 +400,8 @@ struct ProjectFeature { // no-ops so this switch stays exhaustive, and nothing runs twice. case .templatesButtonTapped, .templatePickerClosed, .templateQueryChanged, .templateSelectionMoved, .templateTokenJumpRequested, .templateFocusConsumed, - .templateChosen, .templateLaunched, .templateChipRemoved, + .templateLibraryRequested, .startFromTemplateTapped, .templateChosen, .templateLaunched, + .templateChipRemoved, .templateShapeUndone, .saveTemplateTapped, .saveLoopTemplateTapped, .saveTemplateConfirmed, .saveTemplateCancelled, .templateSaved, .templateSaveNoticeDismissed, .templateRelocationTapped, .templateLibraryChanged, @@ -828,7 +831,7 @@ extension ProjectFeature { }) } - private func openNodeForm( + func openNodeForm( _ state: inout State, backend: CLISessionBackendKind?, parentNodeID: UUID?, custodial: Bool = false, declaresEntry: Bool = false ) -> Effect { diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index 4f376618..476ec02d 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -160,18 +160,30 @@ extension ProjectFeature.State { || template.name.lowercased().contains(query) || template.body.lowercased().contains(query) } + // Starters stand apart only while they are the whole library. Once somebody has + // saved a brief of their own, the scaffolding stops outranking their work and + // sorts in with everything else in All projects. + let pinStarters = !templates.library.contains { !$0.isStarter && !$0.origin.isProject } var project: [PromptTemplate] = [] + var starters: [PromptTemplate] = [] var home: [PromptTemplate] = [] for template in templates.library where matches(template) { - if template.origin.isProject { project.append(template) } else { home.append(template) } + if template.origin.isProject { + project.append(template) + } else if pinStarters, template.isStarter { + starters.append(template) + } else { + home.append(template) + } + } + func rows( + _ templates: [PromptTemplate], _ scope: ProjectFeature.TemplatePickerScope + ) -> [ProjectFeature.TemplatePickerRow] { + templates + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { ProjectFeature.TemplatePickerRow(template: $0, scope: scope) } } - return - project - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - .map { ProjectFeature.TemplatePickerRow(template: $0, scope: .project) } - + home - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - .map { ProjectFeature.TemplatePickerRow(template: $0, scope: .home) } + return rows(project, .project) + rows(starters, .starter) + rows(home, .home) } /// The fields still holding a `{token}`, in the order `⇥` walks them — the order @@ -191,6 +203,15 @@ extension ProjectFeature.State { } } + /// The starters offered on an empty canvas: the shipped picks, in the order + /// `StarterTemplates` names them, and only the ones still on disk — a starter + /// somebody deleted is not offered back to them. + var firstLaunchStarters: [PromptTemplate] { + StarterTemplates.firstLaunchPicks.compactMap { pick in + templates.library.first { $0.id == pick.id } + } + } + var hasProjectTemplates: Bool { templates.library.contains(where: \.origin.isProject) } @@ -367,15 +388,18 @@ extension ProjectFeature { case branch } - /// The two scope groups the picker sorts by — a project's committed templates - /// above the home library, always. + /// The groups the picker sorts by — a project's committed templates above the home + /// library, always, with the briefs the app ships pinned between them while they are + /// still the only thing here. enum TemplatePickerScope: Equatable { case project + case starter case home var displayName: String { switch self { case .project: return "This project" + case .starter: return "Starters" case .home: return "All projects" } } diff --git a/graphcode/Sources/Features/Project/TemplatePickerView.swift b/graphcode/Sources/Features/Project/TemplatePickerView.swift index f6d80084..9ac38044 100644 --- a/graphcode/Sources/Features/Project/TemplatePickerView.swift +++ b/graphcode/Sources/Features/Project/TemplatePickerView.swift @@ -106,8 +106,7 @@ struct TemplatePickerView: View { .foregroundStyle(.white.opacity(0.4)) .padding( .top, - group.scope == .home && !grouped[0].scope.isProjectEquivalent - ? 10 : 2 + group.scope != grouped[0].scope ? 10 : 2 ) .padding(.bottom, 3) .padding(.leading, 2) @@ -288,9 +287,3 @@ struct TemplatePickerView: View { } } } - -extension ProjectFeature.TemplatePickerScope { - var isProjectEquivalent: Bool { - self == .project - } -} diff --git a/graphcode/Tests/StarterTemplateTests.swift b/graphcode/Tests/StarterTemplateTests.swift new file mode 100644 index 00000000..6c0a5916 --- /dev/null +++ b/graphcode/Tests/StarterTemplateTests.swift @@ -0,0 +1,173 @@ +import Foundation +import GraphcodeKit +import Testing + +/// The briefs a fresh install starts with, and the once-only seeding that puts them +/// on disk. See `StarterTemplates` for why an empty ⌘T picker is the problem being +/// solved, and what each template is chosen to demonstrate. +@Suite +struct StarterTemplateTests { + private let home: URL + private let storage: TemplateStorage + + init() { + home = FileManager.default.temporaryDirectory + .appendingPathComponent("starter-tests-\(UUID().uuidString)", isDirectory: true) + storage = TemplateStorage( + homeDirectory: home, + projectDirectory: { _ in URL(fileURLWithPath: "/nonexistent", isDirectory: true) }) + } + + // MARK: - The set itself + + /// The starter set is a taxonomy lesson before it is a convenience: every loop type + /// has to be represented, or the type with no example is the one nobody tries. + @Test + func everyLoopTypeHasAStarter() { + let shapes = Set(StarterTemplates.all.map { $0.shape }) + #expect(shapes == [nil, .goalBased, .timeBased, .turnBased, .composite]) + } + + /// Each type's lesson lives in its *settings*, not its prose — this is the table in + /// `StarterTemplates`' doc comment, asserted. + @Test + func eachTypesSettingsTeachThatType() throws { + // Main asks nothing: no done check, no cadence, nothing to decide. + for main in StarterTemplates.all where main.shape == nil { + #expect(main.settings == nil) + } + // Goal shows all three of its cases: a check that decides, no check at all, and + // a metric. + let goals = StarterTemplates.all.filter { $0.shape == .goalBased } + let withCheck = goals.filter { $0.settings?.doneCheck != nil }.count + let withoutCheck = goals.filter { $0.settings?.doneCheck == nil }.count + let withMetric = goals.filter { $0.settings?.metric != nil }.count + #expect(withCheck > 0) + #expect(withoutCheck > 0) + #expect(withMetric > 0) + // Timed always carries a cadence — a timed loop without one is not the type. + for timed in StarterTemplates.all where timed.shape == .timeBased { + #expect(timed.settings?.cadence?.isEmpty == false) + } + // Turn shows both pause rhythms side by side; one of each is the whole lesson. + let pauses = StarterTemplates.all + .filter { $0.shape == .turnBased } + .compactMap { $0.settings?.pausesBeforeWritesOnly } + #expect(Set(pauses) == [true, false]) + } + + /// The composite carries a real orchestration — children *and* the edges between + /// them, which is what makes a composite template worth sharing. + @Test + func theCompositeStarterCarriesItsChildrenAndEdges() throws { + let composite = try #require(StarterTemplates.all.first(where: { $0.shape == .composite })) + let graph = try #require(composite.settings?.carriedGraph) + #expect(graph.nodes.map(\.title) == ["Reviewer", "Fixer", "Verifier"]) + #expect(graph.edges.count == 2) + // Wired as a chain, not a fan: the reviewer's findings reach the verifier only by + // going through the fixer. + let byID = Dictionary(uniqueKeysWithValues: graph.nodes.map { ($0.id, $0.title) }) + let wiring = Set(graph.edges.map { "\(byID[$0.from] ?? "?")→\(byID[$0.to] ?? "?")" }) + #expect(wiring == ["Reviewer→Fixer", "Fixer→Verifier"]) + } + + /// Ids are fixed, not freshly minted: a loop following a starter has to keep + /// following it across a reinstall, and two machines have to agree on which + /// template a shared file is. + @Test + func starterIdsAreStableAcrossCalls() { + #expect(StarterTemplates.all.map(\.id) == StarterTemplates.all.map(\.id)) + #expect(Set(StarterTemplates.all.map(\.id)).count == StarterTemplates.all.count) + #expect(Set(StarterTemplates.all.map(\.fileName)).count == StarterTemplates.all.count) + } + + /// The three offered on an empty canvas climb the commitment ladder — that is what + /// makes the row a lesson rather than three arbitrary briefs. + @Test + func theFirstLaunchPicksSpanTheCommitmentLadder() { + let picks = StarterTemplates.firstLaunchPicks + #expect(picks.count == 3) + #expect(picks.map { $0.shape } == [nil, .goalBased, .timeBased]) + // And they are really in the shipped set, not a fourth thing nobody can find again. + let shipped = StarterTemplates.all.map(\.id) + for pick in picks { + #expect(shipped.contains(pick.id)) + } + } + + /// Every token is a hole somebody can be expected to fill from where they're + /// standing — and the brief has to say enough for them to know what to put in it. + @Test + func everyStarterReadsAsAFinishedBrief() { + for template in StarterTemplates.all { + #expect(!template.name.isEmpty) + #expect(template.body.count > 40, "\(template.name) is too terse to teach anything") + #expect(template.isStarter) + #expect(template.origin == .home) + } + } + + // MARK: - Seeding + + @Test + func seedingWritesEveryStarterOnce() throws { + let written = try storage.seedStartersIfNeeded() + #expect(written.count == StarterTemplates.all.count) + #expect(storage.load(projectPath: nil).count == StarterTemplates.all.count) + + // Second launch: nothing more to do. + let second = try storage.seedStartersIfNeeded() + #expect(second.isEmpty) + } + + /// A starter you delete stays deleted. Seeding on every launch would make the + /// library un-curatable, which is worse than shipping nothing. + @Test + func aDeletedStarterDoesNotComeBack() throws { + try storage.seedStartersIfNeeded() + let victim = try #require(storage.load(projectPath: nil).first) + try storage.delete(victim) + + try storage.seedStartersIfNeeded() + let remaining = storage.load(projectPath: nil).map(\.id) + #expect(!remaining.contains(victim.id)) + } + + /// A file already at that name belongs to whoever wrote it, first run or not. + @Test + func seedingNeverOverwritesSomeoneElsesFile() throws { + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let occupied = home.appendingPathComponent(StarterTemplates.all[0].fileName) + try "Mine, not the app's.".write(to: occupied, atomically: true, encoding: .utf8) + + try storage.seedStartersIfNeeded() + let kept = try String(contentsOf: occupied, encoding: .utf8) + #expect(kept == "Mine, not the app's.") + } + + /// `starter: true` is written into the file and read back, so the mark survives an + /// edit and is visible to anyone reading the folder. + @Test + func theStarterMarkRoundTripsThroughTheFile() throws { + try storage.seedStartersIfNeeded() + let loaded = storage.load(projectPath: nil) + let marked = loaded.filter(\.isStarter).count + #expect(marked == StarterTemplates.all.count) + #expect(loaded.count == StarterTemplates.all.count) + + // A template nobody marked is not a starter. + let (mine, _) = try storage.save( + PromptTemplate(name: "Mine", body: "My own brief."), to: .home, projectPath: nil) + #expect(!mine.isStarter) + let reloaded = storage.load(projectPath: nil).first(where: { $0.id == mine.id }) + #expect(reloaded?.isStarter == false) + } + + /// The marker is a dotfile, so it is never mistaken for a template. + @Test + func theSeedMarkerIsNotOfferedAsATemplate() throws { + try storage.seedStartersIfNeeded() + let names = storage.load(projectPath: nil).map(\.name) + #expect(!names.contains(where: { $0.contains("seeded") })) + } +} diff --git a/graphcode/Tests/TemplatePickerTests.swift b/graphcode/Tests/TemplatePickerTests.swift index 76563529..c7a3c857 100644 --- a/graphcode/Tests/TemplatePickerTests.swift +++ b/graphcode/Tests/TemplatePickerTests.swift @@ -176,6 +176,84 @@ struct TemplatePickerTests { #expect(!store.state.showingNewNodeForm) } + /// Starters stand apart while they are the whole library — the scaffolding is what + /// a new person needs at the top of the list. Once somebody has saved a brief of + /// their own, it stops outranking their work and sorts in with the rest. + @Test + @MainActor + func startersArePinnedUntilYouHaveOneOfYourOwn() async { + var starter = homeTemplate("Get the build green", body: "Fix the build.", shape: .goalBased) + starter.isStarter = true + let library = [starter] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + #expect(store.state.templatePickerRows.map(\.scope) == [.starter]) + #expect(ProjectFeature.TemplatePickerScope.starter.displayName == "Starters") + + // The moment there is a template of their own, the group folds away. + let mine = homeTemplate("My brief", body: "Something I wrote.") + await store.send(.templateLibraryChanged([starter, mine])) + #expect(store.state.templatePickerRows.map(\.scope) == [.home, .home]) + } + + /// A project's committed templates outrank the shipped ones, always — the rule the + /// storage design hangs on does not bend for scaffolding. + @Test + @MainActor + func projectTemplatesStillOutrankStarters() async { + var starter = homeTemplate("Get the build green", body: "Fix the build.", shape: .goalBased) + starter.isStarter = true + let committed = PromptTemplate( + id: UUID(), name: "Team review", body: "Ours.", shape: .goalBased, + origin: .project(Self.project.path)) + let library = [starter, committed] + let store = makeStore(library) + store.exhaustivity = .off + + await store.send(.templatesButtonTapped) + await store.send(.templateLibraryChanged(library)) + #expect(store.state.templatePickerRows.map(\.scope) == [.project, .starter]) + } + + /// The empty canvas offers the shipped picks, and offers back only what is still on + /// disk — a starter somebody deleted is not pushed at them again. + @Test + @MainActor + func theCanvasOffersTheFirstLaunchPicksThatSurvive() async { + let seeded = StarterTemplates.firstLaunchPicks + let store = makeStore(seeded) + store.exhaustivity = .off + + await store.send(.templateLibraryChanged(seeded)) + #expect(store.state.firstLaunchStarters.map(\.name) == seeded.map(\.name)) + + // One deleted: the row shrinks rather than offering a template that isn't there. + await store.send(.templateLibraryChanged(Array(seeded.dropFirst()))) + #expect(store.state.firstLaunchStarters.map(\.name) == seeded.dropFirst().map(\.name)) + } + + /// Starting from the canvas opens the dialog with the template already applied — + /// one click from an empty canvas to a filled-in brief. + @Test + @MainActor + func startingFromTheCanvasOpensTheDialogFilledIn() async { + let starter = StarterTemplates.firstLaunchPicks[1] // Get the build green (Goal) + let store = makeStore([starter]) + store.exhaustivity = .off + + await store.send(.templateLibraryChanged([starter])) + await store.send(.startFromTemplateTapped(starter.id)) + #expect(store.state.showingNewNodeForm) + #expect(store.state.draftLoopType == .goalBased) + #expect(store.state.templates.applied?.name == "Get the build green") + // Its token lives in the done check, so that is what Start is waiting on. + #expect(store.state.unfilledTokens == ["test_command"]) + #expect(store.state.draftBlocksOnTokens) + } + @Test @MainActor func theTypeLabelNamesTheShapeAndItsQualifier() {