diff --git a/Package.swift b/Package.swift index a76aa07..1149084 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,18 @@ let package = Package( name: "SwiftASB", targets: ["SwiftASB"] ), + .library( + name: "ASBPresentation", + targets: ["ASBPresentation"] + ), + .library( + name: "ASBAppKit", + targets: ["ASBAppKit"] + ), + .library( + name: "ASBSwiftUI", + targets: ["ASBSwiftUI"] + ), ], dependencies: [ .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.0"), @@ -27,10 +39,34 @@ let package = Package( .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] ), + .target( + name: "ASBPresentation", + dependencies: ["SwiftASB"] + ), + .target( + name: "ASBAppKit", + dependencies: ["ASBPresentation", "SwiftASB"] + ), + .target( + name: "ASBSwiftUI", + dependencies: ["ASBAppKit", "ASBPresentation", "SwiftASB"] + ), .testTarget( name: "SwiftASBTests", dependencies: ["SwiftASB"] ), + .testTarget( + name: "ASBPresentationTests", + dependencies: ["ASBPresentation"] + ), + .testTarget( + name: "ASBAppKitTests", + dependencies: ["ASBAppKit", "ASBPresentation"] + ), + .testTarget( + name: "ASBSwiftUITests", + dependencies: ["ASBAppKit", "ASBPresentation", "ASBSwiftUI"] + ), ], swiftLanguageModes: [.v6] ) diff --git a/ROADMAP.md b/ROADMAP.md index 8b91277..04c9941 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -471,12 +471,16 @@ workflow earns them in a later feature release. package docs and DocC as the source of truth for SwiftASB behavior, then sync the plugin when public API, examples, compatibility windows, diagnostics, approval handling, validation, or recommended integration shape changes. -- [ ] Basic SwiftUI component library for SwiftASB consumers. Start with small, - copyable components that demonstrate the stable observable companions: - dashboard status, turn minimap call snapshots, recent turns, recent files, - recent commands, diagnostics, and approval/elicitation prompts. Keep this as a - development aide and example surface first; do not let it blur the core - package API or force an app-specific design system into the library. +- [ ] Hybrid presentation and UI component targets for SwiftASB consumers. The + package now has `ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` targets, and + `ASBPresentation` now has the framework-neutral foundation for sidebar, turn + timeline, recent activity, agenda, dashboard, selection state, viewport hints, + and typed UI intents. Next, add `ASBAppKit` dense macOS renderers such as + thread sidebars and turn timelines, then add `ASBSwiftUI` native light panels + plus SwiftUI wrappers around AppKit-backed dense components. Keep `SwiftASB` + as the runtime source of truth and avoid letting AppKit or SwiftUI own + separate thread-list, timeline, cache, or action models. See + [`docs/maintainers/presentation-ui-targets-plan.md`](docs/maintainers/presentation-ui-targets-plan.md). ### Public API Curation diff --git a/Sources/ASBAppKit/ASBAppKitModule.swift b/Sources/ASBAppKit/ASBAppKitModule.swift new file mode 100644 index 0000000..6590566 --- /dev/null +++ b/Sources/ASBAppKit/ASBAppKitModule.swift @@ -0,0 +1,7 @@ +import AppKit +import ASBPresentation +import SwiftASB + +struct ASBAppKitModule: Sendable { + static let name = "ASBAppKit" +} diff --git a/Sources/ASBPresentation/AgendaDashboardPresentation.swift b/Sources/ASBPresentation/AgendaDashboardPresentation.swift new file mode 100644 index 0000000..d59cc20 --- /dev/null +++ b/Sources/ASBPresentation/AgendaDashboardPresentation.swift @@ -0,0 +1,407 @@ +import SwiftASB + +/// Framework-neutral state for a thread goal and plan panel. +public struct AgendaSnapshot: Sendable, Equatable { + public var threadID: String? + public var goalTitle: String + public var goalStatus: AgendaGoalStatus? + public var planTitle: String + public var currentPlan: AgendaPlan? + public var proposedPlan: AgendaProposedPlan? + public var updatedAt: Int? + + public init( + threadID: String? = nil, + goalTitle: String = "", + goalStatus: AgendaGoalStatus? = nil, + planTitle: String = "", + currentPlan: AgendaPlan? = nil, + proposedPlan: AgendaProposedPlan? = nil, + updatedAt: Int? = nil + ) { + self.threadID = threadID + self.goalTitle = goalTitle + self.goalStatus = goalStatus + self.planTitle = planTitle + self.currentPlan = currentPlan + self.proposedPlan = proposedPlan + self.updatedAt = updatedAt + } + + public var hasGoal: Bool { + !goalTitle.isEmpty + } + + public var hasPlan: Bool { + currentPlan != nil || proposedPlan != nil + } + + @MainActor + public init(agenda: CodexThread.Agenda) { + self.init( + threadID: agenda.threadID, + goalTitle: agenda.goalTitle, + goalStatus: agenda.goalStatus.map(AgendaGoalStatus.init), + planTitle: agenda.planTitle, + currentPlan: agenda.currentPlan.map(AgendaPlan.init), + proposedPlan: agenda.proposedPlan.map(AgendaProposedPlan.init), + updatedAt: agenda.updatedAt + ) + } +} + +public struct AgendaPlan: Sendable, Equatable { + public var turnID: String + public var explanation: String? + public var steps: [AgendaStep] + + public init( + turnID: String, + explanation: String? = nil, + steps: [AgendaStep] = [] + ) { + self.turnID = turnID + self.explanation = explanation + self.steps = steps + } + + public init(_ plan: CodexThread.Agenda.Plan) { + self.init( + turnID: plan.turnID, + explanation: plan.explanation, + steps: plan.steps.map(AgendaStep.init) + ) + } +} + +public struct AgendaProposedPlan: Sendable, Equatable { + public var turnID: String + public var items: [AgendaProposedItem] + + public init( + turnID: String, + items: [AgendaProposedItem] = [] + ) { + self.turnID = turnID + self.items = items + } + + public init(_ plan: CodexThread.Agenda.ProposedPlan) { + self.init( + turnID: plan.turnID, + items: plan.items.map(AgendaProposedItem.init) + ) + } +} + +public struct AgendaStep: Sendable, Equatable, Identifiable { + public var id: String + public var title: String + public var status: AgendaStepStatus + + public init( + id: String, + title: String, + status: AgendaStepStatus + ) { + self.id = id + self.title = title + self.status = status + } + + public init(_ step: CodexThread.Agenda.Plan.Step) { + self.init( + id: step.id, + title: step.title, + status: .init(step.status) + ) + } +} + +public struct AgendaProposedItem: Sendable, Equatable, Identifiable { + public var id: String + public var text: String + + public init(id: String, text: String) { + self.id = id + self.text = text + } + + public init(_ item: CodexThread.Agenda.ProposedPlan.Item) { + self.init(id: item.id, text: item.text) + } +} + +public enum AgendaGoalStatus: String, Sendable, Equatable { + case active + case blocked + case budgetLimited + case complete + case paused + case usageLimited + + public init(_ status: CodexThread.Goal.Status) { + switch status { + case .active: + self = .active + case .blocked: + self = .blocked + case .budgetLimited: + self = .budgetLimited + case .complete: + self = .complete + case .paused: + self = .paused + case .usageLimited: + self = .usageLimited + } + } +} + +public enum AgendaStepStatus: String, Sendable, Equatable { + case completed + case inProgress + case pending + + public init(_ status: CodexThread.Agenda.Plan.Step.Status) { + switch status { + case .completed: + self = .completed + case .inProgress: + self = .inProgress + case .pending: + self = .pending + } + } +} + +public enum AgendaIntent: Sendable, Equatable { + case startPlanningTurn + case setGoal(objective: String, tokenBudget: Int?) + case pauseGoal + case resumeGoal + case clearGoal +} + +/// Framework-neutral state for a thread status dashboard. +public struct DashboardSnapshot: Sendable, Equatable { + public var threadID: String? + public var title: String + public var preview: String + public var threadStatus: String? + public var isArchived: Bool + public var isClosed: Bool + public var isCompactingThreadContext: Bool + public var goalTitle: String + public var planTitle: String + public var toolCallingStatus: DashboardActivityStatus + public var mcpCallingStatus: DashboardActivityStatus + public var autoReviewStatus: DashboardAutoReviewStatus + public var latestDiagnosticDescription: String? + public var hookRuns: [DashboardHookRun] + public var activeCalls: [DashboardCallSummary] + + public init( + threadID: String? = nil, + title: String = "", + preview: String = "", + threadStatus: String? = nil, + isArchived: Bool = false, + isClosed: Bool = false, + isCompactingThreadContext: Bool = false, + goalTitle: String = "", + planTitle: String = "", + toolCallingStatus: DashboardActivityStatus = .idle, + mcpCallingStatus: DashboardActivityStatus = .idle, + autoReviewStatus: DashboardAutoReviewStatus = .idle, + latestDiagnosticDescription: String? = nil, + hookRuns: [DashboardHookRun] = [], + activeCalls: [DashboardCallSummary] = [] + ) { + self.threadID = threadID + self.title = title + self.preview = preview + self.threadStatus = threadStatus + self.isArchived = isArchived + self.isClosed = isClosed + self.isCompactingThreadContext = isCompactingThreadContext + self.goalTitle = goalTitle + self.planTitle = planTitle + self.toolCallingStatus = toolCallingStatus + self.mcpCallingStatus = mcpCallingStatus + self.autoReviewStatus = autoReviewStatus + self.latestDiagnosticDescription = latestDiagnosticDescription + self.hookRuns = hookRuns + self.activeCalls = activeCalls + } + + @MainActor + public init( + dashboard: CodexThread.Dashboard, + activeMinimap: CodexTurnHandle.Minimap? = nil + ) { + self.init( + threadID: dashboard.threadID, + title: dashboard.name ?? dashboard.preview, + preview: dashboard.preview, + threadStatus: dashboard.status.type.rawValue, + isArchived: dashboard.isArchived, + isClosed: dashboard.isClosed, + isCompactingThreadContext: dashboard.isCompactingThreadContext, + goalTitle: dashboard.goalTitle, + planTitle: dashboard.planTitle, + toolCallingStatus: .init(dashboard.toolCallingStatus), + mcpCallingStatus: .init(dashboard.mcpCallingStatus), + autoReviewStatus: .init(dashboard.autoReviewStatus), + latestDiagnosticDescription: dashboard.latestDiagnostic.map(Self.describe), + hookRuns: dashboard.hookRuns.map(DashboardHookRun.init), + activeCalls: activeMinimap?.callSnapshots.map(DashboardCallSummary.init) ?? [] + ) + } + + private static func describe(_ diagnostic: CodexDiagnosticEvent) -> String { + switch diagnostic { + case let .warning(warning): + warning.message + case let .guardianWarning(warning): + warning.message + case let .modelRerouted(reroute): + "Model rerouted from \(reroute.fromModel) to \(reroute.toModel)." + case .modelVerification: + "Model verification updated." + case let .configWarning(warning): + warning.summary + case let .deprecationNotice(notice): + notice.summary + case let .mcpServerStatusChanged(status): + "MCP server \(status.name) status changed to \(status.status.rawValue)." + case let .remoteControlStatusChanged(status): + "Remote control \(status.serverName) status changed to \(status.status.rawValue)." + } + } +} + +public enum DashboardActivityStatus: String, Sendable, Equatable { + case errored + case idle + case inProgress + + public init(_ status: CodexThread.Dashboard.ActivityStatus) { + switch status { + case .errored: + self = .errored + case .idle: + self = .idle + case .inProgress: + self = .inProgress + } + } +} + +public enum DashboardAutoReviewStatus: String, Sendable, Equatable { + case aborted + case approved + case denied + case idle + case inProgress + case timedOut + + public init(_ status: CodexThread.Dashboard.AutoReviewStatus) { + switch status { + case .aborted: + self = .aborted + case .approved: + self = .approved + case .denied: + self = .denied + case .idle: + self = .idle + case .inProgress: + self = .inProgress + case .timedOut: + self = .timedOut + } + } +} + +public struct DashboardHookRun: Sendable, Equatable, Identifiable { + public var id: String + public var eventName: String + public var status: String + public var sourcePath: String + public var startedAt: Int + public var completedAt: Int? + public var durationMS: Int? + public var statusMessage: String? + + public init( + id: String, + eventName: String, + status: String, + sourcePath: String, + startedAt: Int, + completedAt: Int? = nil, + durationMS: Int? = nil, + statusMessage: String? = nil + ) { + self.id = id + self.eventName = eventName + self.status = status + self.sourcePath = sourcePath + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMS = durationMS + self.statusMessage = statusMessage + } + + public init(_ run: CodexThread.Dashboard.HookRun) { + self.init( + id: run.id, + eventName: run.eventName.rawValue, + status: run.status.rawValue, + sourcePath: run.sourcePath, + startedAt: run.startedAt, + completedAt: run.completedAt, + durationMS: run.durationMS, + statusMessage: run.statusMessage + ) + } +} + +public struct DashboardCallSummary: Sendable, Equatable, Identifiable { + public var id: String + public var title: String + public var kind: TurnTimelineItemKind + public var status: String + public var latestStatusText: String? + + public init( + id: String, + title: String, + kind: TurnTimelineItemKind, + status: String, + latestStatusText: String? = nil + ) { + self.id = id + self.title = title + self.kind = kind + self.status = status + self.latestStatusText = latestStatusText + } + + public init(_ call: CodexTurnHandle.Minimap.CallSnapshot) { + self.init( + id: call.id, + title: call.displayName, + kind: .init(callKind: call.kind), + status: call.status.rawValue, + latestStatusText: call.latestStatusText + ) + } +} + +public enum DashboardIntent: Sendable, Equatable { + case answerApproval(requestID: String, approved: Bool) + case answerElicitation(requestID: String, response: String) + case refreshStatus +} diff --git a/Sources/ASBPresentation/RecentActivityPresentation.swift b/Sources/ASBPresentation/RecentActivityPresentation.swift new file mode 100644 index 0000000..fa5bea4 --- /dev/null +++ b/Sources/ASBPresentation/RecentActivityPresentation.swift @@ -0,0 +1,221 @@ +import SwiftASB + +/// Framework-neutral state for a mixed recent activity rail. +public struct RecentActivitySnapshot: Sendable, Equatable { + public var items: [RecentActivityItem] + public var selectedItemID: String? + public var visibleItemIDs: [String] + public var isLoadingOlderItems: Bool + public var canLoadOlderItems: Bool + public var errorDescription: String? + + public init( + items: [RecentActivityItem] = [], + selectedItemID: String? = nil, + visibleItemIDs: [String] = [], + isLoadingOlderItems: Bool = false, + canLoadOlderItems: Bool = false, + errorDescription: String? = nil + ) { + self.items = items + self.selectedItemID = selectedItemID + self.visibleItemIDs = visibleItemIDs + self.isLoadingOlderItems = isLoadingOlderItems + self.canLoadOlderItems = canLoadOlderItems + self.errorDescription = errorDescription + } + + public var isEmpty: Bool { + items.isEmpty + } + + @MainActor + public init( + recentFiles: CodexThread.RecentFiles? = nil, + recentCommands: CodexThread.RecentCommands? = nil, + selectedItemID: String? = nil, + visibleItemIDs: [String] = [] + ) { + let fileItems = recentFiles?.files.map(RecentActivityItem.init(file:)) ?? [] + let commandItems = recentCommands?.commands.map(RecentActivityItem.init(command:)) ?? [] + let items = (fileItems + commandItems).sorted(by: Self.sort) + + self.init( + items: items, + selectedItemID: selectedItemID ?? recentFiles?.selectedFileID ?? recentCommands?.selectedCommandID, + visibleItemIDs: visibleItemIDs.isEmpty + ? (recentFiles?.visibleFileIDs ?? []) + (recentCommands?.visibleCommandIDs ?? []) + : visibleItemIDs, + isLoadingOlderItems: (recentFiles?.isLoadingOlderFiles ?? false) + || (recentCommands?.isLoadingOlderCommands ?? false), + canLoadOlderItems: recentFiles?.nextOlderCursor != nil || recentCommands?.nextOlderCursor != nil, + errorDescription: recentFiles?.lastLoadErrorDescription ?? recentCommands?.lastLoadErrorDescription + ) + } + + private static func sort(_ lhs: RecentActivityItem, _ rhs: RecentActivityItem) -> Bool { + switch (lhs.turnStartedAt, rhs.turnStartedAt) { + case let (left?, right?) where left != right: + return left > right + case (nil, _?): + return false + case (_?, nil): + return true + default: + break + } + + switch (lhs.turnOrderIndex, rhs.turnOrderIndex) { + case let (left?, right?) where left != right: + return left > right + case (nil, _?): + return false + case (_?, nil): + return true + default: + break + } + + switch (lhs.itemOrderIndex, rhs.itemOrderIndex) { + case let (left?, right?) where left != right: + return left < right + case (nil, _?): + return false + case (_?, nil): + return true + default: + return lhs.id < rhs.id + } + } +} + +/// One command or file activity item in a framework-neutral rail. +public struct RecentActivityItem: Sendable, Equatable, Identifiable { + public var id: String + public var sourceID: String + public var turnID: String + public var kind: RecentActivityKind + public var title: String + public var subtitle: String? + public var status: RecentActivityStatus + public var payloadText: String? + public var isPayloadComplete: Bool + public var omittedPayloadCharacterCount: Int + public var path: String? + public var command: String? + public var turnOrderIndex: Int? + public var itemOrderIndex: Int? + public var turnStartedAt: Int? + + public init( + id: String, + sourceID: String, + turnID: String, + kind: RecentActivityKind, + title: String, + subtitle: String? = nil, + status: RecentActivityStatus, + payloadText: String? = nil, + isPayloadComplete: Bool = true, + omittedPayloadCharacterCount: Int = 0, + path: String? = nil, + command: String? = nil, + turnOrderIndex: Int? = nil, + itemOrderIndex: Int? = nil, + turnStartedAt: Int? = nil + ) { + self.id = id + self.sourceID = sourceID + self.turnID = turnID + self.kind = kind + self.title = title + self.subtitle = subtitle + self.status = status + self.payloadText = payloadText + self.isPayloadComplete = isPayloadComplete + self.omittedPayloadCharacterCount = omittedPayloadCharacterCount + self.path = path + self.command = command + self.turnOrderIndex = turnOrderIndex + self.itemOrderIndex = itemOrderIndex + self.turnStartedAt = turnStartedAt + } + + public init(file: CodexThread.RecentFiles.FileSnapshot) { + self.init( + id: "file:\(file.id)", + sourceID: file.id, + turnID: file.turnID, + kind: .file, + title: file.displayName, + subtitle: file.latestStatusText, + status: .init(file.status), + payloadText: file.payloadText, + isPayloadComplete: file.isPayloadComplete, + omittedPayloadCharacterCount: file.omittedPayloadCharacterCount, + path: file.path, + turnOrderIndex: file.turnOrderIndex, + itemOrderIndex: file.itemOrderIndex, + turnStartedAt: file.turnStartedAt + ) + } + + public init(command: CodexThread.RecentCommands.CommandSnapshot) { + self.init( + id: "command:\(command.id)", + sourceID: command.id, + turnID: command.turnID, + kind: .command, + title: command.displayName, + subtitle: command.latestStatusText, + status: .init(command.status), + payloadText: command.outputText, + isPayloadComplete: command.isOutputComplete, + omittedPayloadCharacterCount: command.omittedOutputCharacterCount, + command: command.command, + turnOrderIndex: command.turnOrderIndex, + itemOrderIndex: command.itemOrderIndex, + turnStartedAt: command.turnStartedAt + ) + } +} + +public enum RecentActivityKind: String, Sendable, Equatable { + case command + case file +} + +public enum RecentActivityStatus: String, Sendable, Equatable { + case completed + case errored + case inProgress + + public init(_ status: CodexThread.RecentFiles.FileSnapshot.Status) { + switch status { + case .completed: + self = .completed + case .errored: + self = .errored + case .inProgress: + self = .inProgress + } + } + + public init(_ status: CodexThread.RecentCommands.CommandSnapshot.Status) { + switch status { + case .completed: + self = .completed + case .errored: + self = .errored + case .inProgress: + self = .inProgress + } + } +} + +public enum RecentActivityIntent: Sendable, Equatable { + case loadOlderItems + case updateVisibleItemIDs([String]) + case selectItem(id: String?) + case rehydratePayload(itemID: String) +} diff --git a/Sources/ASBPresentation/ThreadSidebarPresentation.swift b/Sources/ASBPresentation/ThreadSidebarPresentation.swift new file mode 100644 index 0000000..1f0f8ed --- /dev/null +++ b/Sources/ASBPresentation/ThreadSidebarPresentation.swift @@ -0,0 +1,301 @@ +import SwiftASB + +/// Framework-neutral state for a thread sidebar. +/// +/// The snapshot carries stable list identity, grouping, selection, loading, and +/// error-display inputs for renderers. It intentionally avoids AppKit and +/// SwiftUI concepts such as index paths, reuse identifiers, bindings, or view +/// lifetimes. +public struct ThreadSidebarSnapshot: Sendable, Equatable { + public var sections: [ThreadSidebarSection] + public var selection: ThreadSelectionState + public var isLoading: Bool + public var errorDescription: String? + + public init( + sections: [ThreadSidebarSection] = [], + selection: ThreadSelectionState = .init(), + isLoading: Bool = false, + errorDescription: String? = nil + ) { + self.sections = sections + self.selection = selection + self.isLoading = isLoading + self.errorDescription = errorDescription + } + + /// All visible items in renderer order. + public var items: [ThreadSidebarItem] { + sections.flatMap(\.items) + } + + /// True when there is no visible thread row. + public var isEmpty: Bool { + items.isEmpty + } + + /// Projects the current app-wide library companion into a sidebar snapshot. + @MainActor + public init( + library: CodexAppServer.Library, + includeArchived: Bool = false + ) { + var projectedSections = library.groups.isEmpty + ? Self.ungroupedSections(from: library.unarchivedThreads) + : library.groups.map(ThreadSidebarSection.init(group:)) + + if includeArchived, !library.archivedThreads.isEmpty { + projectedSections.append( + ThreadSidebarSection( + id: ThreadSidebarSection.archiveSectionID, + title: "Archived", + projectID: nil, + worktreeID: nil, + repositoryID: nil, + items: library.archivedThreads.map(ThreadSidebarItem.init(thread:)) + ) + ) + } + + self.init( + sections: projectedSections, + selection: .init( + selectedThreadID: library.selectedThreadID, + selectedWorktreeID: library.selectedWorktree?.id, + selectedRepositoryID: library.selectedRepository?.originURL + ), + isLoading: library.isLoadingLocalSnapshot || library.isReconciling, + errorDescription: library.latestErrorDescription + ) + } + + private static func ungroupedSections( + from threads: [CodexAppServer.Library.ThreadSnapshot] + ) -> [ThreadSidebarSection] { + guard !threads.isEmpty else { return [] } + return [ + ThreadSidebarSection( + id: ThreadSidebarSection.defaultSectionID, + title: "Threads", + projectID: nil, + worktreeID: nil, + repositoryID: nil, + items: threads.map(ThreadSidebarItem.init(thread:)) + ), + ] + } +} + +/// One visible section in a thread sidebar. +public struct ThreadSidebarSection: Sendable, Equatable, Identifiable { + public static let defaultSectionID = "threads" + public static let archiveSectionID = "archived" + + public var id: String + public var title: String + public var projectID: String? + public var worktreeID: String? + public var repositoryID: String? + public var items: [ThreadSidebarItem] + + public init( + id: String, + title: String, + projectID: String? = nil, + worktreeID: String? = nil, + repositoryID: String? = nil, + items: [ThreadSidebarItem] = [] + ) { + self.id = id + self.title = title + self.projectID = projectID + self.worktreeID = worktreeID + self.repositoryID = repositoryID + self.items = items + } + + @MainActor + public init(group: CodexAppServer.Library.ThreadGroup) { + self.init( + id: group.id, + title: group.title, + projectID: group.projectInfo?.id, + worktreeID: group.worktree?.id, + repositoryID: group.worktree?.repository?.originURL, + items: group.threads.map(ThreadSidebarItem.init(thread:)) + ) + } +} + +/// One visible thread row in a framework-neutral sidebar snapshot. +public struct ThreadSidebarItem: Sendable, Equatable, Identifiable { + public var id: String + public var title: String + public var preview: String + public var sourceBadge: ThreadSidebarSourceBadge + public var activityStatus: ThreadSidebarActivityStatus + public var isArchived: Bool + public var isClosed: Bool + public var projectID: String + public var projectTitle: String + public var worktreeID: String + public var worktreeTitle: String + public var repositoryID: String? + public var updatedAt: Int + public var lastCompletedTurnAt: Int? + + public init( + id: String, + title: String, + preview: String = "", + sourceBadge: ThreadSidebarSourceBadge = .unknown, + activityStatus: ThreadSidebarActivityStatus = .idle, + isArchived: Bool = false, + isClosed: Bool = false, + projectID: String, + projectTitle: String, + worktreeID: String, + worktreeTitle: String, + repositoryID: String? = nil, + updatedAt: Int, + lastCompletedTurnAt: Int? = nil + ) { + self.id = id + self.title = title + self.preview = preview + self.sourceBadge = sourceBadge + self.activityStatus = activityStatus + self.isArchived = isArchived + self.isClosed = isClosed + self.projectID = projectID + self.projectTitle = projectTitle + self.worktreeID = worktreeID + self.worktreeTitle = worktreeTitle + self.repositoryID = repositoryID + self.updatedAt = updatedAt + self.lastCompletedTurnAt = lastCompletedTurnAt + } + + public init(thread: CodexAppServer.Library.ThreadSnapshot) { + self.init( + id: thread.id, + title: thread.name ?? thread.preview, + preview: thread.preview, + sourceBadge: .init(source: thread.source), + activityStatus: .init(thread: thread), + isArchived: thread.isArchived, + isClosed: thread.isClosed, + projectID: thread.projectInfo.id, + projectTitle: thread.projectInfo.displayName, + worktreeID: thread.worktree.id, + worktreeTitle: thread.worktree.displayName, + repositoryID: thread.worktree.repository?.originURL, + updatedAt: thread.updatedAt, + lastCompletedTurnAt: thread.lastCompletedTurnAt + ) + } +} + +/// The selected identities owned by a presentation/controller instance. +public struct ThreadSelectionState: Sendable, Equatable { + public var selectedThreadID: String? + public var selectedWorktreeID: String? + public var selectedRepositoryID: String? + + public init( + selectedThreadID: String? = nil, + selectedWorktreeID: String? = nil, + selectedRepositoryID: String? = nil + ) { + self.selectedThreadID = selectedThreadID + self.selectedWorktreeID = selectedWorktreeID + self.selectedRepositoryID = selectedRepositoryID + } + + public func isSelected(_ item: ThreadSidebarItem) -> Bool { + item.id == selectedThreadID + } + + public func selectingThread(_ item: ThreadSidebarItem?) -> Self { + .init( + selectedThreadID: item?.id, + selectedWorktreeID: item?.worktreeID, + selectedRepositoryID: item?.repositoryID + ) + } +} + +/// Renderer-neutral badge for the app-server source that created a thread. +public enum ThreadSidebarSourceBadge: Sendable, Equatable { + case appServer + case cli + case exec + case vscode + case custom(String) + case subAgent(kind: String) + case unknown + + public init(source: CodexAppServer.ThreadSource) { + switch source { + case .appServer: + self = .appServer + case .cli: + self = .cli + case .exec: + self = .exec + case .vscode: + self = .vscode + case let .custom(label): + self = .custom(label) + case let .subAgent(source): + self = .subAgent(kind: source.kind.rawValue) + case .unknown: + self = .unknown + } + } +} + +/// Renderer-neutral activity state for a thread row. +public enum ThreadSidebarActivityStatus: Sendable, Equatable { + case active + case idle + case notLoaded + case systemError + case waitingOnApproval + case waitingOnUserInput + case closed + case removed + + public init(thread: CodexAppServer.Library.ThreadSnapshot) { + if thread.state == .removed { + self = .removed + } else if thread.isClosed { + self = .closed + } else if thread.status.activeFlags.contains(.waitingOnApproval) { + self = .waitingOnApproval + } else if thread.status.activeFlags.contains(.waitingOnUserInput) { + self = .waitingOnUserInput + } else { + switch thread.status.type { + case .active: + self = .active + case .idle: + self = .idle + case .notLoaded: + self = .notLoaded + case .systemError: + self = .systemError + } + } + } +} + +/// User intent emitted by a thread sidebar renderer. +public enum ThreadSidebarIntent: Sendable, Equatable { + case selectThread(id: String?) + case openThread(id: String) + case setThreadArchived(id: String, archived: Bool) + case refreshUnarchivedThreads + case refreshArchivedThreads + case refreshSelectedWorktreeGitStatus +} diff --git a/Sources/ASBPresentation/TurnTimelinePresentation.swift b/Sources/ASBPresentation/TurnTimelinePresentation.swift new file mode 100644 index 0000000..4765c99 --- /dev/null +++ b/Sources/ASBPresentation/TurnTimelinePresentation.swift @@ -0,0 +1,397 @@ +import SwiftASB + +/// Framework-neutral state for a thread turn timeline. +public struct TurnTimelineSnapshot: Sendable, Equatable { + public var sections: [TurnTimelineSection] + public var viewport: TurnTimelineViewportState + public var selectedItemID: String? + public var isLoadingOlderTurns: Bool + public var isLoadingNewerTurns: Bool + public var canLoadOlderTurns: Bool + public var canLoadNewerTurns: Bool + public var errorDescription: String? + + public init( + sections: [TurnTimelineSection] = [], + viewport: TurnTimelineViewportState = .init(), + selectedItemID: String? = nil, + isLoadingOlderTurns: Bool = false, + isLoadingNewerTurns: Bool = false, + canLoadOlderTurns: Bool = false, + canLoadNewerTurns: Bool = false, + errorDescription: String? = nil + ) { + self.sections = sections + self.viewport = viewport + self.selectedItemID = selectedItemID + self.isLoadingOlderTurns = isLoadingOlderTurns + self.isLoadingNewerTurns = isLoadingNewerTurns + self.canLoadOlderTurns = canLoadOlderTurns + self.canLoadNewerTurns = canLoadNewerTurns + self.errorDescription = errorDescription + } + + /// All timeline items in renderer order. + public var items: [TurnTimelineItem] { + sections.flatMap(\.items) + } + + /// True when there is no visible turn or active call row. + public var isEmpty: Bool { + items.isEmpty + } + + @MainActor + public init( + recentTurns: CodexThread.RecentTurns, + activeMinimap: CodexTurnHandle.Minimap? = nil, + viewport: TurnTimelineViewportState? = nil, + selectedItemID: String? = nil + ) { + var sections = recentTurns.turns.map(TurnTimelineSection.init(turn:)) + + if let activeMinimap { + let activeSection = TurnTimelineSection(activeMinimap: activeMinimap) + if let index = sections.firstIndex(where: { $0.id == activeSection.id }) { + sections[index] = activeSection + } else { + sections.insert(activeSection, at: 0) + } + } + + self.init( + sections: sections, + viewport: viewport ?? .init(recentTurns: recentTurns), + selectedItemID: selectedItemID, + isLoadingOlderTurns: recentTurns.isLoadingOlderTurns, + isLoadingNewerTurns: recentTurns.isLoadingNewerTurns, + canLoadOlderTurns: recentTurns.nextOlderCursor != nil, + canLoadNewerTurns: recentTurns.nextNewerCursor != nil, + errorDescription: recentTurns.lastLoadErrorDescription + ) + } +} + +/// One turn section in a framework-neutral timeline. +public struct TurnTimelineSection: Sendable, Equatable, Identifiable { + public var id: String + public var turnID: String + public var title: String + public var status: String + public var startedAt: Int? + public var completedAt: Int? + public var durationMS: Int? + public var tokenSummary: TurnTimelineTokenSummary? + public var items: [TurnTimelineItem] + + public init( + id: String, + turnID: String, + title: String, + status: String, + startedAt: Int? = nil, + completedAt: Int? = nil, + durationMS: Int? = nil, + tokenSummary: TurnTimelineTokenSummary? = nil, + items: [TurnTimelineItem] = [] + ) { + self.id = id + self.turnID = turnID + self.title = title + self.status = status + self.startedAt = startedAt + self.completedAt = completedAt + self.durationMS = durationMS + self.tokenSummary = tokenSummary + self.items = items + } + + public init(turn: CodexThread.RecentTurns.TurnSnapshot) { + self.init( + id: turn.id, + turnID: turn.id, + title: Self.title(for: turn), + status: turn.status, + startedAt: turn.startedAt, + completedAt: turn.completedAt, + durationMS: turn.durationMS, + tokenSummary: turn.tokenUsage.map(TurnTimelineTokenSummary.init(tokenUsage:)), + items: turn.items.map { TurnTimelineItem(turnID: turn.id, item: $0) } + ) + } + + @MainActor + public init(activeMinimap: CodexTurnHandle.Minimap) { + self.init( + id: activeMinimap.turnID, + turnID: activeMinimap.turnID, + title: "Active turn", + status: activeMinimap.currentTurn.status.rawValue, + startedAt: activeMinimap.currentTurn.startedAt, + completedAt: activeMinimap.latestCompletion?.turn.completedAt, + durationMS: activeMinimap.latestCompletion?.turn.durationMS, + tokenSummary: nil, + items: activeMinimap.callSnapshots.map { TurnTimelineItem(turnID: activeMinimap.turnID, call: $0) } + ) + } + + private static func title(for turn: CodexThread.RecentTurns.TurnSnapshot) -> String { + if let completedAt = turn.completedAt { + return "Turn completed at \(completedAt)" + } + if let startedAt = turn.startedAt { + return "Turn started at \(startedAt)" + } + return "Turn \(turn.orderIndex)" + } +} + +/// One visible row inside a turn timeline. +public struct TurnTimelineItem: Sendable, Equatable, Identifiable { + public var id: String + public var turnID: String + public var displayKind: TurnTimelineItemKind + public var title: String + public var subtitle: String? + public var status: String? + public var text: String? + public var path: String? + public var command: String? + public var serverName: String? + public var toolName: String? + public var isPayloadComplete: Bool + public var omittedPayloadCount: Int + public var isLowValueForResidency: Bool + + public init( + id: String, + turnID: String, + displayKind: TurnTimelineItemKind, + title: String, + subtitle: String? = nil, + status: String? = nil, + text: String? = nil, + path: String? = nil, + command: String? = nil, + serverName: String? = nil, + toolName: String? = nil, + isPayloadComplete: Bool = true, + omittedPayloadCount: Int = 0, + isLowValueForResidency: Bool = false + ) { + self.id = id + self.turnID = turnID + self.displayKind = displayKind + self.title = title + self.subtitle = subtitle + self.status = status + self.text = text + self.path = path + self.command = command + self.serverName = serverName + self.toolName = toolName + self.isPayloadComplete = isPayloadComplete + self.omittedPayloadCount = omittedPayloadCount + self.isLowValueForResidency = isLowValueForResidency + } + + public init(turnID: String, item: CodexThread.RecentTurns.TurnSnapshot.Item) { + self.init( + id: item.id, + turnID: turnID, + displayKind: .init(rawKind: item.kind), + title: Self.title(for: item), + subtitle: item.status, + status: item.status, + text: item.text ?? item.streamedText, + path: item.path, + command: item.command, + serverName: item.serverName, + toolName: item.toolName, + isPayloadComplete: true, + omittedPayloadCount: 0, + isLowValueForResidency: item.isLowValueForResidency + ) + } + + public init(turnID: String, call: CodexTurnHandle.Minimap.CallSnapshot) { + self.init( + id: call.id, + turnID: turnID, + displayKind: .init(callKind: call.kind), + title: call.displayName, + subtitle: call.latestStatusText, + status: call.status.rawValue, + text: call.latestStatusText, + path: call.filePath, + serverName: call.serverName, + toolName: call.toolName, + isPayloadComplete: call.status != .inProgress, + omittedPayloadCount: 0, + isLowValueForResidency: false + ) + } + + private static func title(for item: CodexThread.RecentTurns.TurnSnapshot.Item) -> String { + if let command = item.command, !command.isEmpty { + return command + } + if let path = item.path, !path.isEmpty { + return path + } + if let serverName = item.serverName, let toolName = item.toolName { + return "\(serverName).\(toolName)" + } + if let toolName = item.toolName, !toolName.isEmpty { + return toolName + } + if let text = item.text, !text.isEmpty { + return String(text.prefix(120)) + } + return item.kind + } +} + +public enum TurnTimelineItemKind: String, Sendable, Equatable { + case agentMessage + case collabTool + case command + case dynamicTool + case fileEdit + case mcp + case reasoning + case unknown + + public init(rawKind: String) { + switch rawKind { + case "agentMessage": + self = .agentMessage + case "collabAgentToolCall": + self = .collabTool + case "commandExecution": + self = .command + case "dynamicToolCall": + self = .dynamicTool + case "fileChange": + self = .fileEdit + case "mcpToolCall": + self = .mcp + case "reasoning": + self = .reasoning + default: + self = .unknown + } + } + + public init(callKind: CodexTurnHandle.Minimap.CallSnapshot.Kind) { + switch callKind { + case .collabTool: + self = .collabTool + case .command: + self = .command + case .dynamicTool: + self = .dynamicTool + case .fileEdit: + self = .fileEdit + case .mcp: + self = .mcp + } + } +} + +/// Renderer-neutral viewport hints for a timeline. +public struct TurnTimelineViewportState: Sendable, Equatable { + public enum ScrollActivityPhase: String, Sendable, Equatable { + case idle + case tracking + case interacting + case decelerating + case animating + } + + public var visibleTurnIDs: [String] + public var scrollAnchorTurnID: String? + public var scrollActivityPhase: ScrollActivityPhase + public var scrollVelocityPointsPerSecond: Double? + + public init( + visibleTurnIDs: [String] = [], + scrollAnchorTurnID: String? = nil, + scrollActivityPhase: ScrollActivityPhase = .idle, + scrollVelocityPointsPerSecond: Double? = nil + ) { + self.visibleTurnIDs = visibleTurnIDs + self.scrollAnchorTurnID = scrollAnchorTurnID + self.scrollActivityPhase = scrollActivityPhase + self.scrollVelocityPointsPerSecond = scrollVelocityPointsPerSecond + } + + @MainActor + public init(recentTurns: CodexThread.RecentTurns) { + self.init( + visibleTurnIDs: recentTurns.visibleTurnIDs, + scrollAnchorTurnID: recentTurns.scrollPositionTurnID, + scrollActivityPhase: .init(recentTurns.scrollActivityPhase), + scrollVelocityPointsPerSecond: recentTurns.scrollVelocityPointsPerSecond + ) + } +} + +extension TurnTimelineViewportState.ScrollActivityPhase { + public init(_ phase: CodexThread.RecentTurns.ScrollActivityPhase) { + switch phase { + case .idle: + self = .idle + case .tracking: + self = .tracking + case .interacting: + self = .interacting + case .decelerating: + self = .decelerating + case .animating: + self = .animating + } + } +} + +public struct TurnTimelineTokenSummary: Sendable, Equatable { + public var inputTokens: Int? + public var outputTokens: Int? + public var reasoningOutputTokens: Int? + public var totalTokens: Int? + public var modelContextWindow: Int? + + public init( + inputTokens: Int? = nil, + outputTokens: Int? = nil, + reasoningOutputTokens: Int? = nil, + totalTokens: Int? = nil, + modelContextWindow: Int? = nil + ) { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.reasoningOutputTokens = reasoningOutputTokens + self.totalTokens = totalTokens + self.modelContextWindow = modelContextWindow + } + + public init(tokenUsage: CodexThread.RecentTurns.TurnSnapshot.TokenUsage) { + self.init( + inputTokens: tokenUsage.inputTokens, + outputTokens: tokenUsage.outputTokens, + reasoningOutputTokens: tokenUsage.reasoningOutputTokens, + totalTokens: tokenUsage.totalTokens, + modelContextWindow: tokenUsage.modelContextWindow + ) + } + +} + +public enum TurnTimelineIntent: Sendable, Equatable { + case loadOlderTurns + case loadNewerTurns + case updateVisibleTurnIDs([String]) + case updateViewport(TurnTimelineViewportState) + case selectItem(id: String?) + case rehydratePayload(itemID: String) +} diff --git a/Sources/ASBSwiftUI/ASBSwiftUIModule.swift b/Sources/ASBSwiftUI/ASBSwiftUIModule.swift new file mode 100644 index 0000000..eccc81f --- /dev/null +++ b/Sources/ASBSwiftUI/ASBSwiftUIModule.swift @@ -0,0 +1,8 @@ +import ASBAppKit +import ASBPresentation +import SwiftASB +import SwiftUI + +struct ASBSwiftUIModule: Sendable { + static let name = "ASBSwiftUI" +} diff --git a/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift b/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift index c5bed62..0df5a4a 100644 --- a/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift +++ b/Sources/SwiftASB/Public/CodexThread+RecentCommands.swift @@ -62,9 +62,9 @@ extension CodexThread { public private(set) var status: Status public let turnID: String - var itemOrderIndex: Int? - var turnOrderIndex: Int? - var turnStartedAt: Int? + public internal(set) var itemOrderIndex: Int? + public internal(set) var turnOrderIndex: Int? + public internal(set) var turnStartedAt: Int? fileprivate mutating func apply(delta: String) { outputText = (outputText ?? "") + delta diff --git a/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift b/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift index f2f2e81..916ea65 100644 --- a/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift +++ b/Sources/SwiftASB/Public/CodexThread+RecentFiles.swift @@ -74,9 +74,9 @@ extension CodexThread { public private(set) var status: Status public let turnID: String - var itemOrderIndex: Int? - var turnOrderIndex: Int? - var turnStartedAt: Int? + public internal(set) var itemOrderIndex: Int? + public internal(set) var turnOrderIndex: Int? + public internal(set) var turnStartedAt: Int? fileprivate mutating func apply(delta: String) { payloadText = (payloadText ?? "") + delta diff --git a/Tests/ASBAppKitTests/ASBAppKitModuleTests.swift b/Tests/ASBAppKitTests/ASBAppKitModuleTests.swift new file mode 100644 index 0000000..af4d06a --- /dev/null +++ b/Tests/ASBAppKitTests/ASBAppKitModuleTests.swift @@ -0,0 +1,10 @@ +@testable import ASBAppKit +import Testing + +@Suite("ASBAppKit module") +struct ASBAppKitModuleTests { + @Test("module scaffold is available") + func moduleScaffoldIsAvailable() { + #expect(ASBAppKitModule.name == "ASBAppKit") + } +} diff --git a/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift b/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift new file mode 100644 index 0000000..4daee0b --- /dev/null +++ b/Tests/ASBPresentationTests/ASBPresentationBoundaryTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +@Suite("ASBPresentation framework boundary") +struct ASBPresentationBoundaryTests { + @Test("presentation sources do not import renderer frameworks") + func presentationSourcesAvoidRendererFrameworkImports() throws { + let packageRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let sourceRoot = packageRoot.appending(path: "Sources/ASBPresentation") + let fileURLs = try FileManager.default.contentsOfDirectory( + at: sourceRoot, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "swift" } + + #expect(!fileURLs.isEmpty) + + for fileURL in fileURLs { + let source = try String(contentsOf: fileURL, encoding: .utf8) + #expect(!source.contains("import AppKit"), "\(fileURL.lastPathComponent) imports AppKit") + #expect(!source.contains("import SwiftUI"), "\(fileURL.lastPathComponent) imports SwiftUI") + } + } +} diff --git a/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift b/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift new file mode 100644 index 0000000..d804e56 --- /dev/null +++ b/Tests/ASBPresentationTests/AgendaDashboardPresentationTests.swift @@ -0,0 +1,105 @@ +import ASBPresentation +import Testing + +@Suite("Agenda and dashboard presentation") +struct AgendaDashboardPresentationTests { + @Test("agenda snapshot exposes goal, current plan, and proposed plan") + func agendaSnapshotCarriesGoalAndPlan() { + let plan = AgendaPlan( + turnID: "turn-plan", + explanation: "Prepare implementation", + steps: [ + .init(id: "step-1", title: "Inspect sources", status: .completed), + .init(id: "step-2", title: "Add contracts", status: .inProgress), + ] + ) + let proposed = AgendaProposedPlan( + turnID: "turn-proposed", + items: [.init(id: "proposal-1", text: "Validate before renderer work")] + ) + let snapshot = AgendaSnapshot( + threadID: "thread-1", + goalTitle: "Finish ASBPresentation", + goalStatus: .active, + planTitle: "Add contracts", + currentPlan: plan, + proposedPlan: proposed, + updatedAt: 42 + ) + + #expect(snapshot.hasGoal) + #expect(snapshot.hasPlan) + #expect(snapshot.currentPlan?.steps.map(\.id) == ["step-1", "step-2"]) + #expect(snapshot.currentPlan?.steps[1].status == .inProgress) + #expect(snapshot.proposedPlan?.items.first?.text == "Validate before renderer work") + } + + @Test("agenda intents represent goal and planning actions") + func agendaIntentsRepresentRuntimeActions() { + let intents: [AgendaIntent] = [ + .startPlanningTurn, + .setGoal(objective: "Ship foundation", tokenBudget: 4000), + .pauseGoal, + .resumeGoal, + .clearGoal, + ] + + #expect(intents.contains(.startPlanningTurn)) + #expect(intents.contains(.setGoal(objective: "Ship foundation", tokenBudget: 4000))) + #expect(intents.contains(.clearGoal)) + } + + @Test("dashboard snapshot carries status summaries and active calls") + func dashboardSnapshotCarriesStatusSummaries() { + let call = DashboardCallSummary( + id: "call-1", + title: "swift test", + kind: .command, + status: "inProgress", + latestStatusText: "Running tests" + ) + let hook = DashboardHookRun( + id: "hook-1", + eventName: "postToolUse", + status: "completed", + sourcePath: ".codex/hooks/post-tool-use.sh", + startedAt: 10, + completedAt: 12, + durationMS: 2 + ) + let snapshot = DashboardSnapshot( + threadID: "thread-1", + title: "SwiftASB", + preview: "Working on presentation", + threadStatus: "active", + isCompactingThreadContext: true, + goalTitle: "Finish ASBPresentation", + planTitle: "Add dashboard", + toolCallingStatus: .inProgress, + mcpCallingStatus: .idle, + autoReviewStatus: .approved, + latestDiagnosticDescription: "Model verification updated.", + hookRuns: [hook], + activeCalls: [call] + ) + + #expect(snapshot.threadID == "thread-1") + #expect(snapshot.isCompactingThreadContext) + #expect(snapshot.toolCallingStatus == .inProgress) + #expect(snapshot.hookRuns.map(\.id) == ["hook-1"]) + #expect(snapshot.activeCalls.map(\.id) == ["call-1"]) + } + + @Test("dashboard intents keep request answers typed") + func dashboardIntentsKeepRequestAnswersTyped() { + let intents: [DashboardIntent] = [ + .answerApproval(requestID: "request-1", approved: true), + .answerElicitation(requestID: "request-2", response: "value"), + .refreshStatus, + ] + + #expect(intents.contains(.answerApproval(requestID: "request-1", approved: true))) + #expect(intents.contains(.answerElicitation(requestID: "request-2", response: "value"))) + #expect(intents.contains(.refreshStatus)) + } +} diff --git a/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift b/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift new file mode 100644 index 0000000..e3a14c3 --- /dev/null +++ b/Tests/ASBPresentationTests/RecentActivityPresentationTests.swift @@ -0,0 +1,77 @@ +import ASBPresentation +import Testing + +@Suite("Recent activity presentation") +struct RecentActivityPresentationTests { + @Test("activity snapshot keeps command and file identity distinct") + func activityIDsIncludeKindPrefix() { + let command = RecentActivityItem( + id: "command:item-1", + sourceID: "item-1", + turnID: "turn-1", + kind: .command, + title: "swift build", + status: .completed, + command: "swift build", + turnOrderIndex: 2, + itemOrderIndex: 0, + turnStartedAt: 20 + ) + let file = RecentActivityItem( + id: "file:item-1", + sourceID: "item-1", + turnID: "turn-1", + kind: .file, + title: "Package.swift", + status: .completed, + path: "Package.swift", + turnOrderIndex: 2, + itemOrderIndex: 1, + turnStartedAt: 20 + ) + let snapshot = RecentActivitySnapshot( + items: [command, file], + selectedItemID: "file:item-1", + visibleItemIDs: ["command:item-1", "file:item-1"] + ) + + #expect(snapshot.items.map(\.id) == ["command:item-1", "file:item-1"]) + #expect(snapshot.items.map(\.sourceID) == ["item-1", "item-1"]) + #expect(snapshot.items.map(\.itemOrderIndex) == [0, 1]) + #expect(snapshot.selectedItemID == "file:item-1") + #expect(snapshot.visibleItemIDs == ["command:item-1", "file:item-1"]) + } + + @Test("activity rows carry payload residency state") + func activityRowsCarryPayloadResidency() { + let slimmed = RecentActivityItem( + id: "command:item-2", + sourceID: "item-2", + turnID: "turn-1", + kind: .command, + title: "swift test", + status: .completed, + isPayloadComplete: false, + omittedPayloadCharacterCount: 128, + command: "swift test" + ) + + #expect(!slimmed.isPayloadComplete) + #expect(slimmed.omittedPayloadCharacterCount == 128) + #expect(slimmed.command == "swift test") + } + + @Test("activity intents describe list and payload actions") + func activityIntentsAreNarrow() { + let intents: [RecentActivityIntent] = [ + .loadOlderItems, + .updateVisibleItemIDs(["command:item-1"]), + .selectItem(id: "command:item-1"), + .rehydratePayload(itemID: "command:item-1"), + ] + + #expect(intents.contains(.loadOlderItems)) + #expect(intents.contains(.updateVisibleItemIDs(["command:item-1"]))) + #expect(intents.contains(.rehydratePayload(itemID: "command:item-1"))) + } +} diff --git a/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift b/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift new file mode 100644 index 0000000..ef74a96 --- /dev/null +++ b/Tests/ASBPresentationTests/ThreadSidebarPresentationTests.swift @@ -0,0 +1,137 @@ +import ASBPresentation +import Testing + +@Suite("Thread sidebar presentation") +struct ThreadSidebarPresentationTests { + @Test("items keep stable thread identity") + func itemIdentityIsStableThreadID() { + let original = item( + id: "thread-1", + title: "Initial title", + preview: "First preview", + updatedAt: 10 + ) + let renamed = item( + id: "thread-1", + title: "Renamed thread", + preview: "New preview", + updatedAt: 20 + ) + + #expect(original.id == "thread-1") + #expect(renamed.id == original.id) + #expect(renamed.title != original.title) + #expect(renamed.updatedAt != original.updatedAt) + } + + @Test("sections are grouping-ready without renderer concepts") + func sectionsCarryGroupingIdentityAndItems() { + let worktreeItem = item( + id: "thread-worktree", + title: "Worktree thread", + projectID: "repo://swiftasb", + projectTitle: "SwiftASB", + worktreeID: "worktree://swiftasb-main", + worktreeTitle: "SwiftASB main", + repositoryID: "https://github.com/gaelic-ghost/SwiftASB" + ) + let section = ThreadSidebarSection( + id: "repo://swiftasb", + title: "SwiftASB", + projectID: "repo://swiftasb", + worktreeID: "worktree://swiftasb-main", + repositoryID: "https://github.com/gaelic-ghost/SwiftASB", + items: [worktreeItem] + ) + let snapshot = ThreadSidebarSnapshot(sections: [section]) + + #expect(snapshot.sections.map(\.id) == ["repo://swiftasb"]) + #expect(snapshot.items.map(\.id) == ["thread-worktree"]) + #expect(snapshot.sections[0].worktreeID == "worktree://swiftasb-main") + #expect(snapshot.sections[0].repositoryID == "https://github.com/gaelic-ghost/SwiftASB") + } + + @Test("selection state identifies the selected thread row") + func selectionStateMarksSelectedItem() { + let selected = item(id: "selected-thread", title: "Selected") + let other = item(id: "other-thread", title: "Other") + let state = ThreadSelectionState().selectingThread(selected) + + #expect(state.selectedThreadID == "selected-thread") + #expect(state.selectedWorktreeID == selected.worktreeID) + #expect(state.selectedRepositoryID == selected.repositoryID) + #expect(state.isSelected(selected)) + #expect(!state.isSelected(other)) + } + + @Test("snapshot exposes loading, empty, flattened items, and errors") + func snapshotExposesListState() { + let first = item(id: "first", title: "First") + let second = item(id: "second", title: "Second") + let snapshot = ThreadSidebarSnapshot( + sections: [ + .init(id: "recent", title: "Recent", items: [first]), + .init(id: "archived", title: "Archived", items: [second]), + ], + selection: .init(selectedThreadID: "second"), + isLoading: true, + errorDescription: "Library refresh failed while reading local thread history." + ) + + #expect(snapshot.items.map(\.id) == ["first", "second"]) + #expect(!snapshot.isEmpty) + #expect(snapshot.isLoading) + #expect(snapshot.errorDescription == "Library refresh failed while reading local thread history.") + #expect(snapshot.selection.selectedThreadID == "second") + } + + @Test("intent values are narrow and renderer-neutral") + func sidebarIntentsCarryRuntimeCommands() { + let intents: [ThreadSidebarIntent] = [ + .selectThread(id: "thread-1"), + .openThread(id: "thread-1"), + .setThreadArchived(id: "thread-1", archived: true), + .refreshUnarchivedThreads, + .refreshArchivedThreads, + .refreshSelectedWorktreeGitStatus, + ] + + #expect(intents.contains(.selectThread(id: "thread-1"))) + #expect(intents.contains(.setThreadArchived(id: "thread-1", archived: true))) + #expect(intents.contains(.refreshSelectedWorktreeGitStatus)) + } + + private func item( + id: String, + title: String, + preview: String = "", + sourceBadge: ThreadSidebarSourceBadge = .cli, + activityStatus: ThreadSidebarActivityStatus = .idle, + isArchived: Bool = false, + isClosed: Bool = false, + projectID: String = "project://default", + projectTitle: String = "Default Project", + worktreeID: String = "worktree://default", + worktreeTitle: String = "Default Worktree", + repositoryID: String? = "https://github.com/gaelic-ghost/SwiftASB", + updatedAt: Int = 1, + lastCompletedTurnAt: Int? = nil + ) -> ThreadSidebarItem { + ThreadSidebarItem( + id: id, + title: title, + preview: preview, + sourceBadge: sourceBadge, + activityStatus: activityStatus, + isArchived: isArchived, + isClosed: isClosed, + projectID: projectID, + projectTitle: projectTitle, + worktreeID: worktreeID, + worktreeTitle: worktreeTitle, + repositoryID: repositoryID, + updatedAt: updatedAt, + lastCompletedTurnAt: lastCompletedTurnAt + ) + } +} diff --git a/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift b/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift new file mode 100644 index 0000000..523a69b --- /dev/null +++ b/Tests/ASBPresentationTests/TurnTimelinePresentationTests.swift @@ -0,0 +1,95 @@ +import ASBPresentation +import Testing + +@Suite("Turn timeline presentation") +struct TurnTimelinePresentationTests { + @Test("snapshot flattens sections and preserves viewport hints") + func snapshotFlattensSectionsAndViewport() { + let command = item(id: "item-command", turnID: "turn-1", title: "swift test") + let file = item(id: "item-file", turnID: "turn-2", title: "Package.swift", kind: .fileEdit) + let snapshot = TurnTimelineSnapshot( + sections: [ + .init(id: "turn-1", turnID: "turn-1", title: "First", status: "completed", items: [command]), + .init(id: "turn-2", turnID: "turn-2", title: "Second", status: "inProgress", items: [file]), + ], + viewport: .init( + visibleTurnIDs: ["turn-1", "turn-2"], + scrollAnchorTurnID: "turn-2", + scrollActivityPhase: .interacting, + scrollVelocityPointsPerSecond: 240 + ), + selectedItemID: "item-file", + isLoadingOlderTurns: true, + canLoadOlderTurns: true + ) + + #expect(snapshot.items.map(\.id) == ["item-command", "item-file"]) + #expect(snapshot.viewport.visibleTurnIDs == ["turn-1", "turn-2"]) + #expect(snapshot.viewport.scrollAnchorTurnID == "turn-2") + #expect(snapshot.selectedItemID == "item-file") + #expect(snapshot.isLoadingOlderTurns) + #expect(snapshot.canLoadOlderTurns) + } + + @Test("timeline item identity stays stable across payload hydration") + func itemIdentitySurvivesHydration() { + let slimmed = item( + id: "item-1", + turnID: "turn-1", + title: "Edited README.md", + kind: .fileEdit, + isPayloadComplete: false, + omittedPayloadCount: 400 + ) + let hydrated = item( + id: "item-1", + turnID: "turn-1", + title: "Edited README.md", + kind: .fileEdit, + text: "diff --git a/README.md b/README.md", + isPayloadComplete: true, + omittedPayloadCount: 0 + ) + + #expect(hydrated.id == slimmed.id) + #expect(hydrated.turnID == slimmed.turnID) + #expect(hydrated.isPayloadComplete) + #expect(hydrated.omittedPayloadCount == 0) + } + + @Test("timeline intents describe runtime actions without renderer indexes") + func timelineIntentsAreRendererNeutral() { + let intents: [TurnTimelineIntent] = [ + .loadOlderTurns, + .loadNewerTurns, + .updateVisibleTurnIDs(["turn-1"]), + .updateViewport(.init(scrollAnchorTurnID: "turn-1")), + .selectItem(id: "item-1"), + .rehydratePayload(itemID: "item-1"), + ] + + #expect(intents.contains(.loadOlderTurns)) + #expect(intents.contains(.updateVisibleTurnIDs(["turn-1"]))) + #expect(intents.contains(.selectItem(id: "item-1"))) + } + + private func item( + id: String, + turnID: String, + title: String, + kind: TurnTimelineItemKind = .command, + text: String? = nil, + isPayloadComplete: Bool = true, + omittedPayloadCount: Int = 0 + ) -> TurnTimelineItem { + TurnTimelineItem( + id: id, + turnID: turnID, + displayKind: kind, + title: title, + text: text, + isPayloadComplete: isPayloadComplete, + omittedPayloadCount: omittedPayloadCount + ) + } +} diff --git a/Tests/ASBSwiftUITests/ASBSwiftUIModuleTests.swift b/Tests/ASBSwiftUITests/ASBSwiftUIModuleTests.swift new file mode 100644 index 0000000..011b99b --- /dev/null +++ b/Tests/ASBSwiftUITests/ASBSwiftUIModuleTests.swift @@ -0,0 +1,10 @@ +@testable import ASBSwiftUI +import Testing + +@Suite("ASBSwiftUI module") +struct ASBSwiftUIModuleTests { + @Test("module scaffold is available") + func moduleScaffoldIsAvailable() { + #expect(ASBSwiftUIModule.name == "ASBSwiftUI") + } +} diff --git a/docs/maintainers/presentation-ui-targets-plan.md b/docs/maintainers/presentation-ui-targets-plan.md new file mode 100644 index 0000000..ea518d8 --- /dev/null +++ b/docs/maintainers/presentation-ui-targets-plan.md @@ -0,0 +1,375 @@ +# Presentation UI Targets Plan + +This plan records the intended shape for SwiftASB UI component targets. The goal +is to let consuming macOS apps build high-performance Codex interfaces without +duplicating thread-list, transcript, selection, cache, and action mapping logic +between AppKit and SwiftUI. + +Status: presentation foundation complete. The package now has +`ASBPresentation`, `ASBAppKit`, and `ASBSwiftUI` targets. `ASBPresentation` +ships framework-neutral contracts for thread sidebars, turn timelines, recent +activity, agenda panels, dashboard panels, viewport state, selection state, and +typed UI intents. Renderer implementation can now start in `ASBAppKit` and +`ASBSwiftUI`. + +## Decision + +Use a hybrid of the shared-presentation and AppKit-first options: + +- `SwiftASB` remains the runtime, protocol, history, diagnostics, and observable + companion package. +- `ASBPresentation` becomes the framework-neutral presentation target. +- `ASBAppKit` becomes the high-performance macOS component target. +- `ASBSwiftUI` becomes the ergonomic SwiftUI target, with native SwiftUI views + for light surfaces and AppKit-backed wrappers for dense surfaces. + +This is a durable building-block change. It creates one shared presentation +contract for both AppKit and SwiftUI while still allowing the dense renderers to +use AppKit data sources, reuse, selection, and scrolling behavior. + +## Practical Failure Mode + +The failure mode to avoid is letting AppKit and SwiftUI each invent their own +thread-list model, turn-list model, selection state, cache visibility inputs, +and action mapping. That would make the first components quick but would leave +two subtly different UI data paths to maintain. + +The preferred data path is: + +```text +SwiftASB runtime and companions + -> ASBPresentation snapshots and intents + -> ASBAppKit data sources and views or ASBSwiftUI views and wrappers + -> ASBPresentation intents + -> SwiftASB runtime actions +``` + +`ASBPresentation` must not become a hidden UI framework. It should not import +AppKit or SwiftUI. Its job is to own value snapshots, list identity, selection +inputs, viewport hints, and typed intents. + +## Target Responsibilities + +### SwiftASB + +`SwiftASB` keeps the current public runtime model: + +- `CodexAppServer` +- `CodexThread` +- `CodexTurnHandle` +- local history and reconciliation +- public query descriptors +- observable companions such as `Library`, `Agenda`, `Dashboard`, + `RecentTurns`, `RecentFiles`, `RecentCommands`, and `Minimap` +- feature policy and operation events + +Existing observable companions should not be moved wholesale into +`ASBPresentation`. They are useful public state owners and remain the primary +runtime-facing objects. + +### ASBPresentation + +`ASBPresentation` owns framework-neutral projections over SwiftASB state. It +adapts current-state companions into stable UI snapshots and maps UI commands +into typed intents. + +Foundation families: + +- `ThreadSidebarSnapshot` +- `ThreadSidebarSection` +- `ThreadSidebarItem` +- `ThreadSelectionState` +- `TurnTimelineSnapshot` +- `TurnTimelineSection` +- `TurnTimelineItem` +- `TurnTimelineViewportState` +- `RecentActivitySnapshot` +- `RecentActivityItem` +- `AgendaSnapshot` +- `DashboardSnapshot` +- focused intent enums for each surface + +The foundation implementation should keep these shapes intentionally small. +Prefer plain value types with stable identifiers over protocols or generic +renderer models. Add broader search, filtering, and decoration payloads only +when a component needs them. The presentation target is complete enough for +renderer work only when the sidebar, timeline, recent-activity, agenda, and +dashboard families all have framework-neutral snapshots, typed intents, and +tests proving stable identity and projection behavior. + +### ASBAppKit + +`ASBAppKit` owns high-density macOS UI. It should depend on `SwiftASB` and +`ASBPresentation`. + +Likely first components: + +- `ASBThreadSidebarView` +- `ASBTurnTimelineView` +- `ASBRecentActivityView` + +The dense views should use AppKit collection, table, or outline data sources as +appropriate. Their data sources consume `ASBPresentation` snapshots and send +`ASBPresentation` intents back to a host-provided handler. AppKit-specific +concepts such as index paths, reuse identifiers, and view-controller lifetimes +must stay inside `ASBAppKit`. + +### ASBSwiftUI + +`ASBSwiftUI` owns SwiftUI ergonomics. It should depend on `SwiftASB`, +`ASBPresentation`, and, for AppKit-backed dense components on macOS, +`ASBAppKit`. + +Likely first components: + +- `ThreadSidebar` +- `TurnTimeline` +- `AgendaPanel` +- `DashboardPanel` + +Use native SwiftUI for light surfaces such as agenda, dashboard, connection +status, empty states, controls, and small inspectors. Use AppKit-backed +representable wrappers for dense surfaces such as the thread sidebar and turn +timeline when scrolling, selection, reuse, or incremental updates are central to +the component. + +## Ownership Rules + +- SwiftASB owns runtime truth. +- Presentation snapshots are read-only value projections. +- Selection state belongs to the presentation/controller instance unless the + host app explicitly persists it. +- Visibility and scroll-position inputs are UI hints, not runtime state. +- AppKit data sources own view reuse and index-path mechanics only. +- SwiftUI wrappers own SwiftUI environment, binding, and lifecycle adaptation + only. +- User actions cross framework boundaries as typed intents, not closures that + mutate arbitrary runtime state from inside reusable cells. + +## Initial Data Contracts + +### Thread Sidebar + +Inputs: + +- `CodexAppServer.Library` +- grouping and sorting choices from the library configuration or a presentation + configuration +- selected thread id +- optional selected worktree or repository id + +Snapshot outputs: + +- sections for ungrouped, cwd, repository, or worktree grouping +- thread rows with stable ids, title, source badge, archive state, activity + status, project/worktree summary, updated time, and optional Git status +- empty and loading states +- presentation errors that can be shown without exposing raw transport details + +Intents: + +- select thread +- open thread +- archive or unarchive thread +- refresh unarchived threads +- refresh archived threads +- refresh selected worktree Git status + +### Turn Timeline + +Inputs: + +- `CodexThread.RecentTurns` +- active `CodexTurnHandle.Minimap` when a turn is running +- visibility and scroll-position hints from the renderer + +Snapshot outputs: + +- turn sections or rows with stable turn ids +- item rows with stable item ids and display kind +- compact display summaries for commands, files, MCP activity, reasoning, and + messages +- loading and pagination affordances +- slimmed or rehydratable payload state when the recent-turn cache has trimmed + older details + +Intents: + +- load older turns +- load newer turns when supported by the current query shape +- mark visible turn ids +- mark scroll anchor or viewport state +- select item +- rehydrate selected or visible payloads + +### Agenda And Dashboard + +Inputs: + +- `CodexThread.Agenda` +- `CodexThread.Dashboard` + +Snapshot outputs: + +- current goal title and status +- current plan title and step list +- proposed plan text while planning is active +- tool, MCP, hook, compaction, and auto-review status summaries + +Intents: + +- start a planning turn +- set, pause, resume, or clear a goal +- answer approval or elicitation requests through the owning thread or turn + +## Implementation Slices + +### Slice 1: Planning And Package Boundary + +- Add this maintainer plan. +- Update the roadmap backlog item from a basic SwiftUI component library to the + hybrid presentation/UI target plan. +- Do not add targets yet. + +### Slice 2: ASBPresentation Skeleton + +- Add the `ASBPresentation` target and tests. +- Add the first value snapshot types for thread sidebar state. +- Add a small projection from `CodexAppServer.Library` into + `ThreadSidebarSnapshot`. +- Add tests that prove stable identity, grouping, and selection behavior. + +Status: complete. This slice landed in `ThreadSidebarPresentation.swift`. + +### Slice 3: ASBPresentation Foundation Completion + +Complete `ASBPresentation` before renderer work starts. This slice is the +foundation gate for `ASBAppKit` and `ASBSwiftUI`. + +Add the missing framework-neutral surface families: + +- `TurnTimelineSnapshot` +- `TurnTimelineSection` +- `TurnTimelineItem` +- `TurnTimelineViewportState` +- `RecentActivitySnapshot` +- `RecentActivityItem` +- `AgendaSnapshot` +- `AgendaStep` +- `DashboardSnapshot` +- focused intent enums for timeline, agenda, dashboard, and recent-activity + surfaces + +Projection work: + +- Project `CodexThread.RecentTurns` into timeline sections and rows. +- Project `CodexTurnHandle.Minimap` into active-turn timeline and dashboard + summaries where the current public API exposes enough information. +- Project `CodexThread.RecentFiles` and `CodexThread.RecentCommands` into a + shared recent-activity snapshot when a renderer wants one mixed activity rail. +- Project `CodexThread.Agenda` into agenda state. +- Project `CodexThread.Dashboard` into dashboard state. + +Intent work: + +- Keep intents renderer-neutral and runtime-shaped. +- Model pagination, visibility, selection, rehydration, goal, planning, and + approval or elicitation actions without AppKit index paths, SwiftUI bindings, + or arbitrary cell closures. +- Keep action execution outside `ASBPresentation`; hosts or renderer adapters + translate intents back into SwiftASB owner calls. + +Test work: + +- Prove stable identity for sections, turns, items, commands, files, plan + steps, and status summaries. +- Prove viewport and selection values remain renderer-neutral. +- Prove projection feasibility from existing SwiftASB companions. If a + companion cannot be constructed directly in tests, cover pure presentation + values and rely on `swift build` to validate projection initializers. +- Prove `ASBPresentation` does not import AppKit or SwiftUI. + +Completion gate: + +- `ASBPresentation` contains no placeholder scaffold files. +- Every foundation family has public value types, focused intents where useful, + and tests. +- `swift build`, `swift test`, repo-maintenance validation, and whitespace + checks pass. +- This plan and the roadmap accurately describe the landed presentation + foundation before renderer work begins. + +Status: complete. The foundation families landed in: + +- `ThreadSidebarPresentation.swift` +- `TurnTimelinePresentation.swift` +- `RecentActivityPresentation.swift` +- `AgendaDashboardPresentation.swift` + +`CodexThread.RecentFiles.FileSnapshot` and +`CodexThread.RecentCommands.CommandSnapshot` expose public read-only order +hints so `ASBPresentation` can build a mixed recent-activity rail without +guessing chronology. + +### Slice 4: ASBAppKit Sidebar Prototype + +- Add the `ASBAppKit` target and tests where practical. +- Build `ASBThreadSidebarView` around AppKit data-source ownership. +- Keep item reuse, index paths, and AppKit selection inside the target. +- Emit typed presentation intents instead of mutating SwiftASB directly from + view cells. + +### Slice 5: ASBSwiftUI Sidebar Wrapper And Light Panels + +- Add the `ASBSwiftUI` target. +- Add a SwiftUI `ThreadSidebar` wrapper around `ASBThreadSidebarView`. +- Add native SwiftUI `AgendaPanel` and `DashboardPanel` components because those + are light current-state surfaces. +- Document when consumers should choose native SwiftUI components versus + AppKit-backed wrappers. + +### Slice 6: Turn Timeline Renderer + +- Add `ASBTurnTimelineView` in `ASBAppKit`. +- Add a SwiftUI `TurnTimeline` wrapper. +- Wire viewport and visible-item hints back to `CodexThread.RecentTurns` without + exposing AppKit index paths or SwiftUI scroll internals through + `ASBPresentation`. + +## Validation Plan + +For planning-only changes: + +```bash +bash scripts/repo-maintenance/validate-all.sh +git diff --check +``` + +For target additions: + +```bash +swift build +swift test +bash scripts/repo-maintenance/validate-all.sh +git diff --check +``` + +For AppKit components, add focused unit tests around presentation projection +and data-source snapshots first. UI interaction tests can follow once the view +surface has enough stable behavior to justify Xcode-managed test execution. + +## Open Questions + +- Should `ASBPresentation` expose only value snapshots, or should it also own + small `@Observable` presenter objects that bridge SwiftASB companions to + snapshots? +- Should `ASBSwiftUI` depend on `ASBAppKit` directly, or should AppKit-backed + wrappers be isolated behind a macOS-only product if future non-macOS SwiftUI + reuse becomes important? +- Should the first sidebar target use collection view, outline view, or table + view? The likely answer is collection view for custom rows and modern + layouts, but the first prototype should validate keyboard navigation, + selection, row reuse, and grouped-section rendering. +- How much styling belongs in reusable components versus examples? The default + should be enough visual structure to be useful, with host apps retaining + theme and layout ownership.