From 35d8a3a887b4b65cabe5d15ffd7e3f137480f14a Mon Sep 17 00:00:00 2001 From: fatih Date: Tue, 3 Mar 2026 16:47:06 +0300 Subject: [PATCH 1/7] feat: implement Phase 3 project features (#168, #170, #156, #157, #159) Add five project management features across Swift/macOS and Tauri/React: - #168 Project time tracking with duration analytics and per-agent breakdown - #170 Project export as ZIP (project files + tasks/reviews JSON) - #156 Project templates with 6 built-in templates for common project types - #157 Task comments and notes with user/system author support - #159 Prompt history per task showing usage records with outcomes and scores Includes DB migrations v21 (Swift/GRDB) and v22 (Rust/SQLite), 7 new files, and updates to 16 existing files. Co-Authored-By: Claude Sonnet 4.6 --- .../CreedFlow/Database/AppDatabase.swift | 20 ++ .../CreedFlow/Engine/Orchestrator.swift | 20 +- .../Sources/CreedFlow/Models/Project.swift | 3 + .../CreedFlow/Models/ProjectTemplate.swift | 172 ++++++++++ .../CreedFlow/Models/TaskComment.swift | 35 ++ .../CreedFlow/Services/ProjectExporter.swift | 84 +++++ .../Views/Projects/ProjectDetailView.swift | 140 ++++++++ .../Views/Projects/ProjectListView.swift | 200 ++++++++++- .../Views/Tasks/TaskDetailView.swift | 180 +++++++++- .../src-tauri/src/commands/projects.rs | 322 +++++++++++++++++- .../src-tauri/src/commands/tasks.rs | 44 ++- .../src-tauri/src/db/migrations.rs | 15 + creedflow-desktop/src-tauri/src/db/models.rs | 152 ++++++++- creedflow-desktop/src-tauri/src/lib.rs | 7 + .../src/components/layout/DetailPanel.tsx | 12 +- .../projects/ProjectDetailPanel.tsx | 37 +- .../src/components/projects/ProjectList.tsx | 25 +- .../projects/ProjectTemplateSelector.tsx | 130 +++++++ .../components/projects/ProjectTimeStats.tsx | 99 ++++++ .../src/components/tasks/TaskComments.tsx | 108 ++++++ .../components/tasks/TaskPromptHistory.tsx | 98 ++++++ creedflow-desktop/src/tauri.ts | 29 ++ creedflow-desktop/src/types/models.ts | 66 ++++ 23 files changed, 1975 insertions(+), 23 deletions(-) create mode 100644 CreedFlow/Sources/CreedFlow/Models/ProjectTemplate.swift create mode 100644 CreedFlow/Sources/CreedFlow/Models/TaskComment.swift create mode 100644 CreedFlow/Sources/CreedFlow/Services/ProjectExporter.swift create mode 100644 creedflow-desktop/src/components/projects/ProjectTemplateSelector.tsx create mode 100644 creedflow-desktop/src/components/projects/ProjectTimeStats.tsx create mode 100644 creedflow-desktop/src/components/tasks/TaskComments.tsx create mode 100644 creedflow-desktop/src/components/tasks/TaskPromptHistory.tsx diff --git a/CreedFlow/Sources/CreedFlow/Database/AppDatabase.swift b/CreedFlow/Sources/CreedFlow/Database/AppDatabase.swift index a7d782b..2913a1f 100644 --- a/CreedFlow/Sources/CreedFlow/Database/AppDatabase.swift +++ b/CreedFlow/Sources/CreedFlow/Database/AppDatabase.swift @@ -515,6 +515,26 @@ public struct AppDatabase { ) } + migrator.registerMigration("v21_project_completion_and_comments") { db in + try db.alter(table: "project") { t in + t.add(column: "completedAt", .datetime) + } + + try db.create(table: "taskComment") { t in + t.primaryKey("id", .text).notNull() + t.column("taskId", .text).notNull() + .references("agentTask", onDelete: .cascade) + t.column("content", .text).notNull() + t.column("author", .text).notNull().defaults(to: "user") + t.column("createdAt", .datetime).notNull() + } + try db.create( + index: "taskComment_on_taskId", + on: "taskComment", + columns: ["taskId"] + ) + } + return migrator } } diff --git a/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift b/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift index 2644a1a..209a30f 100644 --- a/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift +++ b/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift @@ -1590,17 +1590,33 @@ final class Orchestrator { } } - /// Check if all tasks for a project are done and update project + prompt usage accordingly. + /// Check if all tasks for a project are done and update project status + prompt usage accordingly. private func checkProjectCompletion(projectId: UUID) async { do { let (allDone, anyFailed) = try await dbQueue.read { db -> (Bool, Bool) in - let tasks = try AgentTask.filter(Column("projectId") == projectId).fetchAll(db) + let tasks = try AgentTask + .filter(Column("projectId") == projectId) + .filter(Column("archivedAt") == nil) + .fetchAll(db) let pending = tasks.contains { $0.status == .queued || $0.status == .inProgress } let failed = tasks.contains { $0.status == .failed } return (!pending, failed) } guard allDone else { return } + // Set project completedAt if not already set + try await dbQueue.write { db in + guard var project = try Project.fetchOne(db, id: projectId) else { return } + if project.completedAt == nil { + project.completedAt = Date() + if !anyFailed { + project.status = .completed + } + project.updatedAt = Date() + try project.update(db) + } + } + let outcome: PromptUsage.Outcome = anyFailed ? .failed : .completed await backfillPromptUsageOutcome(projectId: projectId, outcome: outcome) } catch { diff --git a/CreedFlow/Sources/CreedFlow/Models/Project.swift b/CreedFlow/Sources/CreedFlow/Models/Project.swift index 55b3f49..182374e 100644 --- a/CreedFlow/Sources/CreedFlow/Models/Project.swift +++ b/CreedFlow/Sources/CreedFlow/Models/Project.swift @@ -10,6 +10,7 @@ package struct Project: Codable, Identifiable, Equatable { package var directoryPath: String package var projectType: ProjectType package var stagingPrNumber: Int? + package var completedAt: Date? package var telegramChatId: Int64? package var createdAt: Date package var updatedAt: Date @@ -43,6 +44,7 @@ package struct Project: Codable, Identifiable, Equatable { directoryPath: String = "", projectType: ProjectType = .software, stagingPrNumber: Int? = nil, + completedAt: Date? = nil, telegramChatId: Int64? = nil, createdAt: Date = Date(), updatedAt: Date = Date() @@ -55,6 +57,7 @@ package struct Project: Codable, Identifiable, Equatable { self.directoryPath = directoryPath self.projectType = projectType self.stagingPrNumber = stagingPrNumber + self.completedAt = completedAt self.telegramChatId = telegramChatId self.createdAt = createdAt self.updatedAt = updatedAt diff --git a/CreedFlow/Sources/CreedFlow/Models/ProjectTemplate.swift b/CreedFlow/Sources/CreedFlow/Models/ProjectTemplate.swift new file mode 100644 index 0000000..81d6aa2 --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Models/ProjectTemplate.swift @@ -0,0 +1,172 @@ +import Foundation + +package struct ProjectTemplate: Identifiable { + package let id: String + package let name: String + package let description: String + package let icon: String + package let techStack: String + package let projectType: Project.ProjectType + package let features: [TemplateFeature] +} + +package struct TemplateFeature { + package let name: String + package let description: String + package let tasks: [TemplateTask] +} + +package struct TemplateTask { + package let agentType: AgentTask.AgentType + package let title: String + package let description: String + package let priority: Int +} + +// MARK: - Built-in Templates + +extension ProjectTemplate { + package static let builtInTemplates: [ProjectTemplate] = [ + webApp, mobileApp, restAPI, landingPage, blogCMS, cliTool, + ] + + static let webApp = ProjectTemplate( + id: "web-app", + name: "Web App", + description: "Full-stack web application with authentication, CRUD operations, and deployment", + icon: "globe", + techStack: "React, Node.js, PostgreSQL", + projectType: .software, + features: [ + TemplateFeature(name: "Authentication", description: "User registration, login, and session management", tasks: [ + TemplateTask(agentType: .coder, title: "Implement auth API endpoints", description: "Create signup, login, logout, and password reset endpoints with JWT tokens", priority: 9), + TemplateTask(agentType: .coder, title: "Build auth UI components", description: "Create login, register, and forgot password forms with validation", priority: 8), + TemplateTask(agentType: .tester, title: "Test authentication flow", description: "Write integration tests for auth endpoints and UI", priority: 7), + ]), + TemplateFeature(name: "CRUD Operations", description: "Core data management with list, create, edit, and delete", tasks: [ + TemplateTask(agentType: .coder, title: "Implement data API endpoints", description: "Create REST endpoints for CRUD operations with pagination and filtering", priority: 8), + TemplateTask(agentType: .coder, title: "Build data management UI", description: "Create list view, detail view, and forms for data management", priority: 7), + TemplateTask(agentType: .tester, title: "Test CRUD operations", description: "Write tests for all CRUD endpoints and UI interactions", priority: 6), + ]), + TemplateFeature(name: "Deployment", description: "Docker-based deployment configuration", tasks: [ + TemplateTask(agentType: .devops, title: "Set up Docker configuration", description: "Create Dockerfile and docker-compose.yml for the application", priority: 5), + TemplateTask(agentType: .devops, title: "Configure CI/CD pipeline", description: "Set up automated build, test, and deploy pipeline", priority: 4), + ]), + ] + ) + + static let mobileApp = ProjectTemplate( + id: "mobile-app", + name: "Mobile App", + description: "Cross-platform mobile application with native UI, API integration, and push notifications", + icon: "iphone", + techStack: "React Native, TypeScript", + projectType: .software, + features: [ + TemplateFeature(name: "App UI", description: "Core screens and navigation", tasks: [ + TemplateTask(agentType: .designer, title: "Design app screens", description: "Create design specs for main screens: home, detail, profile, settings", priority: 9), + TemplateTask(agentType: .coder, title: "Implement navigation and screens", description: "Build tab navigation, stack navigation, and core screen layouts", priority: 8), + ]), + TemplateFeature(name: "API Integration", description: "Backend API connectivity", tasks: [ + TemplateTask(agentType: .coder, title: "Set up API client", description: "Configure HTTP client, authentication headers, and error handling", priority: 8), + TemplateTask(agentType: .coder, title: "Implement data fetching", description: "Add API calls for all screens with loading states and caching", priority: 7), + ]), + TemplateFeature(name: "Authentication", description: "User auth with secure storage", tasks: [ + TemplateTask(agentType: .coder, title: "Implement auth flow", description: "Build login, register, and token refresh with secure storage", priority: 8), + TemplateTask(agentType: .tester, title: "Test auth and API integration", description: "Write tests for auth flow and API interactions", priority: 6), + ]), + ] + ) + + static let restAPI = ProjectTemplate( + id: "rest-api", + name: "REST API", + description: "Backend API service with authentication, database, and documentation", + icon: "server.rack", + techStack: "Node.js, Express, PostgreSQL", + projectType: .software, + features: [ + TemplateFeature(name: "API Endpoints", description: "RESTful API design and implementation", tasks: [ + TemplateTask(agentType: .analyzer, title: "Design API schema", description: "Define data models, relationships, and API endpoint structure", priority: 10), + TemplateTask(agentType: .coder, title: "Implement API endpoints", description: "Build all REST endpoints with validation and error handling", priority: 9), + TemplateTask(agentType: .coder, title: "Set up database and migrations", description: "Configure database connection, create schemas, and seed data", priority: 9), + ]), + TemplateFeature(name: "Auth & Security", description: "API authentication and security", tasks: [ + TemplateTask(agentType: .coder, title: "Implement JWT authentication", description: "Add auth middleware, token generation, and refresh logic", priority: 8), + TemplateTask(agentType: .coder, title: "Add rate limiting and security headers", description: "Configure rate limiting, CORS, helmet, and input sanitization", priority: 7), + ]), + TemplateFeature(name: "Testing & Docs", description: "API tests and documentation", tasks: [ + TemplateTask(agentType: .tester, title: "Write API tests", description: "Create integration tests for all endpoints with edge cases", priority: 7), + TemplateTask(agentType: .contentWriter, title: "Generate API documentation", description: "Create OpenAPI/Swagger docs with examples for all endpoints", priority: 5), + ]), + ] + ) + + static let landingPage = ProjectTemplate( + id: "landing-page", + name: "Landing Page", + description: "Marketing landing page with responsive design, SEO optimization, and analytics", + icon: "doc.richtext", + techStack: "HTML, CSS, JavaScript", + projectType: .content, + features: [ + TemplateFeature(name: "Design & Layout", description: "Visual design and responsive layout", tasks: [ + TemplateTask(agentType: .designer, title: "Design landing page layout", description: "Create hero section, features, testimonials, CTA, and footer sections", priority: 9), + TemplateTask(agentType: .coder, title: "Implement responsive layout", description: "Build the landing page with mobile-first responsive design", priority: 8), + ]), + TemplateFeature(name: "Content & SEO", description: "Copywriting and search optimization", tasks: [ + TemplateTask(agentType: .contentWriter, title: "Write landing page copy", description: "Create compelling headlines, feature descriptions, and CTAs", priority: 8), + TemplateTask(agentType: .coder, title: "Optimize for SEO", description: "Add meta tags, structured data, sitemap, and performance optimizations", priority: 6), + ]), + TemplateFeature(name: "Deploy", description: "Publish the landing page", tasks: [ + TemplateTask(agentType: .devops, title: "Deploy landing page", description: "Set up hosting and deploy the landing page", priority: 5), + ]), + ] + ) + + static let blogCMS = ProjectTemplate( + id: "blog-cms", + name: "Blog / CMS", + description: "Content management system with blog, categories, and multi-channel publishing", + icon: "newspaper", + techStack: "Next.js, MDX, Tailwind CSS", + projectType: .content, + features: [ + TemplateFeature(name: "Content System", description: "Blog post management and rendering", tasks: [ + TemplateTask(agentType: .coder, title: "Build blog engine", description: "Create MDX-based blog with categories, tags, and search", priority: 9), + TemplateTask(agentType: .coder, title: "Implement admin interface", description: "Create post editor, media library, and settings panel", priority: 8), + ]), + TemplateFeature(name: "Design", description: "Blog theme and components", tasks: [ + TemplateTask(agentType: .designer, title: "Design blog theme", description: "Create layout, typography, and component designs for blog", priority: 8), + TemplateTask(agentType: .coder, title: "Implement blog theme", description: "Build responsive blog theme with dark mode support", priority: 7), + ]), + TemplateFeature(name: "Publishing", description: "SEO and content distribution", tasks: [ + TemplateTask(agentType: .contentWriter, title: "Write initial content", description: "Create initial blog posts and about page content", priority: 6), + TemplateTask(agentType: .coder, title: "Add SEO and RSS", description: "Implement SEO meta tags, RSS feed, and sitemap", priority: 5), + ]), + ] + ) + + static let cliTool = ProjectTemplate( + id: "cli-tool", + name: "CLI Tool", + description: "Command-line tool with argument parsing, subcommands, and documentation", + icon: "terminal", + techStack: "Python, Click", + projectType: .software, + features: [ + TemplateFeature(name: "Core Logic", description: "Main functionality and commands", tasks: [ + TemplateTask(agentType: .analyzer, title: "Design CLI architecture", description: "Define command structure, arguments, and output formats", priority: 10), + TemplateTask(agentType: .coder, title: "Implement core commands", description: "Build main CLI commands with argument parsing and validation", priority: 9), + TemplateTask(agentType: .coder, title: "Add configuration management", description: "Implement config file loading, defaults, and environment variables", priority: 7), + ]), + TemplateFeature(name: "Testing", description: "Unit and integration tests", tasks: [ + TemplateTask(agentType: .tester, title: "Write CLI tests", description: "Create tests for all commands with various argument combinations", priority: 7), + ]), + TemplateFeature(name: "Documentation", description: "User docs and packaging", tasks: [ + TemplateTask(agentType: .contentWriter, title: "Write CLI documentation", description: "Create README, usage examples, and installation guide", priority: 5), + TemplateTask(agentType: .devops, title: "Set up packaging and distribution", description: "Configure package build, versioning, and distribution (PyPI/Homebrew)", priority: 4), + ]), + ] + ) +} diff --git a/CreedFlow/Sources/CreedFlow/Models/TaskComment.swift b/CreedFlow/Sources/CreedFlow/Models/TaskComment.swift new file mode 100644 index 0000000..2fede19 --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Models/TaskComment.swift @@ -0,0 +1,35 @@ +import Foundation +import GRDB + +package struct TaskComment: Codable, Identifiable, Equatable { + package var id: UUID + package var taskId: UUID + package var content: String + package var author: Author + package var createdAt: Date + + package enum Author: String, Codable, DatabaseValueConvertible { + case user + case system + } + + package init( + id: UUID = UUID(), + taskId: UUID, + content: String, + author: Author = .user, + createdAt: Date = Date() + ) { + self.id = id + self.taskId = taskId + self.content = content + self.author = author + self.createdAt = createdAt + } +} + +// MARK: - Persistence + +extension TaskComment: FetchableRecord, PersistableRecord { + package static let databaseTableName = "taskComment" +} diff --git a/CreedFlow/Sources/CreedFlow/Services/ProjectExporter.swift b/CreedFlow/Sources/CreedFlow/Services/ProjectExporter.swift new file mode 100644 index 0000000..bfe3391 --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Services/ProjectExporter.swift @@ -0,0 +1,84 @@ +import Foundation +import GRDB + +package struct ProjectExporter { + /// Export a project as a ZIP archive containing project files, tasks JSON, and reviews JSON. + package static func exportAsZIP( + project: Project, + dbQueue: DatabaseQueue, + to outputURL: URL + ) throws { + let fm = FileManager.default + let tempDir = fm.temporaryDirectory.appendingPathComponent("creedflow-export-\(UUID().uuidString)") + try fm.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: tempDir) } + + let exportDir = tempDir.appendingPathComponent(project.name) + try fm.createDirectory(at: exportDir, withIntermediateDirectories: true) + + // Copy project directory contents if it exists + let projectDir = URL(fileURLWithPath: project.directoryPath) + if fm.fileExists(atPath: project.directoryPath) { + let contents = try fm.contentsOfDirectory( + at: projectDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + for item in contents { + let dest = exportDir.appendingPathComponent(item.lastPathComponent) + try fm.copyItem(at: item, to: dest) + } + } + + // Fetch tasks and reviews from DB + let (tasks, reviews) = try dbQueue.read { db -> ([AgentTask], [Review]) in + let tasks = try AgentTask + .filter(Column("projectId") == project.id) + .order(Column("priority").desc, Column("createdAt").asc) + .fetchAll(db) + let taskIds = tasks.map(\.id) + let reviews = try Review + .filter(taskIds.contains(Column("taskId"))) + .order(Column("createdAt").desc) + .fetchAll(db) + return (tasks, reviews) + } + + // Write tasks.json + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let tasksData = try encoder.encode(tasks) + try tasksData.write(to: exportDir.appendingPathComponent("tasks.json")) + + // Write reviews.json + let reviewsData = try encoder.encode(reviews) + try reviewsData.write(to: exportDir.appendingPathComponent("reviews.json")) + + // Run zip command (macOS ships with /usr/bin/zip) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-r", outputURL.path, project.name] + process.currentDirectoryURL = tempDir + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ExportError.zipFailed(process.terminationStatus) + } + } + + enum ExportError: LocalizedError { + case zipFailed(Int32) + + var errorDescription: String? { + switch self { + case .zipFailed(let code): + return "ZIP creation failed with exit code \(code)" + } + } + } +} diff --git a/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectDetailView.swift b/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectDetailView.swift index 0e4b7de..03ab32d 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectDetailView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectDetailView.swift @@ -12,6 +12,7 @@ struct ProjectDetailView: View { @State private var features: [Feature] = [] @State private var showAnalyze = false @State private var showRevisionSheet = false + @State private var showExportZip = false @State private var errorMessage: String? @AppStorage("preferredEditor") private var preferredEditor = "" @@ -70,6 +71,11 @@ struct ProjectDetailView: View { ) } + // Time stats + if !tasks.isEmpty { + ProjectTimeStatsView(project: project, tasks: tasks) + } + if let errorMessage { ForgeErrorBanner(message: errorMessage, onDismiss: { self.errorMessage = nil }) } @@ -121,6 +127,13 @@ struct ProjectDetailView: View { .buttonStyle(.bordered) .disabled(!FileManager.default.fileExists(atPath: project.directoryPath)) } + + Button { + showExportZip = true + } label: { + Label("Export ZIP", systemImage: "arrow.down.doc") + } + .buttonStyle(.bordered) } // Recent tasks @@ -172,6 +185,20 @@ struct ProjectDetailView: View { ) } } + .onChange(of: showExportZip) { _, show in + guard show, let project, let db = appDatabase else { return } + showExportZip = false + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(project.name).zip" + panel.allowedContentTypes = [.zip] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try ProjectExporter.exportAsZIP(project: project, dbQueue: db.dbQueue, to: url) + } catch { + errorMessage = "Export failed: \(error.localizedDescription)" + } + } } private var analyzerRunning: Bool { @@ -265,3 +292,116 @@ struct ProjectDetailView: View { ] } + +// MARK: - Project Time Stats + +struct ProjectTimeStatsView: View { + let project: Project + let tasks: [AgentTask] + + private var elapsedMs: Int64 { + let end = project.completedAt ?? Date() + return Int64(end.timeIntervalSince(project.createdAt) * 1000) + } + + private var totalWorkMs: Int64 { + tasks.compactMap(\.durationMs).reduce(0, +) + } + + private var idleMs: Int64 { + max(0, elapsedMs - totalWorkMs) + } + + private var agentBreakdown: [(agentType: String, totalMs: Int64, count: Int)] { + var grouped: [String: (ms: Int64, count: Int)] = [:] + for task in tasks { + let key = task.agentType.rawValue + let existing = grouped[key] ?? (ms: 0, count: 0) + grouped[key] = (ms: existing.ms + (task.durationMs ?? 0), count: existing.count + 1) + } + return grouped.map { (agentType: $0.key, totalMs: $0.value.ms, count: $0.value.count) } + .sorted { $0.totalMs > $1.totalMs } + } + + var body: some View { + DisclosureGroup("Time Tracking") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + timeStatItem(label: "Elapsed", value: formatDuration(ms: elapsedMs), icon: "clock", color: .forgeInfo) + timeStatItem(label: "Work", value: formatDuration(ms: totalWorkMs), icon: "hammer", color: .forgeSuccess) + timeStatItem(label: "Idle", value: formatDuration(ms: idleMs), icon: "pause.circle", color: .forgeNeutral) + } + + if !agentBreakdown.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Per Agent") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + + let maxMs = agentBreakdown.map(\.totalMs).max() ?? 1 + ForEach(agentBreakdown, id: \.agentType) { item in + HStack(spacing: 6) { + Text(item.agentType.capitalized) + .font(.system(size: 11)) + .frame(width: 80, alignment: .leading) + + GeometryReader { geo in + let fraction = maxMs > 0 ? CGFloat(item.totalMs) / CGFloat(maxMs) : 0 + RoundedRectangle(cornerRadius: 2) + .fill(.forgeAmber.opacity(0.6)) + .frame(width: geo.size.width * fraction) + } + .frame(height: 8) + + Text("\(ForgeDuration.format(ms: item.totalMs)) (\(item.count))") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 90, alignment: .trailing) + } + } + } + } + } + .padding(.top, 4) + } + .font(.subheadline.bold()) + } + + private func timeStatItem(label: String, value: String, icon: String, color: Color) -> some View { + VStack(spacing: 2) { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 10)) + Text(label) + .font(.system(size: 10)) + } + .foregroundStyle(.tertiary) + Text(value) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity) + .padding(6) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + private func formatDuration(ms: Int64) -> String { + let totalSeconds = Double(ms) / 1000.0 + if totalSeconds < 60 { + return String(format: "%.0fs", totalSeconds) + } else if totalSeconds < 3600 { + let minutes = Int(totalSeconds) / 60 + let seconds = Int(totalSeconds) % 60 + return "\(minutes)m \(seconds)s" + } else if totalSeconds < 86400 { + let hours = Int(totalSeconds) / 3600 + let minutes = (Int(totalSeconds) % 3600) / 60 + return "\(hours)h \(minutes)m" + } else { + let days = Int(totalSeconds) / 86400 + let hours = (Int(totalSeconds) % 86400) / 3600 + return "\(days)d \(hours)h" + } + } +} diff --git a/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectListView.swift b/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectListView.swift index 06ee5ea..0563445 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectListView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Projects/ProjectListView.swift @@ -9,6 +9,7 @@ struct ProjectListView: View { @State private var projects: [Project] = [] @State private var showNewProject = false + @State private var showTemplateSelector = false @State private var errorMessage: String? @State private var searchText = "" @State private var projectToDelete: Project? @@ -38,8 +39,17 @@ struct ProjectListView: View { .padding(.vertical, 5) .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) - Button { - showNewProject = true + Menu { + Button { + showNewProject = true + } label: { + Label("New Project", systemImage: "plus") + } + Button { + showTemplateSelector = true + } label: { + Label("New from Template", systemImage: "doc.on.doc") + } } label: { Image(systemName: "plus") } @@ -131,6 +141,11 @@ struct ProjectListView: View { .sheet(isPresented: $showNewProject) { ProjectCreationWizard(appDatabase: appDatabase) } + .sheet(isPresented: $showTemplateSelector) { + ProjectTemplateSelectorSheet(appDatabase: appDatabase) { projectId in + selectedProjectId = projectId + } + } .confirmationDialog( "Delete Project", isPresented: Binding( @@ -215,6 +230,187 @@ struct ProjectListView: View { } } +// MARK: - Project Template Selector Sheet + +struct ProjectTemplateSelectorSheet: View { + let appDatabase: AppDatabase? + var onCreated: ((UUID) -> Void)? + + @Environment(\.dismiss) private var dismiss + @State private var selectedTemplate: ProjectTemplate? + @State private var projectName: String = "" + @State private var errorMessage: String? + @State private var isCreating = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("New from Template") + .font(.headline) + Spacer() + Button("Cancel") { dismiss() } + .buttonStyle(.plain) + } + .padding() + + Divider() + + if let template = selectedTemplate { + // Configuration step + VStack(alignment: .leading, spacing: 12) { + Button { + selectedTemplate = nil + } label: { + Label("Back to Templates", systemImage: "chevron.left") + .font(.footnote) + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + Image(systemName: template.icon) + .font(.title2) + .foregroundStyle(.forgeAmber) + VStack(alignment: .leading) { + Text(template.name).font(.headline) + Text(template.description).font(.caption).foregroundStyle(.secondary) + } + } + + TextField("Project Name", text: $projectName) + .textFieldStyle(.roundedBorder) + + Text("This will create \(template.features.count) features with \(template.features.flatMap(\.tasks).count) tasks.") + .font(.footnote) + .foregroundStyle(.secondary) + + if let errorMessage { + ForgeErrorBanner(message: errorMessage, onDismiss: { self.errorMessage = nil }) + } + + HStack { + Spacer() + Button { + Task { await createFromTemplate(template) } + } label: { + if isCreating { + ProgressView().controlSize(.small) + } else { + Text("Create Project") + } + } + .buttonStyle(.borderedProminent) + .tint(.forgeAmber) + .disabled(projectName.trimmingCharacters(in: .whitespaces).isEmpty || isCreating) + } + } + .padding() + } else { + // Template grid + ScrollView { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(ProjectTemplate.builtInTemplates) { template in + Button { + selectedTemplate = template + projectName = "" + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: template.icon) + .font(.title3) + .foregroundStyle(.forgeAmber) + Spacer() + Text(template.projectType.rawValue) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + Text(template.name) + .font(.subheadline.bold()) + .foregroundStyle(.primary) + Text(template.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(template.techStack) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.quaternary, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + .padding() + } + } + } + .frame(width: 500, height: 420) + } + + private func createFromTemplate(_ template: ProjectTemplate) async { + guard let db = appDatabase else { return } + let name = projectName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + + isCreating = true + defer { isCreating = false } + + do { + let projectId = UUID() + let projectsDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("CreedFlow/projects/\(name)") + try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true) + + try await db.dbQueue.write { dbConn in + var project = Project( + id: projectId, + name: name, + description: template.description, + techStack: template.techStack, + directoryPath: projectsDir.path, + projectType: template.projectType + ) + try project.insert(dbConn) + + for templateFeature in template.features { + let featureId = UUID() + var feature = Feature( + id: featureId, + projectId: projectId, + name: templateFeature.name, + description: templateFeature.description + ) + try feature.insert(dbConn) + + for templateTask in templateFeature.tasks { + var task = AgentTask( + projectId: projectId, + featureId: featureId, + agentType: templateTask.agentType, + title: templateTask.title, + description: templateTask.description, + priority: templateTask.priority + ) + try task.insert(dbConn) + } + } + } + + onCreated?(projectId) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} + struct ProjectRowView: View { let project: Project var isSelected: Bool = false diff --git a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift index 68b0b7b..813db63 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift @@ -14,6 +14,9 @@ struct TaskDetailView: View { @State private var errorMessage: String? @State private var showCancelConfirm = false @State private var revisionText: String = "" + @State private var comments: [TaskComment] = [] + @State private var newCommentText: String = "" + @State private var promptHistory: [PromptUsageWithTitle] = [] var body: some View { VStack(spacing: 0) { @@ -154,6 +157,10 @@ struct TaskDetailView: View { } } + commentsSection + + promptHistorySection + // Revision prompt (for failed/needs_revision tasks) if task.status == .failed || task.status == .needsRevision { VStack(alignment: .leading, spacing: 4) { @@ -239,12 +246,139 @@ struct TaskDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } + // MARK: - Comments Section + + @ViewBuilder + private var commentsSection: some View { + DisclosureGroup("Comments (\(comments.count))") { + VStack(alignment: .leading, spacing: 6) { + if comments.isEmpty { + Text("No comments yet") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 4) + } else { + ForEach(comments) { comment in + commentRow(comment) + } + } + + HStack(spacing: 6) { + TextField("Add a comment...", text: $newCommentText) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12)) + + Button { + Task { await addComment() } + } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 12)) + } + .buttonStyle(.borderedProminent) + .tint(.forgeAmber) + .controlSize(.small) + .disabled(newCommentText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(.top, 4) + } + .font(.subheadline.bold()) + } + + private func commentRow(_ comment: TaskComment) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: comment.author == .user ? "person.circle.fill" : "gearshape.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(comment.author == .user ? .forgeInfo : .forgeNeutral) + + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(comment.author == .user ? "You" : "System") + .font(.system(size: 11, weight: .semibold)) + Spacer() + Text(comment.createdAt, style: .relative) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + Text(comment.content) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .padding(6) + .background(comment.author == .user ? Color.forgeInfo.opacity(0.05) : Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // MARK: - Prompt History Section + + @ViewBuilder + private var promptHistorySection: some View { + if !promptHistory.isEmpty { + DisclosureGroup("Prompt History (\(promptHistory.count))") { + VStack(alignment: .leading, spacing: 4) { + ForEach(promptHistory, id: \.id) { record in + promptHistoryRow(record) + } + } + .padding(.top, 4) + } + .font(.subheadline.bold()) + } + } + + private func promptHistoryRow(_ record: PromptUsageWithTitle) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 14)) + .foregroundStyle(.forgeNeutral) + + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(record.promptTitle ?? "Untitled prompt") + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Spacer() + if let outcome = record.outcome { + HStack(spacing: 2) { + Image(systemName: outcome == .completed ? "checkmark.circle.fill" : "xmark.circle.fill") + .font(.system(size: 10)) + Text(outcome.rawValue) + .font(.system(size: 10)) + } + .foregroundStyle(outcome == .completed ? .forgeSuccess : .forgeDanger) + } + } + HStack(spacing: 6) { + if let agent = record.agentType { + Text(agent) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + if let score = record.reviewScore { + Text(String(format: "%.1f/10", score)) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(score >= 7.0 ? .forgeSuccess : score >= 5.0 ? .forgeWarning : .forgeDanger) + } + Spacer() + Text(record.usedAt, style: .relative) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + } + .padding(6) + .background(Color.gray.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + // MARK: - Data Observation private func observeData() async { guard let db = appDatabase else { return } let tid = taskId - let observation = ValueObservation.tracking { db -> (AgentTask?, [Review], [AgentLog]) in + let observation = ValueObservation.tracking { db -> (AgentTask?, [Review], [AgentLog], [TaskComment], [PromptUsageWithTitle]) in let task = try AgentTask.fetchOne(db, id: tid) let reviews = try Review .filter(Column("taskId") == tid) @@ -255,14 +389,42 @@ struct TaskDetailView: View { .order(Column("createdAt").asc) .limit(200) .fetchAll(db) - return (task, reviews, logs) + let comments = try TaskComment + .filter(Column("taskId") == tid) + .order(Column("createdAt").asc) + .fetchAll(db) + let prompts = try PromptUsageWithTitle.fetchAll(db, sql: """ + SELECT pu.id, pu.promptId, pu.agentType, pu.outcome, pu.reviewScore, pu.usedAt, + p.title AS promptTitle + FROM promptUsage pu + LEFT JOIN prompt p ON p.id = pu.promptId + WHERE pu.taskId = ? + ORDER BY pu.usedAt DESC + """, arguments: [tid.uuidString]) + return (task, reviews, logs, comments, prompts) } do { - for try await (t, r, l) in observation.values(in: db.dbQueue) { + for try await (t, r, l, c, p) in observation.values(in: db.dbQueue) { task = t reviews = r logs = l + comments = c + promptHistory = p + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func addComment() async { + let text = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, let db = appDatabase else { return } + do { + try await db.dbQueue.write { dbConn in + let comment = TaskComment(taskId: taskId, content: text, author: .user) + try comment.save(dbConn) } + newCommentText = "" } catch { errorMessage = error.localizedDescription } @@ -433,3 +595,15 @@ struct LogOutputView: View { } } } + +// MARK: - Prompt Usage With Title (join result) + +struct PromptUsageWithTitle: Codable, FetchableRecord { + var id: UUID + var promptId: UUID + var agentType: String? + var outcome: PromptUsage.Outcome? + var reviewScore: Double? + var usedAt: Date + var promptTitle: String? +} diff --git a/creedflow-desktop/src-tauri/src/commands/projects.rs b/creedflow-desktop/src-tauri/src/commands/projects.rs index d8a02bf..ba7205e 100644 --- a/creedflow-desktop/src-tauri/src/commands/projects.rs +++ b/creedflow-desktop/src-tauri/src/commands/projects.rs @@ -1,5 +1,6 @@ -use crate::db::models::Project; +use crate::db::models::{AgentTask, AgentTimeStat, Feature, Project, ProjectTemplate, ProjectTimeStats, Review, TemplateFeature, TemplateTask}; use crate::state::AppState; +use rusqlite::params; use tauri::State; use uuid::Uuid; @@ -54,6 +55,7 @@ pub async fn create_project( project_type, telegram_chat_id: None, staging_pr_number: None, + completed_at: None, created_at: now.clone(), updated_at: now, }; @@ -78,6 +80,58 @@ pub async fn delete_project(state: State<'_, AppState>, id: String) -> Result<() Project::delete(&db.conn, &id).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_project_time_stats( + state: State<'_, AppState>, + project_id: String, +) -> Result { + let db = state.db.lock().await; + let project = Project::get(&db.conn, &project_id).map_err(|e| e.to_string())?; + + // Calculate elapsed time + let created = chrono::NaiveDateTime::parse_from_str(&project.created_at, "%Y-%m-%d %H:%M:%S") + .unwrap_or_default(); + let end = if let Some(ref completed) = project.completed_at { + chrono::NaiveDateTime::parse_from_str(completed, "%Y-%m-%d %H:%M:%S") + .unwrap_or_else(|_| chrono::Utc::now().naive_utc()) + } else { + chrono::Utc::now().naive_utc() + }; + let elapsed_ms = (end - created).num_milliseconds(); + + // Sum task durations + let total_work_ms: i64 = db.conn.query_row( + "SELECT COALESCE(SUM(durationMs), 0) FROM agentTask WHERE projectId = ?1 AND archivedAt IS NULL", + [&project_id], + |row| row.get(0), + ).unwrap_or(0); + + let idle_ms = (elapsed_ms - total_work_ms).max(0); + + // Per-agent breakdown + let mut stmt = db.conn.prepare( + "SELECT agentType, COALESCE(SUM(durationMs), 0) as totalMs, COUNT(*) as taskCount + FROM agentTask WHERE projectId = ?1 AND archivedAt IS NULL + GROUP BY agentType ORDER BY totalMs DESC" + ).map_err(|e| e.to_string())?; + + let breakdown = stmt.query_map(params![project_id], |row| { + Ok(AgentTimeStat { + agent_type: row.get("agentType")?, + total_ms: row.get("totalMs")?, + task_count: row.get("taskCount")?, + }) + }).map_err(|e| e.to_string())? + .collect::, _>>().map_err(|e| e.to_string())?; + + Ok(ProjectTimeStats { + elapsed_ms, + total_work_ms, + idle_ms, + agent_breakdown: breakdown, + }) +} + /// Export project documentation (architecture, diagrams, summary) to a single markdown file. /// Useful for importing into NotebookLM or other documentation tools. #[tauri::command] @@ -159,3 +213,269 @@ pub async fn export_project_docs( Ok(output_path) } + +/// Export a project as a ZIP archive containing project files, tasks JSON, and reviews JSON. +#[tauri::command] +pub async fn export_project_zip( + state: State<'_, AppState>, + project_id: String, + output_path: String, +) -> Result { + let db = state.db.lock().await; + let project = Project::get(&db.conn, &project_id).map_err(|e| e.to_string())?; + + // Fetch tasks and reviews + let tasks = AgentTask::all_for_project(&db.conn, &project_id).map_err(|e| e.to_string())?; + let task_ids: Vec<&str> = tasks.iter().map(|t| t.id.as_str()).collect(); + let reviews = Review::all(&db.conn).map_err(|e| e.to_string())?; + let project_reviews: Vec<&Review> = reviews.iter() + .filter(|r| task_ids.contains(&r.task_id.as_str())) + .collect(); + + // Create temp directory + let temp_dir = std::env::temp_dir().join(format!("creedflow-export-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).map_err(|e| format!("Failed to create temp dir: {}", e))?; + let export_dir = temp_dir.join(&project.name); + std::fs::create_dir_all(&export_dir).map_err(|e| e.to_string())?; + + // Copy project directory contents (skip hidden files) + let project_dir = std::path::Path::new(&project.directory_path); + if project_dir.is_dir() { + if let Ok(entries) = std::fs::read_dir(project_dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { continue; } + let dest = export_dir.join(&name); + if entry.path().is_dir() { + let _ = copy_dir_recursive(&entry.path(), &dest); + } else { + let _ = std::fs::copy(entry.path(), dest); + } + } + } + } + + // Write tasks.json and reviews.json + let tasks_json = serde_json::to_string_pretty(&tasks).unwrap_or_default(); + std::fs::write(export_dir.join("tasks.json"), &tasks_json).map_err(|e| e.to_string())?; + let reviews_json = serde_json::to_string_pretty(&project_reviews).unwrap_or_default(); + std::fs::write(export_dir.join("reviews.json"), &reviews_json).map_err(|e| e.to_string())?; + + // Create ZIP using system zip command + let status = std::process::Command::new("zip") + .args(["-r", &output_path, &project.name]) + .current_dir(&temp_dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| format!("Failed to run zip: {}", e))?; + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); + + if !status.success() { + return Err("ZIP creation failed".to_string()); + } + + Ok(output_path) +} + +#[tauri::command] +pub async fn list_project_templates() -> Result, String> { + Ok(built_in_templates()) +} + +#[tauri::command] +pub async fn create_project_from_template( + state: State<'_, AppState>, + template_id: String, + name: String, + directory_path: Option, +) -> Result { + let templates = built_in_templates(); + let template = templates.iter().find(|t| t.id == template_id) + .ok_or_else(|| format!("Template not found: {}", template_id))?; + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let project_id = Uuid::new_v4().to_string(); + + let dir_path = if let Some(ref path) = directory_path { + path.clone() + } else { + let projects_dir = dirs::home_dir() + .unwrap_or_default() + .join("CreedFlow") + .join("projects") + .join(&name); + std::fs::create_dir_all(&projects_dir) + .map_err(|e| format!("Failed to create directory: {}", e))?; + projects_dir.to_string_lossy().to_string() + }; + + let project = Project { + id: project_id.clone(), + name, + description: template.description.clone(), + tech_stack: template.tech_stack.clone(), + status: "planning".to_string(), + directory_path: dir_path, + project_type: template.project_type.clone(), + telegram_chat_id: None, + staging_pr_number: None, + completed_at: None, + created_at: now.clone(), + updated_at: now.clone(), + }; + + let db = state.db.lock().await; + Project::insert(&db.conn, &project).map_err(|e| e.to_string())?; + + // Create features and tasks from template + for feature_tmpl in &template.features { + let feature_id = Uuid::new_v4().to_string(); + db.conn.execute( + "INSERT INTO feature (id, projectId, name, description, priority, status, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, 0, 'pending', ?5, ?5)", + params![feature_id, project_id, feature_tmpl.name, feature_tmpl.description, now], + ).map_err(|e| e.to_string())?; + + for task_tmpl in &feature_tmpl.tasks { + let task_id = Uuid::new_v4().to_string(); + db.conn.execute( + "INSERT INTO agentTask (id, projectId, featureId, agentType, title, description, priority, status, retryCount, maxRetries, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', 0, 3, ?8, ?8)", + params![task_id, project_id, feature_id, task_tmpl.agent_type, task_tmpl.title, task_tmpl.description, task_tmpl.priority, now], + ).map_err(|e| e.to_string())?; + } + } + + Ok(project) +} + +fn built_in_templates() -> Vec { + vec![ + ProjectTemplate { + id: "web-app".to_string(), + name: "Web App".to_string(), + description: "Full-stack web application with authentication, CRUD operations, and deployment".to_string(), + icon: "globe".to_string(), + tech_stack: "React, Node.js, PostgreSQL".to_string(), + project_type: "software".to_string(), + features: vec![ + TemplateFeature { name: "Authentication".to_string(), description: "User registration, login, and session management".to_string(), tasks: vec![ + TemplateTask { agent_type: "coder".to_string(), title: "Implement auth API endpoints".to_string(), description: "Create signup, login, logout, and password reset endpoints".to_string(), priority: 9 }, + TemplateTask { agent_type: "coder".to_string(), title: "Build auth UI components".to_string(), description: "Create login, register, and forgot password forms".to_string(), priority: 8 }, + TemplateTask { agent_type: "tester".to_string(), title: "Test authentication flow".to_string(), description: "Write integration tests for auth endpoints and UI".to_string(), priority: 7 }, + ]}, + TemplateFeature { name: "CRUD Operations".to_string(), description: "Core data management".to_string(), tasks: vec![ + TemplateTask { agent_type: "coder".to_string(), title: "Implement data API endpoints".to_string(), description: "Create REST endpoints for CRUD operations".to_string(), priority: 8 }, + TemplateTask { agent_type: "coder".to_string(), title: "Build data management UI".to_string(), description: "Create list, detail, and form views".to_string(), priority: 7 }, + ]}, + TemplateFeature { name: "Deployment".to_string(), description: "Docker-based deployment".to_string(), tasks: vec![ + TemplateTask { agent_type: "devops".to_string(), title: "Set up Docker configuration".to_string(), description: "Create Dockerfile and docker-compose.yml".to_string(), priority: 5 }, + ]}, + ], + }, + ProjectTemplate { + id: "mobile-app".to_string(), + name: "Mobile App".to_string(), + description: "Cross-platform mobile application with native UI and API integration".to_string(), + icon: "smartphone".to_string(), + tech_stack: "React Native, TypeScript".to_string(), + project_type: "software".to_string(), + features: vec![ + TemplateFeature { name: "App UI".to_string(), description: "Core screens and navigation".to_string(), tasks: vec![ + TemplateTask { agent_type: "designer".to_string(), title: "Design app screens".to_string(), description: "Create design specs for main screens".to_string(), priority: 9 }, + TemplateTask { agent_type: "coder".to_string(), title: "Implement navigation and screens".to_string(), description: "Build tab and stack navigation".to_string(), priority: 8 }, + ]}, + TemplateFeature { name: "API Integration".to_string(), description: "Backend connectivity".to_string(), tasks: vec![ + TemplateTask { agent_type: "coder".to_string(), title: "Set up API client".to_string(), description: "Configure HTTP client and error handling".to_string(), priority: 8 }, + ]}, + ], + }, + ProjectTemplate { + id: "rest-api".to_string(), + name: "REST API".to_string(), + description: "Backend API service with authentication, database, and documentation".to_string(), + icon: "server".to_string(), + tech_stack: "Node.js, Express, PostgreSQL".to_string(), + project_type: "software".to_string(), + features: vec![ + TemplateFeature { name: "API Endpoints".to_string(), description: "RESTful API design and implementation".to_string(), tasks: vec![ + TemplateTask { agent_type: "analyzer".to_string(), title: "Design API schema".to_string(), description: "Define data models and endpoint structure".to_string(), priority: 10 }, + TemplateTask { agent_type: "coder".to_string(), title: "Implement API endpoints".to_string(), description: "Build all REST endpoints".to_string(), priority: 9 }, + ]}, + TemplateFeature { name: "Testing & Docs".to_string(), description: "API tests and documentation".to_string(), tasks: vec![ + TemplateTask { agent_type: "tester".to_string(), title: "Write API tests".to_string(), description: "Create integration tests for all endpoints".to_string(), priority: 7 }, + TemplateTask { agent_type: "contentWriter".to_string(), title: "Generate API documentation".to_string(), description: "Create OpenAPI/Swagger docs".to_string(), priority: 5 }, + ]}, + ], + }, + ProjectTemplate { + id: "landing-page".to_string(), + name: "Landing Page".to_string(), + description: "Marketing landing page with responsive design and SEO optimization".to_string(), + icon: "file-text".to_string(), + tech_stack: "HTML, CSS, JavaScript".to_string(), + project_type: "content".to_string(), + features: vec![ + TemplateFeature { name: "Design & Layout".to_string(), description: "Visual design and responsive layout".to_string(), tasks: vec![ + TemplateTask { agent_type: "designer".to_string(), title: "Design landing page layout".to_string(), description: "Create hero, features, and CTA sections".to_string(), priority: 9 }, + TemplateTask { agent_type: "coder".to_string(), title: "Implement responsive layout".to_string(), description: "Build mobile-first responsive design".to_string(), priority: 8 }, + ]}, + TemplateFeature { name: "Content & SEO".to_string(), description: "Copywriting and search optimization".to_string(), tasks: vec![ + TemplateTask { agent_type: "contentWriter".to_string(), title: "Write landing page copy".to_string(), description: "Create headlines, descriptions, and CTAs".to_string(), priority: 8 }, + ]}, + ], + }, + ProjectTemplate { + id: "blog-cms".to_string(), + name: "Blog / CMS".to_string(), + description: "Content management system with blog and multi-channel publishing".to_string(), + icon: "newspaper".to_string(), + tech_stack: "Next.js, MDX, Tailwind CSS".to_string(), + project_type: "content".to_string(), + features: vec![ + TemplateFeature { name: "Content System".to_string(), description: "Blog post management".to_string(), tasks: vec![ + TemplateTask { agent_type: "coder".to_string(), title: "Build blog engine".to_string(), description: "Create MDX-based blog with categories and search".to_string(), priority: 9 }, + TemplateTask { agent_type: "coder".to_string(), title: "Implement admin interface".to_string(), description: "Create post editor and media library".to_string(), priority: 8 }, + ]}, + TemplateFeature { name: "Design".to_string(), description: "Blog theme and components".to_string(), tasks: vec![ + TemplateTask { agent_type: "designer".to_string(), title: "Design blog theme".to_string(), description: "Create layout and typography designs".to_string(), priority: 8 }, + ]}, + ], + }, + ProjectTemplate { + id: "cli-tool".to_string(), + name: "CLI Tool".to_string(), + description: "Command-line tool with argument parsing, subcommands, and documentation".to_string(), + icon: "terminal".to_string(), + tech_stack: "Python, Click".to_string(), + project_type: "software".to_string(), + features: vec![ + TemplateFeature { name: "Core Logic".to_string(), description: "Main functionality and commands".to_string(), tasks: vec![ + TemplateTask { agent_type: "analyzer".to_string(), title: "Design CLI architecture".to_string(), description: "Define command structure and arguments".to_string(), priority: 10 }, + TemplateTask { agent_type: "coder".to_string(), title: "Implement core commands".to_string(), description: "Build main CLI commands with argument parsing".to_string(), priority: 9 }, + ]}, + TemplateFeature { name: "Testing & Docs".to_string(), description: "Tests and documentation".to_string(), tasks: vec![ + TemplateTask { agent_type: "tester".to_string(), title: "Write CLI tests".to_string(), description: "Create tests for all commands".to_string(), priority: 7 }, + TemplateTask { agent_type: "contentWriter".to_string(), title: "Write CLI documentation".to_string(), description: "Create README and usage examples".to_string(), priority: 5 }, + ]}, + ], + }, + ] +} + +fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let dest = dst.join(entry.file_name()); + if entry.path().is_dir() { + copy_dir_recursive(&entry.path(), &dest)?; + } else { + std::fs::copy(entry.path(), dest)?; + } + } + Ok(()) +} diff --git a/creedflow-desktop/src-tauri/src/commands/tasks.rs b/creedflow-desktop/src-tauri/src/commands/tasks.rs index ce03001..20e63ca 100644 --- a/creedflow-desktop/src-tauri/src/commands/tasks.rs +++ b/creedflow-desktop/src-tauri/src/commands/tasks.rs @@ -1,4 +1,4 @@ -use crate::db::models::{AgentTask, TaskDependency}; +use crate::db::models::{AgentTask, PromptUsageRecord, TaskComment, TaskDependency}; use crate::state::AppState; use rusqlite::params; use tauri::State; @@ -209,3 +209,45 @@ pub async fn retry_task_with_revision( ).map_err(|e| e.to_string())?; Ok(()) } + +// ─── Task Comments ────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn add_task_comment( + state: State<'_, AppState>, + task_id: String, + content: String, + author: Option, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let comment = TaskComment { + id: Uuid::new_v4().to_string(), + task_id, + content, + author: author.unwrap_or_else(|| "user".to_string()), + created_at: now, + }; + let db = state.db.lock().await; + TaskComment::insert(&db.conn, &comment).map_err(|e| e.to_string())?; + Ok(comment) +} + +#[tauri::command] +pub async fn list_task_comments( + state: State<'_, AppState>, + task_id: String, +) -> Result, String> { + let db = state.db.lock().await; + TaskComment::all_for_task(&db.conn, &task_id).map_err(|e| e.to_string()) +} + +// ─── Task Prompt History ──────────────────────────────────────────────────── + +#[tauri::command] +pub async fn get_task_prompt_history( + state: State<'_, AppState>, + task_id: String, +) -> Result, String> { + let db = state.db.lock().await; + PromptUsageRecord::for_task(&db.conn, &task_id).map_err(|e| e.to_string()) +} diff --git a/creedflow-desktop/src-tauri/src/db/migrations.rs b/creedflow-desktop/src-tauri/src/db/migrations.rs index e970742..7c0f393 100644 --- a/creedflow-desktop/src-tauri/src/db/migrations.rs +++ b/creedflow-desktop/src-tauri/src/db/migrations.rs @@ -40,6 +40,7 @@ pub fn run_all(conn: &Connection) -> Result<(), rusqlite::Error> { (19, V19_PROJECT_MESSAGE), (20, V20_MESSAGE_ATTACHMENTS), (21, V21_NOTIFICATIONS_AND_HEALTH), + (22, V22_PROJECT_COMPLETION_AND_COMMENTS), ]; for (version, sql) in migrations { @@ -449,3 +450,17 @@ CREATE TABLE IF NOT EXISTS healthEvent ( CREATE INDEX IF NOT EXISTS idx_healthEvent_targetType_name ON healthEvent(targetType, targetName); CREATE INDEX IF NOT EXISTS idx_healthEvent_checkedAt ON healthEvent(checkedAt); "#; + +const V22_PROJECT_COMPLETION_AND_COMMENTS: &str = r#" +ALTER TABLE project ADD COLUMN completedAt TEXT; + +CREATE TABLE IF NOT EXISTS taskComment ( + id TEXT PRIMARY KEY NOT NULL, + taskId TEXT NOT NULL REFERENCES agentTask(id) ON DELETE CASCADE, + content TEXT NOT NULL, + author TEXT NOT NULL DEFAULT 'user', + createdAt TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_taskComment_taskId ON taskComment(taskId); +"#; diff --git a/creedflow-desktop/src-tauri/src/db/models.rs b/creedflow-desktop/src-tauri/src/db/models.rs index cf19641..429f9c0 100644 --- a/creedflow-desktop/src-tauri/src/db/models.rs +++ b/creedflow-desktop/src-tauri/src/db/models.rs @@ -374,6 +374,7 @@ pub struct Project { pub project_type: String, pub telegram_chat_id: Option, pub staging_pr_number: Option, + pub completed_at: Option, pub created_at: String, pub updated_at: String, } @@ -390,6 +391,7 @@ impl Project { project_type: row.get("projectType")?, telegram_chat_id: row.get("telegramChatId")?, staging_pr_number: row.get("stagingPrNumber")?, + completed_at: row.get("completedAt")?, created_at: row.get("createdAt")?, updated_at: row.get("updatedAt")?, }) @@ -413,12 +415,12 @@ impl Project { pub fn insert(conn: &Connection, project: &Self) -> rusqlite::Result<()> { conn.execute( - "INSERT INTO project (id, name, description, techStack, status, directoryPath, projectType, telegramChatId, createdAt, updatedAt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + "INSERT INTO project (id, name, description, techStack, status, directoryPath, projectType, telegramChatId, completedAt, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ project.id, project.name, project.description, project.tech_stack, project.status, project.directory_path, project.project_type, - project.telegram_chat_id, project.created_at, project.updated_at, + project.telegram_chat_id, project.completed_at, project.created_at, project.updated_at, ], )?; Ok(()) @@ -427,12 +429,12 @@ impl Project { pub fn update(conn: &Connection, project: &Self) -> rusqlite::Result<()> { conn.execute( "UPDATE project SET name=?2, description=?3, techStack=?4, status=?5, - directoryPath=?6, projectType=?7, telegramChatId=?8, updatedAt=datetime('now') + directoryPath=?6, projectType=?7, telegramChatId=?8, completedAt=?9, updatedAt=datetime('now') WHERE id=?1", params![ project.id, project.name, project.description, project.tech_stack, project.status, project.directory_path, project.project_type, - project.telegram_chat_id, + project.telegram_chat_id, project.completed_at, ], )?; Ok(()) @@ -1357,3 +1359,143 @@ impl ProjectMessage { Ok(()) } } + +// ─── Task Comments ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskComment { + pub id: String, + pub task_id: String, + pub content: String, + pub author: String, + pub created_at: String, +} + +impl TaskComment { + pub fn from_row(row: &Row) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + task_id: row.get("taskId")?, + content: row.get("content")?, + author: row.get("author")?, + created_at: row.get("createdAt")?, + }) + } + + pub fn insert(conn: &Connection, comment: &Self) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO taskComment (id, taskId, content, author, createdAt) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + comment.id, comment.task_id, comment.content, + comment.author, comment.created_at, + ], + )?; + Ok(()) + } + + pub fn all_for_task(conn: &Connection, task_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT * FROM taskComment WHERE taskId = ?1 ORDER BY createdAt ASC" + )?; + let rows = stmt.query_map([task_id], |row| Self::from_row(row))?; + rows.collect() + } +} + +// ─── Prompt Usage (read-only for task prompt history) ─────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptUsageRecord { + pub id: String, + pub prompt_id: String, + pub prompt_title: Option, + pub project_id: Option, + pub task_id: Option, + pub chain_id: Option, + pub agent_type: Option, + pub outcome: Option, + pub review_score: Option, + pub used_at: String, +} + +impl PromptUsageRecord { + pub fn from_row(row: &Row) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + prompt_id: row.get("promptId")?, + prompt_title: row.get("promptTitle")?, + project_id: row.get("projectId")?, + task_id: row.get("taskId")?, + chain_id: row.get("chainId")?, + agent_type: row.get("agentType")?, + outcome: row.get("outcome")?, + review_score: row.get("reviewScore")?, + used_at: row.get("usedAt")?, + }) + } + + pub fn for_task(conn: &Connection, task_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT pu.*, p.title AS promptTitle + FROM promptUsage pu + LEFT JOIN prompt p ON p.id = pu.promptId + WHERE pu.taskId = ?1 + ORDER BY pu.usedAt DESC" + )?; + let rows = stmt.query_map([task_id], |row| Self::from_row(row))?; + rows.collect() + } +} + +// ─── Project Time Stats ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectTimeStats { + pub elapsed_ms: i64, + pub total_work_ms: i64, + pub idle_ms: i64, + pub agent_breakdown: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTimeStat { + pub agent_type: String, + pub total_ms: i64, + pub task_count: i64, +} + +// ─── Project Template ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectTemplate { + pub id: String, + pub name: String, + pub description: String, + pub icon: String, + pub tech_stack: String, + pub project_type: String, + pub features: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateFeature { + pub name: String, + pub description: String, + pub tasks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TemplateTask { + pub agent_type: String, + pub title: String, + pub description: String, + pub priority: i32, +} diff --git a/creedflow-desktop/src-tauri/src/lib.rs b/creedflow-desktop/src-tauri/src/lib.rs index a8969b3..b6a77d3 100644 --- a/creedflow-desktop/src-tauri/src/lib.rs +++ b/creedflow-desktop/src-tauri/src/lib.rs @@ -40,6 +40,10 @@ pub fn run() { commands::projects::update_project, commands::projects::delete_project, commands::projects::export_project_docs, + commands::projects::get_project_time_stats, + commands::projects::export_project_zip, + commands::projects::list_project_templates, + commands::projects::create_project_from_template, // Tasks commands::tasks::list_tasks, commands::tasks::get_task, @@ -52,6 +56,9 @@ pub fn run() { commands::tasks::list_archived_tasks, commands::tasks::retry_task_with_revision, commands::tasks::duplicate_task, + commands::tasks::add_task_comment, + commands::tasks::list_task_comments, + commands::tasks::get_task_prompt_history, // Backends commands::backends::list_backends, commands::backends::check_backend, diff --git a/creedflow-desktop/src/components/layout/DetailPanel.tsx b/creedflow-desktop/src/components/layout/DetailPanel.tsx index 96a95ed..47bc5da 100644 --- a/creedflow-desktop/src/components/layout/DetailPanel.tsx +++ b/creedflow-desktop/src/components/layout/DetailPanel.tsx @@ -7,6 +7,8 @@ import { FileText, Terminal, MessageSquare, + MessageCircle, + History, } from "lucide-react"; import { useTaskStore } from "../../store/taskStore"; import { StatusBadge } from "../shared/StatusBadge"; @@ -14,10 +16,12 @@ import { BackendBadge } from "../shared/BackendBadge"; import { AgentTypeBadge } from "../shared/AgentTypeBadge"; import { RevisionPromptSection } from "../tasks/RevisionPromptSection"; import { TerminalOutput } from "../shared/TerminalOutput"; +import { TaskComments } from "../tasks/TaskComments"; +import { TaskPromptHistory } from "../tasks/TaskPromptHistory"; import * as api from "../../tauri"; import type { Review } from "../../types/models"; -type Tab = "info" | "output" | "reviews"; +type Tab = "info" | "output" | "reviews" | "comments" | "prompts"; interface DetailPanelProps { onClose: () => void; @@ -62,6 +66,8 @@ export function DetailPanel({ onClose }: DetailPanelProps) { { id: "info", label: "Info", icon: FileText }, { id: "output", label: "Output", icon: Terminal }, { id: "reviews", label: "Reviews", icon: MessageSquare, count: reviews.length }, + { id: "comments", label: "Comments", icon: MessageCircle }, + { id: "prompts", label: "Prompts", icon: History }, ]; return ( @@ -204,6 +210,10 @@ export function DetailPanel({ onClose }: DetailPanelProps) { )} )} + + {tab === "comments" && } + + {tab === "prompts" && } ); diff --git a/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx b/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx index 77067dd..ab11891 100644 --- a/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx +++ b/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx @@ -10,10 +10,13 @@ import { Code2, Play, GitBranch, + Download, } from "lucide-react"; +import { save } from "@tauri-apps/plugin-dialog"; import { useProjectStore } from "../../store/projectStore"; import * as api from "../../tauri"; import type { AgentTask, DetectedEditor } from "../../types/models"; +import { ProjectTimeStats } from "./ProjectTimeStats"; interface ProjectDetailPanelProps { projectId: string; @@ -111,6 +114,9 @@ export function ProjectDetailPanel({ projectId, onClose, onViewTasks }: ProjectD + {/* Time stats */} + {totalTasks > 0 && } + {/* Branch info */} {currentBranch && (
@@ -124,13 +130,30 @@ export function ProjectDetailPanel({ projectId, onClose, onViewTasks }: ProjectD - +
+ + +
{project.directoryPath && (
{showNew && setShowNew(false)} />} + {showTemplate && ( +
+
+ { + setShowTemplate(false); + selectProject(id); + fetchProjects(); + }} + onCancel={() => setShowTemplate(false)} + /> +
+
+ )}
); } diff --git a/creedflow-desktop/src/components/projects/ProjectTemplateSelector.tsx b/creedflow-desktop/src/components/projects/ProjectTemplateSelector.tsx new file mode 100644 index 0000000..6f70def --- /dev/null +++ b/creedflow-desktop/src/components/projects/ProjectTemplateSelector.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import * as api from "../../tauri"; +import type { ProjectTemplate } from "../../types/models"; + +interface ProjectTemplateSelectorProps { + onCreated: (projectId: string) => void; + onCancel: () => void; +} + +const templateIcons: Record = { + globe: "\ud83c\udf10", + smartphone: "\ud83d\udcf1", + server: "\ud83d\udda5\ufe0f", + "file-text": "\ud83d\udcc4", + newspaper: "\ud83d\udcf0", + terminal: "\ud83d\udcbb", +}; + +export function ProjectTemplateSelector({ onCreated, onCancel }: ProjectTemplateSelectorProps) { + const [templates, setTemplates] = useState([]); + const [selected, setSelected] = useState(null); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.listProjectTemplates().then(setTemplates).catch(console.error); + }, []); + + const handleCreate = async () => { + if (!selected || !name.trim()) return; + setCreating(true); + setError(null); + try { + const project = await api.createProjectFromTemplate(selected.id, name.trim()); + onCreated(project.id); + } catch (e) { + setError(String(e)); + } finally { + setCreating(false); + } + }; + + if (selected) { + const totalTasks = selected.features.reduce((sum, f) => sum + f.tasks.length, 0); + return ( +
+ + +
+ {templateIcons[selected.icon] || "\ud83d\udce6"} +
+

{selected.name}

+

{selected.description}

+
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-brand-500" + autoFocus + /> + +

+ Creates {selected.features.length} features with {totalTasks} tasks. + Tech: {selected.techStack} +

+ + {error && ( +

{error}

+ )} + +
+ + +
+
+ ); + } + + return ( +
+
+

New from Template

+ +
+
+ {templates.map((tmpl) => ( + + ))} +
+
+ ); +} diff --git a/creedflow-desktop/src/components/projects/ProjectTimeStats.tsx b/creedflow-desktop/src/components/projects/ProjectTimeStats.tsx new file mode 100644 index 0000000..7456f28 --- /dev/null +++ b/creedflow-desktop/src/components/projects/ProjectTimeStats.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { Clock, Hammer, PauseCircle } from "lucide-react"; +import * as api from "../../tauri"; +import type { ProjectTimeStats as TimeStats } from "../../types/models"; + +interface ProjectTimeStatsProps { + projectId: string; +} + +function formatDuration(ms: number): string { + const totalSeconds = ms / 1000; + if (totalSeconds < 60) return `${Math.round(totalSeconds)}s`; + if (totalSeconds < 3600) { + const m = Math.floor(totalSeconds / 60); + const s = Math.floor(totalSeconds % 60); + return `${m}m ${s}s`; + } + if (totalSeconds < 86400) { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + return `${h}h ${m}m`; + } + const d = Math.floor(totalSeconds / 86400); + const h = Math.floor((totalSeconds % 86400) / 3600); + return `${d}d ${h}h`; +} + +export function ProjectTimeStats({ projectId }: ProjectTimeStatsProps) { + const [stats, setStats] = useState(null); + + useEffect(() => { + api.getProjectTimeStats(projectId).then(setStats).catch(console.error); + }, [projectId]); + + if (!stats) return null; + + const maxMs = Math.max(...stats.agentBreakdown.map((a) => a.totalMs), 1); + + return ( +
+ + +
+ + + +
+ + {stats.agentBreakdown.length > 0 && ( +
+ Per Agent + {stats.agentBreakdown.map((agent) => ( +
+ + {agent.agentType} + +
+
+
+ + {formatDuration(agent.totalMs)} + + + {agent.taskCount} + +
+ ))} +
+ )} +
+ ); +} + +function TimeStat({ + icon: Icon, + label, + value, + color, +}: { + icon: React.FC<{ className?: string }>; + label: string; + value: string; + color: string; +}) { + return ( +
+
+ + {label} +
+

{value}

+
+ ); +} diff --git a/creedflow-desktop/src/components/tasks/TaskComments.tsx b/creedflow-desktop/src/components/tasks/TaskComments.tsx new file mode 100644 index 0000000..0d41ad3 --- /dev/null +++ b/creedflow-desktop/src/components/tasks/TaskComments.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { Send, User, Settings } from "lucide-react"; +import * as api from "../../tauri"; +import type { TaskComment } from "../../types/models"; + +interface TaskCommentsProps { + taskId: string; +} + +export function TaskComments({ taskId }: TaskCommentsProps) { + const [comments, setComments] = useState([]); + const [text, setText] = useState(""); + const [sending, setSending] = useState(false); + + const fetchComments = () => { + api.listTaskComments(taskId).then(setComments).catch(console.error); + }; + + useEffect(() => { + fetchComments(); + }, [taskId]); + + const handleSend = async () => { + const content = text.trim(); + if (!content) return; + setSending(true); + try { + await api.addTaskComment(taskId, content); + setText(""); + fetchComments(); + } catch (e) { + console.error(e); + } finally { + setSending(false); + } + }; + + return ( +
+ + + {comments.length === 0 ? ( +

No comments yet

+ ) : ( +
+ {comments.map((c) => ( +
+ {c.author === "user" ? ( + + ) : ( + + )} +
+
+ + {c.author === "user" ? "You" : "System"} + + {formatRelative(c.createdAt)} +
+

{c.content}

+
+
+ ))} +
+ )} + +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()} + placeholder="Add a comment..." + className="flex-1 px-2 py-1.5 text-xs bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder-zinc-500 focus:outline-none focus:border-zinc-600" + /> + +
+
+ ); +} + +function formatRelative(dateStr: string): string { + const date = new Date(dateStr + "Z"); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} diff --git a/creedflow-desktop/src/components/tasks/TaskPromptHistory.tsx b/creedflow-desktop/src/components/tasks/TaskPromptHistory.tsx new file mode 100644 index 0000000..a5f2ef5 --- /dev/null +++ b/creedflow-desktop/src/components/tasks/TaskPromptHistory.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; +import { FileText, CheckCircle, XCircle } from "lucide-react"; +import * as api from "../../tauri"; +import type { PromptUsageRecord } from "../../types/models"; + +interface TaskPromptHistoryProps { + taskId: string; +} + +export function TaskPromptHistory({ taskId }: TaskPromptHistoryProps) { + const [records, setRecords] = useState([]); + + useEffect(() => { + api.getTaskPromptHistory(taskId).then(setRecords).catch(console.error); + }, [taskId]); + + return ( +
+ + + {records.length === 0 ? ( +

No prompt usage recorded

+ ) : ( +
+ {records.map((r) => ( +
+ +
+
+ + {r.promptTitle || "Untitled prompt"} + + {r.outcome && ( + + {r.outcome === "completed" ? ( + + ) : ( + + )} + {r.outcome} + + )} +
+
+ {r.agentType && ( + + {r.agentType} + + )} + {r.reviewScore != null && ( + = 7 + ? "text-green-400" + : r.reviewScore >= 5 + ? "text-yellow-400" + : "text-red-400" + }`} + > + {r.reviewScore.toFixed(1)}/10 + + )} + + {formatRelative(r.usedAt)} + +
+
+
+ ))} +
+ )} +
+ ); +} + +function formatRelative(dateStr: string): string { + const date = new Date(dateStr + "Z"); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} diff --git a/creedflow-desktop/src/tauri.ts b/creedflow-desktop/src/tauri.ts index 58ba429..d8d2a62 100644 --- a/creedflow-desktop/src/tauri.ts +++ b/creedflow-desktop/src/tauri.ts @@ -22,6 +22,10 @@ import type { ProjectMessage, Prompt, PromptVersion, + ProjectTimeStats, + ProjectTemplate, + TaskComment, + PromptUsageRecord, } from "./types/models"; // ─── Projects ──────────────────────────────────────────────────────────────── @@ -55,6 +59,22 @@ export const deleteProject = (id: string) => export const exportProjectDocs = (id: string, outputPath: string) => invoke("export_project_docs", { id, outputPath }); +export const getProjectTimeStats = (projectId: string) => + invoke("get_project_time_stats", { projectId }); + +export const exportProjectZip = (projectId: string, outputPath: string) => + invoke("export_project_zip", { projectId, outputPath }); + +export const listProjectTemplates = () => + invoke("list_project_templates"); + +export const createProjectFromTemplate = (templateId: string, name: string, directoryPath?: string) => + invoke("create_project_from_template", { + templateId, + name, + directoryPath: directoryPath ?? null, + }); + // ─── Tasks ─────────────────────────────────────────────────────────────────── export const listTasks = (projectId: string) => @@ -101,6 +121,15 @@ export const retryTaskWithRevision = (id: string, revisionPrompt?: string) => export const duplicateTask = (id: string) => invoke("duplicate_task", { id }); +export const addTaskComment = (taskId: string, content: string, author?: string) => + invoke("add_task_comment", { taskId, content, author: author ?? null }); + +export const listTaskComments = (taskId: string) => + invoke("list_task_comments", { taskId }); + +export const getTaskPromptHistory = (taskId: string) => + invoke("get_task_prompt_history", { taskId }); + // ─── Backends ──────────────────────────────────────────────────────────────── export const listBackends = () => invoke("list_backends"); diff --git a/creedflow-desktop/src/types/models.ts b/creedflow-desktop/src/types/models.ts index 5b3c355..b812f44 100644 --- a/creedflow-desktop/src/types/models.ts +++ b/creedflow-desktop/src/types/models.ts @@ -10,6 +10,7 @@ export interface Project { projectType: ProjectType; telegramChatId: number | null; stagingPrNumber: number | null; + completedAt: string | null; createdAt: string; updatedAt: string; } @@ -412,3 +413,68 @@ export interface HealthEvent { metadata: string | null; checkedAt: string; } + +// ─── Task Comments ────────────────────────────────────────────────────────── + +export interface TaskComment { + id: string; + taskId: string; + content: string; + author: "user" | "system"; + createdAt: string; +} + +// ─── Project Time Stats ───────────────────────────────────────────────────── + +export interface ProjectTimeStats { + elapsedMs: number; + totalWorkMs: number; + idleMs: number; + agentBreakdown: AgentTimeStat[]; +} + +export interface AgentTimeStat { + agentType: string; + totalMs: number; + taskCount: number; +} + +// ─── Project Templates ────────────────────────────────────────────────────── + +export interface ProjectTemplate { + id: string; + name: string; + description: string; + icon: string; + techStack: string; + projectType: ProjectType; + features: TemplateFeature[]; +} + +export interface TemplateFeature { + name: string; + description: string; + tasks: TemplateTask[]; +} + +export interface TemplateTask { + agentType: AgentType; + title: string; + description: string; + priority: number; +} + +// ─── Prompt Usage (for task prompt history) ───────────────────────────────── + +export interface PromptUsageRecord { + id: string; + promptId: string; + promptTitle: string | null; + projectId: string | null; + taskId: string | null; + chainId: string | null; + agentType: string | null; + outcome: string | null; + reviewScore: number | null; + usedAt: string; +} From 04c3fcce334aab8230b2be4bacd44535796bfa6f Mon Sep 17 00:00:00 2001 From: fatih Date: Tue, 3 Mar 2026 22:15:45 +0300 Subject: [PATCH 2/7] fix(tests): update AgentTypeTests for 12 agent types (planner added) Co-Authored-By: Claude Opus 4.6 --- CreedFlow/Tests/CreedFlowTests/AgentTypeTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CreedFlow/Tests/CreedFlowTests/AgentTypeTests.swift b/CreedFlow/Tests/CreedFlowTests/AgentTypeTests.swift index 9bf909f..a90a430 100644 --- a/CreedFlow/Tests/CreedFlowTests/AgentTypeTests.swift +++ b/CreedFlow/Tests/CreedFlowTests/AgentTypeTests.swift @@ -3,7 +3,7 @@ import Foundation enum AgentTypeTests { static func runAll() { - testAllElevenAgentTypes() + testAllAgentTypes() testAgentTypeRawValues() testAgentTypeCaseIterable() testStatusRawValues() @@ -13,9 +13,9 @@ enum AgentTypeTests { print(" AgentTypeTests: 7/7 passed") } - static func testAllElevenAgentTypes() { + static func testAllAgentTypes() { let types = AgentTask.AgentType.allCases - assertEq(types.count, 11) + assertEq(types.count, 12) } static func testAgentTypeRawValues() { @@ -30,6 +30,7 @@ enum AgentTypeTests { assertEq(AgentTask.AgentType.imageGenerator.rawValue, "imageGenerator") assertEq(AgentTask.AgentType.videoEditor.rawValue, "videoEditor") assertEq(AgentTask.AgentType.publisher.rawValue, "publisher") + assertEq(AgentTask.AgentType.planner.rawValue, "planner") } static func testAgentTypeCaseIterable() { From fc8e833473fd5ae67c60d2b2f4285fdb0e7efa37 Mon Sep 17 00:00:00 2001 From: fatih Date: Wed, 4 Mar 2026 07:51:07 +0300 Subject: [PATCH 3/7] feat: implement Phase 4 advanced UI features (#149, #167, #151, #169) - Batch task operations: extend selection to all columns, add batch retry/cancel/archive (#149) - Git graph UI: commit detail panel, search bar, SVG/Canvas graph lanes (#167) - Analytics dashboard: Tasks + Performance tabs with success rates and velocity (#151) - App update checker: GitHub releases API check, update banner with dismiss (#169) Both Swift (macOS) and Tauri (cross-platform) implementations. Co-Authored-By: Claude Opus 4.6 --- .../CreedFlow/Services/UpdateChecker.swift | 75 ++++ .../Sources/CreedFlow/Views/ContentView.swift | 21 + .../Views/Git/GitCommitDetailView.swift | 176 ++++++++ .../Views/Git/GitGraphContentView.swift | 7 + .../CreedFlow/Views/Git/GitGraphView.swift | 128 ++++-- .../Notifications/UpdateBannerView.swift | 49 ++ .../Views/Settings/CostDashboardView.swift | 420 +++++++++++++++++- .../CreedFlow/Views/Tasks/TaskBoardView.swift | 120 +++-- .../src-tauri/src/commands/costs.rs | 117 +++++ .../src-tauri/src/commands/mcp.rs | 86 ++++ .../src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/prompts.rs | 35 ++ .../src-tauri/src/commands/publishing.rs | 56 +++ .../src-tauri/src/commands/tasks.rs | 34 ++ .../src-tauri/src/commands/updates.rs | 91 ++++ creedflow-desktop/src-tauri/src/lib.rs | 15 + creedflow-desktop/src/App.tsx | 144 ++++-- .../src/components/git/GitCommitDetail.tsx | 126 ++++++ .../src/components/git/GitCommitRow.tsx | 111 ++++- .../src/components/git/GitGraphView.tsx | 269 +++++++---- .../src/components/layout/ContentArea.tsx | 3 + .../src/components/layout/Sidebar.tsx | 5 +- .../src/components/prompts/PromptCard.tsx | 14 +- .../components/prompts/PromptChainList.tsx | 174 ++++++-- .../components/prompts/PromptDiffViewer.tsx | 64 +++ .../prompts/PromptVersionHistory.tsx | 146 ++++++ .../src/components/prompts/PromptsLibrary.tsx | 12 + .../components/publishing/PublishingView.tsx | 411 +++++++++++++++++ .../src/components/settings/CostDashboard.tsx | 255 ++++++++++- .../src/components/settings/MCPSettings.tsx | 404 ++++++++++++++--- .../src/components/shared/ErrorBoundary.tsx | 63 +++ .../src/components/shared/UpdateBanner.tsx | 34 ++ .../src/components/tasks/TaskBoard.tsx | 97 +++- creedflow-desktop/src/hooks/useErrorToast.ts | 32 ++ creedflow-desktop/src/store/taskStore.ts | 37 ++ creedflow-desktop/src/tauri.ts | 107 +++++ creedflow-desktop/src/types/models.ts | 46 ++ 37 files changed, 3658 insertions(+), 328 deletions(-) create mode 100644 CreedFlow/Sources/CreedFlow/Services/UpdateChecker.swift create mode 100644 CreedFlow/Sources/CreedFlow/Views/Git/GitCommitDetailView.swift create mode 100644 CreedFlow/Sources/CreedFlow/Views/Notifications/UpdateBannerView.swift create mode 100644 creedflow-desktop/src-tauri/src/commands/mcp.rs create mode 100644 creedflow-desktop/src-tauri/src/commands/updates.rs create mode 100644 creedflow-desktop/src/components/git/GitCommitDetail.tsx create mode 100644 creedflow-desktop/src/components/prompts/PromptDiffViewer.tsx create mode 100644 creedflow-desktop/src/components/prompts/PromptVersionHistory.tsx create mode 100644 creedflow-desktop/src/components/publishing/PublishingView.tsx create mode 100644 creedflow-desktop/src/components/shared/ErrorBoundary.tsx create mode 100644 creedflow-desktop/src/components/shared/UpdateBanner.tsx create mode 100644 creedflow-desktop/src/hooks/useErrorToast.ts diff --git a/CreedFlow/Sources/CreedFlow/Services/UpdateChecker.swift b/CreedFlow/Sources/CreedFlow/Services/UpdateChecker.swift new file mode 100644 index 0000000..9a12d6c --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Services/UpdateChecker.swift @@ -0,0 +1,75 @@ +import Foundation + +package struct UpdateInfo { + let latestVersion: String + let currentVersion: String + let releaseUrl: String + let releaseNotes: String +} + +package actor UpdateChecker { + private let repoOwner = "fatihkan" + private let repoName = "creedflow" + private let currentVersion: String + + package init() { + // Read from bundle or fallback + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + currentVersion = version + } else { + currentVersion = "1.3.0" + } + } + + package func checkForUpdates() async -> UpdateInfo? { + let urlString = "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest" + guard let url = URL(string: urlString) else { return nil } + + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let tagName = json["tag_name"] as? String, + let htmlUrl = json["html_url"] as? String else { + return nil + } + + let latestVersion = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName + let notes = json["body"] as? String ?? "" + + guard isNewer(latestVersion, than: currentVersion) else { + return nil + } + + return UpdateInfo( + latestVersion: latestVersion, + currentVersion: currentVersion, + releaseUrl: htmlUrl, + releaseNotes: notes + ) + } catch { + return nil // Fail silently + } + } + + private func isNewer(_ latest: String, than current: String) -> Bool { + let latestParts = latest.split(separator: ".").compactMap { Int($0) } + let currentParts = current.split(separator: ".").compactMap { Int($0) } + + for i in 0.. c { return true } + if l < c { return false } + } + return false + } +} diff --git a/CreedFlow/Sources/CreedFlow/Views/ContentView.swift b/CreedFlow/Sources/CreedFlow/Views/ContentView.swift index 3fb1c16..8481d24 100644 --- a/CreedFlow/Sources/CreedFlow/Views/ContentView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/ContentView.swift @@ -15,10 +15,21 @@ public struct ContentView: View { @State private var keyboardMonitor: Any? @State private var notificationViewModel: NotificationViewModel? @State private var showShortcutsOverlay = false + @State private var updateInfo: UpdateInfo? public init() {} public var body: some View { + VStack(spacing: 0) { + // Update banner + if let info = updateInfo { + UpdateBannerView(updateInfo: info) { + // Dismiss and remember version + UserDefaults.standard.set(info.latestVersion, forKey: "dismissedUpdateVersion") + withAnimation(.easeInOut(duration: 0.15)) { updateInfo = nil } + } + .transition(.move(edge: .top).combined(with: .opacity)) + } ZStack { HSplitView { SidebarView( @@ -74,6 +85,7 @@ public struct ContentView: View { KeyboardShortcutsView(isPresented: $showShortcutsOverlay) } } // end ZStack + } // end VStack .frame(minWidth: 960, minHeight: 640) .onChange(of: selectedSection) { _, newSection in if newSection != .deploys { @@ -110,6 +122,15 @@ public struct ContentView: View { } } } + .task { + let checker = UpdateChecker() + if let info = await checker.checkForUpdates() { + let dismissed = UserDefaults.standard.string(forKey: "dismissedUpdateVersion") + if dismissed != info.latestVersion { + withAnimation(.easeInOut(duration: 0.3)) { updateInfo = info } + } + } + } .onAppear { keyboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in // Escape — dismiss panels diff --git a/CreedFlow/Sources/CreedFlow/Views/Git/GitCommitDetailView.swift b/CreedFlow/Sources/CreedFlow/Views/Git/GitCommitDetailView.swift new file mode 100644 index 0000000..fb00a35 --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Views/Git/GitCommitDetailView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +struct GitCommitDetailView: View { + let commit: GitCommit + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath.fill") + .foregroundStyle(.forgeAmber) + Text("Commit Detail") + .font(.system(.subheadline, weight: .semibold)) + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Hash + VStack(alignment: .leading, spacing: 4) { + Text("HASH") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + Text(commit.id) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.forgeAmber) + .textSelection(.enabled) + } + + // Message + VStack(alignment: .leading, spacing: 4) { + Text("MESSAGE") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + Text(commit.message) + .font(.system(size: 13)) + .textSelection(.enabled) + } + + // Author + HStack(spacing: 6) { + Image(systemName: "person.fill") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text(commit.author) + .font(.system(size: 12)) + } + + // Date + HStack(spacing: 6) { + Image(systemName: "clock") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text(commit.date, format: .dateTime) + .font(.system(size: 12)) + } + + // Parents + if !commit.parentIds.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text(commit.parentIds.count > 1 ? "PARENTS" : "PARENT") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + ForEach(commit.parentIds, id: \.self) { parent in + Text(String(parent.prefix(7))) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + if commit.isMerge { + Text("Merge commit") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.forgeWarning) + .padding(.top, 2) + } + } + } + + // Decorations + if !commit.decorations.isEmpty { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Text("REFS") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + FlowLayout(spacing: 4) { + ForEach(commit.decorations, id: \.name) { decoration in + Text(decoration.name) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(decorationColor(decoration).opacity(0.14)) + .foregroundStyle(decorationColor(decoration)) + .clipShape(Capsule()) + } + } + } + } + } + .padding(16) + } + } + } + + private func decorationColor(_ decoration: GitDecoration) -> Color { + switch decoration.type { + case .head: return .forgeAmber + case .tag: return .forgeWarning + case .remoteBranch: return .forgeInfo + case .localBranch: return .forgeSuccess + } + } +} + +/// Simple flow layout for wrapping badges +private struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = arrange(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(proposal: proposal, subviews: subviews) + for (index, position) in result.positions.enumerated() where index < subviews.count { + subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified) + } + } + + private struct ArrangeResult { + var size: CGSize + var positions: [CGPoint] + } + + private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> ArrangeResult { + let maxWidth = proposal.width ?? .infinity + var positions: [CGPoint] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + positions.append(CGPoint(x: x, y: y)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + totalHeight = y + rowHeight + } + + return ArrangeResult(size: CGSize(width: maxWidth, height: totalHeight), positions: positions) + } +} diff --git a/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphContentView.swift b/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphContentView.swift index 68d808a..f8ff374 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphContentView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphContentView.swift @@ -3,6 +3,8 @@ import SwiftUI struct GitGraphContentView: View { let graphData: GitGraphData let maxColumns: Int + var selectedCommitId: String? = nil + var onSelectCommit: ((GitCommit) -> Void)? = nil private let laneWidth: CGFloat = 24 private let rowHeight: CGFloat = 36 @@ -32,6 +34,11 @@ struct GitGraphContentView: View { Spacer(minLength: 0) } .frame(height: rowHeight) + .background(selectedCommitId == row.commit.id ? Color.accentColor.opacity(0.08) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + onSelectCommit?(row.commit) + } } } .padding(.vertical, 4) diff --git a/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphView.swift b/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphView.swift index 6384235..6bc7497 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Git/GitGraphView.swift @@ -10,49 +10,71 @@ struct GitGraphView: View { @State private var isLoading = false @State private var errorMessage: String? @State private var selectedBranch: String = "All" + @State private var searchText: String = "" + @State private var selectedCommit: GitCommit? private let gitService = GitService() var body: some View { - VStack(spacing: 0) { - toolbar - Divider() - - if selectedProjectId == nil { - ForgeEmptyState( - icon: "arrow.triangle.branch", - title: "Git History", - subtitle: "Select a project to view its git history" - ) - } else if isLoading { - ProgressView("Loading git history...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = errorMessage { - ForgeEmptyState( - icon: "exclamationmark.triangle", - title: "Git Error", - subtitle: error, - actionTitle: "Retry" - ) { - Task { await loadGraph() } + HStack(spacing: 0) { + VStack(spacing: 0) { + toolbar + Divider() + + if selectedProjectId == nil { + ForgeEmptyState( + icon: "arrow.triangle.branch", + title: "Git History", + subtitle: "Select a project to view its git history" + ) + } else if isLoading { + ProgressView("Loading git history...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = errorMessage { + ForgeEmptyState( + icon: "exclamationmark.triangle", + title: "Git Error", + subtitle: error, + actionTitle: "Retry" + ) { + Task { await loadGraph() } + } + } else if searchFilteredGraphData.rows.isEmpty { + ForgeEmptyState( + icon: "arrow.triangle.branch", + title: searchText.isEmpty ? "No Git History" : "No Matching Commits", + subtitle: searchText.isEmpty ? "This project has no git commits yet" : "No commits match \"\(searchText)\"" + ) + } else { + GitGraphContentView( + graphData: searchFilteredGraphData, + maxColumns: graphData.maxColumns, + selectedCommitId: selectedCommit?.id, + onSelectCommit: { commit in + withAnimation(.easeInOut(duration: 0.15)) { + selectedCommit = selectedCommit?.id == commit.id ? nil : commit + } + } + ) } - } else if graphData.rows.isEmpty { - ForgeEmptyState( - icon: "arrow.triangle.branch", - title: "No Git History", - subtitle: "This project has no git commits yet" - ) - } else { - GitGraphContentView( - graphData: filteredGraphData, - maxColumns: graphData.maxColumns + } + + // Commit detail panel + if let commit = selectedCommit { + Divider() + GitCommitDetailView( + commit: commit, + onDismiss: { withAnimation(.easeInOut(duration: 0.15)) { selectedCommit = nil } } ) + .frame(minWidth: 300, idealWidth: 340, maxWidth: 400) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } .task { await observeProjects() } .onChange(of: selectedProjectId) { _, _ in + selectedCommit = nil Task { await loadGraph() } } } @@ -61,6 +83,30 @@ struct GitGraphView: View { private var toolbar: some View { ForgeToolbar(title: "Git History") { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + TextField("Search commits...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) + .frame(width: 180) + Picker("Project", selection: $selectedProjectId) { Text("Select Project").tag(UUID?.none) ForEach(projects) { project in @@ -110,6 +156,26 @@ struct GitGraphView: View { ) } + private var searchFilteredGraphData: GitGraphData { + let branchFiltered = filteredGraphData + guard !searchText.isEmpty else { return branchFiltered } + + let query = searchText.lowercased() + let filtered = branchFiltered.rows.filter { row in + row.commit.message.lowercased().contains(query) + || row.commit.id.lowercased().contains(query) + || row.commit.shortHash.lowercased().contains(query) + || row.commit.author.lowercased().contains(query) + } + + return GitGraphData( + rows: filtered, + currentBranch: branchFiltered.currentBranch, + maxColumns: branchFiltered.maxColumns, + allBranches: branchFiltered.allBranches + ) + } + // MARK: - Data Loading private func observeProjects() async { diff --git a/CreedFlow/Sources/CreedFlow/Views/Notifications/UpdateBannerView.swift b/CreedFlow/Sources/CreedFlow/Views/Notifications/UpdateBannerView.swift new file mode 100644 index 0000000..d235145 --- /dev/null +++ b/CreedFlow/Sources/CreedFlow/Views/Notifications/UpdateBannerView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import AppKit + +struct UpdateBannerView: View { + let updateInfo: UpdateInfo + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.forgeAmber) + + Text("CreedFlow v\(updateInfo.latestVersion) is available.") + .font(.system(size: 12, weight: .medium)) + Text("You're on v\(updateInfo.currentVersion).") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + Spacer() + + Button { + if let url = URL(string: updateInfo.releaseUrl) { + NSWorkspace.shared.open(url) + } + } label: { + Text("View Release") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.forgeAmber) + } + .buttonStyle(.plain) + + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.forgeAmber.opacity(0.08)) + .overlay(alignment: .bottom) { + Divider() + } + } +} diff --git a/CreedFlow/Sources/CreedFlow/Views/Settings/CostDashboardView.swift b/CreedFlow/Sources/CreedFlow/Views/Settings/CostDashboardView.swift index 88f7b7b..8db924e 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Settings/CostDashboardView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Settings/CostDashboardView.swift @@ -11,26 +11,100 @@ struct CostDashboardView: View { @State private var errorMessage: String? @State private var isLoading = true @State private var visibleCount: Int = 20 + @State private var selectedTab: DashboardTab = .costs + // Task statistics + @State private var tasksByAgent: [(agentType: AgentTask.AgentType, total: Int, passed: Int, failed: Int, needsRevision: Int, avgDurationMs: Double?)] = [] + @State private var dailyCompleted: [(date: String, count: Int)] = [] + @State private var totalTasks: Int = 0 + @State private var successRate: Double = 0 + @State private var avgDurationMs: Double? = nil + + private enum DashboardTab: String, CaseIterable { + case costs = "Costs" + case tasks = "Tasks" + case performance = "Performance" + } var body: some View { VStack(spacing: 0) { - ForgeToolbar(title: "Costs") { - Button { - exportCSV() - } label: { - Label("Export CSV", systemImage: "square.and.arrow.up") + ForgeToolbar(title: "Dashboard") { + if selectedTab == .costs { + Button { + exportCSV() + } label: { + Label("Export CSV", systemImage: "square.and.arrow.up") + } + .disabled(costEntries.isEmpty) + .help("Export cost data as CSV") + } + } + Divider() + + // Tab bar + HStack(spacing: 0) { + ForEach(DashboardTab.allCases, id: \.self) { tab in + Button { + selectedTab = tab + } label: { + HStack(spacing: 4) { + Image(systemName: tabIcon(tab)) + .font(.system(size: 11)) + Text(tab.rawValue) + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(selectedTab == tab ? .forgeAmber : .secondary) + } + .buttonStyle(.plain) + .overlay(alignment: .bottom) { + if selectedTab == tab { + Rectangle() + .fill(Color.forgeAmber) + .frame(height: 2) + } + } } - .disabled(costEntries.isEmpty) - .help("Export cost data as CSV") + Spacer() } + .padding(.horizontal, 16) Divider() if isLoading && costEntries.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { + switch selectedTab { + case .costs: + costsTabContent + case .tasks: + tasksTabContent + case .performance: + performanceTabContent + } + } + } + .task { + await observeCosts() + } + .task { + await observeTaskStatistics() + } + } + + private func tabIcon(_ tab: DashboardTab) -> String { + switch tab { + case .costs: return "dollarsign.circle" + case .tasks: return "chart.bar" + case .performance: return "bolt" + } + } + + // MARK: - Costs Tab + + private var costsTabContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { // Summary header HStack(spacing: 16) { VStack(alignment: .leading, spacing: 2) { @@ -79,7 +153,6 @@ struct CostDashboardView: View { AgentTypeBadge(type: agentType) Spacer() - // Bar proportional to max cost let maxCost = costByAgent.values.max() ?? 1 GeometryReader { geo in RoundedRectangle(cornerRadius: 4) @@ -166,14 +239,291 @@ struct CostDashboardView: View { .forgeCard(cornerRadius: 8) } .padding(16) + } + } + + // MARK: - Tasks Tab + + private var tasksTabContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // KPI cards + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Total Tasks") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text("\(totalTasks)") + .font(.system(.title, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeInfo) + + VStack(alignment: .leading, spacing: 2) { + Text("Success Rate") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text(String(format: "%.1f%%", successRate)) + .font(.system(.title, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeSuccess) + + VStack(alignment: .leading, spacing: 2) { + Text("Needs Revision") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + let revisionCount = tasksByAgent.reduce(0) { $0 + $1.needsRevision } + Text("\(revisionCount)") + .font(.system(.title, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeWarning) + } + + // Bar chart: passed vs failed by agent + VStack(alignment: .leading, spacing: 8) { + Text("Success vs Failure by Agent") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + + if tasksByAgent.isEmpty { + Text("No task data") + .font(.footnote) + .foregroundStyle(.tertiary) + } else { + let maxTotal = tasksByAgent.map(\.total).max() ?? 1 + ForEach(tasksByAgent, id: \.agentType) { agent in + HStack(spacing: 8) { + Text(agent.agentType.rawValue.capitalized) + .font(.system(size: 11)) + .frame(width: 90, alignment: .leading) + .lineLimit(1) + + GeometryReader { geo in + HStack(spacing: 1) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.forgeSuccess.opacity(0.6)) + .frame(width: geo.size.width * CGFloat(agent.passed) / CGFloat(maxTotal)) + RoundedRectangle(cornerRadius: 2) + .fill(Color.forgeDanger.opacity(0.6)) + .frame(width: geo.size.width * CGFloat(agent.failed) / CGFloat(maxTotal)) + RoundedRectangle(cornerRadius: 2) + .fill(Color.forgeWarning.opacity(0.6)) + .frame(width: geo.size.width * CGFloat(agent.needsRevision) / CGFloat(maxTotal)) + } + } + .frame(height: 12) + + Text("\(agent.total)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 30, alignment: .trailing) + } + .padding(.vertical, 2) + } + + HStack(spacing: 12) { + legendDot(color: .forgeSuccess, label: "Passed") + legendDot(color: .forgeDanger, label: "Failed") + legendDot(color: .forgeWarning, label: "Revision") + } + .padding(.top, 4) + } + } + .padding(12) + .forgeCard(cornerRadius: 8) + + // Table + VStack(alignment: .leading, spacing: 8) { + Text("By Agent Type") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + + if tasksByAgent.isEmpty { + Text("No data") + .font(.footnote) + .foregroundStyle(.tertiary) + } else { + // Header + HStack(spacing: 0) { + Text("Agent").frame(maxWidth: .infinity, alignment: .leading) + Text("Total").frame(width: 50, alignment: .trailing) + Text("Pass").frame(width: 50, alignment: .trailing) + Text("Fail").frame(width: 50, alignment: .trailing) + Text("Rev").frame(width: 50, alignment: .trailing) + Text("Rate").frame(width: 60, alignment: .trailing) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + + ForEach(tasksByAgent, id: \.agentType) { agent in + let completed = agent.passed + agent.failed + let rate = completed > 0 ? Double(agent.passed) / Double(completed) * 100 : 0 + HStack(spacing: 0) { + Text(agent.agentType.rawValue.capitalized) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(agent.total)").frame(width: 50, alignment: .trailing) + Text("\(agent.passed)") + .foregroundStyle(.forgeSuccess) + .frame(width: 50, alignment: .trailing) + Text("\(agent.failed)") + .foregroundStyle(.forgeDanger) + .frame(width: 50, alignment: .trailing) + Text("\(agent.needsRevision)") + .foregroundStyle(.forgeWarning) + .frame(width: 50, alignment: .trailing) + Text(String(format: "%.0f%%", rate)) + .frame(width: 60, alignment: .trailing) + } + .font(.system(size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + } + .padding(12) + .forgeCard(cornerRadius: 8) } - } // else + .padding(16) } - .task { - await observeCosts() + } + + // MARK: - Performance Tab + + private var performanceTabContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // KPI cards + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("Avg Duration") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text(formatDuration(avgDurationMs)) + .font(.system(.title, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeInfo) + + VStack(alignment: .leading, spacing: 2) { + Text("Tasks/Day (7d)") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + let last7 = dailyCompleted.suffix(7) + let velocity = last7.isEmpty ? 0.0 : Double(last7.reduce(0) { $0 + $1.count }) / 7.0 + Text(String(format: "%.1f", velocity)) + .font(.system(.title, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeSuccess) + + VStack(alignment: .leading, spacing: 2) { + Text("Fastest Agent") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + let fastest = tasksByAgent.filter { $0.avgDurationMs != nil }.min(by: { ($0.avgDurationMs ?? .infinity) < ($1.avgDurationMs ?? .infinity) }) + Text(fastest?.agentType.rawValue.capitalized ?? "—") + .font(.system(.title3, design: .rounded, weight: .bold)) + } + .forgeMetricCard(accent: .forgeAmber) + } + + // Avg duration by agent + VStack(alignment: .leading, spacing: 8) { + Text("Avg Duration by Agent") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + + let agentsWithDuration = tasksByAgent.filter { $0.avgDurationMs != nil } + if agentsWithDuration.isEmpty { + Text("No data") + .font(.footnote) + .foregroundStyle(.tertiary) + } else { + let maxDur = agentsWithDuration.compactMap(\.avgDurationMs).max() ?? 1 + ForEach(agentsWithDuration, id: \.agentType) { agent in + HStack(spacing: 8) { + Text(agent.agentType.rawValue.capitalized) + .font(.system(size: 11)) + .frame(width: 90, alignment: .leading) + .lineLimit(1) + + GeometryReader { geo in + RoundedRectangle(cornerRadius: 3) + .fill(agent.agentType.themeColor.opacity(0.5)) + .frame(width: geo.size.width * CGFloat((agent.avgDurationMs ?? 0) / maxDur)) + } + .frame(height: 10) + + Text(formatDuration(agent.avgDurationMs)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 60, alignment: .trailing) + } + .padding(.vertical, 2) + } + } + } + .padding(12) + .forgeCard(cornerRadius: 8) + + // Daily completed + VStack(alignment: .leading, spacing: 8) { + Text("Tasks Completed (Last 30 Days)") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + + if dailyCompleted.isEmpty { + Text("No completions in the last 30 days") + .font(.footnote) + .foregroundStyle(.tertiary) + } else { + let maxCount = dailyCompleted.map(\.count).max() ?? 1 + // Bar chart + HStack(alignment: .bottom, spacing: 2) { + ForEach(dailyCompleted, id: \.date) { day in + let height = CGFloat(day.count) / CGFloat(maxCount) + RoundedRectangle(cornerRadius: 2) + .fill(Color.forgeSuccess.opacity(0.6)) + .frame(maxWidth: .infinity) + .frame(height: max(height * 80, 2)) + .help("\(day.date): \(day.count) tasks") + } + } + .frame(height: 80) + + // Table + ForEach(dailyCompleted.reversed(), id: \.date) { day in + HStack { + Text(day.date) + .font(.system(size: 12)) + Spacer() + Text("\(day.count)") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + .padding(12) + .forgeCard(cornerRadius: 8) + } + .padding(16) } } + private func legendDot(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle().fill(color.opacity(0.6)).frame(width: 6, height: 6) + Text(label).font(.system(size: 10)).foregroundStyle(.tertiary) + } + } + + private func formatDuration(_ ms: Double?) -> String { + guard let ms else { return "—" } + if ms < 1000 { return String(format: "%.0fms", ms) } + if ms < 60000 { return String(format: "%.1fs", ms / 1000) } + return String(format: "%.1fm", ms / 60000) + } + private func observeCosts() async { guard let db = appDatabase else { return } let observation = ValueObservation.tracking { db in @@ -194,6 +544,50 @@ struct CostDashboardView: View { } } + private func observeTaskStatistics() async { + guard let db = appDatabase else { return } + let observation = ValueObservation.tracking { db in + try AgentTask.fetchAll(db) + } + do { + for try await tasks in observation.values(in: db.dbQueue) { + let grouped = Dictionary(grouping: tasks, by: \.agentType) + tasksByAgent = grouped.map { agentType, agentTasks in + let passed = agentTasks.filter { $0.status == .passed }.count + let failed = agentTasks.filter { $0.status == .failed }.count + let needsRevision = agentTasks.filter { $0.status == .needsRevision }.count + let durations = agentTasks.compactMap(\.durationMs) + let avgDur: Double? = durations.isEmpty ? nil : Double(durations.reduce(0, +)) / Double(durations.count) + return (agentType: agentType, total: agentTasks.count, passed: passed, failed: failed, needsRevision: needsRevision, avgDurationMs: avgDur) + }.sorted { $0.total > $1.total } + + totalTasks = tasks.count + let completed = tasks.filter { $0.status == .passed || $0.status == .failed } + let passedCount = tasks.filter { $0.status == .passed }.count + successRate = completed.isEmpty ? 0 : Double(passedCount) / Double(completed.count) * 100 + + let allDurations = tasks.compactMap(\.durationMs) + avgDurationMs = allDurations.isEmpty ? nil : Double(allDurations.reduce(0, +)) / Double(allDurations.count) + + // Daily completed (last 30 days) + let calendar = Calendar.current + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let now = Date() + let thirtyDaysAgo = calendar.date(byAdding: .day, value: -30, to: now)! + let completedTasks = tasks.filter { ($0.status == .passed || $0.status == .failed) && $0.updatedAt >= thirtyDaysAgo } + let byDay = Dictionary(grouping: completedTasks) { dateFormatter.string(from: $0.updatedAt) } + var daily: [(date: String, count: Int)] = [] + for dayOffset in 0..<30 { + let date = calendar.date(byAdding: .day, value: -29 + dayOffset, to: now)! + let key = dateFormatter.string(from: date) + daily.append((date: key, count: byDay[key]?.count ?? 0)) + } + dailyCompleted = daily + } + } catch { /* observation error */ } + } + private func exportCSV() { let panel = NSSavePanel() panel.allowedContentTypes = [.commaSeparatedText] diff --git a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskBoardView.swift b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskBoardView.swift index 29564e4..58f27c8 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskBoardView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskBoardView.swift @@ -50,6 +50,20 @@ struct TaskBoardView: View { private var archivableCount: Int { archivableTasks.count } + /// Tasks that can be retried (failed + needs_revision + cancelled) + private static let retryableStatuses: Set = [.failed, .needsRevision, .cancelled] + + /// Selected tasks eligible for each batch action + private var selectedRetryableCount: Int { + tasks.filter { archiveSelection.contains($0.id) && Self.retryableStatuses.contains($0.status) }.count + } + private var selectedCancellableCount: Int { + tasks.filter { archiveSelection.contains($0.id) && $0.status == .queued }.count + } + private var selectedArchivableCount: Int { + tasks.filter { archiveSelection.contains($0.id) && ($0.status == .passed || $0.status == .failed || $0.status == .cancelled) }.count + } + /// Count of failed tasks whose error message mentions MCP private var mcpFailedCount: Int { tasks.filter { task in @@ -124,43 +138,53 @@ struct TaskBoardView: View { } .frame(maxWidth: 200) - if archivableCount > 0 { - if isArchiveSelectionMode { - Button { - if archiveSelection.count == archivableTasks.count { - archiveSelection.removeAll() - } else { - archiveSelection = Set(archivableTasks.map(\.id)) - } - } label: { - Label( - archiveSelection.count == archivableTasks.count ? "Deselect All" : "Select All", - systemImage: archiveSelection.count == archivableTasks.count ? "checkmark.circle" : "circle" - ) - } + if isArchiveSelectionMode { + if !archiveSelection.isEmpty { + Text("\(archiveSelection.count) selected") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + if selectedRetryableCount > 0 { Button { - showArchiveConfirm = true + batchRetryTasks() } label: { - Label("Archive Selected (\(archiveSelection.count))", systemImage: "archivebox") + Label("Re-queue (\(selectedRetryableCount))", systemImage: "arrow.counterclockwise") } - .disabled(archiveSelection.isEmpty) + .help("Re-queue failed/revision/cancelled tasks") + } + if selectedCancellableCount > 0 { Button { - isArchiveSelectionMode = false - archiveSelection.removeAll() + batchCancelTasks() } label: { - Label("Cancel", systemImage: "xmark") + Label("Cancel (\(selectedCancellableCount))", systemImage: "xmark.circle") } - } else { + .help("Cancel queued tasks") + } + + if selectedArchivableCount > 0 { Button { - isArchiveSelectionMode = true - archiveSelection.removeAll() + showArchiveConfirm = true } label: { - Label("Archive", systemImage: "archivebox") + Label("Archive (\(selectedArchivableCount))", systemImage: "archivebox") } - .help("Select tasks to archive") } + + Button { + isArchiveSelectionMode = false + archiveSelection.removeAll() + } label: { + Label("Done", systemImage: "xmark") + } + } else { + Button { + isArchiveSelectionMode = true + archiveSelection.removeAll() + } label: { + Label("Select", systemImage: "checkmark.circle") + } + .help("Select tasks for batch operations") } if filterProjectId != nil { @@ -325,6 +349,45 @@ struct TaskBoardView: View { isArchiveSelectionMode = false } + private func batchRetryTasks() { + guard let db = appDatabase else { return } + let retryableIds = tasks + .filter { archiveSelection.contains($0.id) && Self.retryableStatuses.contains($0.status) } + .map(\.id) + guard !retryableIds.isEmpty else { return } + try? db.dbQueue.write { dbConn in + try AgentTask + .filter(retryableIds.contains(Column("id"))) + .updateAll( + dbConn, + Column("status").set(to: AgentTask.Status.queued.rawValue), + Column("retryCount").set(to: Column("retryCount") + 1), + Column("updatedAt").set(to: Date()) + ) + } + archiveSelection.removeAll() + isArchiveSelectionMode = false + } + + private func batchCancelTasks() { + guard let db = appDatabase else { return } + let cancellableIds = tasks + .filter { archiveSelection.contains($0.id) && $0.status == .queued } + .map(\.id) + guard !cancellableIds.isEmpty else { return } + try? db.dbQueue.write { dbConn in + try AgentTask + .filter(cancellableIds.contains(Column("id"))) + .updateAll( + dbConn, + Column("status").set(to: AgentTask.Status.cancelled.rawValue), + Column("updatedAt").set(to: Date()) + ) + } + archiveSelection.removeAll() + isArchiveSelectionMode = false + } + private func moveTask(taskId: UUID, to newStatus: AgentTask.Status) { guard let db = appDatabase else { return } try? db.dbQueue.write { dbConn in @@ -459,8 +522,6 @@ struct KanbanColumnView: View { @State private var isDropTargeted = false - private static let archivableStatuses: Set = [.passed, .failed, .cancelled] - var body: some View { VStack(alignment: .leading, spacing: 8) { // Column header @@ -498,9 +559,8 @@ struct KanbanColumnView: View { ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 6) { ForEach(tasks) { task in - let isArchivable = Self.archivableStatuses.contains(task.status) HStack(spacing: 6) { - if isArchiveSelectionMode && isArchivable { + if isArchiveSelectionMode { Button { if archiveSelection.contains(task.id) { archiveSelection.remove(task.id) @@ -529,7 +589,7 @@ struct KanbanColumnView: View { ) } .onTapGesture { - if isArchiveSelectionMode && isArchivable { + if isArchiveSelectionMode { if archiveSelection.contains(task.id) { archiveSelection.remove(task.id) } else { diff --git a/creedflow-desktop/src-tauri/src/commands/costs.rs b/creedflow-desktop/src-tauri/src/commands/costs.rs index 39c7222..ac965c0 100644 --- a/creedflow-desktop/src-tauri/src/commands/costs.rs +++ b/creedflow-desktop/src-tauri/src/commands/costs.rs @@ -80,3 +80,120 @@ pub async fn get_cost_timeline(state: State<'_, AppState>) -> Result, _>>().map_err(|e| e.to_string()) } + +// ─── Task Statistics ──────────────────────────────────────────────────────── + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTaskStats { + pub agent_type: String, + pub total: i64, + pub passed: i64, + pub failed: i64, + pub needs_revision: i64, + pub avg_duration_ms: Option, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyCount { + pub date: String, + pub count: i64, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskStatistics { + pub by_agent: Vec, + pub daily_completed: Vec, + pub total_tasks: i64, + pub success_rate: f64, + pub avg_duration_ms: Option, +} + +#[tauri::command] +pub async fn get_task_statistics(state: State<'_, AppState>) -> Result { + let db = state.db.lock().await; + + let total_tasks: i64 = db.conn + .query_row("SELECT COUNT(*) FROM agentTask WHERE archivedAt IS NULL", [], |r| r.get(0)) + .map_err(|e| e.to_string())?; + + let passed_count: i64 = db.conn + .query_row( + "SELECT COUNT(*) FROM agentTask WHERE status = 'passed' AND archivedAt IS NULL", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + + let completed: i64 = db.conn + .query_row( + "SELECT COUNT(*) FROM agentTask WHERE status IN ('passed', 'failed') AND archivedAt IS NULL", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + + let success_rate = if completed > 0 { + (passed_count as f64 / completed as f64) * 100.0 + } else { + 0.0 + }; + + let avg_duration_ms: Option = db.conn + .query_row( + "SELECT AVG(durationMs) FROM agentTask WHERE durationMs IS NOT NULL AND archivedAt IS NULL", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + + let mut by_agent_stmt = db.conn.prepare( + "SELECT agentType, + COUNT(*) as total, + SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) as passed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN status = 'needs_revision' THEN 1 ELSE 0 END) as needs_revision, + AVG(durationMs) as avg_duration + FROM agentTask WHERE archivedAt IS NULL + GROUP BY agentType ORDER BY total DESC" + ).map_err(|e| e.to_string())?; + + let by_agent = by_agent_stmt.query_map([], |row| { + Ok(AgentTaskStats { + agent_type: row.get(0)?, + total: row.get(1)?, + passed: row.get(2)?, + failed: row.get(3)?, + needs_revision: row.get(4)?, + avg_duration_ms: row.get(5)?, + }) + }).map_err(|e| e.to_string())? + .collect::, _>>().map_err(|e| e.to_string())?; + + let mut daily_stmt = db.conn.prepare( + "SELECT DATE(completedAt) as day, COUNT(*) as cnt + FROM agentTask + WHERE completedAt IS NOT NULL + AND completedAt >= datetime('now', '-30 days') + AND archivedAt IS NULL + GROUP BY day ORDER BY day" + ).map_err(|e| e.to_string())?; + + let daily_completed = daily_stmt.query_map([], |row| { + Ok(DailyCount { + date: row.get(0)?, + count: row.get(1)?, + }) + }).map_err(|e| e.to_string())? + .collect::, _>>().map_err(|e| e.to_string())?; + + Ok(TaskStatistics { + by_agent, + daily_completed, + total_tasks, + success_rate, + avg_duration_ms, + }) +} diff --git a/creedflow-desktop/src-tauri/src/commands/mcp.rs b/creedflow-desktop/src-tauri/src/commands/mcp.rs new file mode 100644 index 0000000..bfee40c --- /dev/null +++ b/creedflow-desktop/src-tauri/src/commands/mcp.rs @@ -0,0 +1,86 @@ +use crate::db::models::MCPServerConfig; +use crate::state::AppState; +use rusqlite::params; +use tauri::State; +use uuid::Uuid; + +impl MCPServerConfig { + pub fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Self { + id: row.get("id")?, + name: row.get("name")?, + command: row.get("command")?, + arguments: row.get("arguments")?, + environment_vars: row.get("environmentVars")?, + is_enabled: row.get::<_, i32>("isEnabled")? != 0, + created_at: row.get("createdAt")?, + updated_at: row.get("updatedAt")?, + }) + } + + pub fn all(conn: &rusqlite::Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT * FROM mcpServerConfig ORDER BY name")?; + let rows = stmt.query_map([], |row| Self::from_row(row))?; + rows.collect() + } +} + +#[tauri::command] +pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result, String> { + let db = state.db.lock().await; + MCPServerConfig::all(&db.conn).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn create_mcp_server( + state: State<'_, AppState>, + name: String, + command: String, + arguments: String, + environment_vars: String, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let id = Uuid::new_v4().to_string(); + let db = state.db.lock().await; + db.conn.execute( + "INSERT INTO mcpServerConfig (id, name, command, arguments, environmentVars, isEnabled, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7)", + params![id, name, command, arguments, environment_vars, now, now], + ).map_err(|e| e.to_string())?; + db.conn.query_row( + "SELECT * FROM mcpServerConfig WHERE id = ?1", + [&id], + |row| MCPServerConfig::from_row(row), + ).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn update_mcp_server( + state: State<'_, AppState>, + id: String, + name: String, + command: String, + arguments: String, + environment_vars: String, + is_enabled: bool, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let db = state.db.lock().await; + db.conn.execute( + "UPDATE mcpServerConfig SET name = ?1, command = ?2, arguments = ?3, environmentVars = ?4, isEnabled = ?5, updatedAt = ?6 WHERE id = ?7", + params![name, command, arguments, environment_vars, is_enabled as i32, now, id], + ).map_err(|e| e.to_string())?; + db.conn.query_row( + "SELECT * FROM mcpServerConfig WHERE id = ?1", + [&id], + |row| MCPServerConfig::from_row(row), + ).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_mcp_server(state: State<'_, AppState>, id: String) -> Result<(), String> { + let db = state.db.lock().await; + db.conn.execute("DELETE FROM mcpServerConfig WHERE id = ?1", [&id]) + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/creedflow-desktop/src-tauri/src/commands/mod.rs b/creedflow-desktop/src-tauri/src/commands/mod.rs index aa0cd2f..dbeeec8 100644 --- a/creedflow-desktop/src-tauri/src/commands/mod.rs +++ b/creedflow-desktop/src-tauri/src/commands/mod.rs @@ -13,3 +13,5 @@ pub mod git; pub mod platform; pub mod chat; pub mod notifications; +pub mod mcp; +pub mod updates; diff --git a/creedflow-desktop/src-tauri/src/commands/prompts.rs b/creedflow-desktop/src-tauri/src/commands/prompts.rs index 55a66f4..45f0d1e 100644 --- a/creedflow-desktop/src-tauri/src/commands/prompts.rs +++ b/creedflow-desktop/src-tauri/src/commands/prompts.rs @@ -237,6 +237,41 @@ pub async fn reorder_chain_steps( Ok(()) } +#[tauri::command] +pub async fn update_chain_step( + state: State<'_, AppState>, + id: String, + transition_note: Option, +) -> Result<(), String> { + let db = state.db.lock().await; + db.conn.execute( + "UPDATE promptChainStep SET transitionNote = ?1 WHERE id = ?2", + params![transition_note, id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn update_prompt_chain( + state: State<'_, AppState>, + id: String, + name: String, + description: String, + category: String, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let db = state.db.lock().await; + db.conn.execute( + "UPDATE promptChain SET name = ?1, description = ?2, category = ?3, updatedAt = ?4 WHERE id = ?5", + params![name, description, category, now, id], + ).map_err(|e| e.to_string())?; + db.conn.query_row( + "SELECT * FROM promptChain WHERE id = ?1", + [&id], + |row| PromptChain::from_row(row), + ).map_err(|e| e.to_string()) +} + // ─── Prompt Effectiveness ─────────────────────────────────────────────────── #[derive(serde::Serialize)] diff --git a/creedflow-desktop/src-tauri/src/commands/publishing.rs b/creedflow-desktop/src-tauri/src/commands/publishing.rs index 18dcf62..ccd3b73 100644 --- a/creedflow-desktop/src-tauri/src/commands/publishing.rs +++ b/creedflow-desktop/src-tauri/src/commands/publishing.rs @@ -1,6 +1,8 @@ use crate::db::models::{Publication, PublishingChannel}; use crate::state::AppState; +use rusqlite::params; use tauri::State; +use uuid::Uuid; #[tauri::command] pub async fn list_channels(state: State<'_, AppState>) -> Result, String> { @@ -13,3 +15,57 @@ pub async fn list_publications(state: State<'_, AppState>) -> Result, + name: String, + channel_type: String, + credentials_json: String, + default_tags: String, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let id = Uuid::new_v4().to_string(); + let db = state.db.lock().await; + db.conn.execute( + "INSERT INTO publishingChannel (id, name, channelType, credentialsJSON, isEnabled, defaultTags, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7)", + params![id, name, channel_type, credentials_json, default_tags, now, now], + ).map_err(|e| e.to_string())?; + db.conn.query_row( + "SELECT * FROM publishingChannel WHERE id = ?1", + [&id], + |row| PublishingChannel::from_row(row), + ).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn update_channel( + state: State<'_, AppState>, + id: String, + name: String, + channel_type: String, + credentials_json: String, + default_tags: String, + is_enabled: bool, +) -> Result { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let db = state.db.lock().await; + db.conn.execute( + "UPDATE publishingChannel SET name = ?1, channelType = ?2, credentialsJSON = ?3, defaultTags = ?4, isEnabled = ?5, updatedAt = ?6 WHERE id = ?7", + params![name, channel_type, credentials_json, default_tags, is_enabled as i32, now, id], + ).map_err(|e| e.to_string())?; + db.conn.query_row( + "SELECT * FROM publishingChannel WHERE id = ?1", + [&id], + |row| PublishingChannel::from_row(row), + ).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_channel(state: State<'_, AppState>, id: String) -> Result<(), String> { + let db = state.db.lock().await; + db.conn.execute("DELETE FROM publishingChannel WHERE id = ?1", [&id]) + .map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/creedflow-desktop/src-tauri/src/commands/tasks.rs b/creedflow-desktop/src-tauri/src/commands/tasks.rs index 20e63ca..f5d5d64 100644 --- a/creedflow-desktop/src-tauri/src/commands/tasks.rs +++ b/creedflow-desktop/src-tauri/src/commands/tasks.rs @@ -210,6 +210,40 @@ pub async fn retry_task_with_revision( Ok(()) } +// ─── Batch Operations ─────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn batch_retry_tasks( + state: State<'_, AppState>, + ids: Vec, +) -> Result<(), String> { + let db = state.db.lock().await; + for id in &ids { + db.conn.execute( + "UPDATE agentTask SET status = 'queued', retryCount = retryCount + 1, updatedAt = datetime('now') + WHERE id = ?1 AND status IN ('failed', 'needs_revision', 'cancelled')", + params![id], + ).map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +pub async fn batch_cancel_tasks( + state: State<'_, AppState>, + ids: Vec, +) -> Result<(), String> { + let db = state.db.lock().await; + for id in &ids { + db.conn.execute( + "UPDATE agentTask SET status = 'cancelled', updatedAt = datetime('now') + WHERE id = ?1 AND status = 'queued'", + params![id], + ).map_err(|e| e.to_string())?; + } + Ok(()) +} + // ─── Task Comments ────────────────────────────────────────────────────────── #[tauri::command] diff --git a/creedflow-desktop/src-tauri/src/commands/updates.rs b/creedflow-desktop/src-tauri/src/commands/updates.rs new file mode 100644 index 0000000..c1502cf --- /dev/null +++ b/creedflow-desktop/src-tauri/src/commands/updates.rs @@ -0,0 +1,91 @@ +use serde::Serialize; +use tauri::State; +use crate::state::AppState; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + pub latest_version: String, + pub current_version: String, + pub release_url: String, + pub release_notes: String, +} + +#[tauri::command] +pub async fn check_for_updates( + _state: State<'_, AppState>, +) -> Result, String> { + let current = env!("CARGO_PKG_VERSION"); + + let client = reqwest::Client::builder() + .user_agent("CreedFlow-Desktop") + .build() + .map_err(|e| e.to_string())?; + + let resp = client + .get("https://api.github.com/repos/fatihkan/creedflow/releases/latest") + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + // Fail silently — no update info + return Ok(None); + } + + let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + + let tag = json["tag_name"] + .as_str() + .unwrap_or("") + .trim_start_matches('v'); + + if tag.is_empty() { + return Ok(None); + } + + // Simple semver comparison + if is_newer(tag, current) { + let release_url = json["html_url"] + .as_str() + .unwrap_or("https://github.com/fatihkan/creedflow/releases") + .to_string(); + + let release_notes = json["body"] + .as_str() + .unwrap_or("") + .chars() + .take(500) + .collect::(); + + Ok(Some(UpdateInfo { + latest_version: tag.to_string(), + current_version: current.to_string(), + release_url, + release_notes, + })) + } else { + Ok(None) + } +} + +fn is_newer(latest: &str, current: &str) -> bool { + let parse = |v: &str| -> Vec { + v.split('.') + .filter_map(|s| s.parse().ok()) + .collect() + }; + let l = parse(latest); + let c = parse(current); + for i in 0..3 { + let lv = l.get(i).copied().unwrap_or(0); + let cv = c.get(i).copied().unwrap_or(0); + if lv > cv { + return true; + } + if lv < cv { + return false; + } + } + false +} diff --git a/creedflow-desktop/src-tauri/src/lib.rs b/creedflow-desktop/src-tauri/src/lib.rs index b6a77d3..7031198 100644 --- a/creedflow-desktop/src-tauri/src/lib.rs +++ b/creedflow-desktop/src-tauri/src/lib.rs @@ -56,6 +56,8 @@ pub fn run() { commands::tasks::list_archived_tasks, commands::tasks::retry_task_with_revision, commands::tasks::duplicate_task, + commands::tasks::batch_retry_tasks, + commands::tasks::batch_cancel_tasks, commands::tasks::add_task_comment, commands::tasks::list_task_comments, commands::tasks::get_task_prompt_history, @@ -76,6 +78,7 @@ pub fn run() { commands::costs::get_cost_by_agent, commands::costs::get_cost_by_backend, commands::costs::get_cost_timeline, + commands::costs::get_task_statistics, // Reviews commands::reviews::list_reviews, commands::reviews::approve_review, @@ -94,6 +97,9 @@ pub fn run() { // Publishing commands::publishing::list_channels, commands::publishing::list_publications, + commands::publishing::create_channel, + commands::publishing::update_channel, + commands::publishing::delete_channel, // Deploy commands::deploy::list_deployments, commands::deploy::create_deployment, @@ -114,6 +120,8 @@ pub fn run() { commands::prompts::add_chain_step, commands::prompts::remove_chain_step, commands::prompts::reorder_chain_steps, + commands::prompts::update_chain_step, + commands::prompts::update_prompt_chain, // Prompt Effectiveness commands::prompts::get_prompt_effectiveness, // Prompt Import/Export @@ -147,6 +155,11 @@ pub fn run() { commands::notifications::dismiss_notification, commands::notifications::get_backend_health_status, commands::notifications::get_mcp_health_status, + // MCP + commands::mcp::list_mcp_servers, + commands::mcp::create_mcp_server, + commands::mcp::update_mcp_server, + commands::mcp::delete_mcp_server, // Git commands::git::git_ensure_branch_structure, commands::git::git_setup_feature_branch, @@ -158,6 +171,8 @@ pub fn run() { commands::git::git_log, commands::git::get_git_config, commands::git::set_git_config, + // Updates + commands::updates::check_for_updates, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::Destroyed = event { diff --git a/creedflow-desktop/src/App.tsx b/creedflow-desktop/src/App.tsx index 4fb0ab2..aa2511f 100644 --- a/creedflow-desktop/src/App.tsx +++ b/creedflow-desktop/src/App.tsx @@ -7,12 +7,16 @@ import { ProjectChatPanel } from "./components/chat/ProjectChatPanel"; import { SetupWizard } from "./components/setup/SetupWizard"; import { ToastOverlay } from "./components/notifications/ToastOverlay"; import { KeyboardShortcutsOverlay } from "./components/shared/KeyboardShortcutsOverlay"; +import { ErrorBoundary } from "./components/shared/ErrorBoundary"; +import { UpdateBanner } from "./components/shared/UpdateBanner"; import { useProjectStore } from "./store/projectStore"; import { useTaskStore } from "./store/taskStore"; import { useSettingsStore } from "./store/settingsStore"; import { useNotificationStore } from "./store/notificationStore"; import { useThemeStore } from "./store/themeStore"; import { useTauriEvent } from "./hooks/useTauriEvent"; +import * as api from "./tauri"; +import type { UpdateInfo } from "./tauri"; type DetailMode = "none" | "task" | "project"; @@ -22,6 +26,7 @@ function App() { const [showChatPanel, setShowChatPanel] = useState(false); const [chatProjectId, setChatProjectId] = useState(null); const [showShortcuts, setShowShortcuts] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); const selectedProjectId = useProjectStore((s) => s.selectedProjectId); const projects = useProjectStore((s) => s.projects); const selectedTaskId = useTaskStore((s) => s.selectedTaskId); @@ -38,12 +43,47 @@ function App() { fetchSettings(); fetchUnreadCount(); + // Check for updates + const dismissedVersion = localStorage.getItem("creedflow_dismissed_update"); + api.checkForUpdates() + .then((info) => { + if (info && info.latestVersion !== dismissedVersion) { + setUpdateInfo(info); + } + }) + .catch(() => {/* fail silently */}); + // Poll for unread count every 10s const interval = setInterval(() => { fetchUnreadCount(); }, 10000); - return () => clearInterval(interval); - }, [fetchSettings, fetchUnreadCount]); + + // Catch unhandled promise rejections + const handleRejection = (event: PromiseRejectionEvent) => { + event.preventDefault(); + const message = + event.reason instanceof Error + ? event.reason.message + : String(event.reason); + addToast({ + id: crypto.randomUUID(), + category: "system", + severity: "error", + title: "Unhandled Error", + message, + metadata: null, + isRead: false, + isDismissed: false, + createdAt: new Date().toISOString(), + }); + }; + window.addEventListener("unhandledrejection", handleRejection); + + return () => { + clearInterval(interval); + window.removeEventListener("unhandledrejection", handleRejection); + }; + }, [fetchSettings, fetchUnreadCount, addToast]); // Show project detail when a project is selected from the projects list useEffect(() => { @@ -173,50 +213,74 @@ function App() { } }; + const handleDismissUpdate = () => { + if (updateInfo) { + localStorage.setItem("creedflow_dismissed_update", updateInfo.latestVersion); + } + setUpdateInfo(null); + }; + + const handleViewRelease = () => { + if (updateInfo) { + api.openUrl(updateInfo.releaseUrl).catch(console.error); + } + }; + return ( -
- - setShowShortcuts(false)} - /> - -
- {/* Left: Chat panel */} - {showChatPanel && chatProjectId && chatProject && ( -
- setShowChatPanel(false)} + +
+ {updateInfo && ( + + )} +
+ + setShowShortcuts(false)} + /> + +
+ {/* Left: Chat panel */} + {showChatPanel && chatProjectId && chatProject && ( +
+ setShowChatPanel(false)} + /> +
+ )} + + {/* Center: Content */} +
+
- )} - {/* Center: Content */} -
- + {/* Right: Detail panels */} + {detailMode === "task" && ( + + )} + {detailMode === "project" && selectedProjectId && ( + + )} +
- - {/* Right: Detail panels */} - {detailMode === "task" && ( - - )} - {detailMode === "project" && selectedProjectId && ( - - )}
-
+
); } diff --git a/creedflow-desktop/src/components/git/GitCommitDetail.tsx b/creedflow-desktop/src/components/git/GitCommitDetail.tsx new file mode 100644 index 0000000..c79a489 --- /dev/null +++ b/creedflow-desktop/src/components/git/GitCommitDetail.tsx @@ -0,0 +1,126 @@ +import type { GitLogEntry } from "../../tauri"; +import { X, GitCommit, User, Clock, GitBranch } from "lucide-react"; + +interface GitCommitDetailProps { + commit: GitLogEntry; + onClose: () => void; +} + +export function GitCommitDetail({ commit, onClose }: GitCommitDetailProps) { + const date = new Date(commit.timestamp * 1000); + + const branches = commit.decorations + ? commit.decorations + .replace(/[()]/g, "") + .split(",") + .map((d) => d.trim()) + .filter(Boolean) + : []; + + return ( +
+ {/* Header */} +
+
+ + + Commit Detail + +
+ +
+ + {/* Content */} +
+ {/* Hash */} +
+ +

+ {commit.hash} +

+
+ + {/* Message */} +
+ +

+ {commit.message} +

+
+ + {/* Author */} +
+ + {commit.author} +
+ + {/* Date */} +
+ + + {date.toLocaleDateString()} {date.toLocaleTimeString()} + +
+ + {/* Parents */} + {commit.parents.length > 0 && ( +
+ +
+ {commit.parents.map((p) => ( +

+ {p.substring(0, 7)} +

+ ))} +
+ {commit.parents.length > 1 && ( + + Merge commit + + )} +
+ )} + + {/* Branches / Tags */} + {branches.length > 0 && ( +
+ +
+ {branches.map((b) => { + const isHead = b.includes("HEAD"); + const isTag = b.startsWith("tag:"); + const isOrigin = b.startsWith("origin/"); + let cls = "bg-green-900/30 text-green-400"; + if (isHead) cls = "bg-brand-600/20 text-brand-400"; + else if (isTag) cls = "bg-amber-900/30 text-amber-400"; + else if (isOrigin) cls = "bg-blue-900/30 text-blue-400"; + return ( + + {b} + + ); + })} +
+
+ )} +
+
+ ); +} diff --git a/creedflow-desktop/src/components/git/GitCommitRow.tsx b/creedflow-desktop/src/components/git/GitCommitRow.tsx index bc5d461..aeb7aa3 100644 --- a/creedflow-desktop/src/components/git/GitCommitRow.tsx +++ b/creedflow-desktop/src/components/git/GitCommitRow.tsx @@ -1,14 +1,34 @@ import type { GitLogEntry } from "../../tauri"; +export interface LaneData { + lane: number; + totalLanes: number; + connections: { from: number; to: number; type: "continue" | "merge" | "branch" }[]; + color: string; +} + +const LANE_COLORS = [ + "#6366f1", // brand/indigo + "#22c55e", // green + "#3b82f6", // blue + "#f59e0b", // amber + "#ef4444", // red + "#a855f7", // purple + "#06b6d4", // cyan + "#ec4899", // pink +]; + interface GitCommitRowProps { commit: GitLogEntry; + laneData?: LaneData; + onClick?: () => void; + isSelected?: boolean; } -export function GitCommitRow({ commit }: GitCommitRowProps) { +export function GitCommitRow({ commit, laneData, onClick, isSelected }: GitCommitRowProps) { const date = new Date(commit.timestamp * 1000); const timeAgo = formatRelativeTime(date); - // Parse decorations into branch/tag badges const branches = commit.decorations ? commit.decorations .replace(/[()]/g, "") @@ -18,7 +38,18 @@ export function GitCommitRow({ commit }: GitCommitRowProps) { : []; return ( - + + {/* Graph lane */} + + {laneData && } + {commit.shortHash} @@ -40,6 +71,80 @@ export function GitCommitRow({ commit }: GitCommitRowProps) { ); } +function LaneSvg({ data }: { data: LaneData }) { + const width = 60; + const height = 28; + const laneWidth = 12; + const cx = data.lane * laneWidth + laneWidth / 2 + 4; + const cy = height / 2; + + return ( + + {/* Vertical continuation lines for active lanes */} + {data.connections.map((conn, i) => { + if (conn.type === "continue") { + const x = conn.from * laneWidth + laneWidth / 2 + 4; + return ( + + ); + } + if (conn.type === "merge") { + const fromX = conn.from * laneWidth + laneWidth / 2 + 4; + const toX = conn.to * laneWidth + laneWidth / 2 + 4; + return ( + + ); + } + if (conn.type === "branch") { + const fromX = conn.from * laneWidth + laneWidth / 2 + 4; + const toX = conn.to * laneWidth + laneWidth / 2 + 4; + return ( + + ); + } + return null; + })} + + {/* Commit circle */} + + + ); +} + function BranchTag({ name }: { name: string }) { const isHead = name.includes("HEAD"); const isTag = name.startsWith("tag:"); diff --git a/creedflow-desktop/src/components/git/GitGraphView.tsx b/creedflow-desktop/src/components/git/GitGraphView.tsx index 39b9d99..47d6e79 100644 --- a/creedflow-desktop/src/components/git/GitGraphView.tsx +++ b/creedflow-desktop/src/components/git/GitGraphView.tsx @@ -1,8 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { gitLog, gitCurrentBranch, type GitLogEntry } from "../../tauri"; -import { GitCommitRow } from "./GitCommitRow"; +import { GitCommitRow, type LaneData } from "./GitCommitRow"; +import { GitCommitDetail } from "./GitCommitDetail"; +import { SearchBar } from "../shared/SearchBar"; import { RefreshCw, GitBranch, Filter } from "lucide-react"; +const LANE_COLORS = [ + "#6366f1", "#22c55e", "#3b82f6", "#f59e0b", + "#ef4444", "#a855f7", "#06b6d4", "#ec4899", +]; + interface GitGraphViewProps { projectId: string; } @@ -14,6 +21,8 @@ export function GitGraphView({ projectId }: GitGraphViewProps) { const [error, setError] = useState(null); const [count, setCount] = useState(50); const [branchFilter, setBranchFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [selectedCommit, setSelectedCommit] = useState(null); const fetchData = async () => { setLoading(true); @@ -41,7 +50,6 @@ export function GitGraphView({ projectId }: GitGraphViewProps) { const branchSet = new Set(); commits.forEach((c) => { if (c.decorations) { - // Parse decorations like "HEAD -> main, origin/main, dev" c.decorations.split(",").forEach((d) => { const trimmed = d.trim() .replace("HEAD -> ", "") @@ -57,92 +65,199 @@ export function GitGraphView({ projectId }: GitGraphViewProps) { }, [commits]); // Filter commits by branch - const filteredCommits = useMemo(() => { + const branchFiltered = useMemo(() => { if (branchFilter === "all") return commits; return commits.filter((c) => c.decorations.includes(branchFilter) ); }, [commits, branchFilter]); + // Filter by search query + const filteredCommits = useMemo(() => { + if (!search.trim()) return branchFiltered; + const q = search.toLowerCase(); + return branchFiltered.filter( + (c) => + c.message.toLowerCase().includes(q) || + c.hash.toLowerCase().includes(q) || + c.shortHash.toLowerCase().includes(q) || + c.author.toLowerCase().includes(q), + ); + }, [branchFiltered, search]); + + // Compute graph lane data + const laneMap = useMemo(() => { + const map = new Map(); + // Track active lanes: each lane holds the hash of the commit it's waiting for + const activeLanes: (string | null)[] = []; + + const findLane = (hash: string): number => { + for (let i = 0; i < activeLanes.length; i++) { + if (activeLanes[i] === hash) return i; + } + return -1; + }; + + const nextFreeLane = (): number => { + for (let i = 0; i < activeLanes.length; i++) { + if (activeLanes[i] === null) return i; + } + activeLanes.push(null); + return activeLanes.length - 1; + }; + + for (const commit of commits) { + let lane = findLane(commit.hash); + if (lane === -1) { + lane = nextFreeLane(); + } + + const connections: LaneData["connections"] = []; + + // Draw continuation lines for all other active lanes + for (let i = 0; i < activeLanes.length; i++) { + if (activeLanes[i] !== null && i !== lane) { + connections.push({ from: i, to: i, type: "continue" }); + } + } + + const parents = commit.parents; + + if (parents.length === 0) { + // Root commit — close lane + activeLanes[lane] = null; + } else if (parents.length === 1) { + // Linear commit — assign parent to same lane + activeLanes[lane] = parents[0]; + } else { + // Merge commit — first parent stays in lane, others get merge lines + activeLanes[lane] = parents[0]; + for (let p = 1; p < parents.length; p++) { + const parentLane = findLane(parents[p]); + if (parentLane !== -1) { + connections.push({ from: parentLane, to: lane, type: "merge" }); + } else { + // Parent not in any lane — assign to new lane and draw merge + const newLane = nextFreeLane(); + activeLanes[newLane] = parents[p]; + connections.push({ from: newLane, to: lane, type: "merge" }); + } + } + } + + map.set(commit.hash, { + lane, + totalLanes: activeLanes.length, + connections, + color: LANE_COLORS[lane % LANE_COLORS.length], + }); + } + + return map; + }, [commits]); + return ( -
- {/* Header */} -
-
- -

Git History

- {currentBranch && ( - - {currentBranch} - - )} +
+
+ {/* Header */} +
+
+ +

Git History

+ {currentBranch && ( + + {currentBranch} + + )} +
+
+ + + {/* Branch filter */} + {branches.length > 0 && ( +
+ + +
+ )} + + + +
-
- {/* Branch filter */} - {branches.length > 0 && ( -
- - -
- )} - - - -
+ + +
+ )}
- {/* Content */} - {error ? ( -
- {error} -
- ) : filteredCommits.length === 0 && !loading ? ( -
- No commits found -
- ) : ( -
- - - - - - - - - - - - {filteredCommits.map((commit) => ( - - ))} - -
HashMessageBranchesAuthorDate
-
+ {/* Commit detail panel */} + {selectedCommit && ( + setSelectedCommit(null)} + /> )}
); diff --git a/creedflow-desktop/src/components/layout/ContentArea.tsx b/creedflow-desktop/src/components/layout/ContentArea.tsx index 5fcfec3..38c585e 100644 --- a/creedflow-desktop/src/components/layout/ContentArea.tsx +++ b/creedflow-desktop/src/components/layout/ContentArea.tsx @@ -10,6 +10,7 @@ import { ReviewList } from "../reviews/ReviewList"; import { GitGraphView } from "../git/GitGraphView"; import { PromptsLibrary } from "../prompts/PromptsLibrary"; import { ProjectAssetsView } from "../assets/ProjectAssetsView"; +import { PublishingView } from "../publishing/PublishingView"; interface ContentAreaProps { section: SidebarSection; @@ -61,6 +62,8 @@ export function ContentArea({ return ; case "assets": return ; + case "publishing": + return ; default: return ; } diff --git a/creedflow-desktop/src/components/layout/Sidebar.tsx b/creedflow-desktop/src/components/layout/Sidebar.tsx index 7c0f2b6..abfe907 100644 --- a/creedflow-desktop/src/components/layout/Sidebar.tsx +++ b/creedflow-desktop/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { ChevronRight, Circle, Bell, + Radio, } from "lucide-react"; import { useProjectStore } from "../../store/projectStore"; import { useTaskStore } from "../../store/taskStore"; @@ -34,7 +35,8 @@ export type SidebarSection = | "prompts" | "archive" | "gitHistory" - | "assets"; + | "assets" + | "publishing"; interface SidebarProps { selected: SidebarSection; @@ -172,6 +174,7 @@ export function Sidebar({ selected, onSelect }: SidebarProps) {
+
)} diff --git a/creedflow-desktop/src/components/prompts/PromptCard.tsx b/creedflow-desktop/src/components/prompts/PromptCard.tsx index 37c9f0b..055999a 100644 --- a/creedflow-desktop/src/components/prompts/PromptCard.tsx +++ b/creedflow-desktop/src/components/prompts/PromptCard.tsx @@ -1,4 +1,4 @@ -import { Star, Trash2, Copy, Check } from "lucide-react"; +import { Star, Trash2, Copy, Check, Clock } from "lucide-react"; import { useState } from "react"; import type { Prompt } from "../../store/promptStore"; @@ -6,9 +6,10 @@ interface PromptCardProps { prompt: Prompt; onToggleFavorite: () => void; onDelete: () => void; + onShowHistory?: () => void; } -export function PromptCard({ prompt, onToggleFavorite, onDelete }: PromptCardProps) { +export function PromptCard({ prompt, onToggleFavorite, onDelete, onShowHistory }: PromptCardProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -36,6 +37,15 @@ export function PromptCard({ prompt, onToggleFavorite, onDelete }: PromptCardPro )} + {onShowHistory && ( + + )} -
+ step={step} + index={i} + promptTitle={getPromptTitle(step.promptId)} + onRemove={() => handleRemoveStep(step.id)} + onDragStart={() => setDragStepId(step.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => handleDrop(chain.id, chain.steps, i)} + isDragging={dragStepId === step.id} + onTransitionNoteBlur={(val) => + handleTransitionNoteBlur(step.id, val) + } + /> ))} {/* Add step */} @@ -177,14 +199,18 @@ export function PromptChainList() { }) )} - {/* Create dialog */} - {showCreate && ( - setShowCreate(false)} - onCreate={async (name, description, category) => { - await api.createPromptChain(name, description, category); + {/* Create / Edit dialog */} + {(showCreate || editingChain) && ( + { + setShowCreate(false); + setEditingChain(null); + }} + onSaved={() => { fetchChains(); setShowCreate(false); + setEditingChain(null); }} /> )} @@ -192,22 +218,92 @@ export function PromptChainList() { ); } -function CreateChainDialog({ +function StepRow({ + step, + index, + promptTitle, + onRemove, + onDragStart, + onDragOver, + onDrop, + isDragging, + onTransitionNoteBlur, +}: { + step: PromptChainStep; + index: number; + promptTitle: string; + onRemove: () => void; + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: () => void; + isDragging: boolean; + onTransitionNoteBlur: (value: string) => void; +}) { + const [note, setNote] = useState(step.transitionNote ?? ""); + const noteRef = useRef(null); + + return ( +
+ + + {index + 1}. + + {promptTitle} + setNote(e.target.value)} + onBlur={() => onTransitionNoteBlur(note)} + placeholder="transition note..." + className="w-[140px] px-2 py-0.5 text-[10px] bg-zinc-900 border border-zinc-700/50 rounded text-zinc-400 placeholder:text-zinc-600 focus:outline-none focus:border-brand-500" + /> + +
+ ); +} + +function ChainFormDialog({ + chain, onClose, - onCreate, + onSaved, }: { + chain: PromptChainWithSteps | null; onClose: () => void; - onCreate: (name: string, description: string, category: string) => Promise; + onSaved: () => void; }) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [category, setCategory] = useState("general"); + const isEditing = chain !== null; + const [name, setName] = useState(chain?.name ?? ""); + const [description, setDescription] = useState(chain?.description ?? ""); + const [category, setCategory] = useState(chain?.category ?? "general"); + + const handleSubmit = async () => { + if (isEditing) { + await api.updatePromptChain(chain.id, name, description, category); + } else { + await api.createPromptChain(name, description, category); + } + onSaved(); + }; return (

- New Prompt Chain + {isEditing ? "Edit Prompt Chain" : "New Prompt Chain"}

diff --git a/creedflow-desktop/src/components/prompts/PromptDiffViewer.tsx b/creedflow-desktop/src/components/prompts/PromptDiffViewer.tsx new file mode 100644 index 0000000..34ff015 --- /dev/null +++ b/creedflow-desktop/src/components/prompts/PromptDiffViewer.tsx @@ -0,0 +1,64 @@ +import type { PromptVersionDiff } from "../../types/models"; + +interface Props { + diff: PromptVersionDiff; +} + +export function PromptDiffViewer({ diff }: Props) { + return ( +
+ {/* Header */} +
+ + v{diff.versionA.version} + {diff.versionA.changeNote && ( + ({diff.versionA.changeNote}) + )} + + vs + + v{diff.versionB.version} + {diff.versionB.changeNote && ( + ({diff.versionB.changeNote}) + )} + +
+ + {/* Diff lines */} +
+ {diff.diffLines.map((line, i) => { + let bg = ""; + let textColor = "text-zinc-300"; + let prefix = " "; + + if (line.lineType === "added") { + bg = "bg-green-500/10"; + textColor = "text-green-300"; + prefix = "+"; + } else if (line.lineType === "removed") { + bg = "bg-red-500/10"; + textColor = "text-red-300"; + prefix = "-"; + } + + return ( +
+ + {line.lineNumberA ?? ""} + + + {line.lineNumberB ?? ""} + + + {prefix} + + + {line.content} + +
+ ); + })} +
+
+ ); +} diff --git a/creedflow-desktop/src/components/prompts/PromptVersionHistory.tsx b/creedflow-desktop/src/components/prompts/PromptVersionHistory.tsx new file mode 100644 index 0000000..a84f1ba --- /dev/null +++ b/creedflow-desktop/src/components/prompts/PromptVersionHistory.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { X, GitCompare, Clock } from "lucide-react"; +import type { PromptVersion, PromptVersionDiff } from "../../types/models"; +import * as api from "../../tauri"; +import { PromptDiffViewer } from "./PromptDiffViewer"; +import { useErrorToast } from "../../hooks/useErrorToast"; + +interface Props { + promptId: string; + promptTitle: string; + onClose: () => void; +} + +export function PromptVersionHistory({ promptId, promptTitle, onClose }: Props) { + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState>(new Set()); + const [diff, setDiff] = useState(null); + const [comparing, setComparing] = useState(false); + const withError = useErrorToast(); + + useEffect(() => { + (async () => { + setLoading(true); + await withError(async () => { + const data = await api.getPromptVersions(promptId); + setVersions(data); + }); + setLoading(false); + })(); + }, [promptId]); + + const toggleVersion = (version: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(version)) { + next.delete(version); + } else { + if (next.size >= 2) { + const arr = Array.from(next); + next.delete(arr[0]); + } + next.add(version); + } + return next; + }); + setDiff(null); + }; + + const handleCompare = async () => { + const [a, b] = Array.from(selected).sort((x, y) => x - y); + if (a == null || b == null) return; + setComparing(true); + await withError(async () => { + const result = await api.getPromptVersionDiff(promptId, a, b); + setDiff(result); + }); + setComparing(false); + }; + + return ( +
+
+ {/* Header */} +
+
+ +

+ Version History +

+ + {promptTitle} + +
+ +
+ +
+ {loading ? ( +
+ Loading versions... +
+ ) : versions.length === 0 ? ( +
+ No version history available +
+ ) : ( + <> + {/* Version list */} +
+ {versions.map((v) => ( + + ))} +
+ + {/* Compare button */} +
+ +
+ + {/* Diff viewer */} + {diff && } + + )} +
+
+
+ ); +} diff --git a/creedflow-desktop/src/components/prompts/PromptsLibrary.tsx b/creedflow-desktop/src/components/prompts/PromptsLibrary.tsx index 3ae2826..eb8c508 100644 --- a/creedflow-desktop/src/components/prompts/PromptsLibrary.tsx +++ b/creedflow-desktop/src/components/prompts/PromptsLibrary.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { usePromptStore } from "../../store/promptStore"; import { PromptCard } from "./PromptCard"; import { PromptEditDialog } from "./PromptEditDialog"; +import { PromptVersionHistory } from "./PromptVersionHistory"; import { Plus, Search, Star, BookOpen } from "lucide-react"; import { PromptChainList } from "./PromptChainList"; import { PromptEffectivenessDashboard } from "./PromptEffectivenessDashboard"; @@ -23,6 +24,7 @@ export function PromptsLibrary() { usePromptStore(); const [showCreate, setShowCreate] = useState(false); const [tab, setTab] = useState<"library" | "chains" | "effectiveness">("library"); + const [historyPrompt, setHistoryPrompt] = useState<{ id: string; title: string } | null>(null); useEffect(() => { fetchPrompts(); @@ -139,6 +141,7 @@ export function PromptsLibrary() { prompt={prompt} onToggleFavorite={() => toggleFavorite(prompt.id)} onDelete={() => deletePrompt(prompt.id)} + onShowHistory={() => setHistoryPrompt({ id: prompt.id, title: prompt.title })} /> ))}
@@ -153,6 +156,15 @@ export function PromptsLibrary() { {/* Create dialog */} {showCreate && setShowCreate(false)} />} + + {/* Version history modal */} + {historyPrompt && ( + setHistoryPrompt(null)} + /> + )}
); } diff --git a/creedflow-desktop/src/components/publishing/PublishingView.tsx b/creedflow-desktop/src/components/publishing/PublishingView.tsx new file mode 100644 index 0000000..e654286 --- /dev/null +++ b/creedflow-desktop/src/components/publishing/PublishingView.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState } from "react"; +import { + Radio, + Plus, + Trash2, + Pencil, + ExternalLink, + ToggleLeft, + ToggleRight, + X, +} from "lucide-react"; +import type { PublishingChannel, Publication } from "../../types/models"; +import * as api from "../../tauri"; +import { useErrorToast } from "../../hooks/useErrorToast"; + +type ChannelType = "medium" | "wordpress" | "twitter" | "linkedin" | "devTo"; + +const CHANNEL_TYPES: { value: ChannelType; label: string; color: string }[] = [ + { value: "medium", label: "Medium", color: "bg-green-500/20 text-green-400" }, + { value: "wordpress", label: "WordPress", color: "bg-blue-500/20 text-blue-400" }, + { value: "twitter", label: "Twitter", color: "bg-sky-500/20 text-sky-400" }, + { value: "linkedin", label: "LinkedIn", color: "bg-indigo-500/20 text-indigo-400" }, + { value: "devTo", label: "Dev.to", color: "bg-zinc-500/20 text-zinc-300" }, +]; + +const CREDENTIAL_FIELDS: Record = { + medium: [{ key: "integrationToken", label: "Integration Token", type: "password" }], + wordpress: [ + { key: "url", label: "Site URL", type: "text" }, + { key: "username", label: "Username", type: "text" }, + { key: "appPassword", label: "Application Password", type: "password" }, + ], + twitter: [ + { key: "apiKey", label: "API Key", type: "password" }, + { key: "apiSecret", label: "API Secret", type: "password" }, + ], + linkedin: [{ key: "accessToken", label: "Access Token", type: "password" }], + devTo: [{ key: "apiKey", label: "API Key", type: "password" }], +}; + +function getTypeInfo(type: string) { + return CHANNEL_TYPES.find((t) => t.value === type) ?? CHANNEL_TYPES[0]; +} + +export function PublishingView() { + const [tab, setTab] = useState<"channels" | "publications">("channels"); + const [channels, setChannels] = useState([]); + const [publications, setPublications] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + const withError = useErrorToast(); + + const fetchData = async () => { + setLoading(true); + await withError(async () => { + const [ch, pub] = await Promise.all([ + api.listChannels(), + api.listPublications(), + ]); + setChannels(ch); + setPublications(pub); + }); + setLoading(false); + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleToggle = async (channel: PublishingChannel) => { + await withError(async () => { + const updated = await api.updateChannel( + channel.id, + channel.name, + channel.channelType, + channel.credentialsJson, + channel.defaultTags, + !channel.isEnabled, + ); + setChannels((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + }); + }; + + const handleDelete = async (id: string) => { + await withError(async () => { + await api.deleteChannel(id); + setChannels((prev) => prev.filter((c) => c.id !== id)); + }); + }; + + const handleEdit = (channel: PublishingChannel) => { + setEditingChannel(channel); + setShowForm(true); + }; + + const handleSaved = (channel: PublishingChannel) => { + setChannels((prev) => { + const exists = prev.find((c) => c.id === channel.id); + if (exists) return prev.map((c) => (c.id === channel.id ? channel : c)); + return [...prev, channel]; + }); + setShowForm(false); + setEditingChannel(null); + }; + + const getChannelName = (id: string) => + channels.find((c) => c.id === id)?.name ?? "Unknown"; + + return ( +
+ {/* Header */} +
+
+ +

Publishing

+
+ {tab === "channels" && ( + + )} +
+ + {/* Tabs */} +
+ {(["channels", "publications"] as const).map((t) => ( + + ))} +
+ + {loading ? ( +
+ Loading... +
+ ) : tab === "channels" ? ( +
+ {channels.length === 0 ? ( +
+ +

No publishing channels configured

+

+ Add a channel to publish content to external platforms +

+
+ ) : ( + channels.map((channel) => { + const info = getTypeInfo(channel.channelType); + return ( +
+ + {info.label} + + + {channel.name} + + {channel.defaultTags && ( + + {channel.defaultTags} + + )} + + + +
+ ); + }) + )} +
+ ) : ( +
+ {publications.length === 0 ? ( +
+

No publications yet

+

+ Publications appear when content is published through channels +

+
+ ) : ( + publications.map((pub) => { + const statusColor = { + published: "bg-green-500/20 text-green-400", + publishing: "bg-blue-500/20 text-blue-400", + scheduled: "bg-amber-500/20 text-amber-400", + failed: "bg-red-500/20 text-red-400", + }[pub.status] ?? "bg-zinc-500/20 text-zinc-400"; + return ( +
+ + {pub.status} + + + {getChannelName(pub.channelId)} + + + {new Date(pub.createdAt).toLocaleDateString()} + + {pub.publishedUrl && ( + + + + )} +
+ ); + }) + )} +
+ )} + + {/* Channel form modal */} + {showForm && ( + { + setShowForm(false); + setEditingChannel(null); + }} + onSaved={handleSaved} + /> + )} +
+ ); +} + +function ChannelFormModal({ + channel, + onClose, + onSaved, +}: { + channel: PublishingChannel | null; + onClose: () => void; + onSaved: (channel: PublishingChannel) => void; +}) { + const isEditing = channel !== null; + const [name, setName] = useState(channel?.name ?? ""); + const [channelType, setChannelType] = useState( + (channel?.channelType as ChannelType) ?? "medium", + ); + const [credentials, setCredentials] = useState>(() => { + if (channel?.credentialsJson) { + try { + return JSON.parse(channel.credentialsJson); + } catch { + return {}; + } + } + return {}; + }); + const [defaultTags, setDefaultTags] = useState(channel?.defaultTags ?? ""); + const [saving, setSaving] = useState(false); + const withError = useErrorToast(); + + const fields = CREDENTIAL_FIELDS[channelType] ?? []; + + const handleSave = async () => { + setSaving(true); + await withError(async () => { + const credJson = JSON.stringify(credentials); + let result: PublishingChannel; + if (isEditing) { + result = await api.updateChannel( + channel.id, + name, + channelType, + credJson, + defaultTags, + channel.isEnabled, + ); + } else { + result = await api.createChannel(name, channelType, credJson, defaultTags); + } + onSaved(result); + }); + setSaving(false); + }; + + return ( +
+
+
+

+ {isEditing ? "Edit Channel" : "Add Publishing Channel"} +

+ +
+ +
+ setName(e.target.value)} + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-brand-500" + /> + + + + {/* Dynamic credential fields */} + {fields.length > 0 && ( +
+

+ Credentials +

+ {fields.map((f) => ( +
+ + + setCredentials((prev) => ({ ...prev, [f.key]: e.target.value })) + } + placeholder={f.label} + className="w-full px-3 py-1.5 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-brand-500 font-mono" + /> +
+ ))} +
+ )} + + setDefaultTags(e.target.value)} + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-brand-500" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/creedflow-desktop/src/components/settings/CostDashboard.tsx b/creedflow-desktop/src/components/settings/CostDashboard.tsx index 3e1a4f8..96c5fbd 100644 --- a/creedflow-desktop/src/components/settings/CostDashboard.tsx +++ b/creedflow-desktop/src/components/settings/CostDashboard.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react"; import { useCostStore } from "../../store/costStore"; import * as api from "../../tauri"; import type { CostBreakdown } from "../../tauri"; -import { DollarSign, Cpu, Server, Calendar } from "lucide-react"; +import type { TaskStatistics } from "../../types/models"; +import { DollarSign, Cpu, Server, Calendar, BarChart3, Zap } from "lucide-react"; -type Tab = "overview" | "agents" | "backends" | "timeline"; +type Tab = "overview" | "agents" | "backends" | "timeline" | "tasks" | "performance"; export function CostDashboard() { const { summary, fetchSummary } = useCostStore(); @@ -12,12 +13,14 @@ export function CostDashboard() { const [byAgent, setByAgent] = useState([]); const [byBackend, setByBackend] = useState([]); const [timeline, setTimeline] = useState([]); + const [taskStats, setTaskStats] = useState(null); useEffect(() => { fetchSummary(); api.getCostByAgent().then(setByAgent).catch(console.error); api.getCostByBackend().then(setByBackend).catch(console.error); api.getCostTimeline().then(setTimeline).catch(console.error); + api.getTaskStatistics().then(setTaskStats).catch(console.error); }, [fetchSummary]); return ( @@ -36,17 +39,19 @@ export function CostDashboard() {
{/* Tabs */} -
+
{([ { id: "overview" as Tab, label: "Overview", icon: DollarSign }, { id: "agents" as Tab, label: "By Agent", icon: Cpu }, { id: "backends" as Tab, label: "By Backend", icon: Server }, { id: "timeline" as Tab, label: "Timeline", icon: Calendar }, + { id: "tasks" as Tab, label: "Tasks", icon: BarChart3 }, + { id: "performance" as Tab, label: "Performance", icon: Zap }, ]).map(({ id, label, icon: Icon }) => ( +
+ + +

MCP servers extend agent capabilities with external tool access (image - generation, design tools, etc.). + generation, design tools, etc.). Configurations persist across restarts.

{/* Quick templates */} @@ -105,15 +211,15 @@ export function MCPSettings() {

Quick Setup Templates

{TEMPLATES.map((t) => { - const alreadyAdded = servers.some((s) => s.name === t.server.name); + const alreadyAdded = servers.some((s) => s.name === t.template.name); return ( - +
+ + + +
- {isExpanded && Object.keys(server.env).length > 0 && ( + {isExpanded && Object.keys(envObj).length > 0 && (

Environment Variables

- {Object.entries(server.env).map(([key, value]) => ( + {Object.entries(envObj).map(([key, value]) => (
{key} @@ -175,9 +307,18 @@ export function MCPSettings() { - updateEnv(server.name, key, e.target.value) - } + onChange={(e) => { + // Update local state immediately, persist on blur + const newVal = e.target.value; + setServers((prev) => + prev.map((s) => { + if (s.id !== server.id) return s; + const obj = { ...envObj, [key]: newVal }; + return { ...s, environmentVars: JSON.stringify(obj) }; + }), + ); + }} + onBlur={(e) => updateEnv(server, key, e.target.value)} placeholder="Enter value..." className="flex-1 px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded text-zinc-300 placeholder-zinc-600 focus:outline-none focus:border-brand-500 font-mono" /> @@ -190,6 +331,133 @@ export function MCPSettings() { })}
)} + + {/* Add/Edit modal */} + {showForm && ( + { + setShowForm(false); + setEditingServer(null); + }} + onSaved={handleSaved} + /> + )} +
+ ); +} + +function MCPServerFormModal({ + server, + onClose, + onSaved, +}: { + server: MCPServerConfig | null; + onClose: () => void; + onSaved: (server: MCPServerConfig) => void; +}) { + const isEditing = server !== null; + const [name, setName] = useState(server?.name ?? ""); + const [command, setCommand] = useState(server?.command ?? ""); + const [args, setArgs] = useState(server?.arguments ?? ""); + const [envVars, setEnvVars] = useState(server?.environmentVars ?? "{}"); + const [saving, setSaving] = useState(false); + const withError = useErrorToast(); + + const handleSave = async () => { + setSaving(true); + await withError(async () => { + let result: MCPServerConfig; + if (isEditing) { + result = await api.updateMcpServer( + server.id, + name, + command, + args, + envVars, + server.isEnabled, + ); + } else { + result = await api.createMcpServer(name, command, args, envVars); + } + onSaved(result); + }); + setSaving(false); + }; + + return ( +
+
+
+

+ {isEditing ? "Edit MCP Server" : "Add MCP Server"} +

+ +
+ +
+
+ + setName(e.target.value)} + placeholder="e.g. dalle" + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-brand-500" + /> +
+
+ + setCommand(e.target.value)} + placeholder="e.g. npx" + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-brand-500 font-mono" + /> +
+
+ + setArgs(e.target.value)} + placeholder="e.g. -y @anthropic/mcp-dalle" + className="w-full px-3 py-2 text-sm bg-zinc-800 border border-zinc-700 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-brand-500 font-mono" + /> +
+
+ +