diff --git a/CLI/cmux.swift b/CLI/cmux.swift index eefb3a698918..89b910a1ca42 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,6 @@ import Foundation import CMUXAgentLaunch +import CMUXWorkstream import CoreFoundation import CryptoKit import Darwin @@ -19336,7 +19337,15 @@ struct CMUXCLI { "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "terminationReason", "title", "summary", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", - "additional_details", "additionalDetails", "description", + "additional_details", "additionalDetails", "description", "model", "subagent_model", "subagentModel", + "parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream", + "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", + "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", + "parent_source", "parentSource", "parent_agent_source", "parentAgentSource", + "child_source", "childSource", "subagent_source", "subagentSource", + "subagent_type", "subagentType", "agent_type", "agentType", + "task_description", "taskDescription", "name", ] { if let value = compactClaudeHookValue(object[key], key: key) { compact[key] = value @@ -19345,7 +19354,18 @@ struct CMUXCLI { if let toolInput = object["tool_input"] as? [String: Any] { var compactToolInput: [String: Any] = [:] - for key in ["file_path", "command", "pattern", "description", "query", "plan", "planFilePath"] { + for key in [ + "file_path", "command", "pattern", "description", "query", "plan", "planFilePath", + "prompt", "message", "model", "subagent_model", "subagentModel", + "parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream", + "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", + "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", + "parent_source", "parentSource", "parent_agent_source", "parentAgentSource", + "child_source", "childSource", "subagent_source", "subagentSource", + "subagent_type", "subagentType", "agent_type", "agentType", + "task_description", "taskDescription", "title", "name", + ] { if let value = compactClaudeHookToolInputValue(toolInput[key], key: key) { compactToolInput[key] = value } @@ -19412,7 +19432,7 @@ struct CMUXCLI { private func claudeHookCompactFieldLimit(for key: String) -> Int { switch key { - case "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source": + case "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "model", "subagent_model", "subagentModel", "subagent_type", "subagentType", "agent_type", "agentType": return 80 case "transcript_path", "transcriptPath": return 240 @@ -25211,6 +25231,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) { event["context"] = context } + copyAgentGraphMetadata(to: &event, rawObject: parsedInput.object, toolName: toolName) enrichUserPromptSubmitFeedEvent( &event, hookEventName: hookEventName, @@ -25302,6 +25323,56 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } + private func copyAgentGraphMetadata( + to event: inout [String: Any], + rawObject: [String: Any]?, + toolName: String? + ) { + guard let rawObject else { return } + let graphKeys = [ + "parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream", + "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "subagent_workstream_id", "subagentWorkstreamId", + "child_session_id", "childSessionId", "childSessionID", "subagent_session_id", "subagentSessionId", + "parent_source", "parentSource", "parent_agent_source", "parentAgentSource", + "child_source", "childSource", "subagent_source", "subagentSource", + "subagent_type", "subagentType", "agent_type", "agentType", + "model", "subagent_model", "subagentModel", + "task_description", "taskDescription", + ] + let genericKeys = ["description", "title", "name"] + + func copyValue(_ raw: Any?, key: String) { + guard event[key] == nil else { return } + if let value = raw as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + event[key] = trimmed + } + } else if let value = raw as? NSNumber { + event[key] = value.stringValue + } + } + + for key in graphKeys { + copyValue(rawObject[key], key: key) + } + + let isSpawnTool = toolName.map(WorkstreamAgentSpawnTool.isSpawnToolName) ?? false + if isSpawnTool { + for key in genericKeys { + copyValue(rawObject[key], key: key) + } + } + + guard isSpawnTool, + let toolInput = feedToolInputDictionary(rawObject["tool_input"]) + else { return } + for key in graphKeys + genericKeys + ["prompt", "message"] { + copyValue(toolInput[key], key: key) + } + } + private func feedPromptText(from object: [String: Any]?) -> String? { guard let object else { return nil } if let direct = firstString(in: object, keys: ["prompt", "text", "message", "body"]) { @@ -27159,6 +27230,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) { eventDict["context"] = context } + copyAgentGraphMetadata(to: &eventDict, rawObject: stdinObj, toolName: toolName) enrichUserPromptSubmitFeedEvent( &eventDict, hookEventName: hookEventName, diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift new file mode 100644 index 000000000000..7d6cc35ff13d --- /dev/null +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -0,0 +1,777 @@ +import Foundation + +public nonisolated enum WorkstreamAgentNodeKind: String, Codable, Sendable, Equatable { + case session + case spawnRequest +} + +public nonisolated enum WorkstreamAgentNodeStatus: String, Codable, Sendable, Equatable { + case running + case waiting + case idle + case done + case unknown +} + +public nonisolated struct WorkstreamAgentTreeNode: Identifiable, Codable, Sendable, Equatable { + public let id: String + public let kind: WorkstreamAgentNodeKind + public let workstreamId: String? + public let focusWorkstreamId: String? + public let source: WorkstreamSource? + public let workspaceId: String? + public let title: String + public let model: String? + public let subagentType: String? + public let status: WorkstreamAgentNodeStatus + public let taskDescription: String? + public let childCount: Int + public let children: [WorkstreamAgentTreeNode] + + public init( + id: String, + kind: WorkstreamAgentNodeKind, + workstreamId: String?, + focusWorkstreamId: String?, + source: WorkstreamSource?, + workspaceId: String?, + title: String, + model: String?, + subagentType: String?, + status: WorkstreamAgentNodeStatus, + taskDescription: String?, + childCount: Int, + children: [WorkstreamAgentTreeNode] + ) { + self.id = id + self.kind = kind + self.workstreamId = workstreamId + self.focusWorkstreamId = focusWorkstreamId + self.source = source + self.workspaceId = workspaceId + self.title = title + self.model = model + self.subagentType = subagentType + self.status = status + self.taskDescription = taskDescription + self.childCount = childCount + self.children = children + } +} + +public nonisolated struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equatable { + public let roots: [WorkstreamAgentTreeNode] + public let nodeCount: Int + public let edgeCount: Int + public let maxDepth: Int + + public var isEmpty: Bool { roots.isEmpty } + + public static let empty = WorkstreamAgentGraphSnapshot( + roots: [], + nodeCount: 0, + edgeCount: 0, + maxDepth: 0 + ) + + public init( + roots: [WorkstreamAgentTreeNode], + nodeCount: Int, + edgeCount: Int, + maxDepth: Int + ) { + self.roots = roots + self.nodeCount = nodeCount + self.edgeCount = edgeCount + self.maxDepth = maxDepth + } +} + +public nonisolated enum WorkstreamAgentGraphBuilder { + private static let sourcesByDescendingPrefixLength = WorkstreamSource.allCases + .sorted(by: { $0.rawValue.count > $1.rawValue.count }) + + public static func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot { + func shouldCancel() -> Bool { + Task.isCancelled + } + + var records: [String: SessionRecord] = [:] + var creationOrder: [String] = [] + var pendingSpawnsByParent: [String: [SpawnRecord]] = [:] + + func ensureRecord( + workstreamId: String, + source: WorkstreamSource, + createdAt: Date, + workspaceId: String? = nil + ) { + if records[workstreamId] == nil { + records[workstreamId] = SessionRecord( + workstreamId: workstreamId, + source: source, + workspaceId: workspaceId, + createdAt: createdAt, + updatedAt: createdAt + ) + creationOrder.append(workstreamId) + } else if let workspaceId, records[workstreamId]?.workspaceId == nil { + var record = records[workstreamId] + record?.workspaceId = workspaceId + records[workstreamId] = record + } + } + + func updateRecord(_ workstreamId: String, _ body: (inout SessionRecord) -> Void) { + guard var record = records[workstreamId] else { return } + body(&record) + records[workstreamId] = record + } + + func linkChildSession( + childWorkstreamId: String, + parentWorkstreamId: String, + metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) { + updateRecord(childWorkstreamId) { record in + record.parentWorkstreamId = parentWorkstreamId + record.mergeChild(metadata: metadata, scope: metadataScope) + } + pruneResolvedSpawn( + parentWorkstreamId: parentWorkstreamId, + metadata: metadata, + metadataScope: metadataScope, + childSource: childSource, + childWorkspaceId: childWorkspaceId + ) + } + + func sourceFromWorkstreamId(_ workstreamId: String) -> WorkstreamSource? { + for source in Self.sourcesByDescendingPrefixLength { + if workstreamId.hasPrefix("\(source.rawValue)-") { + return source + } + } + return nil + } + + for item in items.sorted(by: { $0.createdAt < $1.createdAt }) { + guard !shouldCancel() else { return .empty } + ensureRecord( + workstreamId: item.workstreamId, + source: item.source, + createdAt: item.createdAt, + workspaceId: item.workspaceId + ) + let metadata = AgentGraphMetadata(item: item) + updateRecord(item.workstreamId) { record in + record.absorb(item, metadata: metadata) + } + + if let parentWorkstreamId = metadata.parentWorkstreamId(source: item.source) { + linkChildSession( + childWorkstreamId: item.workstreamId, + parentWorkstreamId: parentWorkstreamId, + metadata: metadata, + metadataScope: .childSession, + childSource: item.source, + childWorkspaceId: item.workspaceId + ) + } + + if let childWorkstreamId = metadata.childWorkstreamId(source: item.source) { + let childSource = metadata.childSource + ?? sourceFromWorkstreamId(childWorkstreamId) + ?? item.source + ensureRecord( + workstreamId: childWorkstreamId, + source: childSource, + createdAt: item.createdAt, + workspaceId: nil + ) + linkChildSession( + childWorkstreamId: childWorkstreamId, + parentWorkstreamId: item.workstreamId, + metadata: metadata, + metadataScope: .parentDeclaration, + childSource: childSource, + childWorkspaceId: nil + ) + } else if let spawn = SpawnRecord(item: item, metadata: metadata), + !hasResolvedChild(parentWorkstreamId: item.workstreamId, spawn: spawn) { + pendingSpawnsByParent[item.workstreamId, default: []].append(spawn) + } + } + + func pruneResolvedSpawn( + parentWorkstreamId: String, + metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) { + guard var spawns = pendingSpawnsByParent[parentWorkstreamId], + !spawns.isEmpty else { return } + guard let index = bestResolvedSpawnIndex( + in: spawns, + metadata: metadata, + metadataScope: metadataScope, + childSource: childSource, + childWorkspaceId: childWorkspaceId + ) else { return } + spawns.remove(at: index) + if spawns.isEmpty { + pendingSpawnsByParent[parentWorkstreamId] = nil + } else { + pendingSpawnsByParent[parentWorkstreamId] = spawns + } + } + + func bestResolvedSpawnIndex( + in spawns: [SpawnRecord], + metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) -> Int? { + var bestIndex: Int? + var bestScore = 0 + var hasBestScoreTie = false + for (index, spawn) in spawns.enumerated() { + guard !shouldCancel() else { return nil } + var score = 0 + if spawn.source == childSource { + score += SpawnResolutionScore.linkedChildSource + } + if let childWorkspaceId, + let workspaceId = spawn.workspaceId, + childWorkspaceId == workspaceId { + score += SpawnResolutionScore.linkedChildWorkspace + } + if let subagentType = metadata.childSubagentType(scope: metadataScope), + let spawnSubagentType = spawn.subagentType, + subagentType == spawnSubagentType { + score += SpawnResolutionScore.subagentType + } + if let model = metadata.childModel(scope: metadataScope), + let spawnModel = spawn.model, + model == spawnModel { + score += SpawnResolutionScore.model + } + if let taskDescription = metadata.childTaskDescription(scope: metadataScope), + let spawnTaskDescription = spawn.taskDescription, + taskDescription == spawnTaskDescription { + score += SpawnResolutionScore.taskDescription + } + guard score > 0 else { continue } + if score > bestScore { + bestIndex = index + bestScore = score + hasBestScoreTie = false + } else if score == bestScore { + hasBestScoreTie = true + } + } + return hasBestScoreTie ? nil : bestIndex + } + + func hasResolvedChild( + parentWorkstreamId: String, + spawn: SpawnRecord + ) -> Bool { + var bestWorkstreamId: String? + var bestScore = 0 + var hasBestScoreTie = false + for record in records.values where record.parentWorkstreamId == parentWorkstreamId { + guard !shouldCancel() else { return false } + let score = spawn.resolutionScore(matching: record) + guard score > 0 else { continue } + if score > bestScore { + bestWorkstreamId = record.workstreamId + bestScore = score + hasBestScoreTie = false + } else if score == bestScore { + hasBestScoreTie = true + } + } + return bestWorkstreamId != nil && !hasBestScoreTie + } + + var childrenByParent: [String: [String]] = [:] + for record in records.values { + guard !shouldCancel() else { return .empty } + guard let parent = record.parentWorkstreamId, + records[parent] != nil, + parent != record.workstreamId + else { continue } + childrenByParent[parent, default: []].append(record.workstreamId) + } + + for parent in childrenByParent.keys { + childrenByParent[parent]?.sort { lhs, rhs in + let l = records[lhs]?.updatedAt ?? .distantPast + let r = records[rhs]?.updatedAt ?? .distantPast + return l > r + } + } + + let childIds = Set(childrenByParent.values.flatMap { $0 }) + let roots = creationOrder + .filter { !childIds.contains($0) } + .sorted { + let lhs = records[$0]?.updatedAt ?? .distantPast + let rhs = records[$1]?.updatedAt ?? .distantPast + return lhs > rhs + } + + var visited: Set = [] + var nodeCount = 0 + var edgeCount = 0 + var maxDepth = 0 + + func makeNode(_ workstreamId: String, depth: Int) -> WorkstreamAgentTreeNode? { + guard !shouldCancel() else { return nil } + guard let record = records[workstreamId], visited.insert(workstreamId).inserted else { + return nil + } + + var children: [WorkstreamAgentTreeNode] = [] + for childId in childrenByParent[workstreamId] ?? [] { + guard !shouldCancel() else { return nil } + if let child = makeNode(childId, depth: depth + 1) { + children.append(child) + edgeCount += 1 + } + } + + for spawn in pendingSpawnsByParent[workstreamId] ?? [] { + children.append(spawn.node(parent: record)) + nodeCount += 1 + edgeCount += 1 + maxDepth = max(maxDepth, depth + 1) + } + + nodeCount += 1 + maxDepth = max(maxDepth, depth) + return record.node(children: children) + } + + var treeRoots = roots.compactMap { makeNode($0, depth: 0) } + for workstreamId in creationOrder where !visited.contains(workstreamId) { + guard !shouldCancel() else { return .empty } + if let fallbackRoot = makeNode(workstreamId, depth: 0) { + treeRoots.append(fallbackRoot) + } + } + + return WorkstreamAgentGraphSnapshot( + roots: treeRoots, + nodeCount: nodeCount, + edgeCount: edgeCount, + maxDepth: maxDepth + ) + } +} + +private enum SpawnResolutionScore { + static let linkedChildSource = 2 + static let linkedChildWorkspace = 2 + static let existingChildSource = 1 + static let existingChildWorkspace = 1 + static let subagentType = 4 + static let model = 3 + static let taskDescription = 4 +} + +private struct SessionRecord { + let workstreamId: String + let source: WorkstreamSource + var workspaceId: String? + var title: String? + var model: String? + var subagentType: String? + var taskDescription: String? + var parentWorkstreamId: String? + var status: WorkstreamAgentNodeStatus = .unknown + let createdAt: Date + var updatedAt: Date + + mutating func absorb(_ item: WorkstreamItem, metadata: AgentGraphMetadata? = nil) { + updatedAt = max(updatedAt, item.updatedAt) + if workspaceId == nil { + workspaceId = item.workspaceId + } + if let cwd = item.cwd, title == nil { + title = Self.basename(cwd) + } + + let resolvedMetadata = metadata ?? AgentGraphMetadata(item: item) + mergeSession(metadata: resolvedMetadata) + + switch item.status { + case .pending: + status = .waiting + case .resolved, .expired, .telemetry: + updateStatus(from: item.payload) + } + + switch item.payload { + case .userPrompt(let text): + mergeTaskDescription(text) + default: + break + } + + if let context = item.context { + mergeTaskDescription(context.lastUserMessage) + } + } + + mutating func mergeSession(metadata: AgentGraphMetadata) { + if let model = metadata.sessionModel, !model.isEmpty { + self.model = model + } + if let subagentType = metadata.sessionSubagentType, !subagentType.isEmpty { + self.subagentType = subagentType + } + mergeTaskDescription(metadata.sessionTaskDescription) + if let description = metadata.sessionDescription, !description.isEmpty, title == nil { + title = description + } + } + + mutating func mergeChild(metadata: AgentGraphMetadata, scope: AgentGraphChildMetadataScope) { + if let model = metadata.childModel(scope: scope), !model.isEmpty { + self.model = model + } + if let subagentType = metadata.childSubagentType(scope: scope), !subagentType.isEmpty { + self.subagentType = subagentType + } + mergeTaskDescription(metadata.childTaskDescription(scope: scope)) + if let description = metadata.childDescription(scope: scope), !description.isEmpty, title == nil { + title = description + } + } + + func node(children: [WorkstreamAgentTreeNode]) -> WorkstreamAgentTreeNode { + WorkstreamAgentTreeNode( + id: "session:\(workstreamId)", + kind: .session, + workstreamId: workstreamId, + focusWorkstreamId: workstreamId, + source: source, + workspaceId: workspaceId, + title: title ?? source.rawValue, + model: model, + subagentType: subagentType, + status: status, + taskDescription: taskDescription, + childCount: children.count, + children: children + ) + } + + private mutating func mergeTaskDescription(_ value: String?) { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return } + taskDescription = value + } + + private mutating func updateStatus(from payload: WorkstreamPayload) { + switch payload { + case .sessionEnd: + status = .done + case .stop: + if status != .done { + status = .idle + } + case .sessionStart, .userPrompt, .toolUse, .toolResult, .todos, .assistantMessage: + if status != .waiting && status != .done { + status = .running + } + case .permissionRequest, .exitPlan, .question: + if status != .waiting && status != .done { + status = .running + } + } + } + + private static func basename(_ path: String) -> String { + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path + let name = (trimmed as NSString).lastPathComponent + return name.isEmpty ? path : name + } + + static func isSpawnTool(_ toolName: String) -> Bool { + WorkstreamAgentSpawnTool.isSpawnToolName(toolName) + } +} + +private struct SpawnRecord { + let id: String + let source: WorkstreamSource + let workspaceId: String? + let title: String + let model: String? + let subagentType: String? + let taskDescription: String? + let createdAt: Date + + init?(item: WorkstreamItem, metadata: AgentGraphMetadata) { + guard case .toolUse(let toolName, _) = item.payload, + SessionRecord.isSpawnTool(toolName) + else { return nil } + self.id = "spawn:\(item.id.uuidString)" + self.source = item.source + self.workspaceId = item.workspaceId + self.title = metadata.childDescription(scope: .parentDeclaration) + ?? metadata.childSubagentType(scope: .parentDeclaration) + ?? String(toolName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) + self.model = metadata.childModel(scope: .parentDeclaration) + self.subagentType = metadata.childSubagentType(scope: .parentDeclaration) + self.taskDescription = metadata.childTaskDescription(scope: .parentDeclaration) + self.createdAt = item.createdAt + } + + func node(parent: SessionRecord) -> WorkstreamAgentTreeNode { + WorkstreamAgentTreeNode( + id: id, + kind: .spawnRequest, + workstreamId: nil, + focusWorkstreamId: parent.workstreamId, + source: source, + workspaceId: workspaceId ?? parent.workspaceId, + title: title, + model: model, + subagentType: subagentType, + status: .waiting, + taskDescription: taskDescription, + childCount: 0, + children: [] + ) + } + + func resolutionScore(matching record: SessionRecord) -> Int { + var score = 0 + if source == record.source { + score += SpawnResolutionScore.existingChildSource + } + if let workspaceId, + let recordWorkspaceId = record.workspaceId, + workspaceId == recordWorkspaceId { + score += SpawnResolutionScore.existingChildWorkspace + } + if let subagentType, + let recordSubagentType = record.subagentType, + subagentType == recordSubagentType { + score += SpawnResolutionScore.subagentType + } + if let model, + let recordModel = record.model, + model == recordModel { + score += SpawnResolutionScore.model + } + if let taskDescription, + let recordTaskDescription = record.taskDescription, + taskDescription == recordTaskDescription { + score += SpawnResolutionScore.taskDescription + } + return score + } +} + +private struct AgentGraphMetadata { + let source: WorkstreamSource + let extra: [String: Any] + let toolInput: [String: Any] + let isSpawnTool: Bool + + init(item: WorkstreamItem) { + let toolName: String? + let toolInputJSON: String? + if case .toolUse(let name, let json) = item.payload { + toolName = name + toolInputJSON = json + } else if case .permissionRequest(_, let name, let json, _) = item.payload { + toolName = name + toolInputJSON = json + } else { + toolName = nil + toolInputJSON = nil + } + self.init( + source: item.source, + extraFieldsJSON: item.extraFieldsJSON, + toolName: toolName, + toolInputJSON: toolInputJSON + ) + } + + init( + source: WorkstreamSource, + extraFieldsJSON: String?, + toolName: String?, + toolInputJSON: String? + ) { + self.source = source + self.extra = Self.dictionary(from: extraFieldsJSON) + let parsedToolInput = Self.dictionary(from: toolInputJSON) + self.isSpawnTool = toolName.map(SessionRecord.isSpawnTool) ?? false + if isSpawnTool { + self.toolInput = parsedToolInput + } else { + self.toolInput = [:] + } + } + + var childSource: WorkstreamSource? { + string(keys: ["child_source", "childSource", "subagent_source", "subagentSource"]) + .flatMap(WorkstreamSource.init(wireName:)) + } + + var parentSource: WorkstreamSource? { + string(keys: ["parent_source", "parentSource", "parent_agent_source", "parentAgentSource"]) + .flatMap(WorkstreamSource.init(wireName:)) + } + + var sessionModel: String? { + if isSpawnTool { + return extraString(keys: ["model"]) + } + return extraString(keys: ["model", "subagent_model", "subagentModel"]) + } + + var sessionSubagentType: String? { + guard !isSpawnTool else { return nil } + return extraString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + } + + var sessionDescription: String? { + guard !isSpawnTool else { return nil } + return extraString(keys: ["description", "title", "name"]) + } + + var sessionTaskDescription: String? { + guard !isSpawnTool else { return nil } + return extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) + } + + func childModel(scope: AgentGraphChildMetadataScope) -> String? { + switch scope { + case .childSession: + return extraString(keys: ["model", "subagent_model", "subagentModel"]) + case .parentDeclaration: + return toolInputString(keys: ["subagent_model", "subagentModel", "model"]) + ?? extraString(keys: ["subagent_model", "subagentModel"]) + } + } + + func childSubagentType(scope: AgentGraphChildMetadataScope) -> String? { + switch scope { + case .childSession: + return extraString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + case .parentDeclaration: + return toolInputString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + ?? extraString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + } + } + + func childDescription(scope: AgentGraphChildMetadataScope) -> String? { + switch scope { + case .childSession: + return extraString(keys: ["description", "title", "name"]) + case .parentDeclaration: + return toolInputString(keys: ["description", "title", "name"]) + ?? extraString(keys: ["description", "title", "name"]) + } + } + + func childTaskDescription(scope: AgentGraphChildMetadataScope) -> String? { + switch scope { + case .childSession: + return extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) + case .parentDeclaration: + return toolInputString(keys: ["task_description", "taskDescription", "prompt", "message"]) + ?? extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) + } + } + + func parentWorkstreamId(source: WorkstreamSource) -> String? { + if let value = string(keys: ["parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream"]) { + return value + } + if let sessionId = string(keys: ["parent_session_id", "parentSessionId", "parentSessionID"]) { + return "\((parentSource ?? source).rawValue)-\(sessionId)" + } + return nil + } + + func childWorkstreamId(source: WorkstreamSource) -> String? { + if let value = string(keys: ["child_workstream_id", "childWorkstreamId", "subagent_workstream_id", "subagentWorkstreamId"]) { + return value + } + if let sessionId = string(keys: ["child_session_id", "childSessionId", "childSessionID", "subagent_session_id", "subagentSessionId"]) { + return "\((childSource ?? source).rawValue)-\(sessionId)" + } + return nil + } + + private func string(keys: [String]) -> String? { + for key in keys { + if let value = normalizedString(extra[key]) { + return value + } + if let value = normalizedString(toolInput[key]) { + return value + } + } + return nil + } + + private func extraString(keys: [String]) -> String? { + for key in keys { + if let value = normalizedString(extra[key]) { + return value + } + } + return nil + } + + private func toolInputString(keys: [String]) -> String? { + for key in keys { + if let value = normalizedString(toolInput[key]) { + return value + } + } + return nil + } + + private static func dictionary(from json: String?) -> [String: Any] { + guard let json, + let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let dict = object as? [String: Any] + else { return [:] } + return dict + } + + private func normalizedString(_ value: Any?) -> String? { + guard let value else { return nil } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if let number = value as? NSNumber { + return number.stringValue + } + return nil + } +} + +private enum AgentGraphChildMetadataScope { + case childSession + case parentDeclaration +} diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentSpawnTool.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentSpawnTool.swift new file mode 100644 index 000000000000..89a232006e9d --- /dev/null +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentSpawnTool.swift @@ -0,0 +1,8 @@ +import Foundation + +public nonisolated enum WorkstreamAgentSpawnTool { + public static func isSpawnToolName(_ toolName: String) -> Bool { + let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "task" || normalized == "agent" + } +} diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift index 09706d73c60e..450de6b38e72 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamItem.swift @@ -38,11 +38,16 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { public let kind: WorkstreamKind public let createdAt: Date public var updatedAt: Date + public var workspaceId: String? public var cwd: String? public var title: String? public var status: WorkstreamStatus public var payload: WorkstreamPayload public var context: WorkstreamContext? + /// Unknown hook fields preserved from the inbound event. Agent tree + /// derivation reads explicit parent/child metadata from this JSON when + /// an agent emits it, while older events simply leave it nil. + public var extraFieldsJSON: String? /// PID of the agent process that emitted the event (hook's parent /// pid). When non-nil, pending items get expired automatically as /// soon as the agent process is gone — a crashed/killed `claude` @@ -57,11 +62,13 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { kind: WorkstreamKind, createdAt: Date = Date(), updatedAt: Date? = nil, + workspaceId: String? = nil, cwd: String? = nil, title: String? = nil, status: WorkstreamStatus? = nil, payload: WorkstreamPayload, context: WorkstreamContext? = nil, + extraFieldsJSON: String? = nil, ppid: Int? = nil ) { self.id = id @@ -70,12 +77,14 @@ public struct WorkstreamItem: Identifiable, Codable, Sendable, Equatable { self.kind = kind self.createdAt = createdAt self.updatedAt = updatedAt ?? createdAt + self.workspaceId = workspaceId?.isEmpty == false ? workspaceId : nil self.cwd = cwd self.title = title let resolvedStatus = status ?? (kind.isActionable ? .pending : .telemetry) self.status = kind.isActionable ? resolvedStatus : .telemetry self.payload = payload self.context = context?.isEmpty == true ? nil : context + self.extraFieldsJSON = extraFieldsJSON?.isEmpty == false ? extraFieldsJSON : nil self.ppid = ppid } } diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift index a8e500607ac2..956cb7b1ed9d 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift @@ -204,15 +204,56 @@ public final class WorkstreamStore { kind: kind, createdAt: event.receivedAt, updatedAt: event.receivedAt, + workspaceId: event.workspaceId, cwd: event.cwd, title: defaultTitle(for: event), status: status, payload: payload, context: context(for: event, payload: payload), + extraFieldsJSON: Self.sanitizedExtraFieldsJSON(event.extraFieldsJSON), ppid: event.ppid ) } + private static let allowedExtraFieldKeys: Set = [ + "parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream", + "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "subagent_workstream_id", "subagentWorkstreamId", + "child_session_id", "childSessionId", "childSessionID", "subagent_session_id", "subagentSessionId", + "parent_source", "parentSource", "parent_agent_source", "parentAgentSource", + "child_source", "childSource", "subagent_source", "subagentSource", + "subagent_type", "subagentType", "agent_type", "agentType", + "model", "subagent_model", "subagentModel", + "task_description", "taskDescription", "prompt", "message", "description", "title", "name", + ] + + private static func sanitizedExtraFieldsJSON(_ rawJSON: String?) -> String? { + guard let rawJSON, + let data = rawJSON.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let extraFields = object as? [String: Any] + else { return nil } + + var sanitized: [String: Any] = [:] + for key in allowedExtraFieldKeys { + guard let value = extraFields[key] else { continue } + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + sanitized[key] = trimmed + } + } else if let number = value as? NSNumber { + sanitized[key] = number + } + } + + guard !sanitized.isEmpty, + JSONSerialization.isValidJSONObject(sanitized), + let encoded = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]) + else { return nil } + return String(data: encoded, encoding: .utf8) + } + /// Marks every pending item with `ppid` as `.expired`. Meant to /// be called from a kqueue/DispatchSource process-exit handler /// so the exact moment an agent dies, its pending cards close. diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift new file mode 100644 index 000000000000..a2b07bffa9e1 --- /dev/null +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -0,0 +1,471 @@ +import Foundation +import Testing +@testable import CMUXWorkstream + +@MainActor +@Suite("WorkstreamAgentGraph") +struct WorkstreamAgentGraphTests { + @Test("Task tool telemetry appears as a child spawn request") + func taskToolCreatesSpawnRequestNode() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + cwd: "/tmp/project" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","subagent_model":"child-sonnet","prompt":"Map settings code paths"}"#, + extraFieldsJSON: #"{"model":"parent-opus"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + #expect(graph.roots.count == 1) + let parent = graph.roots.first + #expect(parent?.model == "parent-opus") + #expect(parent?.subagentType == nil) + #expect(parent?.taskDescription == nil) + let child = graph.roots.first?.children.first + #expect(child?.kind == .spawnRequest) + #expect(child?.subagentType == "explorer") + #expect(child?.model == "child-sonnet") + #expect(child?.taskDescription == "Map settings code paths") + #expect(child?.focusWorkstreamId == "claude-parent") + } + + @Test("Spawn request without a child model does not inherit parent model") + func spawnRequestWithoutChildModelDoesNotInheritParentModel() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"#, + extraFieldsJSON: #"{"model":"parent-opus"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + let parent = graph.roots.first + #expect(parent?.model == "parent-opus") + let child = parent?.children.first + #expect(child?.kind == .spawnRequest) + #expect(child?.model == nil) + } + + @Test("Explicit parent metadata links a child session under its parent") + func explicitParentMetadataLinksChildSession() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .userPromptSubmit, + source: "claude", + workspaceId: "workspace-1", + toolInputJSON: #"{"prompt":"coordinate the rollout"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","subagent_type":"planner","model":"sonnet"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + #expect(graph.maxDepth == 1) + #expect(graph.roots.map(\.workstreamId) == ["claude-parent"]) + let child = graph.roots.first?.children.first + #expect(child?.workstreamId == "claude-child") + #expect(child?.subagentType == "planner") + #expect(child?.model == "sonnet") + #expect(child?.focusWorkstreamId == "claude-child") + } + + @Test("Child spawn tool metadata does not overwrite child session metadata") + func childSpawnToolMetadataDoesNotOverwriteChildSessionMetadata() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","subagent_type":"worker","task_description":"Handle parent task"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Spawn nested","subagent_type":"nested","prompt":"Handle nested task"}"#, + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + let child = graph.roots.first?.children.first + #expect(child?.workstreamId == "claude-child") + #expect(child?.subagentType == "worker") + #expect(child?.taskDescription == "Handle parent task") + let nestedSpawn = child?.children.first + #expect(nestedSpawn?.kind == .spawnRequest) + #expect(nestedSpawn?.title == "Spawn nested") + #expect(nestedSpawn?.subagentType == "nested") + #expect(nestedSpawn?.taskDescription == "Handle nested task") + } + + @Test("Sanitized extra prompt metadata supplies graph task description") + func sanitizedExtraPromptMetadataSuppliesGraphTaskDescription() throws { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","prompt":"Map settings code paths","secret":"do not persist"}"# + )) + + let extraData = try #require(store.items.last?.extraFieldsJSON?.data(using: .utf8)) + let extra = try #require( + try JSONSerialization.jsonObject(with: extraData) as? [String: Any] + ) + #expect(extra["prompt"] as? String == "Map settings code paths") + #expect(extra["secret"] == nil) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + let child = graph.roots.first?.children.first + #expect(child?.workstreamId == "claude-child") + #expect(child?.taskDescription == "Map settings code paths") + } + + @Test("Session metadata on non-tool events appears on root nodes") + func sessionMetadataOnNonToolEventsAppearsOnRootNodes() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-root", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"description":"Coordinate rollout","subagent_type":"planner","model":"sonnet"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 1) + let root = graph.roots.first + #expect(root?.workstreamId == "claude-root") + #expect(root?.title == "Coordinate rollout") + #expect(root?.subagentType == "planner") + #expect(root?.model == "sonnet") + #expect(root?.taskDescription == nil) + } + + @Test("Description-only spawn metadata is not duplicated as task text") + func descriptionOnlySpawnMetadataDoesNotDuplicateTaskText() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + let child = graph.roots.first?.children.first + #expect(child?.kind == .spawnRequest) + #expect(child?.title == "Explore settings") + #expect(child?.taskDescription == nil) + } + + @Test("Non-spawn tool input does not create graph metadata") + func nonSpawnToolInputDoesNotCreateGraphMetadata() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Read", + toolInputJSON: #"{"parent_workstream_id":"claude-parent","file_path":"/tmp/notes.json"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 0) + #expect(graph.roots.map(\.workstreamId).sorted() == ["claude-child", "claude-parent"]) + } + + @Test("Explicit child session prunes matching pending spawn") + func explicitChildSessionPrunesMatchingPendingSpawn() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","subagent_type":"explorer"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + let child = graph.roots.first?.children.first + #expect(child?.kind == .session) + #expect(child?.workstreamId == "claude-child") + } + + @Test("Explicit child session before spawn suppresses duplicate pending spawn") + func explicitChildSessionBeforeSpawnSuppressesDuplicatePendingSpawn() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","subagent_type":"explorer"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + let children = graph.roots.first?.children ?? [] + #expect(children.map(\.kind) == [.session]) + #expect(children.map(\.workstreamId) == ["claude-child"]) + } + + @Test("Metadata-light child before spawn suppresses duplicate pending spawn") + func metadataLightChildBeforeSpawnSuppressesDuplicatePendingSpawn() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + let children = graph.roots.first?.children ?? [] + #expect(children.map(\.kind) == [.session]) + #expect(children.map(\.workstreamId) == ["claude-child"]) + } + + @Test("Ambiguous child matches before spawn keep pending spawn") + func ambiguousChildMatchesBeforeSpawnKeepPendingSpawn() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child-type", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","subagent_type":"explorer"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child-task", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent","task_description":"Map settings code paths"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 4) + #expect(graph.edgeCount == 3) + let children = graph.roots.first?.children ?? [] + #expect(children.filter { $0.kind == .session }.count == 2) + #expect(children.filter { $0.kind == .spawnRequest }.count == 1) + } + + @Test("Parent child metadata prunes matching pending spawn") + func parentChildMetadataPrunesMatchingPendingSpawn() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"child_workstream_id":"claude-child","subagent_type":"explorer"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + let child = graph.roots.first?.children.first + #expect(child?.kind == .session) + #expect(child?.workstreamId == "claude-child") + } + + @Test("Parent-declared child link does not copy parent model to child") + func parentDeclaredChildLinkDoesNotCopyParentModelToChild() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"child_workstream_id":"claude-child","model":"parent-opus"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + let parent = graph.roots.first + #expect(parent?.model == "parent-opus") + let child = parent?.children.first + #expect(child?.kind == .session) + #expect(child?.workstreamId == "claude-child") + #expect(child?.model == nil) + } + + @Test("Explicit child without a unique metadata match keeps pending spawns") + func explicitChildWithoutUniqueMetadataMatchKeepsPendingSpawns() { + let store = WorkstreamStore(ringCapacity: 10) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1" + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-parent", + hookEventName: .preToolUse, + source: "claude", + workspaceId: "workspace-1", + toolName: "Task", + toolInputJSON: #"{"description":"Audit theme","subagent_type":"auditor","prompt":"Audit theme code paths"}"# + )) + store.ingest(WorkstreamEvent( + sessionId: "claude-child", + hookEventName: .sessionStart, + source: "claude", + workspaceId: "workspace-1", + extraFieldsJSON: #"{"parent_workstream_id":"claude-parent"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 4) + #expect(graph.edgeCount == 3) + let children = graph.roots.first?.children ?? [] + #expect(children.filter { $0.kind == .session }.map(\.workstreamId) == ["claude-child"]) + #expect(children.filter { $0.kind == .spawnRequest }.count == 2) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index db674105e604..177d1beaa4d8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -114247,6 +114247,275 @@ } } }, + "feed.filter.agentTree": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツリー" + } + } + } + }, + "feed.empty.agentTree.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No agent tree yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントツリーはまだありません" + } + } + } + }, + "feed.empty.agentTree.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Task-spawned subagents will appear here as parent and child activity arrives." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Taskから起動されたサブエージェントは、親子アクティビティの到着に合わせてここに表示されます。" + } + } + } + }, + "feed.agentTree.nodes": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld node" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld nodes" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lldノード" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lldノード" + } + } + } + } + } + } + }, + "feed.agentTree.edges": { + "extractionState": "manual", + "localizations": { + "en": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lld edge" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lld edges" + } + } + } + } + }, + "ja": { + "variations": { + "plural": { + "one": { + "stringUnit": { + "state": "translated", + "value": "%lldエッジ" + } + }, + "other": { + "stringUnit": { + "state": "translated", + "value": "%lldエッジ" + } + } + } + } + } + } + }, + "feed.agentTree.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@ · depth %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@ · 深さ%lld" + } + } + } + }, + "feed.agentTree.expand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand subtree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブツリーを展開" + } + } + } + }, + "feed.agentTree.collapse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse subtree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブツリーを折りたたむ" + } + } + } + }, + "feed.agentTree.status.running": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "running" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + } + } + }, + "feed.agentTree.status.waiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "waiting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + } + } + }, + "feed.agentTree.status.idle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "idle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイドル" + } + } + } + }, + "feed.agentTree.status.done": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "done" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了" + } + } + } + }, + "feed.agentTree.status.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, "feed.history.loadOlder": { "extractionState": "manual", "localizations": { diff --git a/Sources/Feed/FeedAgentTreeController.swift b/Sources/Feed/FeedAgentTreeController.swift new file mode 100644 index 000000000000..f32748d60a2c --- /dev/null +++ b/Sources/Feed/FeedAgentTreeController.swift @@ -0,0 +1,190 @@ +import CMUXWorkstream +import Observation + +@MainActor +@Observable +final class FeedAgentTreeController { + var collapsedNodeIds: Set = [] + private(set) var selectedNodeId: String? + private(set) var scrollRequest: FeedAgentTreeScrollRequest? + @ObservationIgnored private var scrollRequestSequence = 0 + + func toggle(_ nodeId: String) { + if collapsedNodeIds.contains(nodeId) { + collapsedNodeIds.remove(nodeId) + } else { + collapsedNodeIds.insert(nodeId) + } + } + + func visibleSnapshot(from graph: WorkstreamAgentGraphSnapshot) -> FeedAgentTreeVisibleSnapshot { + FeedAgentTreeVisibleSnapshot( + graph: graph, + collapsedNodeIds: collapsedNodeIds + ) + } + + func reconcileSelection(with visibleSnapshot: FeedAgentTreeVisibleSnapshot) { + guard let selectedNodeId, + !visibleSnapshot.focusTargets.contains(where: { $0.nodeId == selectedNodeId }) + else { return } + self.selectedNodeId = nil + } + + func focusFirst( + in targets: [FeedAgentTreeFocusTarget], + focusHost: Bool + ) -> FeedAgentTreeSelectionEffect? { + guard let target = preferredTarget(in: targets) else { return nil } + return select(target, focusFeed: focusHost) + } + + func move( + in targets: [FeedAgentTreeFocusTarget], + delta: Int + ) -> FeedAgentTreeSelectionEffect? { + guard !targets.isEmpty else { return nil } + let nodeIds = targets.map(\.nodeId) + let targetIndex: Int + if let selectedNodeId, + let currentIndex = nodeIds.firstIndex(of: selectedNodeId) { + targetIndex = min(max(currentIndex + delta, 0), nodeIds.count - 1) + } else { + if selectedNodeId != nil { + self.selectedNodeId = nil + } + targetIndex = delta >= 0 ? 0 : nodeIds.count - 1 + } + return select(targets[targetIndex], focusFeed: false) + } + + func activate( + in targets: [FeedAgentTreeFocusTarget] + ) -> FeedAgentTreeSelectionEffect? { + guard let target = activationTarget(in: targets), + let focusWorkstreamId = target.focusWorkstreamId + else { return nil } + var effect = select(target, focusFeed: true) + effect.jumpWorkstreamId = focusWorkstreamId + return effect + } + + func select( + _ node: WorkstreamAgentTreeNode, + focusFeed: Bool + ) -> FeedAgentTreeSelectionEffect { + select(FeedAgentTreeFocusTarget(node: node), focusFeed: focusFeed) + } + + private func select( + _ target: FeedAgentTreeFocusTarget, + focusFeed: Bool + ) -> FeedAgentTreeSelectionEffect { + selectedNodeId = target.nodeId + scrollRequestSequence &+= 1 + let request = FeedAgentTreeScrollRequest( + nodeId: target.nodeId, + sequence: scrollRequestSequence + ) + scrollRequest = request + return FeedAgentTreeSelectionEffect( + nodeId: target.nodeId, + focusHost: focusFeed, + scrollRequest: request, + jumpWorkstreamId: nil + ) + } + + private func preferredTarget( + in targets: [FeedAgentTreeFocusTarget] + ) -> FeedAgentTreeFocusTarget? { + if let selectedNodeId, + let target = targets.first(where: { $0.nodeId == selectedNodeId }) { + return target + } + return targets.first + } + + private func activationTarget( + in targets: [FeedAgentTreeFocusTarget] + ) -> FeedAgentTreeFocusTarget? { + guard let selectedNodeId else { + return targets.first + } + return targets.first { $0.nodeId == selectedNodeId } + } +} + +struct FeedAgentTreeSelectionEffect: Equatable { + let nodeId: String + let focusHost: Bool + let scrollRequest: FeedAgentTreeScrollRequest + var jumpWorkstreamId: String? +} + +struct FeedAgentTreeVisibleSnapshot: Equatable { + let rows: [FeedAgentTreeRow] + let focusTargets: [FeedAgentTreeFocusTarget] + + static let empty = FeedAgentTreeVisibleSnapshot(rows: [], focusTargets: []) + + init(rows: [FeedAgentTreeRow], focusTargets: [FeedAgentTreeFocusTarget]) { + self.rows = rows + self.focusTargets = focusTargets + } + + init( + graph: WorkstreamAgentGraphSnapshot, + collapsedNodeIds: Set + ) { + var rows: [FeedAgentTreeRow] = [] + var focusTargets: [FeedAgentTreeFocusTarget] = [] + for root in graph.roots { + Self.append( + node: root, + depth: 0, + collapsedNodeIds: collapsedNodeIds, + rows: &rows, + focusTargets: &focusTargets + ) + } + self.rows = rows + self.focusTargets = focusTargets + } + + private static func append( + node: WorkstreamAgentTreeNode, + depth: Int, + collapsedNodeIds: Set, + rows: inout [FeedAgentTreeRow], + focusTargets: inout [FeedAgentTreeFocusTarget] + ) { + rows.append(FeedAgentTreeRow(node: node, depth: depth)) + focusTargets.append(FeedAgentTreeFocusTarget(node: node)) + guard !collapsedNodeIds.contains(node.id) else { return } + for child in node.children { + append( + node: child, + depth: depth + 1, + collapsedNodeIds: collapsedNodeIds, + rows: &rows, + focusTargets: &focusTargets + ) + } + } +} + +struct FeedAgentTreeFocusTarget: Equatable { + let nodeId: String + let focusWorkstreamId: String? + + init(node: WorkstreamAgentTreeNode) { + self.nodeId = node.id + self.focusWorkstreamId = node.focusWorkstreamId + } +} + +struct FeedAgentTreeScrollRequest: Equatable { + let nodeId: String + let sequence: Int +} diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift new file mode 100644 index 000000000000..5cbba130d1c6 --- /dev/null +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -0,0 +1,335 @@ +import CMUXWorkstream +import SwiftUI + +struct FeedAgentTreeView: View { + let graph: WorkstreamAgentGraphSnapshot + let rows: [FeedAgentTreeRow] + let actions: FeedRowActions + let collapsedNodeIds: Set + let selectedNodeId: String? + let isKeyboardActive: Bool + let scrollRequest: FeedAgentTreeScrollRequest? + let showsLoadMore: Bool + let isLoadingOlderItems: Bool + let onLoadOlderItems: () -> Void + let onToggle: (String) -> Void + let onSelect: (WorkstreamAgentTreeNode) -> Void + + var body: some View { + VStack(spacing: 0) { + if !graph.isEmpty { + summaryBar + } + treeScrollView + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var treeScrollView: some View { + ScrollViewReader { proxy in + ScrollView(.vertical) { + rowStack + } + .feedZeroScrollContentMargins() + .onChange(of: scrollRequest) { _, request in + guard let request else { return } + proxy.scrollTo(request.nodeId, anchor: .center) + } + } + } + + private var rowStack: some View { + LazyVStack(alignment: .leading, spacing: 0) { + if rows.isEmpty { + emptyState + } else { + ForEach(rows) { row in + rowView(for: row) + } + } + if showsLoadMore { + FeedHistoryLoadMoreRow( + isLoading: isLoadingOlderItems, + action: onLoadOlderItems + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func rowView(for row: FeedAgentTreeRow) -> some View { + let nodeId = row.node.id + let rowIsSelected = selectedNodeId == nodeId + return FeedAgentTreeRowView( + row: row, + isCollapsed: collapsedNodeIds.contains(nodeId), + isSelected: rowIsSelected, + isFocusActive: isKeyboardActive && rowIsSelected, + onToggle: { + onToggle(nodeId) + }, + onFocus: { + onSelect(row.node) + if let workstreamId = row.node.focusWorkstreamId { + actions.jump(workstreamId) + } + } + ) + .equatable() + .id(nodeId) + } + + private var summaryBar: some View { + HStack(spacing: 6) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + Text(summaryText) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + Spacer(minLength: 4) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.035)) + } + + private var summaryText: String { + let nodeText = String.localizedStringWithFormat( + NSLocalizedString( + "feed.agentTree.nodes", + tableName: nil, + bundle: .main, + value: "%lld nodes", + comment: "Agent tree node count" + ), + graph.nodeCount + ) + let edgeText = String.localizedStringWithFormat( + NSLocalizedString( + "feed.agentTree.edges", + tableName: nil, + bundle: .main, + value: "%lld edges", + comment: "Agent tree edge count" + ), + graph.edgeCount + ) + return String.localizedStringWithFormat( + NSLocalizedString( + "feed.agentTree.summary", + tableName: nil, + bundle: .main, + value: "%@ · %@ · depth %lld", + comment: "Agent tree summary" + ), + nodeText, + edgeText, + graph.maxDepth + ) + } + + private var emptyState: some View { + VStack(spacing: 4) { + Text(String(localized: "feed.empty.agentTree.title", + defaultValue: "No agent tree yet")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Text(String(localized: "feed.empty.agentTree.subtitle", + defaultValue: "Task-spawned subagents will appear here as parent and child activity arrives.")) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 44) + } + +} + +struct FeedAgentTreeRow: Identifiable, Equatable { + let node: WorkstreamAgentTreeNode + let depth: Int + + var id: String { node.id } +} + +private struct FeedAgentTreeRowView: View, Equatable { + let row: FeedAgentTreeRow + let isCollapsed: Bool + let isSelected: Bool + let isFocusActive: Bool + let onToggle: () -> Void + let onFocus: () -> Void + + static func == (lhs: FeedAgentTreeRowView, rhs: FeedAgentTreeRowView) -> Bool { + lhs.row == rhs.row + && lhs.isCollapsed == rhs.isCollapsed + && lhs.isSelected == rhs.isSelected + && lhs.isFocusActive == rhs.isFocusActive + } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 8) { + indentGuides + disclosure + statusIcon + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text(row.node.title) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.middle) + metadataChips + } + if let task = row.node.taskDescription, !task.isEmpty { + Text(task) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(2) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(rowBackgroundFill) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + .help(helpText) + } + + private var rowBackgroundFill: Color { + guard isSelected else { return .clear } + if isFocusActive { + return tint.opacity(0.14) + } + return Color.primary.opacity(0.07) + } + + private var indentGuides: some View { + HStack(spacing: 5) { + ForEach(0.. 0 { + Button(action: onToggle) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .frame(width: 12, height: 16) + } + .buttonStyle(.plain) + .help(isCollapsed + ? String(localized: "feed.agentTree.expand", defaultValue: "Expand subtree") + : String(localized: "feed.agentTree.collapse", defaultValue: "Collapse subtree")) + } else { + Color.clear.frame(width: 12, height: 16) + } + } + + private var statusIcon: some View { + Image(systemName: iconName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(tint) + .frame(width: 14, height: 16) + } + + private var metadataChips: some View { + HStack(spacing: 4) { + chip(text: statusLabel, fg: tint, bg: tint.opacity(0.14)) + if let source = row.node.source { + chip(text: source.rawValue.capitalized, fg: .secondary, bg: Color.primary.opacity(0.08)) + } + if let subagentType = row.node.subagentType, !subagentType.isEmpty { + chip(text: subagentType, fg: .blue, bg: Color.blue.opacity(0.12)) + } + if let model = row.node.model, !model.isEmpty { + chip(text: model, fg: .secondary, bg: Color.primary.opacity(0.08)) + } + } + } + + private func chip(text: String, fg: Color, bg: Color) -> some View { + Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(fg) + .lineLimit(1) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(bg) + ) + } + + private var iconName: String { + switch row.node.kind { + case .session: + switch row.node.status { + case .waiting: return "exclamationmark.circle.fill" + case .running: return "bolt.fill" + case .idle: return "pause.circle.fill" + case .done: return "checkmark.circle.fill" + case .unknown: return "circle" + } + case .spawnRequest: + return "arrow.triangle.branch" + } + } + + private var tint: Color { + switch row.node.status { + case .waiting: return .orange + case .running: return .blue + case .idle: return .secondary + case .done: return .green + case .unknown: return .secondary.opacity(0.8) + } + } + + private var statusLabel: String { + switch row.node.status { + case .running: + return String(localized: "feed.agentTree.status.running", defaultValue: "running") + case .waiting: + return String(localized: "feed.agentTree.status.waiting", defaultValue: "waiting") + case .idle: + return String(localized: "feed.agentTree.status.idle", defaultValue: "idle") + case .done: + return String(localized: "feed.agentTree.status.done", defaultValue: "done") + case .unknown: + return String(localized: "feed.agentTree.status.unknown", defaultValue: "unknown") + } + } + + private var helpText: String { + var lines = [row.node.title, statusLabel] + if let task = row.node.taskDescription, !task.isEmpty { + lines.append(task) + } + if let workstreamId = row.node.workstreamId { + lines.append(workstreamId) + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 7f4658859973..55fac23b2a51 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -846,7 +846,13 @@ enum FeedSocketEncoding { "updated_at": isoFormatter.string(from: item.updatedAt), ] if let cwd = item.cwd { dict["cwd"] = cwd } + if let workspaceId = item.workspaceId { dict["workspace_id"] = workspaceId } if let title = item.title { dict["title"] = title } + if let extraFieldsJSON = item.extraFieldsJSON, + let data = extraFieldsJSON.data(using: .utf8), + let extra = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + dict["extra_fields"] = extra + } switch item.status { case .pending: dict["status"] = "pending" diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift new file mode 100644 index 000000000000..be7c7e615205 --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -0,0 +1,202 @@ +import AppKit +import SwiftUI + +#if DEBUG +func feedDebugResponderSummary(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return String(describing: type(of: responder)) +} +#endif + +struct FeedKeyboardFocusBridge: NSViewRepresentable { + let onEscape: () -> Void + let onMoveSelection: (Int) -> Void + let onActivateSelection: () -> Void + let onFocusFirstItemRequested: () -> Void + let onFocusChanged: (Bool) -> Void + let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void + + func makeNSView(context: Context) -> FeedKeyboardFocusView { + let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) + view.onEscape = onEscape + view.onMoveSelection = onMoveSelection + view.onActivateSelection = onActivateSelection + view.onFocusFirstItemRequested = onFocusFirstItemRequested + view.onFocusChanged = onFocusChanged + view.onFocusSnapshotChanged = onFocusSnapshotChanged + return view + } + + func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { + nsView.onEscape = onEscape + nsView.onMoveSelection = onMoveSelection + nsView.onActivateSelection = onActivateSelection + nsView.onFocusFirstItemRequested = onFocusFirstItemRequested + nsView.onFocusChanged = onFocusChanged + nsView.onFocusSnapshotChanged = onFocusSnapshotChanged + nsView.registerWithKeyboardFocusCoordinatorIfNeeded() + } +} + +final class FeedKeyboardFocusView: NSView { + var onEscape: (() -> Void)? + var onMoveSelection: ((Int) -> Void)? + var onActivateSelection: (() -> Void)? + var onFocusFirstItemRequested: (() -> Void)? + var onFocusChanged: ((Bool) -> Void)? + var onFocusSnapshotChanged: ((FeedFocusSnapshot) -> Void)? + private weak var registeredWindow: NSWindow? + + override var acceptsFirstResponder: Bool { true } + override var canBecomeKeyView: Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + registerWithKeyboardFocusCoordinatorIfNeeded() + guard let window else { return } + #if DEBUG + cmuxDebugLog("feed.focus.host attach window=\(ObjectIdentifier(window))") + #endif + } + + func registerWithKeyboardFocusCoordinatorIfNeeded() { + guard let window else { + registeredWindow = nil + return + } + guard registeredWindow !== window else { return } + guard let coordinator = AppDelegate.shared?.keyboardFocusCoordinator(for: window) else { + registeredWindow = nil + return + } + coordinator.registerFeedHost(self) + registeredWindow = window + } + + override func layout() { + super.layout() + registerWithKeyboardFocusCoordinatorIfNeeded() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.type == .keyDown, event.keyCode == 53 { + #if DEBUG + cmuxDebugLog( + "feed.focus.host escape window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) + #endif + onEscape?() + return true + } + if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { + onMoveSelection?(delta) + return true + } + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { + #if DEBUG + let chars = event.charactersIgnoringModifiers ?? "" + cmuxDebugLog( + "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) + #endif + if let mode = RightSidebarMode.modeShortcut(for: event) { + _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: mode, + focusFirstItem: true, + preferredWindow: window + ) + return + } + + if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { + onMoveSelection?(delta) + return + } + + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasShortcutModifier = !normalizedFlags.intersection([.command, .control, .option]).isEmpty + guard !hasShortcutModifier else { + super.keyDown(with: event) + return + } + + switch event.keyCode { + case 36, 76: + onActivateSelection?() + return + case 53: + onEscape?() + return + default: + break + } + + if let characters = event.charactersIgnoringModifiers, !characters.isEmpty { + return + } + super.keyDown(with: event) + } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + onFocusChanged?(true) + } + #if DEBUG + cmuxDebugLog( + "feed.focus.host become result=\(result ? 1 : 0) " + + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) + #endif + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + onFocusChanged?(false) + } + #if DEBUG + cmuxDebugLog( + "feed.focus.host resign result=\(result ? 1 : 0) " + + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + + "fr=\(feedDebugResponderSummary(window?.firstResponder))" + ) + #endif + return result + } + + func focusFirstItemFromCoordinator() { + onFocusFirstItemRequested?() + } + + func focusHostFromCoordinator() -> Bool { + guard let window else { return false } + #if DEBUG + let before = feedDebugResponderSummary(window.firstResponder) + #endif + let result = window.makeFirstResponder(self) + #if DEBUG + cmuxDebugLog( + "feed.focus.host request result=\(result ? 1 : 0) " + + "window=\(ObjectIdentifier(window)) before=\(before) " + + "after=\(feedDebugResponderSummary(window.firstResponder))" + ) + #endif + return result + } + + func applyFocusSnapshotFromController(_ snapshot: FeedFocusSnapshot) { + onFocusSnapshotChanged?(snapshot) + } + + func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { + responder === self || responder is FeedKeyboardFocusResponder + } +} diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index bd09fd10817a..5eb48f621d92 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -2,12 +2,6 @@ import AppKit import Bonsplit import CMUXWorkstream import SwiftUI -#if DEBUG -private func feedDebugResponderSummary(_ responder: NSResponder?) -> String { - guard let responder else { return "nil" } - return String(describing: type(of: responder)) -} -#endif private extension WorkstreamPermissionMode { var displayLabel: String { @@ -54,6 +48,7 @@ struct FeedPanelView: View { enum Filter: String, CaseIterable, Identifiable { case actionable case activity + case agentTree var id: String { rawValue } var label: String { switch self { @@ -61,18 +56,21 @@ struct FeedPanelView: View { return String(localized: "feed.filter.actionable", defaultValue: "Actionable") case .activity: return String(localized: "feed.filter.activity", defaultValue: "All Activity") + case .agentTree: + return String(localized: "feed.filter.agentTree", defaultValue: "Tree") } } var symbolName: String { switch self { case .actionable: return "exclamationmark.circle" case .activity: return "checklist" + case .agentTree: return "point.3.connected.trianglepath.dotted" } } } @State private var filter: Filter = .actionable - @StateObject private var viewModel = FeedPanelViewModel() + @State private var viewModel = FeedPanelViewModel() var body: some View { VStack(spacing: 0) { @@ -80,11 +78,18 @@ struct FeedPanelView: View { FeedListView( filter: filter, items: viewModel.items, + agentGraphSnapshot: viewModel.agentGraphSnapshot, hasMorePersistedItems: viewModel.hasMorePersistedItems, isLoadingOlderItems: viewModel.isLoadingOlderItems, onLoadOlderItems: viewModel.loadOlderItems ) } + .onAppear { + viewModel.setAgentTreeActive(filter == .agentTree) + } + .onChange(of: filter) { _, filter in + viewModel.setAgentTreeActive(filter == .agentTree) + } } private var controlBar: some View { @@ -154,6 +159,7 @@ private struct FeedSecondaryFilterButton: View { private struct FeedListView: View { let filter: FeedPanelView.Filter let items: [WorkstreamItem] + let agentGraphSnapshot: WorkstreamAgentGraphSnapshot let hasMorePersistedItems: Bool let isLoadingOlderItems: Bool let onLoadOlderItems: () -> Void @@ -162,28 +168,59 @@ private struct FeedListView: View { @State private var scrollRequest: FeedScrollRequest? @State private var scrollRequestSequence = 0 @State private var stopDrafts: [UUID: FeedStopDraft] = [:] + @State private var agentTreeController = FeedAgentTreeController() var body: some View { - let snapshots = visibleSnapshots(items) + let rowActions = FeedRowActions.bound() + let snapshots = filter == .agentTree ? [] : visibleSnapshots(items) let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil let focusSnapshots = activityGroups?.ordered ?? snapshots - let rowActions = FeedRowActions.bound() + let visibleAgentTree = agentTreeController.visibleSnapshot(from: agentGraphSnapshot) ScrollViewReader { proxy in Group { - if snapshots.isEmpty && !shouldShowActivityHistoryLoader { - emptyState - } else { - contentBody( - snapshots: snapshots, - activityGroups: activityGroups, - actions: rowActions + if filter == .agentTree { + FeedAgentTreeView( + graph: agentGraphSnapshot, + rows: visibleAgentTree.rows, + actions: rowActions, + collapsedNodeIds: agentTreeController.collapsedNodeIds, + selectedNodeId: agentTreeController.selectedNodeId, + isKeyboardActive: focusSnapshot.isKeyboardActive, + scrollRequest: agentTreeController.scrollRequest, + showsLoadMore: hasMorePersistedItems, + isLoadingOlderItems: isLoadingOlderItems, + onLoadOlderItems: onLoadOlderItems, + onToggle: { nodeId in + agentTreeController.toggle(nodeId) + let updatedVisibleTree = agentTreeController.visibleSnapshot(from: agentGraphSnapshot) + agentTreeController.reconcileSelection(with: updatedVisibleTree) + }, + onSelect: { node in + applyAgentTreeSelectionEffect( + agentTreeController.select(node, focusFeed: false) + ) + } ) + } else { + if snapshots.isEmpty && !shouldShowActivityHistoryLoader { + emptyState + } else { + contentBody( + snapshots: snapshots, + activityGroups: activityGroups, + actions: rowActions + ) + } } } - .onChange(of: scrollRequest) { request in + .onChange(of: scrollRequest) { _, request in guard let request else { return } proxy.scrollTo(request.id, anchor: .top) } + .onChange(of: agentGraphSnapshot) { _, newSnapshot in + let updatedVisibleTree = agentTreeController.visibleSnapshot(from: newSnapshot) + agentTreeController.reconcileSelection(with: updatedVisibleTree) + } .background( FeedKeyboardFocusBridge( onEscape: { @@ -194,13 +231,39 @@ private struct FeedListView: View { syncFeedFocusSnapshot(window: window) }, onMoveSelection: { delta in - moveSelection(in: focusSnapshots, delta: delta) + if filter == .agentTree { + if let effect = agentTreeController.move(in: visibleAgentTree.focusTargets, delta: delta) { + applyAgentTreeSelectionEffect(effect) + } + } else { + moveSelection(in: focusSnapshots, delta: delta) + } }, onActivateSelection: { - activateSelection(in: focusSnapshots, actions: rowActions) + if filter == .agentTree { + if let effect = agentTreeController.activate(in: visibleAgentTree.focusTargets) { + applyAgentTreeSelectionEffect(effect) + if let workstreamId = effect.jumpWorkstreamId { + rowActions.jump(workstreamId) + } + } + } else { + activateSelection(in: focusSnapshots, actions: rowActions) + } }, onFocusFirstItemRequested: { - focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + if filter == .agentTree { + if let effect = agentTreeController.focusFirst( + in: visibleAgentTree.focusTargets, + focusHost: false + ) { + applyAgentTreeSelectionEffect(effect) + } else { + updateAgentTreeFocusIntent(focusHost: false) + } + } else { + focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + } }, onFocusChanged: { focused in let window = activeFeedWindow() @@ -236,6 +299,8 @@ private struct FeedListView: View { actions: actions, showsLoadMore: hasMorePersistedItems ) + case .agentTree: + EmptyView() } } @@ -394,7 +459,7 @@ private struct FeedListView: View { switch filter { case .actionable: base = items.filter { $0.kind.isActionable } - case .activity: + case .activity, .agentTree: // Actionable kinds + todos + stop. Tool use, user prompts, // assistant messages, session markers, and raw // notifications are intentionally excluded — they're too @@ -550,6 +615,33 @@ private struct FeedListView: View { actions.jump(snapshot.workstreamId) } + private func applyAgentTreeSelectionEffect(_ effect: FeedAgentTreeSelectionEffect) { + updateAgentTreeFocusIntent(focusHost: effect.focusHost) + #if DEBUG + dlog( + "feed.agentTree.focus.select node=\(effect.nodeId.prefix(16)) " + + "focusFeed=\(effect.focusHost ? 1 : 0)" + ) + #endif + } + + private func updateAgentTreeFocusIntent(focusHost: Bool, window: NSWindow? = nil) { + let targetWindow = window ?? activeFeedWindow() + if focusHost { + _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( + mode: .feed, + focusFirstItem: false, + preferredWindow: targetWindow + ) + } else { + AppDelegate.shared?.noteRightSidebarKeyboardFocusIntent( + mode: .feed, + in: targetWindow + ) + } + syncFeedFocusSnapshot(window: targetWindow) + } + private func activeFeedWindow() -> NSWindow? { NSApp.keyWindow ?? NSApp.mainWindow } @@ -569,20 +661,34 @@ private struct FeedListView: View { .frame(height: 1) } + @ViewBuilder private var emptyState: some View { + switch filter { + case .actionable: + emptyStateContent( + title: String(localized: "feed.empty.actionable.title", + defaultValue: "No pending decisions"), + subtitle: String(localized: "feed.empty.actionable.subtitle", + defaultValue: "Permission, plan, and question requests from AI agents will appear here.") + ) + case .activity: + emptyStateContent( + title: String(localized: "feed.empty.activity.title", + defaultValue: "No activity yet"), + subtitle: String(localized: "feed.empty.activity.subtitle", + defaultValue: "Agent decisions and todo-list updates will appear here.") + ) + case .agentTree: + EmptyView() + } + } + + private func emptyStateContent(title: String, subtitle: String) -> some View { VStack(spacing: 4) { - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.title", - defaultValue: "No pending decisions") - : String(localized: "feed.empty.activity.title", - defaultValue: "No activity yet")) + Text(title) .font(.system(size: 12)) .foregroundColor(.secondary) - Text(filter == .actionable - ? String(localized: "feed.empty.actionable.subtitle", - defaultValue: "Permission, plan, and question requests from AI agents will appear here.") - : String(localized: "feed.empty.activity.subtitle", - defaultValue: "Agent decisions and todo-list updates will appear here.")) + Text(subtitle) .font(.system(size: 11)) .foregroundColor(.secondary.opacity(0.7)) .multilineTextAlignment(.center) @@ -685,7 +791,8 @@ private struct FeedRowSurface: View { } } -private extension View { +// Shared by the feed list and the agent tree view. +extension View { @ViewBuilder func feedZeroScrollContentMargins() -> some View { if #available(macOS 14.0, *) { @@ -696,189 +803,6 @@ private extension View { } } -private struct FeedKeyboardFocusBridge: NSViewRepresentable { - let onEscape: () -> Void - let onMoveSelection: (Int) -> Void - let onActivateSelection: () -> Void - let onFocusFirstItemRequested: () -> Void - let onFocusChanged: (Bool) -> Void - let onFocusSnapshotChanged: (FeedFocusSnapshot) -> Void - - func makeNSView(context: Context) -> FeedKeyboardFocusView { - let view = FeedKeyboardFocusView(frame: NSRect(x: 0, y: 0, width: 1, height: 1)) - view.onEscape = onEscape - view.onMoveSelection = onMoveSelection - view.onActivateSelection = onActivateSelection - view.onFocusFirstItemRequested = onFocusFirstItemRequested - view.onFocusChanged = onFocusChanged - view.onFocusSnapshotChanged = onFocusSnapshotChanged - return view - } - - func updateNSView(_ nsView: FeedKeyboardFocusView, context: Context) { - nsView.onEscape = onEscape - nsView.onMoveSelection = onMoveSelection - nsView.onActivateSelection = onActivateSelection - nsView.onFocusFirstItemRequested = onFocusFirstItemRequested - nsView.onFocusChanged = onFocusChanged - nsView.onFocusSnapshotChanged = onFocusSnapshotChanged - nsView.registerWithKeyboardFocusCoordinatorIfNeeded() - } -} - -final class FeedKeyboardFocusView: NSView { - var onEscape: (() -> Void)? - var onMoveSelection: ((Int) -> Void)? - var onActivateSelection: (() -> Void)? - var onFocusFirstItemRequested: (() -> Void)? - var onFocusChanged: ((Bool) -> Void)? - var onFocusSnapshotChanged: ((FeedFocusSnapshot) -> Void)? - - override var acceptsFirstResponder: Bool { true } - override var canBecomeKeyView: Bool { true } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) -#if DEBUG - dlog("feed.focus.host attach window=\(ObjectIdentifier(window))") -#endif - } - - func registerWithKeyboardFocusCoordinatorIfNeeded() { - guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) - } - - override func layout() { - super.layout() - registerWithKeyboardFocusCoordinatorIfNeeded() - } - - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if event.type == .keyDown, event.keyCode == 53 { -#if DEBUG - dlog( - "feed.focus.host escape window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - onEscape?() - return true - } - if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - onMoveSelection?(delta) - return true - } - return super.performKeyEquivalent(with: event) - } - - override func keyDown(with event: NSEvent) { -#if DEBUG - let chars = event.charactersIgnoringModifiers ?? "" - dlog( - "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - if let mode = RightSidebarMode.modeShortcut(for: event) { - _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow( - mode: mode, - focusFirstItem: true, - preferredWindow: window - ) - return - } - - if let delta = RightSidebarKeyboardNavigation.moveDelta(for: event) { - onMoveSelection?(delta) - return - } - - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasShortcutModifier = !normalizedFlags.intersection([.command, .control, .option]).isEmpty - guard !hasShortcutModifier else { - super.keyDown(with: event) - return - } - - switch event.keyCode { - case 36, 76: - onActivateSelection?() - return - case 53: - onEscape?() - return - default: - break - } - - if let characters = event.charactersIgnoringModifiers, !characters.isEmpty { - return - } - super.keyDown(with: event) - } - - override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - if result { - onFocusChanged?(true) - } -#if DEBUG - dlog( - "feed.focus.host become result=\(result ? 1 : 0) " + - "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - return result - } - - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - if result { - onFocusChanged?(false) - } -#if DEBUG - dlog( - "feed.focus.host resign result=\(result ? 1 : 0) " + - "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + - "fr=\(feedDebugResponderSummary(window?.firstResponder))" - ) -#endif - return result - } - - func focusFirstItemFromCoordinator() { - onFocusFirstItemRequested?() - } - - func focusHostFromCoordinator() -> Bool { - guard let window else { return false } -#if DEBUG - let before = feedDebugResponderSummary(window.firstResponder) -#endif - let result = window.makeFirstResponder(self) -#if DEBUG - dlog( - "feed.focus.host request result=\(result ? 1 : 0) " + - "window=\(ObjectIdentifier(window)) before=\(before) " + - "after=\(feedDebugResponderSummary(window.firstResponder))" - ) -#endif - return result - } - - func applyFocusSnapshotFromController(_ snapshot: FeedFocusSnapshot) { - onFocusSnapshotChanged?(snapshot) - } - - func ownsKeyboardFocus(_ responder: NSResponder) -> Bool { - responder === self || responder is FeedKeyboardFocusResponder - } -} - // MARK: - Row snapshot + actions (respects snapshot-boundary rule) /// Immutable snapshot of a `WorkstreamItem` handed to row views so rows diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 37589af39ee4..204cbcda907d 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -3,15 +3,25 @@ import Foundation import Observation import SwiftUI -/// Bridges the `@Observable` WorkstreamStore to a Combine `@Published` -/// snapshot so SwiftUI reliably re-renders the Feed panel on every -/// mutation. +/// Bridges the `@Observable` WorkstreamStore to a panel-owned +/// observation snapshot so SwiftUI re-renders the Feed panel on every +/// relevant mutation. @MainActor -final class FeedPanelViewModel: ObservableObject { - @Published private(set) var items: [WorkstreamItem] = [] - @Published private(set) var hasMorePersistedItems = false - @Published private(set) var isLoadingOlderItems = false - private var storeInstalledObserver: NSObjectProtocol? +@Observable +final class FeedPanelViewModel { + private(set) var items: [WorkstreamItem] = [] + private(set) var agentGraphSnapshot: WorkstreamAgentGraphSnapshot = .empty + private(set) var hasMorePersistedItems = false + private(set) var isLoadingOlderItems = false + @ObservationIgnored private var storeInstalledObserver: NSObjectProtocol? + @ObservationIgnored private var graphBuildWorker = FeedAgentGraphBuildWorker() + @ObservationIgnored private var graphBuildTask: Task? + @ObservationIgnored private var graphBuildSequence = 0 + @ObservationIgnored private var pendingGraphBuildRequest: AgentGraphBuildRequest? + @ObservationIgnored private var activeGraphBuildSequence: Int? + @ObservationIgnored private var isAgentTreeActive = false + @ObservationIgnored private var loadOlderItemsTask: Task? + @ObservationIgnored private var loadOlderItemsSequence = 0 init() { storeInstalledObserver = NotificationCenter.default.addObserver( @@ -27,6 +37,8 @@ final class FeedPanelViewModel: ObservableObject { } deinit { + graphBuildTask?.cancel() + loadOlderItemsTask?.cancel() if let storeInstalledObserver { NotificationCenter.default.removeObserver(storeInstalledObserver) } @@ -34,23 +46,138 @@ final class FeedPanelViewModel: ObservableObject { private func arm() { guard let store = FeedCoordinator.shared.store else { return } - withObservationTracking { - items = store.items - hasMorePersistedItems = store.hasMorePersistedItems - isLoadingOlderItems = store.isLoadingOlderItems + let storeSnapshot = withObservationTracking { + FeedStoreObservationSnapshot( + items: store.items, + hasMorePersistedItems: store.hasMorePersistedItems, + isLoadingOlderItems: store.isLoadingOlderItems + ) } onChange: { [weak self] in Task { @MainActor in self?.arm() } } + applyStoreObservationSnapshot(storeSnapshot) } - nonisolated func loadOlderItems() { - Task { @MainActor [weak self] in - guard let self, !self.isLoadingOlderItems, self.hasMorePersistedItems else { return } + private func applyStoreObservationSnapshot(_ snapshot: FeedStoreObservationSnapshot) { + let previousItems = items + items = snapshot.items + if snapshot.items != previousItems { + scheduleAgentGraphRebuildIfNeeded(from: snapshot.items) + } + hasMorePersistedItems = snapshot.hasMorePersistedItems + isLoadingOlderItems = snapshot.isLoadingOlderItems + } + + func loadOlderItems() { + guard !isLoadingOlderItems, + hasMorePersistedItems, + loadOlderItemsTask == nil + else { return } + loadOlderItemsSequence &+= 1 + let sequence = loadOlderItemsSequence + loadOlderItemsTask = Task { @MainActor [weak self, sequence] in + guard let self, !Task.isCancelled else { return } + defer { + if self.loadOlderItemsSequence == sequence { + self.loadOlderItemsTask = nil + } + } await FeedCoordinator.shared.store?.loadOlderItems() } } + + func setAgentTreeActive(_ active: Bool) { + guard isAgentTreeActive != active else { return } + isAgentTreeActive = active + if active { + scheduleAgentGraphRebuildIfNeeded(from: items) + } else { + graphBuildSequence &+= 1 + graphBuildTask?.cancel() + graphBuildTask = nil + pendingGraphBuildRequest = nil + activeGraphBuildSequence = nil + agentGraphSnapshot = .empty + } + } + + private func scheduleAgentGraphRebuildIfNeeded(from currentItems: [WorkstreamItem]) { + guard isAgentTreeActive else { + graphBuildTask?.cancel() + graphBuildTask = nil + pendingGraphBuildRequest = nil + activeGraphBuildSequence = nil + if !agentGraphSnapshot.isEmpty { + agentGraphSnapshot = .empty + } + return + } + + graphBuildSequence &+= 1 + pendingGraphBuildRequest = AgentGraphBuildRequest( + sequence: graphBuildSequence, + items: currentItems + ) + startNextAgentGraphBuildIfNeeded() + } + + private func startNextAgentGraphBuildIfNeeded() { + guard isAgentTreeActive, + activeGraphBuildSequence == nil, + let request = pendingGraphBuildRequest + else { return } + + pendingGraphBuildRequest = nil + activeGraphBuildSequence = request.sequence + graphBuildTask = Task { [weak self, request] in + guard let self, !Task.isCancelled else { return } + guard let snapshot = await self.graphBuildWorker.snapshot(from: request.items) else { + self.completeAgentGraphBuild(sequence: request.sequence, snapshot: nil) + return + } + self.completeAgentGraphBuild( + sequence: request.sequence, + snapshot: Task.isCancelled ? nil : snapshot + ) + } + } + + private func completeAgentGraphBuild( + sequence: Int, + snapshot: WorkstreamAgentGraphSnapshot? + ) { + guard activeGraphBuildSequence == sequence else { return } + activeGraphBuildSequence = nil + graphBuildTask = nil + + if let snapshot, + isAgentTreeActive, + graphBuildSequence == sequence { + agentGraphSnapshot = snapshot + } + + startNextAgentGraphBuildIfNeeded() + } +} + +private struct AgentGraphBuildRequest { + let sequence: Int + let items: [WorkstreamItem] +} + +private struct FeedStoreObservationSnapshot { + let items: [WorkstreamItem] + let hasMorePersistedItems: Bool + let isLoadingOlderItems: Bool +} + +private actor FeedAgentGraphBuildWorker { + func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot? { + guard !Task.isCancelled else { return nil } + return WorkstreamAgentGraphBuilder.snapshot(from: items) + } } struct FeedHistoryLoadMoreRow: View { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index fe7cac4b2187..1243df41549e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -100,12 +100,16 @@ F4350A120000000000000001 /* AppBundleIconPersistencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; + AA11BB22CC33DD44EE550004 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; 3069F1D10000000000000005 /* CMUXPasteboardFidelity in Frameworks */ = {isa = PBXBuildFile; productRef = 3069F1D10000000000000006 /* CMUXPasteboardFidelity */; }; F53000A0A1B2C3D4E5F60718 /* CMUXAgentVault in Frameworks */ = {isa = PBXBuildFile; productRef = F53000A2A1B2C3D4E5F60718 /* CMUXAgentVault */; }; A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; A5B00005A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; + FEED0000000000000000F015 /* FeedAgentTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F014 /* FeedAgentTreeView.swift */; }; + FEED0000000000000000F017 /* FeedAgentTreeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F016 /* FeedAgentTreeController.swift */; }; + FEED0000000000000000F019 /* FeedKeyboardFocusBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F018 /* FeedKeyboardFocusBridge.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; @@ -599,6 +603,9 @@ 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchPanelCaptureManager.swift; sourceTree = ""; }; 312DE7503B4658DD173121B8 /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = ""; }; + FEED0000000000000000F014 /* FeedAgentTreeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedAgentTreeView.swift; sourceTree = ""; }; + FEED0000000000000000F016 /* FeedAgentTreeController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedAgentTreeController.swift; sourceTree = ""; }; + FEED0000000000000000F018 /* FeedKeyboardFocusBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedKeyboardFocusBridge.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; @@ -1090,6 +1097,7 @@ buildActionMask = 2147483647; files = ( B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, + AA11BB22CC33DD44EE550004 /* CMUXWorkstream in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, B900004CA1B2C3D4E5F60719 /* CMUXSocketPathDomain in Frameworks */, ); @@ -1146,6 +1154,9 @@ isa = PBXGroup; children = ( FEED0000000000000000F001 /* FeedCoordinator.swift */, + FEED0000000000000000F016 /* FeedAgentTreeController.swift */, + FEED0000000000000000F014 /* FeedAgentTreeView.swift */, + FEED0000000000000000F018 /* FeedKeyboardFocusBridge.swift */, FEED0000000000000000F004 /* FeedPanelView.swift */, FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */, FEED0000000000000000F010 /* FeedPanelViewModel.swift */, @@ -1732,6 +1743,7 @@ name = "cmux-cli"; packageProductDependencies = ( A5001251 /* Sentry */, + AA11BB22CC33DD44EE550002 /* CMUXWorkstream */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, A5354305A5354305A5354305 /* CMUXSocketPathDomain */, ); @@ -2242,6 +2254,9 @@ D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */, FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */, + FEED0000000000000000F017 /* FeedAgentTreeController.swift in Sources */, + FEED0000000000000000F015 /* FeedAgentTreeView.swift in Sources */, + FEED0000000000000000F019 /* FeedKeyboardFocusBridge.swift in Sources */, FEED0000000000000000F005 /* FeedPanelView.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, diff --git a/ghostty b/ghostty index ff6e1260d2e7..aef980e27b58 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit ff6e1260d2e7767de55b8d9307b328e4060545b7 +Subproject commit aef980e27b584a9d914f1ff0499b13c6ed1973e0