diff --git a/GraphcodeKit/Sources/Domain/LoopType.swift b/GraphcodeKit/Sources/Domain/LoopType.swift index 46b7b7b1..d490a924 100644 --- a/GraphcodeKit/Sources/Domain/LoopType.swift +++ b/GraphcodeKit/Sources/Domain/LoopType.swift @@ -40,4 +40,16 @@ public enum LoopType: String, Codable, CaseIterable, Sendable { public var runsUnattended: Bool { self == .timeBased || self == .goalBased } + + /// The one other shape a committed type can take after creation. Goal and time are each + /// other's only destination because they are the two unattended types: the session keeps + /// running and only what ends it changes. Turn waits on a human and composite is a + /// sub-graph, so neither is one decision away from anything. + public var retypeTarget: LoopType? { + switch self { + case .goalBased: .timeBased + case .timeBased: .goalBased + case .sketch, .turnBased, .composite: nil + } + } } diff --git a/GraphcodeKit/Sources/Domain/SketchPromotion.swift b/GraphcodeKit/Sources/Domain/SketchPromotion.swift index e68560b6..306bf4a3 100644 --- a/GraphcodeKit/Sources/Domain/SketchPromotion.swift +++ b/GraphcodeKit/Sources/Domain/SketchPromotion.swift @@ -9,7 +9,7 @@ import Foundation /// context, and a promoted goal loop starts already knowing what was found. /// /// The same cases retype a goal loop as time-based and a time-based loop as a goal loop -/// (`GraphStore.retypeTarget(of:)`): each is the one decision the other type never asked +/// (`LoopType.retypeTarget`): each is the one decision the other type never asked /// for, and the session, edges and memory carry over the same way. /// /// Promotion is one-way by construction. There is no case that lands on `.sketch`, so diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index fffb7639..6f815762 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1891,7 +1891,7 @@ public actor GraphStore { ? "update refused: nothing in it applies to a main loop — give it a shape " + "first with `graphcode node promote`" : "update refused: nothing in it applies to a \(node.loopType) loop" - + (Self.retypeTarget(of: node.loopType) == nil + + (node.loopType.retypeTarget == nil ? "" : " — change its type with `graphcode node promote`")) return } @@ -1961,7 +1961,7 @@ public actor GraphStore { } let source = node.loopType let retypes = source != .sketch - guard !retypes || Self.retypeTarget(of: source) == promotion.targetType else { + guard !retypes || source.retypeTarget == promotion.targetType else { announceError( "promotion refused: \(node.title) is a \(source) loop — a main loop takes any shape, " + "a goal loop can become time-based, and a time-based loop a goal loop") @@ -2097,18 +2097,6 @@ public actor GraphStore { } } - /// The one other shape each committed type can take. Goal and time are each other's - /// only destination because they are the two unattended types: the session keeps - /// running and only what ends it changes. Turn waits on a human and composite is a - /// sub-graph, so neither is one decision away from anything. - static func retypeTarget(of source: LoopType) -> LoopType? { - switch source { - case .goalBased: .timeBased - case .timeBased: .goalBased - case .sketch, .turnBased, .composite: nil - } - } - /// What a retyped session is told, in order: drop the old shape, then the new shape's /// own opening prompt — verbatim, so a `/goal` or `/loop` directive arms the way a fresh /// launch would arm it rather than arriving as prose about one. diff --git a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift index 29a9f4bc..c1951b9f 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift @@ -114,6 +114,11 @@ extension ProjectCanvasView { } } } + if let target = node.loopType.retypeTarget, node.state != .stopped { + Button("Change to \(target.displayName)…") { + store.send(.promoteNodeRequested(node.id, to: target)) + } + } // Available on a resolved loop too: a finished loop is still something you read the // graph by, and its name is what you read. Button("Rename…") { store.send(.renameNodeRequested(node.id)) } diff --git a/graphcode/Sources/Features/Project/ProjectFeature.swift b/graphcode/Sources/Features/Project/ProjectFeature.swift index 99b78542..21bed147 100644 --- a/graphcode/Sources/Features/Project/ProjectFeature.swift +++ b/graphcode/Sources/Features/Project/ProjectFeature.swift @@ -130,15 +130,19 @@ struct ProjectFeature { var nodePendingRename: UUID? var draftRenameTitle = "" - /// Set while a sketch's promotion form is up: which sketch, which shape it is - /// taking, and the one field that shape asks for. Flat fields to match how the - /// rename prompt and the creation form's `draft*` fields already work here. + /// Set while a promotion form is up — a sketch taking a shape, or a goal or time loop + /// swapping to the other: which loop, which shape it is taking, and the one field + /// that shape asks for. Flat fields to match how the rename prompt and the creation + /// form's `draft*` fields already work here. var nodePendingPromotion: UUID? var promotionTarget: LoopType = .goalBased var promotionGoal = "" var promotionPausesBeforeWritesOnly = false var promotionInterval: IntervalChoice = .hourly var promotionCustomInterval = "" + /// What each pass does when a goal loop turns time-based. A sketch repeats its note; + /// a goal loop has none, and repeating its withdrawn goal would be the wrong task. + var promotionTask = "" /// Loops a human said really are a beginning, despite having no edges — the answer /// to a card's "Mark as entry". View state, not graph state: the graph's own answer @@ -692,18 +696,23 @@ extension ProjectFeature { return remembered == .composite ? .sketch : (remembered ?? .sketch) } - /// Resets the promotion form's one field and opens it for the chosen target — only - /// ever for a sketch; anything already shaped has nothing to promote. + /// Resets the promotion form's fields and opens it for the chosen target — for a sketch + /// taking any shape, or a goal or time loop taking the other one. The daemon refuses a + /// stopped loop, so the form never opens on one. private func openPromotionForm( _ state: inout State, nodeID: UUID, target: LoopType ) -> Effect { - guard state.graph.nodes[id: nodeID]?.loopType == .sketch else { return .none } + guard let node = state.graph.nodes[id: nodeID], + node.loopType == .sketch + || (node.loopType.retypeTarget == target && node.state != .stopped) + else { return .none } state.nodePendingPromotion = nodeID state.promotionTarget = target state.promotionGoal = "" state.promotionPausesBeforeWritesOnly = false state.promotionInterval = .hourly state.promotionCustomInterval = "" + state.promotionTask = "" return .none } diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index 244522a5..5ef67ba3 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -330,11 +330,17 @@ extension ProjectFeature.State { case .turnBased: return .turn(pausesBeforeWritesOnly: promotionPausesBeforeWritesOnly) case .timeBased: - let note = - nodePendingPromotion - .flatMap { graph.nodes[id: $0]?.firstInstruction }? - .trimmingCharacters(in: .whitespaces) ?? "" - let task = note.isEmpty ? "Carry on with what this session has been doing." : note + let task: String + if promotionSource == .goalBased { + task = promotionTask.trimmingCharacters(in: .whitespacesAndNewlines) + guard !task.isEmpty else { return nil } + } else { + let note = + nodePendingPromotion + .flatMap { graph.nodes[id: $0]?.firstInstruction }? + .trimmingCharacters(in: .whitespaces) ?? "" + task = note.isEmpty ? "Carry on with what this session has been doing." : note + } let cadence = promotionInterval.directiveValue(custom: promotionCustomInterval) return .timed(triggerPrompt: "/loop \(cadence) \(task)") case .sketch, .composite: @@ -342,6 +348,12 @@ extension ProjectFeature.State { } } + /// The type the loop under the promotion form has now — `.sketch` for a promotion, + /// `.goalBased` or `.timeBased` for a retype. + var promotionSource: LoopType? { + nodePendingPromotion.flatMap { graph.nodes[id: $0]?.loopType } + } + /// The composed `/loop` line the promotion form shows before it is written. var promotionTriggerPreview: String { guard promotionTarget == .timeBased, case .timed(let prompt)? = promotion else { return "" } diff --git a/graphcode/Sources/Features/Project/SketchPromotionForm.swift b/graphcode/Sources/Features/Project/SketchPromotionForm.swift index 50295f15..f455b68d 100644 --- a/graphcode/Sources/Features/Project/SketchPromotionForm.swift +++ b/graphcode/Sources/Features/Project/SketchPromotionForm.swift @@ -2,7 +2,8 @@ import ComposableArchitecture import GraphcodeKit import SwiftUI -/// The one-field form a sketch's "Promote to…" opens — never a re-brief. +/// The one-field form a sketch's "Promote to…" opens — never a re-brief. A goal or time +/// loop's "Change to…" opens it too, for the other of the two. /// /// The dialog asks for exactly what the target type needs and a sketch never did: /// a done check for Goal, where to pause for Turn, a cadence for Timed. Everything @@ -15,6 +16,8 @@ struct SketchPromotionForm: View { store.nodePendingPromotion.flatMap { store.graph.nodes[id: $0] } } + private var retypes: Bool { (store.promotionSource ?? .sketch) != .sketch } + var body: some View { VStack(alignment: .leading, spacing: 16) { header @@ -35,15 +38,24 @@ struct SketchPromotionForm: View { RoundedRectangle(cornerRadius: 2.5) .fill(store.promotionTarget.accent) .frame(width: 9, height: 9) - Text("Promote to \(store.promotionTarget.displayName)") + Text("\(retypes ? "Change" : "Promote") to \(store.promotionTarget.displayName)") .font(.system(size: 16, weight: .semibold)) } - Text( - "Keep the session, add a shape — \(node?.title ?? "this loop") stays " - + "exactly where it is." - ) - .font(.system(size: 12)) - .foregroundStyle(.white.opacity(0.45)) + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.45)) + } + } + + private var subtitle: String { + let title = node?.title ?? "this loop" + switch store.promotionSource { + case .goalBased: + return "Keep the session, change what ends it — \(title) stops working toward its goal." + case .timeBased: + return "Keep the session, change what ends it — \(title) stops its cadence." + default: + return "Keep the session, add a shape — \(title) stays exactly where it is." } } @@ -71,9 +83,19 @@ struct SketchPromotionForm: View { } } case .timeBased: + if store.promotionSource == .goalBased { + DraftField( + label: "What should each pass do?", + help: "Its goal is withdrawn, so each pass needs a task of its own." + ) { + DraftProseField( + placeholder: "check the flake hasn't come back", text: $store.promotionTask) + } + } DraftField( label: "How often?", - help: "The work it repeats is what the session is already doing." + help: store.promotionSource == .goalBased + ? nil : "The work it repeats is what the session is already doing." ) { VStack(alignment: .leading, spacing: 8) { Picker("", selection: $store.promotionInterval) { @@ -108,8 +130,8 @@ struct SketchPromotionForm: View { .font(.system(size: 12.5)) .foregroundStyle(.white.opacity(0.7)) Spacer(minLength: 8) - if store.promotion == nil, store.promotionTarget == .goalBased { - Text("Say what done looks like to continue") + if store.promotion == nil, let hint = missingFieldHint { + Text(hint) .font(.system(size: 11.5)) .foregroundStyle(.white.opacity(0.62)) .lineLimit(1) @@ -118,13 +140,21 @@ struct SketchPromotionForm: View { } } + private var missingFieldHint: String? { + switch store.promotionTarget { + case .goalBased: "Say what done looks like to continue" + case .timeBased: "Say what each pass does to continue" + default: nil + } + } + private var promoteButton: some View { let isValid = store.promotion != nil return Button { store.send(.promotionConfirmed) } label: { HStack(spacing: 6) { - Text("Promote") + Text(retypes ? "Change" : "Promote") .font(.system(size: 13, weight: .semibold)) Text("⏎").font(.system(size: 11, design: .monospaced)).opacity(0.7) } diff --git a/graphcode/Tests/LoopRetypeFormTests.swift b/graphcode/Tests/LoopRetypeFormTests.swift new file mode 100644 index 00000000..78311d80 --- /dev/null +++ b/graphcode/Tests/LoopRetypeFormTests.swift @@ -0,0 +1,126 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +/// The canvas half of goal ↔ time retyping: "Change to…" opens the promotion form on a +/// goal or time loop for the other type, and confirming sends the same `promoteNode` the +/// CLI's `node promote` does. +@Suite +@MainActor +struct LoopRetypeFormTests { + private static let project = ProjectRef(path: "/tmp/retype", name: "retype") + + private let goal = LoopNode( + title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: "the flake is fixed")) + private let time = LoopNode( + title: "Watch", loopType: .timeBased, triggerPrompt: "/loop 1h triage new issues") + + private func state(_ nodes: [LoopNode]) -> ProjectFeature.State { + ProjectFeature.State( + graph: LoopGraph(project: Self.project, nodes: IdentifiedArray(uniqueElements: nodes))) + } + + @Test + func eachUnattendedTypeRetypesOnlyToTheOther() { + #expect(LoopType.goalBased.retypeTarget == .timeBased) + #expect(LoopType.timeBased.retypeTarget == .goalBased) + #expect(LoopType.turnBased.retypeTarget == nil) + #expect(LoopType.composite.retypeTarget == nil) + #expect(LoopType.sketch.retypeTarget == nil) + } + + @Test + func aGoalLoopsTimedFormNeedsATaskOfItsOwn() { + var state = state([goal]) + state.nodePendingPromotion = goal.id + state.promotionTarget = .timeBased + state.promotionInterval = .hourly + + // A goal loop has no note to repeat, and repeating its withdrawn goal would be wrong. + #expect(state.promotion == nil) + state.promotionTask = "check the flake hasn't come back" + #expect(state.promotion == .timed(triggerPrompt: "/loop 1h check the flake hasn't come back")) + } + + @Test + func changingAGoalLoopToTimedSendsThePromotion() async { + let sent = SentCommandsBox() + let store = TestStore(initialState: state([goal])) { + ProjectFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.promoteNodeRequested(goal.id, to: .timeBased)) + #expect(store.state.nodePendingPromotion == goal.id) + #expect(store.state.promotionSource == .goalBased) + + await store.send(.binding(.set(\.promotionTask, "check the flake hasn't come back"))) + await store.send(.promotionConfirmed) + await store.finish() + + #expect(store.state.nodePendingPromotion == nil) + #expect( + await sent.all == [ + .graphCommand( + projectPath: Self.project.path, + command: .promoteNode( + goal.id, promotion: .timed(triggerPrompt: "/loop 1h check the flake hasn't come back"), + promotedBy: nil)) + ]) + } + + @Test + func changingATimeLoopToGoalSendsThePromotion() async { + let sent = SentCommandsBox() + let store = TestStore(initialState: state([time])) { + ProjectFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in await sent.append(command) } + } + store.exhaustivity = .off + + await store.send(.promoteNodeRequested(time.id, to: .goalBased)) + await store.send(.binding(.set(\.promotionGoal, "the backlog is empty"))) + await store.send(.promotionConfirmed) + await store.finish() + + #expect( + await sent.all == [ + .graphCommand( + projectPath: Self.project.path, + command: .promoteNode( + time.id, promotion: .goal(GoalSpec(summary: "the backlog is empty")), + promotedBy: nil)) + ]) + } + + /// The daemon refuses these; the form must not open on them and let a human fill it in + /// for nothing. + @Test + func theFormStaysShutForAnythingTheDaemonWouldRefuse() async { + let turn = LoopNode(title: "Review", checkDescription: "Sound?") + var stopped = time + stopped.state = .stopped + let store = TestStore(initialState: state([goal, turn, stopped])) { + ProjectFeature() + } + store.exhaustivity = .off + + await store.send(.promoteNodeRequested(goal.id, to: .turnBased)) + await store.send(.promoteNodeRequested(goal.id, to: .goalBased)) + await store.send(.promoteNodeRequested(turn.id, to: .timeBased)) + await store.send(.promoteNodeRequested(stopped.id, to: .goalBased)) + + #expect(store.state.nodePendingPromotion == nil) + } +} + +private actor SentCommandsBox { + private(set) var all: [DaemonCommand] = [] + func append(_ command: DaemonCommand) { all.append(command) } +}