From 34d54611a4e89945caedf3a29a04e6f562ec820f Mon Sep 17 00:00:00 2001 From: jamesrochabrun Date: Sat, 11 Jul 2026 21:43:56 -0700 Subject: [PATCH] Port Easel Tweaks workflow --- .../xcshareddata/swiftpm/Package.resolved | 4 +- app/modules/AgentHubCore/Package.resolved | 6 +- app/modules/AgentHubCore/Package.swift | 2 +- .../Models/InspectorTweakResult.swift | 15 ++ .../Models/TweakWorkspaceTransaction.swift | 13 + .../Services/TweakWorkspaceCoordinator.swift | 113 +++++++++ .../Services/TweakWorkspaceError.swift | 23 ++ .../TweaksDefaultsWriteCoordinator.swift | 146 ++++++----- .../Services/TweaksDefaultsWriteError.swift | 26 ++ .../WebPreviewTweakAgentService.swift | 232 ++++++++++++++++++ .../UI/MultiProviderMonitoringPanelView.swift | 1 + .../UI/TweaksButtonPresentation.swift | 19 ++ .../Sources/AgentHub/UI/WebPreviewView.swift | 171 ++++++++++--- .../TweakWorkspaceCoordinatorTests.swift | 110 +++++++++ .../TweaksDefaultsWriteCoordinatorTests.swift | 169 ++++++++----- .../WebPreviewTweakAgentServiceTests.swift | 127 ++++++++++ 16 files changed, 999 insertions(+), 178 deletions(-) create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Models/InspectorTweakResult.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Models/TweakWorkspaceTransaction.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceCoordinator.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceError.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteError.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/Services/WebPreviewTweakAgentService.swift create mode 100644 app/modules/AgentHubCore/Sources/AgentHub/UI/TweaksButtonPresentation.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/TweakWorkspaceCoordinatorTests.swift create mode 100644 app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTweakAgentServiceTests.swift diff --git a/app/AgentHub.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/AgentHub.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 70f88f4d..3cd5bfdf 100644 --- a/app/AgentHub.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/app/AgentHub.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jamesrochabrun/Canvas", "state" : { - "revision" : "1ec719b8ad3f79cd4a9128c999f36d2790fe5d49", - "version" : "1.3.0" + "revision" : "7c489d3ef4910c32a4cecb33a0c91cc5e20e58e9", + "version" : "1.3.2" } }, { diff --git a/app/modules/AgentHubCore/Package.resolved b/app/modules/AgentHubCore/Package.resolved index ed839e3c..7a4ae9da 100644 --- a/app/modules/AgentHubCore/Package.resolved +++ b/app/modules/AgentHubCore/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e3e8f2a407d044f71c9d9556b5607f36736cc53e9a67d3397434df29324e2d30", + "originHash" : "993ac3fcefff89226a13257843eff29d8ae108579c8fc43ad4fe64965c1abc62", "pins" : [ { "identity" : "beautiful-mermaid-swift", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/jamesrochabrun/Canvas", "state" : { - "revision" : "1ec719b8ad3f79cd4a9128c999f36d2790fe5d49", - "version" : "1.3.0" + "revision" : "7c489d3ef4910c32a4cecb33a0c91cc5e20e58e9", + "version" : "1.3.2" } }, { diff --git a/app/modules/AgentHubCore/Package.swift b/app/modules/AgentHubCore/Package.swift index 1446b030..e8af0357 100644 --- a/app/modules/AgentHubCore/Package.swift +++ b/app/modules/AgentHubCore/Package.swift @@ -46,7 +46,7 @@ let package = Package( .package(path: "../AgentHubGitHub"), .package(path: "../Storybook"), .package(path: "../SimulatorPreview"), - .package(url: "https://github.com/jamesrochabrun/Canvas", exact: "1.3.0"), + .package(url: "https://github.com/jamesrochabrun/Canvas", exact: "1.3.2"), .package(url: "https://github.com/jamesrochabrun/PierreDiffsSwift", exact: "1.2.2"), .package(url: "https://github.com/jamesrochabrun/SwiftTerm", exact: "1.13.0-agenthub.8"), .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"), diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/InspectorTweakResult.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/InspectorTweakResult.swift new file mode 100644 index 00000000..ba8ac3c2 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/InspectorTweakResult.swift @@ -0,0 +1,15 @@ +// +// InspectorTweakResult.swift +// AgentHub +// + +enum InspectorTweakResult: Equatable, Sendable { + case applied + case noChanges + case conflict +} + +enum InspectorTweakPolicy: Equatable, Sendable { + case flexible + case additive +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/TweakWorkspaceTransaction.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/TweakWorkspaceTransaction.swift new file mode 100644 index 00000000..a212a3db --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/TweakWorkspaceTransaction.swift @@ -0,0 +1,13 @@ +// +// TweakWorkspaceTransaction.swift +// AgentHub +// + +import Foundation + +struct TweakWorkspaceTransaction: Sendable { + let rootURL: URL + let workingFileURL: URL + let targetFileURL: URL + let baseContents: Data +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceCoordinator.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceCoordinator.swift new file mode 100644 index 00000000..5b7b8cde --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceCoordinator.swift @@ -0,0 +1,113 @@ +// +// TweakWorkspaceCoordinator.swift +// AgentHub +// + +import Canvas +import Foundation + +protocol TweakWorkspaceCoordinating: Sendable { + func prepare(targetFileURL: URL) async throws -> TweakWorkspaceTransaction + func finish( + _ transaction: TweakWorkspaceTransaction, + policy: InspectorTweakPolicy + ) async throws -> InspectorTweakResult + func discard(_ transaction: TweakWorkspaceTransaction) async +} + +actor TweakWorkspaceCoordinator: TweakWorkspaceCoordinating { + private let fileManager: FileManager + private let temporaryRootURL: URL + + init( + fileManager: FileManager = .default, + temporaryRootURL: URL? = nil + ) { + self.fileManager = fileManager + self.temporaryRootURL = temporaryRootURL + ?? fileManager.temporaryDirectory.appendingPathComponent("AgentHub-Tweaks", isDirectory: true) + } + + func prepare(targetFileURL: URL) async throws -> TweakWorkspaceTransaction { + let targetURL = targetFileURL.standardizedFileURL.resolvingSymlinksInPath() + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: targetURL.path, isDirectory: &isDirectory) else { + throw TweakWorkspaceError.missingTarget + } + guard !isDirectory.boolValue else { + throw TweakWorkspaceError.unsupportedTarget + } + + let baseContents = try Data(contentsOf: targetURL) + let rootURL = temporaryRootURL.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true) + let workingFileURL = rootURL.appendingPathComponent(targetURL.lastPathComponent) + try baseContents.write(to: workingFileURL, options: .atomic) + + return TweakWorkspaceTransaction( + rootURL: rootURL, + workingFileURL: workingFileURL, + targetFileURL: targetURL, + baseContents: baseContents + ) + } + + func finish( + _ transaction: TweakWorkspaceTransaction, + policy: InspectorTweakPolicy + ) async throws -> InspectorTweakResult { + defer { try? fileManager.removeItem(at: transaction.rootURL) } + + let generatedContents = try Data(contentsOf: transaction.workingFileURL) + guard generatedContents != transaction.baseContents else { + return .noChanges + } + + if policy == .additive { + try validateCumulativeTweaks( + baseContents: transaction.baseContents, + generatedContents: generatedContents + ) + } + + guard fileManager.fileExists(atPath: transaction.targetFileURL.path) else { + return .conflict + } + let currentContents = try Data(contentsOf: transaction.targetFileURL) + guard currentContents == transaction.baseContents else { + return .conflict + } + + try generatedContents.write(to: transaction.targetFileURL, options: .atomic) + return .applied + } + + func discard(_ transaction: TweakWorkspaceTransaction) async { + try? fileManager.removeItem(at: transaction.rootURL) + } + + private func validateCumulativeTweaks( + baseContents: Data, + generatedContents: Data + ) throws { + let baseSource = String(decoding: baseContents, as: UTF8.self) + guard let baseNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: baseSource), + !baseNames.isEmpty else { + return + } + + let generatedSource = String(decoding: generatedContents, as: UTF8.self) + guard let generatedNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: generatedSource), + generatedNames.count == Set(generatedNames).count, + Set(baseNames).isSubset(of: Set(generatedNames)), + let baseProps = try? TweakPropsSourceEditor.parseProps(fromSource: baseSource), + let generatedProps = try? TweakPropsSourceEditor.parseProps(fromSource: generatedSource) else { + throw TweakWorkspaceError.invalidGeneratedTweaks + } + + let generatedPropsByName = Dictionary(uniqueKeysWithValues: generatedProps.map { ($0.name, $0) }) + guard baseProps.allSatisfy({ generatedPropsByName[$0.name] == $0 }) else { + throw TweakWorkspaceError.invalidGeneratedTweaks + } + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceError.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceError.swift new file mode 100644 index 00000000..5467342d --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweakWorkspaceError.swift @@ -0,0 +1,23 @@ +// +// TweakWorkspaceError.swift +// AgentHub +// + +import Foundation + +enum TweakWorkspaceError: LocalizedError { + case missingTarget + case unsupportedTarget + case invalidGeneratedTweaks + + var errorDescription: String? { + switch self { + case .missingTarget: + return "The preview file is no longer available." + case .unsupportedTarget: + return "Tweaks can only be added to a regular design file." + case .invalidGeneratedTweaks: + return "The generated update did not preserve the existing tweak controls. No changes were applied." + } + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteCoordinator.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteCoordinator.swift index 2b30abc2..7f4770de 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteCoordinator.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteCoordinator.swift @@ -2,117 +2,111 @@ // TweaksDefaultsWriteCoordinator.swift // AgentHub // -// Debounced persistence for Canvas tweakable-prop values. Live changes are -// applied in the WKWebView immediately; this coordinator makes direct-file -// HTML/SVG previews keep the latest value as the next declared default. +// Explicitly promotes live Canvas tweak values to source defaults after +// verifying that the source still matches the preview's loaded baseline. // import Canvas import Foundation -enum TweaksDefaultsWriteOutcome: Equatable, Sendable { - case written - case noChange - case invalidPropName - case propNotDeclared - case editFailed(String) +protocol TweaksDefaultsWriting: Sendable { + func saveDefaults( + props: [TweakProp], + filePath: String, + projectPath: String + ) async throws } -actor TweaksDefaultsWriteCoordinator { - static let debounceDuration: Duration = .milliseconds(350) +actor TweaksDefaultsWriteCoordinator: TweaksDefaultsWriting { static let reloadSuppressionDuration: TimeInterval = 1.5 private let fileService: any ProjectFileServiceProtocol - private let debounceDuration: Duration - private var pendingWriteTask: Task? - init( - fileService: any ProjectFileServiceProtocol = ProjectFileService.shared, - debounceDuration: Duration = TweaksDefaultsWriteCoordinator.debounceDuration - ) { + init(fileService: any ProjectFileServiceProtocol = ProjectFileService.shared) { self.fileService = fileService - self.debounceDuration = debounceDuration } - deinit { - pendingWriteTask?.cancel() - } - - func scheduleValueWrite( - propName: String, - value: TweakPropValue, - filePath: String, - projectPath: String - ) { - pendingWriteTask?.cancel() - pendingWriteTask = Task { [debounceDuration] in - try? await Task.sleep(for: debounceDuration) - guard !Task.isCancelled else { return } - _ = await self.writeValue( - propName: propName, - value: value, - filePath: filePath, - projectPath: projectPath - ) + static func resolveFilePath(previewURL: URL, projectPath: String) -> String? { + if previewURL.isFileURL { + return previewURL.standardizedFileURL.resolvingSymlinksInPath().path + } + guard let scheme = previewURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil } - } - func cancelPendingWrite() { - pendingWriteTask?.cancel() - pendingWriteTask = nil + var relativePath = previewURL.path + if relativePath.isEmpty || relativePath == "/" { + relativePath = "/index.html" + } else if previewURL.hasDirectoryPath { + relativePath += "/index.html" + } + return (projectPath as NSString).appendingPathComponent(relativePath) } - func writeValue( - propName: String, - value: TweakPropValue, + func saveDefaults( + props: [TweakProp], filePath: String, projectPath: String - ) async -> TweaksDefaultsWriteOutcome { - guard Self.isValidPropName(propName) else { return .invalidPropName } + ) async throws { + let changedProps = props.filter { $0.value != $0.defaultValue } + guard !changedProps.isEmpty else { return } let source: String do { source = try await fileService.readFile(at: filePath, projectPath: projectPath) } catch { - return .editFailed("Could not read \(filePath): \(error.localizedDescription)") + throw TweaksDefaultsWriteError.cannotReadFile } - do { - let declaredNames = try TweakPropsSourceEditor.parsePropNames(fromSource: source) - guard declaredNames.contains(propName) else { return .propNotDeclared } + guard let diskNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: source), + diskNames == props.map(\.name), + let diskProps = try? TweakPropsSourceEditor.parseProps(fromSource: source) else { + throw TweaksDefaultsWriteError.sourceChanged + } - let edited = try TweakPropsSourceEditor.applyingValueEdit( - propName: propName, - newValue: value, - toSource: source - ) - guard edited != source else { return .noChange } + let diskPropsByName = Dictionary(uniqueKeysWithValues: diskProps.map { ($0.name, $0) }) + for prop in props { + guard let diskProp = diskPropsByName[prop.name] else { + throw TweaksDefaultsWriteError.unsupportedValue(prop.name) + } + guard diskProp == sourceBaseline(for: prop) else { + throw TweaksDefaultsWriteError.sourceChanged + } + } - let verifiedProps = try TweakPropsSourceEditor.parseProps(fromSource: edited) - guard verifiedProps.contains(where: { $0.name == propName && $0.value == value }) else { - return .editFailed("Edited source failed tweak prop verification") + var edited = source + for prop in changedProps { + do { + edited = try TweakPropsSourceEditor.applyingValueEdit( + propName: prop.name, + newValue: prop.value, + toSource: edited + ) + } catch { + throw TweaksDefaultsWriteError.unsupportedValue(prop.name) } + } + guard edited != source else { return } + do { try await fileService.writeFile(at: filePath, content: edited, projectPath: projectPath) - return .written - } catch TweakPropsSourceEditorError.propNotFound { - return .propNotDeclared } catch { - return .editFailed("Tweak default edit rejected: \(error)") + throw TweaksDefaultsWriteError.writeFailed(error.localizedDescription) } } - static func isValidPropName(_ name: String) -> Bool { - guard !name.isEmpty, name.count <= 80 else { return false } - return name.unicodeScalars.allSatisfy { scalar in - switch scalar.value { - case 48...57, 65...90, 97...122: - return true - case 36, 45, 95: - return true - default: - return false - } - } + private func sourceBaseline(for prop: TweakProp) -> TweakProp { + TweakProp( + name: prop.name, + label: prop.label, + type: prop.type, + minimum: prop.minimum, + maximum: prop.maximum, + step: prop.step, + options: prop.options, + value: prop.defaultValue, + defaultValue: prop.defaultValue + ) } } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteError.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteError.swift new file mode 100644 index 00000000..f412aa12 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/TweaksDefaultsWriteError.swift @@ -0,0 +1,26 @@ +// +// TweaksDefaultsWriteError.swift +// AgentHub +// + +import Foundation + +enum TweaksDefaultsWriteError: LocalizedError, Equatable { + case cannotReadFile + case sourceChanged + case unsupportedValue(String) + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .cannotReadFile: + return "The preview file could not be read." + case .sourceChanged: + return "The tweak defaults changed on disk. Reload the preview and try again." + case .unsupportedValue(let propName): + return "\(propName) does not use a literal value that can be saved as a default." + case .writeFailed(let message): + return "The new defaults could not be saved: \(message)" + } + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/WebPreviewTweakAgentService.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/WebPreviewTweakAgentService.swift new file mode 100644 index 00000000..aafcedf3 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/WebPreviewTweakAgentService.swift @@ -0,0 +1,232 @@ +// +// WebPreviewTweakAgentService.swift +// AgentHub +// + +import Foundation + +protocol WebPreviewTweakAgentRunning: Sendable { + func runTweakAgent( + prompt: String, + targetFileURL: URL, + policy: InspectorTweakPolicy, + cliConfiguration: CLICommandConfiguration + ) async throws -> InspectorTweakResult +} + +protocol TweakAgentCommandRunning: Sendable { + func run( + prompt: String, + systemPrompt: String, + workingDirectory: String, + cliConfiguration: CLICommandConfiguration + ) async throws +} + +enum WebPreviewTweakAgentError: LocalizedError { + case timedOut + case executableNotFound(String) + case commandFailed(Int32) + + var errorDescription: String? { + switch self { + case .timedOut: + return "The tweak agent did not finish within three minutes." + case .executableNotFound(let command): + return "Could not find the configured \(command) command." + case .commandFailed(let status): + return "The tweak agent exited with status \(status)." + } + } +} + +actor WebPreviewTweakAgentService: WebPreviewTweakAgentRunning { + static let systemPrompt = """ + You are a focused background editor inside AgentHub. Complete the requested tweak controls by editing the single design file in the working directory. + + Rules: + - Edit only that file. + - Do not start a server, run a browser, create supporting files, or inspect parent directories. + - Preserve the existing design and behavior outside the requested tweak controls. + - Treat existing tweak controls as cumulative project state. Read and understand the current dc_set_props declaration and render behavior before editing; preserve every existing control unless the user explicitly asks to change or remove it. + - When asked for more ideas, add only controls that are distinct in both name and behavior. Extend the existing declaration and render function instead of replacing them. + - Do not stop to explain or ask questions. Make the edit, verify the contract in the prompt, and finish. + """ + + private let workspaceCoordinator: any TweakWorkspaceCoordinating + private let commandRunner: any TweakAgentCommandRunning + private let timeout: Duration + + init( + workspaceCoordinator: any TweakWorkspaceCoordinating = TweakWorkspaceCoordinator(), + commandRunner: any TweakAgentCommandRunning = TweakAgentProcessRunner(), + timeout: Duration = .seconds(180) + ) { + self.workspaceCoordinator = workspaceCoordinator + self.commandRunner = commandRunner + self.timeout = timeout + } + + func runTweakAgent( + prompt: String, + targetFileURL: URL, + policy: InspectorTweakPolicy, + cliConfiguration: CLICommandConfiguration + ) async throws -> InspectorTweakResult { + let transaction = try await workspaceCoordinator.prepare(targetFileURL: targetFileURL) + + do { + try await runCommandWithTimeout( + prompt: prompt, + workingDirectory: transaction.rootURL.path, + cliConfiguration: cliConfiguration + ) + return try await workspaceCoordinator.finish(transaction, policy: policy) + } catch { + await workspaceCoordinator.discard(transaction) + throw error + } + } + + private func runCommandWithTimeout( + prompt: String, + workingDirectory: String, + cliConfiguration: CLICommandConfiguration + ) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [commandRunner] in + try await commandRunner.run( + prompt: prompt, + systemPrompt: Self.systemPrompt, + workingDirectory: workingDirectory, + cliConfiguration: cliConfiguration + ) + } + group.addTask { [timeout] in + try await Task.sleep(for: timeout) + throw WebPreviewTweakAgentError.timedOut + } + + _ = try await group.next() + group.cancelAll() + } + } +} + +final class TweakAgentProcessRunner: TweakAgentCommandRunning, @unchecked Sendable { + private let lock = NSLock() + private var runningProcess: Process? + + func run( + prompt: String, + systemPrompt: String, + workingDirectory: String, + cliConfiguration: CLICommandConfiguration + ) async throws { + let executablePath: String? + switch cliConfiguration.mode { + case .claude: + executablePath = TerminalLauncher.findClaudeExecutable( + command: cliConfiguration.executableName, + additionalPaths: cliConfiguration.additionalPaths + ) + case .codex: + executablePath = TerminalLauncher.findCodexExecutable( + command: cliConfiguration.executableName, + additionalPaths: cliConfiguration.additionalPaths + ) + } + guard let executablePath else { + throw WebPreviewTweakAgentError.executableNotFound(cliConfiguration.executableName) + } + + let arguments = Self.arguments( + prompt: prompt, + systemPrompt: systemPrompt, + workingDirectory: workingDirectory, + cliConfiguration: cliConfiguration + ) + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + var environment = ProcessInfo.processInfo.environment + let paths = CLIPathResolver.executableSearchPaths( + additionalPaths: cliConfiguration.additionalPaths + ).joined(separator: ":") + if !paths.isEmpty { + environment["PATH"] = [paths, environment["PATH"]].compactMap { $0 }.joined(separator: ":") + } + environment.merge(CLIEnvironmentOverrides.environment) { _, new in new } + process.environment = environment + + process.terminationHandler = { [weak self] process in + self?.setRunningProcess(nil) + if process.terminationReason == .exit, process.terminationStatus == 0 { + continuation.resume() + } else { + continuation.resume(throwing: WebPreviewTweakAgentError.commandFailed(process.terminationStatus)) + } + } + + do { + setRunningProcess(process) + try process.run() + } catch { + setRunningProcess(nil) + continuation.resume(throwing: error) + } + } + } onCancel: { + self.cancel() + } + } + + static func arguments( + prompt: String, + systemPrompt: String, + workingDirectory: String, + cliConfiguration: CLICommandConfiguration + ) -> [String] { + let prefix = cliConfiguration.subcommandArgs + cliConfiguration.extraArgs + switch cliConfiguration.mode { + case .claude: + return prefix + [ + "-p", + "--no-session-persistence", + "--permission-mode", "acceptEdits", + "--append-system-prompt", systemPrompt, + prompt + ] + case .codex: + return prefix + [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "--sandbox", "workspace-write", + "--cd", workingDirectory, + "\(systemPrompt)\n\nTask:\n\(prompt)" + ] + } + } + + private func cancel() { + lock.lock() + let process = runningProcess + runningProcess = nil + lock.unlock() + process?.terminate() + } + + private func setRunningProcess(_ process: Process?) { + lock.lock() + runningProcess = process + lock.unlock() + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift index a9b485f9..5f40128b 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift @@ -1029,6 +1029,7 @@ public struct MultiProviderMonitoringPanelView: View { viewModel.sendPromptToActiveTerminal(forKey: sess.id, prompt: prompt) }, viewModel: viewModel, + providerKind: payload.providerKind, mode: mode, agentLocalhostURL: viewModel.monitorStates[sessionId]?.detectedLocalhostURL, monitorState: viewModel.monitorStates[sessionId], diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/TweaksButtonPresentation.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/TweaksButtonPresentation.swift new file mode 100644 index 00000000..cfef836c --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/TweaksButtonPresentation.swift @@ -0,0 +1,19 @@ +// +// TweaksButtonPresentation.swift +// AgentHub +// + +import Canvas + +struct TweaksButtonPresentation: Equatable { + let isLoading: Bool + let accessibilityLabel: String + + static func resolve(agentState: TweaksAgentState) -> TweaksButtonPresentation { + let isLoading = agentState == .working + return TweaksButtonPresentation( + isLoading: isLoading, + accessibilityLabel: isLoading ? "Creating tweaks" : "Tweaks" + ) + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewView.swift index c94e685b..87d35e10 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/WebPreviewView.swift @@ -8,6 +8,7 @@ // import AgentHubGitDiff +import AgentHubSessionGraph import AppKit import SwiftUI import Canvas @@ -119,6 +120,8 @@ public struct WebPreviewView: View { var onInspectSubmit: ((String, CLISession) -> Void)? var onQueuedSubmit: ((String, CLISession) -> Bool)? let viewModel: CLISessionsViewModel? + let providerKind: SessionProviderKind + private let tweakAgentService: any WebPreviewTweakAgentRunning = WebPreviewTweakAgentService() /// Which server this preview is targeting. `.app` honors the agent-detected localhost URL /// and the primary dev server. `.storybook` resolves only against the Storybook compound key. let mode: WebPreviewMode @@ -155,6 +158,8 @@ public struct WebPreviewView: View { @State private var scrollRestorationCoordinator = WebPreviewScrollRestorationCoordinator() @State private var tweaksState = TweaksState() @State private var tweaksDefaultsWriteCoordinator = TweaksDefaultsWriteCoordinator() + @State private var tweaksAgentState: TweaksAgentState = .idle + @State private var tweaksDefaultsSaveState: TweaksDefaultsSaveState = .idle @State private var isTweaksPopoverPresented = false @State private var launchOptionsStatusOverride: String? @State private var askAgentReprobeTask: Task? @@ -193,6 +198,7 @@ public struct WebPreviewView: View { onInspectSubmit: ((String, CLISession) -> Void)? = nil, onQueuedSubmit: ((String, CLISession) -> Bool)? = nil, viewModel: CLISessionsViewModel? = nil, + providerKind: SessionProviderKind = .claude, mode: WebPreviewMode = .app, agentLocalhostURL: URL? = nil, monitorState: SessionMonitorState? = nil, @@ -208,6 +214,7 @@ public struct WebPreviewView: View { self.onInspectSubmit = onInspectSubmit self.onQueuedSubmit = onQueuedSubmit self.viewModel = viewModel + self.providerKind = providerKind self.mode = mode self.agentLocalhostURL = agentLocalhostURL self.monitorState = monitorState @@ -386,7 +393,6 @@ public struct WebPreviewView: View { deactivateInspector() Task { await inspectorViewModel.flushPendingWriteIfNeeded() - await tweaksDefaultsWriteCoordinator.cancelPendingWrite() } localhostReloadTask?.cancel() askAgentReprobeTask?.cancel() @@ -655,21 +661,34 @@ public struct WebPreviewView: View { } private var tweaksButton: some View { - Button { + let presentation = TweaksButtonPresentation.resolve(agentState: tweaksAgentState) + return Button { isTweaksPopoverPresented.toggle() } label: { - Label("Tweaks", systemImage: "slider.horizontal.3") - .font(.caption) + HStack(spacing: 6) { + if presentation.isLoading { + ProgressView() + .controlSize(.mini) + .accessibilityHidden(true) + } + Text("Tweaks") + } + .font(.caption) } .webPreviewSecondaryButtonStyle() .controlSize(.small) + .accessibilityLabel(presentation.accessibilityLabel) .help("Tweak this design with live controls") .popover(isPresented: $isTweaksPopoverPresented, arrowEdge: .bottom) { TweaksPanelView( state: tweaksState, + agentState: tweaksAgentState, + defaultsSaveState: tweaksDefaultsSaveState, onSubmitDescription: sendCustomTweaksPrompt, onIdeas: sendTweaksIdeasPrompt, - onValueChange: handleTweakValueChange + onValueChange: handleTweakValueChange, + onReset: resetTweakValues, + onSaveDefaults: saveTweakDefaults ) .frame(width: 320) } @@ -1234,6 +1253,7 @@ public struct WebPreviewView: View { previewWebView = nil isLoading = false tweaksState.clear() + tweaksDefaultsSaveState = .idle syncReloadCoordinatorBaseline() switch newResolution { @@ -1333,7 +1353,8 @@ public struct WebPreviewView: View { selectedElementId: inspectState.selectedElement?.id, selectorToRestore: activeSelectorToRestore, onWebViewReady: handleWebViewReady, - onTweakPropsChange: handleTweakPropsChange + onTweakPropsChange: handleTweakPropsChange, + onTweakSchemaAvailabilityChange: handleTweakSchemaAvailabilityChange ) .overlay(alignment: .top) { if inspectState.isActive { @@ -1414,7 +1435,8 @@ public struct WebPreviewView: View { selectedElementId: inspectState.selectedElement?.id, selectorToRestore: activeSelectorToRestore, onWebViewReady: handleWebViewReady, - onTweakPropsChange: handleTweakPropsChange + onTweakPropsChange: handleTweakPropsChange, + onTweakSchemaAvailabilityChange: handleTweakSchemaAvailabilityChange ) .webInspectorOverlay( state: inspectState, @@ -1487,66 +1509,131 @@ public struct WebPreviewView: View { } private var tweakPromptTargetName: String { - if let selectedFilePath { - return URL(fileURLWithPath: selectedFilePath).lastPathComponent + if let tweakTargetFilePath { + return URL(fileURLWithPath: tweakTargetFilePath).lastPathComponent } return "\(session.projectName)'s current preview" } + private var tweakTargetFilePath: String? { + if let previewURL = previewWebView?.url, + let resolvedPath = TweaksDefaultsWriteCoordinator.resolveFilePath( + previewURL: previewURL, + projectPath: projectPath + ) { + return resolvedPath + } + return selectedFilePath + } + private func handleTweakPropsChange(_ props: [TweakProp]) { tweaksState.updateSchema(props) + tweaksDefaultsSaveState = .idle } private func handleTweakValueChange(prop: TweakProp, value: TweakPropValue) { tweaksState.updateValue(name: prop.name, value) + if case .failed = tweaksDefaultsSaveState { + tweaksDefaultsSaveState = .idle + } if let previewWebView { TweaksBridge.setProp(name: prop.name, value: value, in: previewWebView) } - persistTweakDefaultIfPossible(propName: prop.name, value: value) } private func sendTweaksIdeasPrompt() { - sendTweaksPrompt(TweaksPromptBuilder.ideasPrompt(fileName: tweakPromptTargetName)) + runTweakAgent( + TweaksPromptBuilder.ideasPrompt( + fileName: tweakPromptTargetName, + existingProps: tweaksState.props + ), + policy: .additive + ) } private func sendCustomTweaksPrompt(_ instruction: String) { - sendTweaksPrompt(TweaksPromptBuilder.customPrompt( - fileName: tweakPromptTargetName, - instruction: instruction - )) + runTweakAgent( + TweaksPromptBuilder.customPrompt( + fileName: tweakPromptTargetName, + instruction: instruction + ), + policy: .flexible + ) } - private func sendTweaksPrompt(_ prompt: String) { - isTweaksPopoverPresented = false - queueSendFailureMessage = nil - - if let onInspectSubmit { - onInspectSubmit(prompt, session) - onCollapseExpandedAfterSend?() + private func runTweakAgent(_ prompt: String, policy: InspectorTweakPolicy) { + guard tweaksAgentState != .working, + let tweakTargetFilePath else { + tweaksAgentState = .failed("The preview file could not be resolved.") return } - guard let onQueuedSubmit, onQueuedSubmit(prompt, session) else { - queueSendFailureMessage = "Could not find an active terminal for this session. Keep the preview open and try again when the terminal is ready." - return + tweaksAgentState = .working + let cliConfiguration = viewModel?.cliConfiguration(for: providerKind) + ?? (providerKind == .claude ? .claudeDefault : .codexDefault) + Task { + do { + let result = try await tweakAgentService.runTweakAgent( + prompt: prompt, + targetFileURL: URL(fileURLWithPath: tweakTargetFilePath), + policy: policy, + cliConfiguration: cliConfiguration + ) + switch result { + case .applied: + tweaksAgentState = .idle + case .noChanges: + tweaksAgentState = .failed("The agent finished without changing the design file.") + case .conflict: + tweaksAgentState = .conflict + } + } catch { + tweaksAgentState = .failed(error.localizedDescription) + } } - onCollapseExpandedAfterSend?() } - private func persistTweakDefaultIfPossible(propName: String, value: TweakPropValue) { - guard case .directFile = resolution, - let selectedFilePath else { + private func resetTweakValues() { + let resetProps = tweaksState.resetToDefaults() + tweaksDefaultsSaveState = .idle + guard let previewWebView else { return } + for prop in resetProps { + TweaksBridge.setProp(name: prop.name, value: prop.value, in: previewWebView) + } + } + + private func saveTweakDefaults() { + guard tweaksState.hasUnsavedChanges, + tweaksDefaultsSaveState != .saving, + tweaksAgentState != .working, + let tweakTargetFilePath else { + if tweaksState.hasUnsavedChanges { + tweaksDefaultsSaveState = .failed("The preview file could not be resolved.") + } return } - fileWatcher.suppressReloads(for: TweaksDefaultsWriteCoordinator.reloadSuppressionDuration) + let propsSnapshot = tweaksState.props + tweaksDefaultsSaveState = .saving Task { - await tweaksDefaultsWriteCoordinator.scheduleValueWrite( - propName: propName, - value: value, - filePath: selectedFilePath, - projectPath: projectPath - ) + do { + fileWatcher.suppressReloads(for: TweaksDefaultsWriteCoordinator.reloadSuppressionDuration) + try await tweaksDefaultsWriteCoordinator.saveDefaults( + props: propsSnapshot, + filePath: tweakTargetFilePath, + projectPath: projectPath + ) + fileWatcher.suppressReloads(for: TweaksDefaultsWriteCoordinator.reloadSuppressionDuration) + guard tweaksState.props == propsSnapshot else { + tweaksDefaultsSaveState = .idle + requestManualReload() + return + } + tweaksState.commitCurrentValuesAsDefaults() + tweaksDefaultsSaveState = .idle + } catch { + tweaksDefaultsSaveState = .failed(error.localizedDescription) + } } } @@ -1660,10 +1747,7 @@ public struct WebPreviewView: View { isLoading = loading handleOverlayReloadingState(loading) - guard !loading else { - tweaksState.clear() - return - } + guard !loading else { return } restorePendingScrollPositionIfNeeded() lastSelectedSelector = nil @@ -1673,6 +1757,13 @@ public struct WebPreviewView: View { beginPendingReloadCaptureIfNeeded() } + private func handleTweakSchemaAvailabilityChange(_ hasDeclaredProps: Bool) { + if !hasDeclaredProps { + tweaksState.clear() + tweaksDefaultsSaveState = .idle + } + } + private func handlePreviewURLChange(_ loadedURL: URL?) { if isExternalServer, loadedURL != nil { hasLoadedExternalContent = true diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/TweakWorkspaceCoordinatorTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/TweakWorkspaceCoordinatorTests.swift new file mode 100644 index 00000000..607e6052 --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/TweakWorkspaceCoordinatorTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing + +@testable import AgentHubCore + +@Suite("TweakWorkspaceCoordinator") +struct TweakWorkspaceCoordinatorTests { + @Test("Applies generated file when target is unchanged") + func appliesGeneratedFile() async throws { + let fixture = try makeFixture(contents: "before") + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let coordinator = TweakWorkspaceCoordinator(temporaryRootURL: fixture.temporaryURL) + let transaction = try await coordinator.prepare(targetFileURL: fixture.targetURL) + try Data("after".utf8).write(to: transaction.workingFileURL) + + let result = try await coordinator.finish(transaction, policy: .flexible) + + #expect(result == .applied) + #expect(try String(contentsOf: fixture.targetURL, encoding: .utf8) == "after") + } + + @Test("Preserves a concurrent target edit") + func preservesConcurrentEdit() async throws { + let fixture = try makeFixture(contents: "before") + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let coordinator = TweakWorkspaceCoordinator(temporaryRootURL: fixture.temporaryURL) + let transaction = try await coordinator.prepare(targetFileURL: fixture.targetURL) + try Data("agent edit".utf8).write(to: transaction.workingFileURL) + try Data("main edit".utf8).write(to: fixture.targetURL) + + let result = try await coordinator.finish(transaction, policy: .flexible) + + #expect(result == .conflict) + #expect(try String(contentsOf: fixture.targetURL, encoding: .utf8) == "main edit") + } + + @Test("Additive policy preserves existing controls") + func additivePolicyPreservesControls() async throws { + let original = html(props: """ + "warmth": { "label": "Warmth", "type": "slider", "min": 0, "max": 100, "step": 1, "value": 60 } + """) + let fixture = try makeFixture(contents: original) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let coordinator = TweakWorkspaceCoordinator(temporaryRootURL: fixture.temporaryURL) + let transaction = try await coordinator.prepare(targetFileURL: fixture.targetURL) + let generated = html(props: """ + "warmth": { "label": "Warmth", "type": "slider", "min": 0, "max": 100, "step": 1, "value": 60 }, + "night": { "label": "Night", "type": "toggle", "value": false } + """) + try Data(generated.utf8).write(to: transaction.workingFileURL) + + let result = try await coordinator.finish(transaction, policy: .additive) + + #expect(result == .applied) + } + + @Test("Additive policy rejects removed or mutated controls", arguments: [ + """ + "contrast": { "label": "Contrast", "type": "toggle", "value": false } + """, + """ + "warmth": { "label": "Warmth", "type": "slider", "min": 0, "max": 100, "step": 1, "value": 20 } + """, + ]) + func additivePolicyRejectsInvalidChanges(generatedProps: String) async throws { + let original = html(props: """ + "warmth": { "label": "Warmth", "type": "slider", "min": 0, "max": 100, "step": 1, "value": 60 } + """) + let fixture = try makeFixture(contents: original) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let coordinator = TweakWorkspaceCoordinator(temporaryRootURL: fixture.temporaryURL) + let transaction = try await coordinator.prepare(targetFileURL: fixture.targetURL) + try Data(html(props: generatedProps).utf8).write(to: transaction.workingFileURL) + + await #expect(throws: TweakWorkspaceError.invalidGeneratedTweaks) { + try await coordinator.finish(transaction, policy: .additive) + } + #expect(try String(contentsOf: fixture.targetURL, encoding: .utf8) == original) + } + + private func html(props: String) -> String { + """ + + """ + } + + private func makeFixture(contents: String) throws -> Fixture { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("TweakWorkspaceCoordinatorTests-\(UUID().uuidString)", isDirectory: true) + let projectURL = rootURL.appendingPathComponent("project", isDirectory: true) + let temporaryURL = rootURL.appendingPathComponent("tasks", isDirectory: true) + try FileManager.default.createDirectory(at: projectURL, withIntermediateDirectories: true) + let targetURL = projectURL.appendingPathComponent("index.html") + try Data(contents.utf8).write(to: targetURL) + return Fixture(rootURL: rootURL, temporaryURL: temporaryURL, targetURL: targetURL) + } +} + +private struct Fixture { + let rootURL: URL + let temporaryURL: URL + let targetURL: URL +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/TweaksDefaultsWriteCoordinatorTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/TweaksDefaultsWriteCoordinatorTests.swift index 3dd93967..1b904454 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/TweaksDefaultsWriteCoordinatorTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/TweaksDefaultsWriteCoordinatorTests.swift @@ -5,106 +5,164 @@ import Testing @testable import AgentHubCore private actor TweaksMockFileService: ProjectFileServiceProtocol { - struct WriteCall: Equatable, Sendable { - let path: String - let content: String - } - - enum MockError: Error { - case missingFile - } + enum MockError: Error { case missingFile, writeFailed } - private var files: [String: String] - private var writes: [WriteCall] = [] + private(set) var files: [String: String] + private(set) var writeCount = 0 + private var shouldFailWrites = false init(files: [String: String]) { self.files = files } + func failWrites() { + shouldFailWrites = true + } + func readFile(at path: String, projectPath: String) async throws -> String { guard let content = files[path] else { throw MockError.missingFile } return content } func writeFile(at path: String, content: String, projectPath: String) async throws { + guard !shouldFailWrites else { throw MockError.writeFailed } files[path] = content - writes.append(WriteCall(path: path, content: content)) + writeCount += 1 } func listTextFiles(in projectPath: String, extensions: Set) async -> [String] { files.keys.sorted() } - - func recordedWrites() -> [WriteCall] { - writes - } } @Suite("TweaksDefaultsWriteCoordinator") struct TweaksDefaultsWriteCoordinatorTests { private let filePath = "/project/index.html" + private let projectPath = "/project" private let html = """ """ - @Test("Writes a declared prop value into the source") - func writesDeclaredPropValue() async throws { - let fileService = TweaksMockFileService(files: [filePath: html]) - let coordinator = TweaksDefaultsWriteCoordinator(fileService: fileService) + @Test("Writes all changed defaults atomically") + func writesChangedDefaults() async throws { + let service = TweaksMockFileService(files: [filePath: html]) + let coordinator = TweaksDefaultsWriteCoordinator(fileService: service) - let outcome = await coordinator.writeValue( - propName: "warmth", - value: .number(72), + try await coordinator.saveDefaults( + props: makeProps(warmth: .number(85), night: .boolean(true)), filePath: filePath, - projectPath: "/project" + projectPath: projectPath ) - #expect(outcome == .written) - let writes = await fileService.recordedWrites() - #expect(writes.count == 1) - #expect(writes.first?.content.contains("\"value\": 72") == true) - #expect(writes.first?.content.contains( - "\"accent\": { \"label\": \"Accent\", \"type\": \"color\", \"value\": \"#ff6b35\" }" - ) == true) + #expect(await service.writeCount == 1) + let content = try #require(await service.files[filePath]) + let props = try TweakPropsSourceEditor.parseProps(fromSource: content) + #expect(props.first(where: { $0.name == "warmth" })?.value == .number(85)) + #expect(props.first(where: { $0.name == "night" })?.value == .boolean(true)) } - @Test("Rejects undeclared prop names without writing") - func rejectsUndeclaredPropName() async throws { - let fileService = TweaksMockFileService(files: [filePath: html]) - let coordinator = TweaksDefaultsWriteCoordinator(fileService: fileService) + @Test("Unchanged values do not write") + func unchangedValuesDoNotWrite() async throws { + let service = TweaksMockFileService(files: [filePath: html]) + let coordinator = TweaksDefaultsWriteCoordinator(fileService: service) - let outcome = await coordinator.writeValue( - propName: "missing", - value: .string("retro"), + try await coordinator.saveDefaults( + props: makeProps(), filePath: filePath, - projectPath: "/project" + projectPath: projectPath ) - #expect(outcome == .propNotDeclared) - let writes = await fileService.recordedWrites() - #expect(writes.isEmpty) + #expect(await service.writeCount == 0) } - @Test("Rejects unsafe prop names before parsing") - func rejectsUnsafePropName() async throws { - let fileService = TweaksMockFileService(files: [filePath: html]) - let coordinator = TweaksDefaultsWriteCoordinator(fileService: fileService) + @Test("Rejects source or schema drift") + func rejectsSourceDrift() async throws { + let changedHTML = html.replacingOccurrences(of: "\"value\": 60", with: "\"value\": 70") + let service = TweaksMockFileService(files: [filePath: changedHTML]) + let coordinator = TweaksDefaultsWriteCoordinator(fileService: service) + + await #expect(throws: TweaksDefaultsWriteError.sourceChanged) { + try await coordinator.saveDefaults( + props: makeProps(warmth: .number(85)), + filePath: filePath, + projectPath: projectPath + ) + } + #expect(await service.writeCount == 0) + } - let outcome = await coordinator.writeValue( - propName: "warmth;alert(1)", - value: .number(72), - filePath: filePath, - projectPath: "/project" + @Test("Reports read and write failures") + func reportsIOFailures() async throws { + let missingService = TweaksMockFileService(files: [:]) + let missingCoordinator = TweaksDefaultsWriteCoordinator(fileService: missingService) + await #expect(throws: TweaksDefaultsWriteError.cannotReadFile) { + try await missingCoordinator.saveDefaults( + props: makeProps(warmth: .number(85)), + filePath: filePath, + projectPath: projectPath + ) + } + + let failingService = TweaksMockFileService(files: [filePath: html]) + await failingService.failWrites() + let failingCoordinator = TweaksDefaultsWriteCoordinator(fileService: failingService) + do { + try await failingCoordinator.saveDefaults( + props: makeProps(warmth: .number(85)), + filePath: filePath, + projectPath: projectPath + ) + Issue.record("Expected a write failure") + } catch let error as TweaksDefaultsWriteError { + guard case .writeFailed = error else { + Issue.record("Expected writeFailed, got \(error)") + return + } + } + } + + @Test("Resolves file and dev-server preview URLs", arguments: [ + (URL(fileURLWithPath: "/project/page.html"), "/project/page.html"), + (URL(string: "http://localhost:3000/")!, "/project/index.html"), + (URL(string: "http://localhost:3000/docs/")!, "/project/docs/index.html"), + (URL(string: "http://localhost:3000/about.html")!, "/project/about.html"), + ]) + func resolvesPreviewURL(argument: (URL, String)) { + #expect( + TweaksDefaultsWriteCoordinator.resolveFilePath( + previewURL: argument.0, + projectPath: projectPath + ) == argument.1 ) + } - #expect(outcome == .invalidPropName) - let writes = await fileService.recordedWrites() - #expect(writes.isEmpty) + private func makeProps( + warmth: TweakPropValue = .number(60), + night: TweakPropValue = .boolean(false) + ) -> [TweakProp] { + [ + TweakProp( + name: "warmth", + label: "Warmth", + type: .slider, + minimum: 0, + maximum: 100, + value: warmth, + defaultValue: .number(60) + ), + TweakProp( + name: "night", + label: "Night", + type: .toggle, + value: night, + defaultValue: .boolean(false) + ), + ] } } @@ -120,7 +178,6 @@ struct WebPreviewFileWatcherSuppressionTests { #expect(watcher.isReloadSuppressed(at: now.addingTimeInterval(0.5))) #expect(watcher.isReloadSuppressed(at: now.addingTimeInterval(1.1)) == false) - #expect(watcher.isReloadSuppressed(at: now.addingTimeInterval(1.2)) == false) } @Test("Later suppression windows extend the active suppression") diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTweakAgentServiceTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTweakAgentServiceTests.swift new file mode 100644 index 00000000..d67b06d6 --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/WebPreviewTweakAgentServiceTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import AgentHubCore + +private actor TweakAgentMockWorkspaceCoordinator: TweakWorkspaceCoordinating { + private(set) var finishedPolicy: InspectorTweakPolicy? + private(set) var didDiscard = false + + func prepare(targetFileURL: URL) async throws -> TweakWorkspaceTransaction { + TweakWorkspaceTransaction( + rootURL: URL(fileURLWithPath: "/tmp/tweak-task"), + workingFileURL: URL(fileURLWithPath: "/tmp/tweak-task/index.html"), + targetFileURL: targetFileURL, + baseContents: Data() + ) + } + + func finish( + _ transaction: TweakWorkspaceTransaction, + policy: InspectorTweakPolicy + ) async throws -> InspectorTweakResult { + finishedPolicy = policy + return .applied + } + + func discard(_ transaction: TweakWorkspaceTransaction) async { + didDiscard = true + } +} + +private actor TweakAgentMockCommandRunner: TweakAgentCommandRunning { + private(set) var prompt: String? + private(set) var workingDirectory: String? + + func run( + prompt: String, + systemPrompt: String, + workingDirectory: String, + cliConfiguration: CLICommandConfiguration + ) async throws { + self.prompt = prompt + self.workingDirectory = workingDirectory + } +} + +@Suite("WebPreviewTweakAgentService") +struct WebPreviewTweakAgentServiceTests { + @Test("Runs in the isolated workspace and finishes with the requested policy") + func runsIsolatedTransaction() async throws { + let workspace = TweakAgentMockWorkspaceCoordinator() + let runner = TweakAgentMockCommandRunner() + let service = WebPreviewTweakAgentService( + workspaceCoordinator: workspace, + commandRunner: runner, + timeout: .seconds(1) + ) + + let result = try await service.runTweakAgent( + prompt: "Add warmth", + targetFileURL: URL(fileURLWithPath: "/project/index.html"), + policy: .additive, + cliConfiguration: .codexDefault + ) + + #expect(result == .applied) + #expect(await runner.prompt == "Add warmth") + #expect(await runner.workingDirectory == "/tmp/tweak-task") + #expect(await workspace.finishedPolicy == .additive) + #expect(await workspace.didDiscard == false) + } +} + +@Suite("TweakAgentProcessRunner command arguments") +struct TweakAgentProcessRunnerTests { + @Test("Builds an ephemeral Claude edit command") + func buildsClaudeCommand() { + let arguments = TweakAgentProcessRunner.arguments( + prompt: "Add warmth", + systemPrompt: "Edit one file", + workingDirectory: "/tmp/task", + cliConfiguration: CLICommandConfiguration( + command: "agenthub claude", + mode: .claude, + extraArgs: ["--model", "sonnet"] + ) + ) + + #expect(arguments.starts(with: ["claude", "--model", "sonnet", "-p"])) + #expect(arguments.contains("--no-session-persistence")) + #expect(arguments.contains("acceptEdits")) + #expect(arguments.last == "Add warmth") + } + + @Test("Builds an ephemeral Codex workspace-write command") + func buildsCodexCommand() { + let arguments = TweakAgentProcessRunner.arguments( + prompt: "Add warmth", + systemPrompt: "Edit one file", + workingDirectory: "/tmp/task", + cliConfiguration: CLICommandConfiguration(command: "codex", mode: .codex) + ) + + #expect(arguments.first == "exec") + #expect(arguments.contains("--ephemeral")) + #expect(arguments.contains("workspace-write")) + #expect(arguments.contains("/tmp/task")) + #expect(arguments.last?.contains("Add warmth") == true) + } +} + +@Suite("TweaksButtonPresentation") +struct TweaksButtonPresentationTests { + @Test("Working state shows progress") + func workingStateShowsProgress() { + let presentation = TweaksButtonPresentation.resolve(agentState: .working) + #expect(presentation.isLoading) + #expect(presentation.accessibilityLabel == "Creating tweaks") + } + + @Test("Idle state uses the standard label") + func idleStateUsesStandardLabel() { + let presentation = TweaksButtonPresentation.resolve(agentState: .idle) + #expect(!presentation.isLoading) + #expect(presentation.accessibilityLabel == "Tweaks") + } +}