From a688a16507711cdb8c9faa29d4dcb60f215c12b6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:53:00 -0700 Subject: [PATCH 01/42] feat: add agent tree feed view --- CLI/cmux.swift | 67 ++- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 499 ++++++++++++++++++ .../CMUXWorkstream/WorkstreamItem.swift | 9 + .../CMUXWorkstream/WorkstreamStore.swift | 2 + .../WorkstreamAgentGraphTests.swift | 67 +++ Resources/Localizable.xcstrings | 187 +++++++ Sources/Feed/FeedCoordinator.swift | 6 + Sources/Feed/FeedPanelView.swift | 328 +++++++++++- 8 files changed, 1143 insertions(+), 22 deletions(-) create mode 100644 Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift create mode 100644 Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fee11771fb25..e57f75ca1204 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14822,7 +14822,11 @@ struct CMUXCLI { "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", - "additional_details", "additionalDetails", "description", + "additional_details", "additionalDetails", "description", "model", + "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", + "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", + "subagent_type", "subagentType", "agent_type", "agentType", ] { if let value = compactClaudeHookValue(object[key], key: key) { compact[key] = value @@ -14831,7 +14835,14 @@ 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", + "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", + "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", + "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", + "subagent_type", "subagentType", "agent_type", "agentType", + ] { if let value = compactClaudeHookToolInputValue(toolInput[key], key: key) { compactToolInput[key] = value } @@ -14898,10 +14909,12 @@ struct CMUXCLI { private func claudeHookCompactFieldLimit(for key: String) -> Int { switch key { - case "tool_name", "turn_id", "turnId", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason": + case "tool_name", "turn_id", "turnId", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "model", "subagent_model", "subagentModel", "subagent_type", "subagentType", "agent_type", "agentType": return 80 case "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description": return 240 + case "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId": + return 160 default: return 160 } @@ -17504,6 +17517,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) { event["context"] = context } + copyAgentGraphMetadata(to: &event, rawObject: parsedInput.object, toolName: toolName) enrichUserPromptSubmitFeedEvent( &event, hookEventName: hookEventName, @@ -17595,6 +17609,52 @@ 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 keys = [ + "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", + "child_source", "childSource", "subagent_source", "subagentSource", + "subagent_type", "subagentType", "agent_type", "agentType", + "model", "subagent_model", "subagentModel", + "task_description", "taskDescription", "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 keys { + copyValue(rawObject[key], key: key) + } + + guard let toolName, isAgentSpawnToolName(toolName), + let toolInput = feedToolInputDictionary(rawObject["tool_input"]) + else { return } + for key in keys + ["prompt", "message"] { + copyValue(toolInput[key], key: key) + } + } + + private func isAgentSpawnToolName(_ toolName: String) -> Bool { + let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "task" || normalized == "agent" + } + 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"]) { @@ -19404,6 +19464,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..09a3efa47b6d --- /dev/null +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -0,0 +1,499 @@ +import Foundation + +public enum WorkstreamAgentNodeKind: String, Codable, Sendable, Equatable { + case session + case spawnRequest +} + +public enum WorkstreamAgentNodeStatus: String, Codable, Sendable, Equatable { + case running + case waiting + case idle + case done + case unknown +} + +public 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 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 init( + roots: [WorkstreamAgentTreeNode], + nodeCount: Int, + edgeCount: Int, + maxDepth: Int + ) { + self.roots = roots + self.nodeCount = nodeCount + self.edgeCount = edgeCount + self.maxDepth = maxDepth + } +} + +public enum WorkstreamAgentGraphBuilder { + public static func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot { + 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 + } + + for item in items.sorted(by: { $0.createdAt < $1.createdAt }) { + ensureRecord( + workstreamId: item.workstreamId, + source: item.source, + createdAt: item.createdAt, + workspaceId: item.workspaceId + ) + updateRecord(item.workstreamId) { record in + record.absorb(item) + } + + let metadata = AgentGraphMetadata(item: item) + if let parentWorkstreamId = metadata.parentWorkstreamId(source: item.source) { + updateRecord(item.workstreamId) { record in + record.parentWorkstreamId = parentWorkstreamId + record.merge(metadata: metadata) + } + } + + if let childWorkstreamId = metadata.childWorkstreamId(source: item.source) { + ensureRecord( + workstreamId: childWorkstreamId, + source: metadata.childSource ?? item.source, + createdAt: item.createdAt, + workspaceId: item.workspaceId + ) + updateRecord(childWorkstreamId) { record in + record.parentWorkstreamId = item.workstreamId + record.merge(metadata: metadata) + } + } else if let spawn = SpawnRecord(item: item, metadata: metadata) { + pendingSpawnsByParent[item.workstreamId, default: []].append(spawn) + } + } + + var childrenByParent: [String: [String]] = [:] + for record in records.values { + 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 let record = records[workstreamId], visited.insert(workstreamId).inserted else { + return nil + } + + var children: [WorkstreamAgentTreeNode] = [] + for childId in childrenByParent[workstreamId] ?? [] { + 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) + } + + let treeRoots = roots.compactMap { makeNode($0, depth: 0) } + + return WorkstreamAgentGraphSnapshot( + roots: treeRoots, + nodeCount: nodeCount, + edgeCount: edgeCount, + maxDepth: maxDepth + ) + } +} + +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) { + updatedAt = max(updatedAt, item.updatedAt) + if workspaceId == nil { + workspaceId = item.workspaceId + } + if let cwd = item.cwd, title == nil { + title = Self.basename(cwd) + } + + switch item.status { + case .pending: + status = .waiting + case .resolved, .expired, .telemetry: + updateStatus(from: item.payload) + } + + switch item.payload { + case .userPrompt(let text): + mergeTaskDescription(text) + case .toolUse(let toolName, let toolInputJSON): + let metadata = AgentGraphMetadata( + source: item.source, + extraFieldsJSON: item.extraFieldsJSON, + toolName: toolName, + toolInputJSON: toolInputJSON + ) + merge(metadata: metadata) + if Self.isSpawnTool(toolName) { + mergeTaskDescription(metadata.taskDescription) + } + default: + break + } + + if let context = item.context { + mergeTaskDescription(context.lastUserMessage) + } + } + + mutating func merge(metadata: AgentGraphMetadata) { + if let model = metadata.model, !model.isEmpty { + self.model = model + } + if let subagentType = metadata.subagentType, !subagentType.isEmpty { + self.subagentType = subagentType + } + mergeTaskDescription(metadata.taskDescription) + if let description = metadata.description, !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 { + let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "task" || normalized == "agent" + } +} + +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.description + ?? metadata.subagentType + ?? String(toolName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) + self.model = metadata.model + self.subagentType = metadata.subagentType + self.taskDescription = metadata.taskDescription + 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: .running, + taskDescription: taskDescription, + childCount: 0, + children: [] + ) + } +} + +private struct AgentGraphMetadata { + let source: WorkstreamSource + let extra: [String: Any] + let toolInput: [String: Any] + + 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) + if let toolName, SessionRecord.isSpawnTool(toolName) { + self.toolInput = parsedToolInput + } else { + self.toolInput = parsedToolInput + } + } + + var childSource: WorkstreamSource? { + string(keys: ["child_source", "childSource", "subagent_source", "subagentSource"]) + .flatMap(WorkstreamSource.init(wireName:)) + } + + var model: String? { + string(keys: ["model", "subagent_model", "subagentModel"]) + } + + var subagentType: String? { + string(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + } + + var description: String? { + string(keys: ["description", "title", "name"]) + } + + var taskDescription: String? { + string(keys: ["task_description", "taskDescription", "prompt", "message", "description"]) + } + + 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 "\(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 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 + } +} 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..b4cdfbbb315c 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift @@ -204,11 +204,13 @@ 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: event.extraFieldsJSON, ppid: event.ppid ) } diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift new file mode 100644 index 000000000000..0105c486cd2c --- /dev/null +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -0,0 +1,67 @@ +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","prompt":"Map settings code paths"}"# + )) + + let graph = WorkstreamAgentGraphBuilder.snapshot(from: store.items) + #expect(graph.nodeCount == 2) + #expect(graph.edgeCount == 1) + #expect(graph.roots.count == 1) + let child = graph.roots.first?.children.first + #expect(child?.kind == .spawnRequest) + #expect(child?.subagentType == "explorer") + #expect(child?.taskDescription == "Map settings code paths") + #expect(child?.focusWorkstreamId == "claude-parent") + } + + @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") + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..ac071d1e0dcf 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110398,6 +110398,193 @@ } } }, + "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.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld nodes · %lld edges · depth %lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldノード · %lldエッジ · 深さ%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/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 9d02e733bde5..0640c0210eaf 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -560,7 +560,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, options: [.fragmentsAllowed]) { + dict["extra_fields"] = extra + } switch item.status { case .pending: dict["status"] = "pending" diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 01731b407c57..e721103e5a77 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -54,6 +54,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,12 +62,15 @@ struct FeedPanelView: View { return String(localized: "feed.filter.actionable", defaultValue: "Actionable") case .activity: return String(localized: "feed.filter.activity", defaultValue: "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" } } } @@ -108,7 +112,7 @@ struct FeedPanelView: View { private var controlBarContent: some View { HStack(spacing: 6) { - ForEach([Filter.actionable]) { f in + ForEach([Filter.actionable, .activity, .agentTree]) { f in FeedSecondaryFilterButton( filter: f, isSelected: filter == f @@ -163,17 +167,29 @@ private struct FeedListView: View { @State private var scrollRequestSequence = 0 var body: some View { - let snapshots = visibleSnapshots(items) let rowActions = FeedRowActions.bound() ScrollViewReader { proxy in Group { - if snapshots.isEmpty && !shouldShowActivityHistoryLoader { - emptyState + if filter == .agentTree { + let graph = WorkstreamAgentGraphBuilder.snapshot(from: items) + if graph.isEmpty { + emptyState + } else { + FeedAgentTreeView( + graph: graph, + actions: rowActions + ) + } } else { - contentBody( - snapshots: snapshots, - actions: rowActions - ) + let snapshots = visibleSnapshots(items) + if snapshots.isEmpty && !shouldShowActivityHistoryLoader { + emptyState + } else { + contentBody( + snapshots: snapshots, + actions: rowActions + ) + } } } .onChange(of: scrollRequest) { request in @@ -371,7 +387,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 @@ -547,19 +563,31 @@ private struct FeedListView: View { } private var emptyState: some View { + let title: String + let subtitle: String + switch filter { + case .actionable: + 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: + 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: + title = String(localized: "feed.empty.agentTree.title", + defaultValue: "No agent tree yet") + subtitle = String(localized: "feed.empty.agentTree.subtitle", + defaultValue: "Task-spawned subagents will appear here as parent and child activity arrives.") + } + 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) @@ -646,6 +674,268 @@ private struct FeedRowSurface: View { } } +private struct FeedAgentTreeView: View { + let graph: WorkstreamAgentGraphSnapshot + let actions: FeedRowActions + + @State private var collapsedNodeIds: Set = [] + + var body: some View { + VStack(spacing: 0) { + summaryBar + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(flattenedRows) { row in + FeedAgentTreeRowView( + row: row, + isCollapsed: collapsedNodeIds.contains(row.node.id), + onToggle: { + toggle(row.node.id) + }, + onFocus: { + if let workstreamId = row.node.focusWorkstreamId { + actions.jump(workstreamId) + } + } + ) + .equatable() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .feedZeroScrollContentMargins() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + 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 { + String.localizedStringWithFormat( + String( + localized: "feed.agentTree.summary", + defaultValue: "%lld nodes · %lld edges · depth %lld" + ), + graph.nodeCount, + graph.edgeCount, + graph.maxDepth + ) + } + + private var flattenedRows: [FeedAgentTreeRow] { + var rows: [FeedAgentTreeRow] = [] + for root in graph.roots { + append(node: root, depth: 0, to: &rows) + } + return rows + } + + private func append( + node: WorkstreamAgentTreeNode, + depth: Int, + to rows: inout [FeedAgentTreeRow] + ) { + rows.append(FeedAgentTreeRow(node: node, depth: depth)) + guard !collapsedNodeIds.contains(node.id) else { return } + for child in node.children { + append(node: child, depth: depth + 1, to: &rows) + } + } + + private func toggle(_ id: String) { + if collapsedNodeIds.contains(id) { + collapsedNodeIds.remove(id) + } else { + collapsedNodeIds.insert(id) + } + } +} + +private 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 onToggle: () -> Void + let onFocus: () -> Void + + static func == (lhs: FeedAgentTreeRowView, rhs: FeedAgentTreeRowView) -> Bool { + lhs.row == rhs.row && lhs.isCollapsed == rhs.isCollapsed + } + + 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) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + .help(helpText) + } + + 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") + } +} + private extension View { @ViewBuilder func feedZeroScrollContentMargins() -> some View { From 79f27281eed8b9c782f97175a455225af046bcba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:58:05 -0700 Subject: [PATCH 02/42] Fix feed empty state view return --- Sources/Feed/FeedPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index e721103e5a77..7ff39848a357 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -583,7 +583,7 @@ private struct FeedListView: View { defaultValue: "Task-spawned subagents will appear here as parent and child activity arrives.") } - VStack(spacing: 4) { + return VStack(spacing: 4) { Text(title) .font(.system(size: 12)) .foregroundColor(.secondary) From 3ce3f8a40e27e20010691f19aef2d2f0a67783be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:01:29 -0700 Subject: [PATCH 03/42] Fix feed tree keyboard snapshot scope --- Sources/Feed/FeedPanelView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 7ff39848a357..98fcbc2898c5 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -168,6 +168,7 @@ private struct FeedListView: View { var body: some View { let rowActions = FeedRowActions.bound() + let snapshots = filter == .agentTree ? [] : visibleSnapshots(items) ScrollViewReader { proxy in Group { if filter == .agentTree { @@ -181,7 +182,6 @@ private struct FeedListView: View { ) } } else { - let snapshots = visibleSnapshots(items) if snapshots.isEmpty && !shouldShowActivityHistoryLoader { emptyState } else { @@ -265,6 +265,8 @@ private struct FeedListView: View { ) } } + case .agentTree: + EmptyView() } } From 99ccd86362247946976e8d2678a0b7ef5ffaddfc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:09:56 -0700 Subject: [PATCH 04/42] Address agent tree review feedback --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + .../CMUXWorkstream/WorkstreamAgentGraph.swift | 19 +- .../WorkstreamAgentGraphTests.swift | 24 ++ Sources/Feed/FeedAgentTreeView.swift | 264 +++++++++++++++++ Sources/Feed/FeedPanelView.swift | 271 +----------------- Sources/Feed/FeedPanelViewModel.swift | 5 +- 6 files changed, 314 insertions(+), 273 deletions(-) create mode 100644 Sources/Feed/FeedAgentTreeView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 897fb006a6db..f1644a79e165 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ 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 */; }; 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 */; }; @@ -523,6 +524,7 @@ 3023B1013023B1013023B101 /* ConfigSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSettingsView.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 = ""; }; 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 = ""; }; @@ -1013,6 +1015,7 @@ isa = PBXGroup; children = ( FEED0000000000000000F001 /* FeedCoordinator.swift */, + FEED0000000000000000F014 /* FeedAgentTreeView.swift */, FEED0000000000000000F004 /* FeedPanelView.swift */, FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */, FEED0000000000000000F010 /* FeedPanelViewModel.swift */, @@ -1953,6 +1956,7 @@ D0C0D0C0D0C0D0C0D0C0D001 /* DockPanelView.swift in Sources */, D0C0D0C0D0C0D0C0D0C0D003 /* DockEmptyView.swift in Sources */, FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */, + FEED0000000000000000F015 /* FeedAgentTreeView.swift in Sources */, FEED0000000000000000F005 /* FeedPanelView.swift in Sources */, FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */, FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */, diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 09a3efa47b6d..cac0db940c4b 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -1,11 +1,11 @@ import Foundation -public enum WorkstreamAgentNodeKind: String, Codable, Sendable, Equatable { +public nonisolated enum WorkstreamAgentNodeKind: String, Codable, Sendable, Equatable { case session case spawnRequest } -public enum WorkstreamAgentNodeStatus: String, Codable, Sendable, Equatable { +public nonisolated enum WorkstreamAgentNodeStatus: String, Codable, Sendable, Equatable { case running case waiting case idle @@ -13,7 +13,7 @@ public enum WorkstreamAgentNodeStatus: String, Codable, Sendable, Equatable { case unknown } -public struct WorkstreamAgentTreeNode: Identifiable, Codable, Sendable, Equatable { +public nonisolated struct WorkstreamAgentTreeNode: Identifiable, Codable, Sendable, Equatable { public let id: String public let kind: WorkstreamAgentNodeKind public let workstreamId: String? @@ -59,7 +59,7 @@ public struct WorkstreamAgentTreeNode: Identifiable, Codable, Sendable, Equatabl } } -public struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equatable { +public nonisolated struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equatable { public let roots: [WorkstreamAgentTreeNode] public let nodeCount: Int public let edgeCount: Int @@ -67,6 +67,13 @@ public struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equatable { public var isEmpty: Bool { roots.isEmpty } + public static let empty = WorkstreamAgentGraphSnapshot( + roots: [], + nodeCount: 0, + edgeCount: 0, + maxDepth: 0 + ) + public init( roots: [WorkstreamAgentTreeNode], nodeCount: Int, @@ -80,7 +87,7 @@ public struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equatable { } } -public enum WorkstreamAgentGraphBuilder { +public nonisolated enum WorkstreamAgentGraphBuilder { public static func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot { var records: [String: SessionRecord] = [:] var creationOrder: [String] = [] @@ -419,7 +426,7 @@ private struct AgentGraphMetadata { if let toolName, SessionRecord.isSpawnTool(toolName) { self.toolInput = parsedToolInput } else { - self.toolInput = parsedToolInput + self.toolInput = [:] } } diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 0105c486cd2c..2b3897d7862d 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -64,4 +64,28 @@ struct WorkstreamAgentGraphTests { #expect(child?.model == "sonnet") #expect(child?.focusWorkstreamId == "claude-child") } + + @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"]) + } } diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift new file mode 100644 index 000000000000..1e50b46abc69 --- /dev/null +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -0,0 +1,264 @@ +import CMUXWorkstream +import SwiftUI + +struct FeedAgentTreeView: View { + let graph: WorkstreamAgentGraphSnapshot + let actions: FeedRowActions + + @State private var collapsedNodeIds: Set = [] + + var body: some View { + VStack(spacing: 0) { + summaryBar + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(flattenedRows) { row in + FeedAgentTreeRowView( + row: row, + isCollapsed: collapsedNodeIds.contains(row.node.id), + onToggle: { + toggle(row.node.id) + }, + onFocus: { + if let workstreamId = row.node.focusWorkstreamId { + actions.jump(workstreamId) + } + } + ) + .equatable() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .feedZeroScrollContentMargins() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + 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 { + String.localizedStringWithFormat( + String( + localized: "feed.agentTree.summary", + defaultValue: "%lld nodes · %lld edges · depth %lld" + ), + graph.nodeCount, + graph.edgeCount, + graph.maxDepth + ) + } + + private var flattenedRows: [FeedAgentTreeRow] { + var rows: [FeedAgentTreeRow] = [] + for root in graph.roots { + append(node: root, depth: 0, to: &rows) + } + return rows + } + + private func append( + node: WorkstreamAgentTreeNode, + depth: Int, + to rows: inout [FeedAgentTreeRow] + ) { + rows.append(FeedAgentTreeRow(node: node, depth: depth)) + guard !collapsedNodeIds.contains(node.id) else { return } + for child in node.children { + append(node: child, depth: depth + 1, to: &rows) + } + } + + private func toggle(_ id: String) { + if collapsedNodeIds.contains(id) { + collapsedNodeIds.remove(id) + } else { + collapsedNodeIds.insert(id) + } + } +} + +private 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 onToggle: () -> Void + let onFocus: () -> Void + + static func == (lhs: FeedAgentTreeRowView, rhs: FeedAgentTreeRowView) -> Bool { + lhs.row == rhs.row && lhs.isCollapsed == rhs.isCollapsed + } + + 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) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(maxWidth: .infinity) + .frame(height: 1) + } + .help(helpText) + } + + 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/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 98fcbc2898c5..a834a37de800 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -84,6 +84,7 @@ struct FeedPanelView: View { FeedListView( filter: filter, items: viewModel.items, + agentGraphSnapshot: viewModel.agentGraphSnapshot, hasMorePersistedItems: viewModel.hasMorePersistedItems, isLoadingOlderItems: viewModel.isLoadingOlderItems, onLoadOlderItems: viewModel.loadOlderItems @@ -158,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 @@ -172,12 +174,11 @@ private struct FeedListView: View { ScrollViewReader { proxy in Group { if filter == .agentTree { - let graph = WorkstreamAgentGraphBuilder.snapshot(from: items) - if graph.isEmpty { + if agentGraphSnapshot.isEmpty { emptyState } else { FeedAgentTreeView( - graph: graph, + graph: agentGraphSnapshot, actions: rowActions ) } @@ -676,269 +677,7 @@ private struct FeedRowSurface: View { } } -private struct FeedAgentTreeView: View { - let graph: WorkstreamAgentGraphSnapshot - let actions: FeedRowActions - - @State private var collapsedNodeIds: Set = [] - - var body: some View { - VStack(spacing: 0) { - summaryBar - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(flattenedRows) { row in - FeedAgentTreeRowView( - row: row, - isCollapsed: collapsedNodeIds.contains(row.node.id), - onToggle: { - toggle(row.node.id) - }, - onFocus: { - if let workstreamId = row.node.focusWorkstreamId { - actions.jump(workstreamId) - } - } - ) - .equatable() - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .feedZeroScrollContentMargins() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - - 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 { - String.localizedStringWithFormat( - String( - localized: "feed.agentTree.summary", - defaultValue: "%lld nodes · %lld edges · depth %lld" - ), - graph.nodeCount, - graph.edgeCount, - graph.maxDepth - ) - } - - private var flattenedRows: [FeedAgentTreeRow] { - var rows: [FeedAgentTreeRow] = [] - for root in graph.roots { - append(node: root, depth: 0, to: &rows) - } - return rows - } - - private func append( - node: WorkstreamAgentTreeNode, - depth: Int, - to rows: inout [FeedAgentTreeRow] - ) { - rows.append(FeedAgentTreeRow(node: node, depth: depth)) - guard !collapsedNodeIds.contains(node.id) else { return } - for child in node.children { - append(node: child, depth: depth + 1, to: &rows) - } - } - - private func toggle(_ id: String) { - if collapsedNodeIds.contains(id) { - collapsedNodeIds.remove(id) - } else { - collapsedNodeIds.insert(id) - } - } -} - -private 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 onToggle: () -> Void - let onFocus: () -> Void - - static func == (lhs: FeedAgentTreeRowView, rhs: FeedAgentTreeRowView) -> Bool { - lhs.row == rhs.row && lhs.isCollapsed == rhs.isCollapsed - } - - 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) - .contentShape(Rectangle()) - .onTapGesture(perform: onFocus) - Rectangle() - .fill(Color.primary.opacity(0.08)) - .frame(maxWidth: .infinity) - .frame(height: 1) - } - .help(helpText) - } - - 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") - } -} - -private extension View { +extension View { @ViewBuilder func feedZeroScrollContentMargins() -> some View { if #available(macOS 14.0, *) { diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 632ce13e0a2f..93d9d6731385 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -9,6 +9,7 @@ import SwiftUI @MainActor final class FeedPanelViewModel: ObservableObject { @Published private(set) var items: [WorkstreamItem] = [] + @Published private(set) var agentGraphSnapshot: WorkstreamAgentGraphSnapshot = .empty @Published private(set) var hasMorePersistedItems = false @Published private(set) var isLoadingOlderItems = false private var storeInstalledObserver: NSObjectProtocol? @@ -35,7 +36,9 @@ final class FeedPanelViewModel: ObservableObject { private func arm() { guard let store = FeedCoordinator.shared.store else { return } withObservationTracking { - items = store.items + let currentItems = store.items + items = currentItems + agentGraphSnapshot = WorkstreamAgentGraphBuilder.snapshot(from: currentItems) hasMorePersistedItems = store.hasMorePersistedItems isLoadingOlderItems = store.isLoadingOlderItems } onChange: { [weak self] in From e5a2392bd55190bb3a8472ca9bdc5cdbc86917c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:40:23 -0700 Subject: [PATCH 05/42] Fix agent tree spawn reconciliation --- CLI/cmux.swift | 4 +- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 69 +++++++++++++++++++ .../WorkstreamAgentGraphTests.swift | 33 +++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index e57f75ca1204..feba8d2ae240 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -14822,7 +14822,7 @@ struct CMUXCLI { "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", - "additional_details", "additionalDetails", "description", "model", + "additional_details", "additionalDetails", "description", "model", "subagent_model", "subagentModel", "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", @@ -14837,7 +14837,7 @@ struct CMUXCLI { var compactToolInput: [String: Any] = [:] for key in [ "file_path", "command", "pattern", "description", "query", "plan", "planFilePath", - "prompt", "message", "model", + "prompt", "message", "model", "subagent_model", "subagentModel", "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId", diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index cac0db940c4b..04cc38c6b155 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -138,6 +138,12 @@ public nonisolated enum WorkstreamAgentGraphBuilder { record.parentWorkstreamId = parentWorkstreamId record.merge(metadata: metadata) } + pruneResolvedSpawn( + parentWorkstreamId: parentWorkstreamId, + metadata: metadata, + childSource: item.source, + childWorkspaceId: item.workspaceId + ) } if let childWorkstreamId = metadata.childWorkstreamId(source: item.source) { @@ -156,6 +162,69 @@ public nonisolated enum WorkstreamAgentGraphBuilder { } } + func pruneResolvedSpawn( + parentWorkstreamId: String, + metadata: AgentGraphMetadata, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) { + guard var spawns = pendingSpawnsByParent[parentWorkstreamId], + !spawns.isEmpty else { return } + let index = bestResolvedSpawnIndex( + in: spawns, + metadata: metadata, + childSource: childSource, + childWorkspaceId: childWorkspaceId + ) + spawns.remove(at: index) + if spawns.isEmpty { + pendingSpawnsByParent[parentWorkstreamId] = nil + } else { + pendingSpawnsByParent[parentWorkstreamId] = spawns + } + } + + func bestResolvedSpawnIndex( + in spawns: [SpawnRecord], + metadata: AgentGraphMetadata, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) -> Int { + var bestIndex = spawns.startIndex + var bestScore = Int.min + for (index, spawn) in spawns.enumerated() { + var score = 0 + if spawn.source == childSource { + score += 2 + } + if let childWorkspaceId, + let workspaceId = spawn.workspaceId, + childWorkspaceId == workspaceId { + score += 2 + } + if let subagentType = metadata.subagentType, + let spawnSubagentType = spawn.subagentType, + subagentType == spawnSubagentType { + score += 4 + } + if let model = metadata.model, + let spawnModel = spawn.model, + model == spawnModel { + score += 3 + } + if let taskDescription = metadata.taskDescription, + let spawnTaskDescription = spawn.taskDescription, + taskDescription == spawnTaskDescription { + score += 4 + } + if score > bestScore { + bestIndex = index + bestScore = score + } + } + return bestIndex + } + var childrenByParent: [String: [String]] = [:] for record in records.values { guard let parent = record.parentWorkstreamId, diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 2b3897d7862d..ede928f343ad 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -88,4 +88,37 @@ struct WorkstreamAgentGraphTests { #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") + } } From 1b889d7913cc914337dce50e1151c252fbb38bb9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 01:29:09 -0700 Subject: [PATCH 06/42] fix: defer feed agent graph rebuilds --- Sources/Feed/FeedPanelView.swift | 6 +++ Sources/Feed/FeedPanelViewModel.swift | 55 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index a834a37de800..bb0dc3b3f4a3 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -90,6 +90,12 @@ struct FeedPanelView: View { onLoadOlderItems: viewModel.loadOlderItems ) } + .onAppear { + viewModel.setAgentTreeActive(filter == .agentTree) + } + .onChange(of: filter) { _, filter in + viewModel.setAgentTreeActive(filter == .agentTree) + } } private var controlBar: some View { diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 93d9d6731385..8598546e236b 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -13,6 +13,10 @@ final class FeedPanelViewModel: ObservableObject { @Published private(set) var hasMorePersistedItems = false @Published private(set) var isLoadingOlderItems = false private var storeInstalledObserver: NSObjectProtocol? + private let graphBuildWorker = FeedAgentGraphBuildWorker() + private var graphBuildTask: Task? + private var graphBuildSequence = 0 + private var isAgentTreeActive = false init() { storeInstalledObserver = NotificationCenter.default.addObserver( @@ -28,6 +32,7 @@ final class FeedPanelViewModel: ObservableObject { } deinit { + graphBuildTask?.cancel() if let storeInstalledObserver { NotificationCenter.default.removeObserver(storeInstalledObserver) } @@ -38,7 +43,7 @@ final class FeedPanelViewModel: ObservableObject { withObservationTracking { let currentItems = store.items items = currentItems - agentGraphSnapshot = WorkstreamAgentGraphBuilder.snapshot(from: currentItems) + scheduleAgentGraphRebuildIfNeeded(from: currentItems) hasMorePersistedItems = store.hasMorePersistedItems isLoadingOlderItems = store.isLoadingOlderItems } onChange: { [weak self] in @@ -54,6 +59,54 @@ final class FeedPanelViewModel: ObservableObject { 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 + agentGraphSnapshot = .empty + } + } + + private func scheduleAgentGraphRebuildIfNeeded(from currentItems: [WorkstreamItem]) { + guard isAgentTreeActive else { + graphBuildSequence &+= 1 + graphBuildTask?.cancel() + graphBuildTask = nil + if !agentGraphSnapshot.isEmpty { + agentGraphSnapshot = .empty + } + return + } + + graphBuildSequence &+= 1 + let sequence = graphBuildSequence + graphBuildTask?.cancel() + graphBuildTask = Task { [weak self, currentItems, sequence] in + try? await Task.sleep(nanoseconds: 100_000_000) + guard let self, !Task.isCancelled else { return } + guard let snapshot = await self.graphBuildWorker.snapshot(from: currentItems) else { + return + } + guard !Task.isCancelled, + self.isAgentTreeActive, + self.graphBuildSequence == sequence + else { return } + self.agentGraphSnapshot = snapshot + } + } +} + +private actor FeedAgentGraphBuildWorker { + func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot? { + guard !Task.isCancelled else { return nil } + return WorkstreamAgentGraphBuilder.snapshot(from: items) + } } struct FeedHistoryLoadMoreRow: View { From 7dc1492aefc0cd8064adb334db35077834dbfc41 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 01:32:32 -0700 Subject: [PATCH 07/42] fix: honor parent source in agent graph --- .../Sources/CMUXWorkstream/WorkstreamAgentGraph.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 04cc38c6b155..ac8da38bb721 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -504,6 +504,11 @@ private struct AgentGraphMetadata { .flatMap(WorkstreamSource.init(wireName:)) } + var parentSource: WorkstreamSource? { + string(keys: ["parent_source", "parentSource", "parent_agent_source", "parentAgentSource"]) + .flatMap(WorkstreamSource.init(wireName:)) + } + var model: String? { string(keys: ["model", "subagent_model", "subagentModel"]) } @@ -525,7 +530,7 @@ private struct AgentGraphMetadata { return value } if let sessionId = string(keys: ["parent_session_id", "parentSessionId", "parentSessionID"]) { - return "\(source.rawValue)-\(sessionId)" + return "\((parentSource ?? source).rawValue)-\(sessionId)" } return nil } From 09c9115821bd242b1a1606a3d13a2430176a133f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:28:12 -0700 Subject: [PATCH 08/42] test: cover ambiguous agent spawn pruning --- .../WorkstreamAgentGraphTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index ede928f343ad..f810215ad2d7 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -121,4 +121,45 @@ struct WorkstreamAgentGraphTests { #expect(child?.kind == .session) #expect(child?.workstreamId == "claude-child") } + + @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) + } } From dab7ff3707ac34d24d8a4ef605be6e75794e5e72 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:29:07 -0700 Subject: [PATCH 09/42] fix: keep ambiguous agent spawns --- CLI/cmux.swift | 2 -- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 17 +++++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 05ee12895d05..3686b5f3f5e7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15253,8 +15253,6 @@ struct CMUXCLI { return 80 case "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description": return 240 - case "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", "child_workstream_id", "childWorkstreamId", "child_session_id", "childSessionId", "childSessionID", "subagent_workstream_id", "subagentWorkstreamId", "subagent_session_id", "subagentSessionId": - return 160 default: return 160 } diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index ac8da38bb721..2ad2318eea20 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -170,12 +170,12 @@ public nonisolated enum WorkstreamAgentGraphBuilder { ) { guard var spawns = pendingSpawnsByParent[parentWorkstreamId], !spawns.isEmpty else { return } - let index = bestResolvedSpawnIndex( + guard let index = bestResolvedSpawnIndex( in: spawns, metadata: metadata, childSource: childSource, childWorkspaceId: childWorkspaceId - ) + ) else { return } spawns.remove(at: index) if spawns.isEmpty { pendingSpawnsByParent[parentWorkstreamId] = nil @@ -189,9 +189,10 @@ public nonisolated enum WorkstreamAgentGraphBuilder { metadata: AgentGraphMetadata, childSource: WorkstreamSource, childWorkspaceId: String? - ) -> Int { - var bestIndex = spawns.startIndex - var bestScore = Int.min + ) -> Int? { + var bestIndex: Int? + var bestScore = 0 + var hasBestScoreTie = false for (index, spawn) in spawns.enumerated() { var score = 0 if spawn.source == childSource { @@ -217,12 +218,16 @@ public nonisolated enum WorkstreamAgentGraphBuilder { taskDescription == spawnTaskDescription { score += 4 } + guard score > 0 else { continue } if score > bestScore { bestIndex = index bestScore = score + hasBestScoreTie = false + } else if score == bestScore { + hasBestScoreTie = true } } - return bestIndex + return hasBestScoreTie ? nil : bestIndex } var childrenByParent: [String: [String]] = [:] From b3c5d9d8a5bfd0552b3a7bc644bd63087148c930 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:36:31 -0700 Subject: [PATCH 10/42] fix: coalesce agent graph rebuilds --- Sources/Feed/FeedPanelViewModel.swift | 60 ++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 49475df55d76..4d542e551f07 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -16,6 +16,8 @@ final class FeedPanelViewModel: ObservableObject { private let graphBuildWorker = FeedAgentGraphBuildWorker() private var graphBuildTask: Task? private var graphBuildSequence = 0 + private var pendingGraphBuildRequest: AgentGraphBuildRequest? + private var activeGraphBuildSequence: Int? private var isAgentTreeActive = false init() { @@ -69,6 +71,8 @@ final class FeedPanelViewModel: ObservableObject { graphBuildSequence &+= 1 graphBuildTask?.cancel() graphBuildTask = nil + pendingGraphBuildRequest = nil + activeGraphBuildSequence = nil agentGraphSnapshot = .empty } } @@ -78,6 +82,8 @@ final class FeedPanelViewModel: ObservableObject { graphBuildSequence &+= 1 graphBuildTask?.cancel() graphBuildTask = nil + pendingGraphBuildRequest = nil + activeGraphBuildSequence = nil if !agentGraphSnapshot.isEmpty { agentGraphSnapshot = .empty } @@ -85,21 +91,55 @@ final class FeedPanelViewModel: ObservableObject { } graphBuildSequence &+= 1 - let sequence = graphBuildSequence - graphBuildTask?.cancel() - graphBuildTask = Task { [weak self, currentItems, sequence] in - try? await Task.sleep(nanoseconds: 100_000_000) + 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: currentItems) else { + guard let snapshot = await self.graphBuildWorker.snapshot(from: request.items) else { + self.completeAgentGraphBuild(sequence: request.sequence, snapshot: nil) return } - guard !Task.isCancelled, - self.isAgentTreeActive, - self.graphBuildSequence == sequence - else { return } - self.agentGraphSnapshot = snapshot + 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 actor FeedAgentGraphBuildWorker { From bca988f15761407c160d49b100a0404fe9b52485 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:39:29 -0700 Subject: [PATCH 11/42] test: cover parent child spawn pruning --- .../WorkstreamAgentGraphTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index f810215ad2d7..9bf832d1d2b1 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -122,6 +122,39 @@ struct WorkstreamAgentGraphTests { #expect(child?.workstreamId == "claude-child") } + @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("Explicit child without a unique metadata match keeps pending spawns") func explicitChildWithoutUniqueMetadataMatchKeepsPendingSpawns() { let store = WorkstreamStore(ringCapacity: 10) From f33a6f71808da86e6afcecfa7897b2299c609ad0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:39:39 -0700 Subject: [PATCH 12/42] fix: centralize agent child linking --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 2ad2318eea20..2f063d58b23b 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -121,6 +121,25 @@ public nonisolated enum WorkstreamAgentGraphBuilder { records[workstreamId] = record } + func linkChildSession( + childWorkstreamId: String, + parentWorkstreamId: String, + metadata: AgentGraphMetadata, + childSource: WorkstreamSource, + childWorkspaceId: String? + ) { + updateRecord(childWorkstreamId) { record in + record.parentWorkstreamId = parentWorkstreamId + record.merge(metadata: metadata) + } + pruneResolvedSpawn( + parentWorkstreamId: parentWorkstreamId, + metadata: metadata, + childSource: childSource, + childWorkspaceId: childWorkspaceId + ) + } + for item in items.sorted(by: { $0.createdAt < $1.createdAt }) { ensureRecord( workstreamId: item.workstreamId, @@ -134,11 +153,8 @@ public nonisolated enum WorkstreamAgentGraphBuilder { let metadata = AgentGraphMetadata(item: item) if let parentWorkstreamId = metadata.parentWorkstreamId(source: item.source) { - updateRecord(item.workstreamId) { record in - record.parentWorkstreamId = parentWorkstreamId - record.merge(metadata: metadata) - } - pruneResolvedSpawn( + linkChildSession( + childWorkstreamId: item.workstreamId, parentWorkstreamId: parentWorkstreamId, metadata: metadata, childSource: item.source, @@ -147,16 +163,20 @@ public nonisolated enum WorkstreamAgentGraphBuilder { } if let childWorkstreamId = metadata.childWorkstreamId(source: item.source) { + let childSource = metadata.childSource ?? item.source ensureRecord( workstreamId: childWorkstreamId, - source: metadata.childSource ?? item.source, + source: childSource, createdAt: item.createdAt, workspaceId: item.workspaceId ) - updateRecord(childWorkstreamId) { record in - record.parentWorkstreamId = item.workstreamId - record.merge(metadata: metadata) - } + linkChildSession( + childWorkstreamId: childWorkstreamId, + parentWorkstreamId: item.workstreamId, + metadata: metadata, + childSource: childSource, + childWorkspaceId: item.workspaceId + ) } else if let spawn = SpawnRecord(item: item, metadata: metadata) { pendingSpawnsByParent[item.workstreamId, default: []].append(spawn) } From ee38cbae6e23598788136c8b6ff17217239cc351 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:10:43 -0700 Subject: [PATCH 13/42] fix: address agent graph review feedback --- CLI/cmux.swift | 1 + .../Sources/CMUXWorkstream/WorkstreamAgentGraph.swift | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3686b5f3f5e7..c2e4c3487a38 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -17958,6 +17958,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { "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", diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 2f063d58b23b..4d2b5b3976b6 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -357,9 +357,6 @@ private struct SessionRecord { toolInputJSON: toolInputJSON ) merge(metadata: metadata) - if Self.isSpawnTool(toolName) { - mergeTaskDescription(metadata.taskDescription) - } default: break } From c830c1604b96d31829f07b5542c485b0d73181ad Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:28:49 -0700 Subject: [PATCH 14/42] fix: address agent tree review findings --- .../CMUXWorkstream/WorkstreamStore.swift | 41 ++++- Sources/Feed/FeedAgentTreeView.swift | 67 ++++--- Sources/Feed/FeedCoordinator.swift | 2 +- Sources/Feed/FeedPanelView.swift | 166 +++++++++++++++++- 4 files changed, 250 insertions(+), 26 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift index b4cdfbbb315c..79f20bd69ab3 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift @@ -210,11 +210,50 @@ public final class WorkstreamStore { status: status, payload: payload, context: context(for: event, payload: payload), - extraFieldsJSON: event.extraFieldsJSON, + 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", "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/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index 1e50b46abc69..bc8d8d540dbf 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -4,33 +4,46 @@ import SwiftUI struct FeedAgentTreeView: View { let graph: WorkstreamAgentGraphSnapshot let actions: FeedRowActions - - @State private var collapsedNodeIds: Set = [] + @Binding var collapsedNodeIds: Set + let selectedNodeId: String? + let isKeyboardActive: Bool + let scrollRequest: FeedAgentTreeScrollRequest? + let onSelect: (WorkstreamAgentTreeNode) -> Void var body: some View { VStack(spacing: 0) { summaryBar - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(flattenedRows) { row in - FeedAgentTreeRowView( - row: row, - isCollapsed: collapsedNodeIds.contains(row.node.id), - onToggle: { - toggle(row.node.id) - }, - onFocus: { - if let workstreamId = row.node.focusWorkstreamId { - actions.jump(workstreamId) + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(flattenedRows) { row in + FeedAgentTreeRowView( + row: row, + isCollapsed: collapsedNodeIds.contains(row.node.id), + isSelected: selectedNodeId == row.node.id, + isFocusActive: isKeyboardActive && selectedNodeId == row.node.id, + onToggle: { + toggle(row.node.id) + }, + onFocus: { + onSelect(row.node) + if let workstreamId = row.node.focusWorkstreamId { + actions.jump(workstreamId) + } } - } - ) - .equatable() + ) + .id(row.node.id) + .equatable() + } } + .frame(maxWidth: .infinity, alignment: .leading) + } + .feedZeroScrollContentMargins() + .onChange(of: scrollRequest) { request in + guard let request else { return } + proxy.scrollTo(request.nodeId, anchor: .center) } - .frame(maxWidth: .infinity, alignment: .leading) } - .feedZeroScrollContentMargins() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } @@ -101,11 +114,16 @@ private struct FeedAgentTreeRow: Identifiable, Equatable { 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.row == rhs.row + && lhs.isCollapsed == rhs.isCollapsed + && lhs.isSelected == rhs.isSelected + && lhs.isFocusActive == rhs.isFocusActive } var body: some View { @@ -136,6 +154,7 @@ private struct FeedAgentTreeRowView: View, Equatable { } .padding(.horizontal, 12) .padding(.vertical, 8) + .background(rowBackgroundFill) .contentShape(Rectangle()) .onTapGesture(perform: onFocus) Rectangle() @@ -146,6 +165,14 @@ private struct FeedAgentTreeRowView: View, Equatable { .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.. = [] + @State private var selectedAgentTreeNodeId: String? + @State private var agentTreeScrollRequest: FeedAgentTreeScrollRequest? + @State private var agentTreeScrollRequestSequence = 0 var body: some View { let rowActions = FeedRowActions.bound() let snapshots = filter == .agentTree ? [] : visibleSnapshots(items) let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil let focusSnapshots = activityGroups?.ordered ?? snapshots + let agentTreeFocusTargets = filter == .agentTree ? visibleAgentTreeFocusTargets(agentGraphSnapshot) : [] ScrollViewReader { proxy in Group { if filter == .agentTree { @@ -188,7 +193,14 @@ private struct FeedListView: View { } else { FeedAgentTreeView( graph: agentGraphSnapshot, - actions: rowActions + actions: rowActions, + collapsedNodeIds: $collapsedAgentTreeNodeIds, + selectedNodeId: selectedAgentTreeNodeId, + isKeyboardActive: focusSnapshot.isKeyboardActive, + scrollRequest: agentTreeScrollRequest, + onSelect: { node in + selectAgentTreeNode(node, focusFeed: false) + } ) } } else { @@ -217,13 +229,25 @@ private struct FeedListView: View { syncFeedFocusSnapshot(window: window) }, onMoveSelection: { delta in - moveSelection(in: focusSnapshots, delta: delta) + if filter == .agentTree { + moveAgentTreeSelection(in: agentTreeFocusTargets, delta: delta) + } else { + moveSelection(in: focusSnapshots, delta: delta) + } }, onActivateSelection: { - activateSelection(in: focusSnapshots, actions: rowActions) + if filter == .agentTree { + activateAgentTreeSelection(in: agentTreeFocusTargets, actions: rowActions) + } else { + activateSelection(in: focusSnapshots, actions: rowActions) + } }, onFocusFirstItemRequested: { - focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + if filter == .agentTree { + focusFirstAgentTreeItem(in: agentTreeFocusTargets, focusHost: false) + } else { + focusFirstVisibleItem(in: focusSnapshots, focusHost: false) + } }, onFocusChanged: { focused in let window = activeFeedWindow() @@ -450,6 +474,29 @@ private struct FeedListView: View { } } + private func visibleAgentTreeFocusTargets( + _ graph: WorkstreamAgentGraphSnapshot + ) -> [FeedAgentTreeFocusTarget] { + var targets: [FeedAgentTreeFocusTarget] = [] + for root in graph.roots { + appendAgentTreeFocusTargets(node: root, to: &targets) + } + return targets + } + + private func appendAgentTreeFocusTargets( + node: WorkstreamAgentTreeNode, + to targets: inout [FeedAgentTreeFocusTarget] + ) { + if let target = FeedAgentTreeFocusTarget(node: node) { + targets.append(target) + } + guard !collapsedAgentTreeNodeIds.contains(node.id) else { return } + for child in node.children { + appendAgentTreeFocusTargets(node: child, to: &targets) + } + } + private func prefersStableSurface(_ snapshot: FeedItemSnapshot) -> Bool { snapshot.status.isPending || snapshot.kind == .stop } @@ -531,6 +578,28 @@ private struct FeedListView: View { return ids.first } + private func focusFirstAgentTreeItem( + in targets: [FeedAgentTreeFocusTarget], + focusHost: Bool = true + ) { + guard let target = preferredAgentTreeFocusTarget(in: targets) else { + let window = activeFeedWindow() + updateAgentTreeFocusIntent(focusHost: focusHost, window: window) + return + } + selectAgentTreeNode(target, focusFeed: focusHost) + } + + private func preferredAgentTreeFocusTarget( + in targets: [FeedAgentTreeFocusTarget] + ) -> FeedAgentTreeFocusTarget? { + if let selectedAgentTreeNodeId, + let target = targets.first(where: { $0.nodeId == selectedAgentTreeNodeId }) { + return target + } + return targets.first + } + private func moveSelection(in snapshots: [FeedItemSnapshot], delta: Int) { guard !snapshots.isEmpty else { return } let ids = snapshots.map(\.id) @@ -559,6 +628,22 @@ private struct FeedListView: View { #endif } + private func moveAgentTreeSelection( + in targets: [FeedAgentTreeFocusTarget], + delta: Int + ) { + guard !targets.isEmpty else { return } + let nodeIds = targets.map(\.nodeId) + let targetIndex: Int + if let selectedAgentTreeNodeId, + let currentIndex = nodeIds.firstIndex(of: selectedAgentTreeNodeId) { + targetIndex = min(max(currentIndex + delta, 0), nodeIds.count - 1) + } else { + targetIndex = delta >= 0 ? 0 : nodeIds.count - 1 + } + selectAgentTreeNode(targets[targetIndex], focusFeed: false) + } + private func activateSelection( in snapshots: [FeedItemSnapshot], actions: FeedRowActions @@ -575,6 +660,63 @@ private struct FeedListView: View { actions.jump(snapshot.workstreamId) } + private func activateAgentTreeSelection( + in targets: [FeedAgentTreeFocusTarget], + actions: FeedRowActions + ) { + guard let target = preferredAgentTreeFocusTarget(in: targets) else { return } + selectAgentTreeNode(target, focusFeed: true) + actions.jump(target.focusWorkstreamId) + } + + private func selectAgentTreeNode( + _ node: WorkstreamAgentTreeNode, + focusFeed: Bool + ) { + guard let target = FeedAgentTreeFocusTarget(node: node) else { + selectedAgentTreeNodeId = node.id + updateAgentTreeFocusIntent(focusHost: focusFeed) + return + } + selectAgentTreeNode(target, focusFeed: focusFeed) + } + + private func selectAgentTreeNode( + _ target: FeedAgentTreeFocusTarget, + focusFeed: Bool + ) { + selectedAgentTreeNodeId = target.nodeId + updateAgentTreeFocusIntent(focusHost: focusFeed) + agentTreeScrollRequestSequence &+= 1 + agentTreeScrollRequest = FeedAgentTreeScrollRequest( + nodeId: target.nodeId, + sequence: agentTreeScrollRequestSequence + ) +#if DEBUG + dlog( + "feed.agentTree.focus.select node=\(target.nodeId.prefix(16)) " + + "focusFeed=\(focusFeed ? 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 } @@ -634,6 +776,22 @@ private struct FeedScrollRequest: Equatable { let sequence: Int } +private struct FeedAgentTreeFocusTarget: Equatable { + let nodeId: String + let focusWorkstreamId: String + + init?(node: WorkstreamAgentTreeNode) { + guard let focusWorkstreamId = node.focusWorkstreamId else { return nil } + self.nodeId = node.id + self.focusWorkstreamId = focusWorkstreamId + } +} + +struct FeedAgentTreeScrollRequest: Equatable { + let nodeId: String + let sequence: Int +} + struct FeedStopDraft: Equatable { var reply = "" From a0390a7ccf1ee459c1f547085147bd6e9b7ae7a4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:33:31 -0700 Subject: [PATCH 15/42] fix: simplify agent tree view type checking --- Sources/Feed/FeedAgentTreeView.swift | 76 ++++++++++++++++------------ 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index bc8d8d540dbf..44b0085c2282 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -13,41 +13,55 @@ struct FeedAgentTreeView: View { var body: some View { VStack(spacing: 0) { summaryBar - ScrollViewReader { proxy in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(flattenedRows) { row in - FeedAgentTreeRowView( - row: row, - isCollapsed: collapsedNodeIds.contains(row.node.id), - isSelected: selectedNodeId == row.node.id, - isFocusActive: isKeyboardActive && selectedNodeId == row.node.id, - onToggle: { - toggle(row.node.id) - }, - onFocus: { - onSelect(row.node) - if let workstreamId = row.node.focusWorkstreamId { - actions.jump(workstreamId) - } - } - ) - .id(row.node.id) - .equatable() - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .feedZeroScrollContentMargins() - .onChange(of: scrollRequest) { request in - guard let request else { return } - proxy.scrollTo(request.nodeId, anchor: .center) - } - } + 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) { + ForEach(flattenedRows) { row in + rowView(for: row) + } + } + .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: { + toggle(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") From a52e30937642b0c3f74b318e217d01091ab83611 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:42:37 -0700 Subject: [PATCH 16/42] fix: share agent spawn tool detection --- CLI/cmux.swift | 24 +++++++++++-------- GhosttyTabs.xcodeproj/project.pbxproj | 3 +++ .../CMUXWorkstream/WorkstreamAgentGraph.swift | 3 +-- .../WorkstreamAgentSpawnTool.swift | 8 +++++++ 4 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentSpawnTool.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c2e4c3487a38..c4e6a604912d 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 @@ -17953,7 +17954,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { toolName: String? ) { guard let rawObject else { return } - let keys = [ + let graphKeys = [ "parent_workstream_id", "parentWorkstreamId", "parent_workstream", "parentWorkstream", "parent_session_id", "parentSessionId", "parentSessionID", "child_workstream_id", "childWorkstreamId", "subagent_workstream_id", "subagentWorkstreamId", @@ -17962,8 +17963,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { "child_source", "childSource", "subagent_source", "subagentSource", "subagent_type", "subagentType", "agent_type", "agentType", "model", "subagent_model", "subagentModel", - "task_description", "taskDescription", "description", "title", "name", + "task_description", "taskDescription", ] + let genericKeys = ["description", "title", "name"] func copyValue(_ raw: Any?, key: String) { guard event[key] == nil else { return } @@ -17977,23 +17979,25 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } } - for key in keys { + for key in graphKeys { copyValue(rawObject[key], key: key) } - guard let toolName, isAgentSpawnToolName(toolName), + 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 keys + ["prompt", "message"] { + for key in graphKeys + genericKeys + ["prompt", "message"] { copyValue(toolInput[key], key: key) } } - private func isAgentSpawnToolName(_ toolName: String) -> Bool { - let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized == "task" || normalized == "agent" - } - 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"]) { diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3652461cc0e1..6daaa3a3df12 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.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 */; }; @@ -964,6 +965,7 @@ buildActionMask = 2147483647; files = ( B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, + AA11BB22CC33DD44EE550004 /* CMUXWorkstream in Frameworks */, A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1542,6 +1544,7 @@ name = "cmux-cli"; packageProductDependencies = ( A5001251 /* Sentry */, + AA11BB22CC33DD44EE550002 /* CMUXWorkstream */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, ); productName = cmux; diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 4d2b5b3976b6..ce4c5f5916e8 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -429,8 +429,7 @@ private struct SessionRecord { } static func isSpawnTool(_ toolName: String) -> Bool { - let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized == "task" || normalized == "agent" + WorkstreamAgentSpawnTool.isSpawnToolName(toolName) } } 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" + } +} From b2ca36e71f4d97554e4634ac4d108e3001e0e929 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:43:58 -0700 Subject: [PATCH 17/42] fix: avoid invalidating inactive agent tree builds --- Sources/Feed/FeedPanelViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 4d542e551f07..1013120e9031 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -79,7 +79,6 @@ final class FeedPanelViewModel: ObservableObject { private func scheduleAgentGraphRebuildIfNeeded(from currentItems: [WorkstreamItem]) { guard isAgentTreeActive else { - graphBuildSequence &+= 1 graphBuildTask?.cancel() graphBuildTask = nil pendingGraphBuildRequest = nil From 812784fef15bad6a2abcc7e4a95b0b96315fe59a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:07:27 -0700 Subject: [PATCH 18/42] fix: address agent tree follow-up reviews --- CLI/cmux.swift | 12 ++- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 34 +++++++- Resources/Localizable.xcstrings | 86 ++++++++++++++++++- Sources/Feed/FeedAgentTreeView.swift | 37 ++++++-- Sources/Feed/FeedPanelView.swift | 6 +- Sources/Feed/FeedPanelViewModel.swift | 5 +- 6 files changed, 162 insertions(+), 18 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c4e6a604912d..f237b32267c5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -15164,10 +15164,14 @@ struct CMUXCLI { "event", "event_name", "hook_event_name", "type", "kind", "notification_type", "matcher", "reason", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description", "model", "subagent_model", "subagentModel", - "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", + "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 = compactClaudeHookValue(object[key], key: key) { compact[key] = value @@ -15179,10 +15183,14 @@ struct CMUXCLI { for key in [ "file_path", "command", "pattern", "description", "query", "plan", "planFilePath", "prompt", "message", "model", "subagent_model", "subagentModel", - "parent_workstream_id", "parentWorkstreamId", "parent_session_id", "parentSessionId", "parentSessionID", + "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 diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index ce4c5f5916e8..40bb7b75547f 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -89,6 +89,10 @@ public nonisolated struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equat public nonisolated enum WorkstreamAgentGraphBuilder { public static func snapshot(from items: [WorkstreamItem]) -> WorkstreamAgentGraphSnapshot { + func shouldCancel() -> Bool { + Task.isCancelled + } + var records: [String: SessionRecord] = [:] var creationOrder: [String] = [] var pendingSpawnsByParent: [String: [SpawnRecord]] = [:] @@ -140,7 +144,17 @@ public nonisolated enum WorkstreamAgentGraphBuilder { ) } + func sourceFromWorkstreamId(_ workstreamId: String) -> WorkstreamSource? { + for source in WorkstreamSource.allCases.sorted(by: { $0.rawValue.count > $1.rawValue.count }) { + 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, @@ -163,19 +177,21 @@ public nonisolated enum WorkstreamAgentGraphBuilder { } if let childWorkstreamId = metadata.childWorkstreamId(source: item.source) { - let childSource = metadata.childSource ?? item.source + let childSource = metadata.childSource + ?? sourceFromWorkstreamId(childWorkstreamId) + ?? item.source ensureRecord( workstreamId: childWorkstreamId, source: childSource, createdAt: item.createdAt, - workspaceId: item.workspaceId + workspaceId: nil ) linkChildSession( childWorkstreamId: childWorkstreamId, parentWorkstreamId: item.workstreamId, metadata: metadata, childSource: childSource, - childWorkspaceId: item.workspaceId + childWorkspaceId: nil ) } else if let spawn = SpawnRecord(item: item, metadata: metadata) { pendingSpawnsByParent[item.workstreamId, default: []].append(spawn) @@ -214,6 +230,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { 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 += 2 @@ -252,6 +269,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { 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 @@ -282,12 +300,14 @@ public nonisolated enum WorkstreamAgentGraphBuilder { 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 @@ -306,7 +326,13 @@ public nonisolated enum WorkstreamAgentGraphBuilder { return record.node(children: children) } - let treeRoots = roots.compactMap { makeNode($0, depth: 0) } + 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, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 03f0703b09d8..8cdcc3c0df06 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110446,19 +110446,101 @@ } } }, + "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": "%lld nodes · %lld edges · depth %lld" + "value": "%@ · %@ · depth %lld" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%lldノード · %lldエッジ · 深さ%lld" + "value": "%@ · %@ · 深さ%lld" } } } diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index 44b0085c2282..a5cf567cfb6c 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -24,7 +24,7 @@ struct FeedAgentTreeView: View { rowStack } .feedZeroScrollContentMargins() - .onChange(of: scrollRequest) { request in + .onChange(of: scrollRequest) { _, request in guard let request else { return } proxy.scrollTo(request.nodeId, anchor: .center) } @@ -78,13 +78,36 @@ struct FeedAgentTreeView: View { } private var summaryText: String { - String.localizedStringWithFormat( - String( - localized: "feed.agentTree.summary", - defaultValue: "%lld nodes · %lld edges · depth %lld" + let nodeText = String.localizedStringWithFormat( + NSLocalizedString( + "feed.agentTree.nodes", + tableName: nil, + bundle: .main, + value: "%lld nodes", + comment: "Agent tree node count" ), - graph.nodeCount, - graph.edgeCount, + 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 ) } diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 900e8b1d9ba4..381db0a2f795 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -215,7 +215,7 @@ private struct FeedListView: View { } } } - .onChange(of: scrollRequest) { request in + .onChange(of: scrollRequest) { _, request in guard let request else { return } proxy.scrollTo(request.id, anchor: .top) } @@ -664,7 +664,9 @@ private struct FeedListView: View { in targets: [FeedAgentTreeFocusTarget], actions: FeedRowActions ) { - guard let target = preferredAgentTreeFocusTarget(in: targets) else { return } + let target = targets.first(where: { $0.nodeId == selectedAgentTreeNodeId }) + ?? (selectedAgentTreeNodeId == nil ? targets.first : nil) + guard let target else { return } selectAgentTreeNode(target, focusFeed: true) actions.jump(target.focusWorkstreamId) } diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 1013120e9031..805c83c3d04e 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -42,10 +42,13 @@ final class FeedPanelViewModel: ObservableObject { private func arm() { guard let store = FeedCoordinator.shared.store else { return } + let previousItems = items withObservationTracking { let currentItems = store.items items = currentItems - scheduleAgentGraphRebuildIfNeeded(from: currentItems) + if currentItems != previousItems { + scheduleAgentGraphRebuildIfNeeded(from: currentItems) + } hasMorePersistedItems = store.hasMorePersistedItems isLoadingOlderItems = store.isLoadingOlderItems } onChange: { [weak self] in From 4d61c044dae9fc33978716a401e39b65a1fec599 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:11:07 -0700 Subject: [PATCH 19/42] fix: polish agent tree pending state --- .../Sources/CMUXWorkstream/WorkstreamAgentGraph.swift | 2 +- Sources/Feed/FeedPanelView.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 40bb7b75547f..25a29be96336 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -496,7 +496,7 @@ private struct SpawnRecord { title: title, model: model, subagentType: subagentType, - status: .running, + status: .waiting, taskDescription: taskDescription, childCount: 0, children: [] diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 381db0a2f795..4215c57ed1fb 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -882,6 +882,7 @@ private struct FeedRowSurface: View { } } +// Shared by the feed list and the agent tree view. extension View { @ViewBuilder func feedZeroScrollContentMargins() -> some View { From c95c6f3e7a120c6f8a6c4a30db5914cfb06e6dd1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:14:04 -0700 Subject: [PATCH 20/42] fix: reduce agent tree rebuild work --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 15 ++- Sources/Feed/FeedAgentTreeView.swift | 25 +--- Sources/Feed/FeedPanelView.swift | 114 +++++++++++++----- 3 files changed, 99 insertions(+), 55 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 25a29be96336..c4dc1677fa20 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -88,6 +88,9 @@ public nonisolated struct WorkstreamAgentGraphSnapshot: Codable, Sendable, Equat } 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 @@ -145,7 +148,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { } func sourceFromWorkstreamId(_ workstreamId: String) -> WorkstreamSource? { - for source in WorkstreamSource.allCases.sorted(by: { $0.rawValue.count > $1.rawValue.count }) { + for source in Self.sourcesByDescendingPrefixLength { if workstreamId.hasPrefix("\(source.rawValue)-") { return source } @@ -161,11 +164,11 @@ public nonisolated enum WorkstreamAgentGraphBuilder { createdAt: item.createdAt, workspaceId: item.workspaceId ) + let metadata = AgentGraphMetadata(item: item) updateRecord(item.workstreamId) { record in - record.absorb(item) + record.absorb(item, metadata: metadata) } - let metadata = AgentGraphMetadata(item: item) if let parentWorkstreamId = metadata.parentWorkstreamId(source: item.source) { linkChildSession( childWorkstreamId: item.workstreamId, @@ -356,7 +359,7 @@ private struct SessionRecord { let createdAt: Date var updatedAt: Date - mutating func absorb(_ item: WorkstreamItem) { + mutating func absorb(_ item: WorkstreamItem, metadata: AgentGraphMetadata? = nil) { updatedAt = max(updatedAt, item.updatedAt) if workspaceId == nil { workspaceId = item.workspaceId @@ -376,13 +379,13 @@ private struct SessionRecord { case .userPrompt(let text): mergeTaskDescription(text) case .toolUse(let toolName, let toolInputJSON): - let metadata = AgentGraphMetadata( + let resolvedMetadata = metadata ?? AgentGraphMetadata( source: item.source, extraFieldsJSON: item.extraFieldsJSON, toolName: toolName, toolInputJSON: toolInputJSON ) - merge(metadata: metadata) + merge(metadata: resolvedMetadata) default: break } diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index a5cf567cfb6c..9c409c59f4dc 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -3,6 +3,7 @@ import SwiftUI struct FeedAgentTreeView: View { let graph: WorkstreamAgentGraphSnapshot + let rows: [FeedAgentTreeRow] let actions: FeedRowActions @Binding var collapsedNodeIds: Set let selectedNodeId: String? @@ -33,7 +34,7 @@ struct FeedAgentTreeView: View { private var rowStack: some View { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(flattenedRows) { row in + ForEach(rows) { row in rowView(for: row) } } @@ -112,26 +113,6 @@ struct FeedAgentTreeView: View { ) } - private var flattenedRows: [FeedAgentTreeRow] { - var rows: [FeedAgentTreeRow] = [] - for root in graph.roots { - append(node: root, depth: 0, to: &rows) - } - return rows - } - - private func append( - node: WorkstreamAgentTreeNode, - depth: Int, - to rows: inout [FeedAgentTreeRow] - ) { - rows.append(FeedAgentTreeRow(node: node, depth: depth)) - guard !collapsedNodeIds.contains(node.id) else { return } - for child in node.children { - append(node: child, depth: depth + 1, to: &rows) - } - } - private func toggle(_ id: String) { if collapsedNodeIds.contains(id) { collapsedNodeIds.remove(id) @@ -141,7 +122,7 @@ struct FeedAgentTreeView: View { } } -private struct FeedAgentTreeRow: Identifiable, Equatable { +struct FeedAgentTreeRow: Identifiable, Equatable { let node: WorkstreamAgentTreeNode let depth: Int diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 4215c57ed1fb..7dc267be7762 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -178,13 +178,14 @@ private struct FeedListView: View { @State private var selectedAgentTreeNodeId: String? @State private var agentTreeScrollRequest: FeedAgentTreeScrollRequest? @State private var agentTreeScrollRequestSequence = 0 + @State private var agentTreeVisibleSnapshot = FeedAgentTreeVisibleSnapshot.empty var body: some View { let rowActions = FeedRowActions.bound() let snapshots = filter == .agentTree ? [] : visibleSnapshots(items) let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil let focusSnapshots = activityGroups?.ordered ?? snapshots - let agentTreeFocusTargets = filter == .agentTree ? visibleAgentTreeFocusTargets(agentGraphSnapshot) : [] + let visibleAgentTree = agentTreeVisibleSnapshot ScrollViewReader { proxy in Group { if filter == .agentTree { @@ -193,6 +194,7 @@ private struct FeedListView: View { } else { FeedAgentTreeView( graph: agentGraphSnapshot, + rows: visibleAgentTree.rows, actions: rowActions, collapsedNodeIds: $collapsedAgentTreeNodeIds, selectedNodeId: selectedAgentTreeNodeId, @@ -230,21 +232,21 @@ private struct FeedListView: View { }, onMoveSelection: { delta in if filter == .agentTree { - moveAgentTreeSelection(in: agentTreeFocusTargets, delta: delta) + moveAgentTreeSelection(in: visibleAgentTree.focusTargets, delta: delta) } else { moveSelection(in: focusSnapshots, delta: delta) } }, onActivateSelection: { if filter == .agentTree { - activateAgentTreeSelection(in: agentTreeFocusTargets, actions: rowActions) + activateAgentTreeSelection(in: visibleAgentTree.focusTargets, actions: rowActions) } else { activateSelection(in: focusSnapshots, actions: rowActions) } }, onFocusFirstItemRequested: { if filter == .agentTree { - focusFirstAgentTreeItem(in: agentTreeFocusTargets, focusHost: false) + focusFirstAgentTreeItem(in: visibleAgentTree.focusTargets, focusHost: false) } else { focusFirstVisibleItem(in: focusSnapshots, focusHost: false) } @@ -262,6 +264,18 @@ private struct FeedListView: View { ) .frame(width: 1, height: 1) ) + .onAppear { + refreshAgentTreeVisibleSnapshot() + } + .onChange(of: filter) { _, _ in + refreshAgentTreeVisibleSnapshot() + } + .onChange(of: agentGraphSnapshot) { _, _ in + refreshAgentTreeVisibleSnapshot() + } + .onChange(of: collapsedAgentTreeNodeIds) { _, _ in + refreshAgentTreeVisibleSnapshot() + } } } @@ -474,29 +488,6 @@ private struct FeedListView: View { } } - private func visibleAgentTreeFocusTargets( - _ graph: WorkstreamAgentGraphSnapshot - ) -> [FeedAgentTreeFocusTarget] { - var targets: [FeedAgentTreeFocusTarget] = [] - for root in graph.roots { - appendAgentTreeFocusTargets(node: root, to: &targets) - } - return targets - } - - private func appendAgentTreeFocusTargets( - node: WorkstreamAgentTreeNode, - to targets: inout [FeedAgentTreeFocusTarget] - ) { - if let target = FeedAgentTreeFocusTarget(node: node) { - targets.append(target) - } - guard !collapsedAgentTreeNodeIds.contains(node.id) else { return } - for child in node.children { - appendAgentTreeFocusTargets(node: child, to: &targets) - } - } - private func prefersStableSurface(_ snapshot: FeedItemSnapshot) -> Bool { snapshot.status.isPending || snapshot.kind == .stop } @@ -560,6 +551,21 @@ private struct FeedListView: View { scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) } + private func refreshAgentTreeVisibleSnapshot() { + let nextSnapshot: FeedAgentTreeVisibleSnapshot + if filter == .agentTree { + nextSnapshot = FeedAgentTreeVisibleSnapshot( + graph: agentGraphSnapshot, + collapsedNodeIds: collapsedAgentTreeNodeIds + ) + } else { + nextSnapshot = .empty + } + if agentTreeVisibleSnapshot != nextSnapshot { + agentTreeVisibleSnapshot = nextSnapshot + } + } + private func preferredFocusItemId(in snapshots: [FeedItemSnapshot]) -> UUID? { let ids = snapshots.map(\.id) let window = activeFeedWindow() @@ -778,6 +784,60 @@ private struct FeedScrollRequest: Equatable { let sequence: Int } +private 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)) + if let target = FeedAgentTreeFocusTarget(node: node) { + focusTargets.append(target) + } + guard !collapsedNodeIds.contains(node.id) else { return } + for child in node.children { + append( + node: child, + depth: depth + 1, + collapsedNodeIds: collapsedNodeIds, + rows: &rows, + focusTargets: &focusTargets + ) + } + } +} + private struct FeedAgentTreeFocusTarget: Equatable { let nodeId: String let focusWorkstreamId: String From 36076bce7f90eac984ea89aa160c6ce6036858c8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:10:50 -0700 Subject: [PATCH 21/42] fix: preserve agent tree session metadata --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 11 +++------- .../WorkstreamAgentGraphTests.swift | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index c4dc1677fa20..fad080d47ba4 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -368,6 +368,9 @@ private struct SessionRecord { title = Self.basename(cwd) } + let resolvedMetadata = metadata ?? AgentGraphMetadata(item: item) + merge(metadata: resolvedMetadata) + switch item.status { case .pending: status = .waiting @@ -378,14 +381,6 @@ private struct SessionRecord { switch item.payload { case .userPrompt(let text): mergeTaskDescription(text) - case .toolUse(let toolName, let toolInputJSON): - let resolvedMetadata = metadata ?? AgentGraphMetadata( - source: item.source, - extraFieldsJSON: item.extraFieldsJSON, - toolName: toolName, - toolInputJSON: toolInputJSON - ) - merge(metadata: resolvedMetadata) default: break } diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 9bf832d1d2b1..4315964a769e 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -65,6 +65,26 @@ struct WorkstreamAgentGraphTests { #expect(child?.focusWorkstreamId == "claude-child") } + @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") + } + @Test("Non-spawn tool input does not create graph metadata") func nonSpawnToolInputDoesNotCreateGraphMetadata() { let store = WorkstreamStore(ringCapacity: 10) From 5ac8709fc35f866856ed22be9f75b9d17ea9ab6a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:57:18 -0700 Subject: [PATCH 22/42] fix: tighten agent tree ownership --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + .../CMUXWorkstream/WorkstreamAgentGraph.swift | 57 ++- .../WorkstreamAgentGraphTests.swift | 33 ++ Sources/Feed/FeedAgentTreeController.swift | 171 ++++++++ Sources/Feed/FeedAgentTreeView.swift | 12 +- Sources/Feed/FeedKeyboardFocusBridge.swift | 192 ++++++++ Sources/Feed/FeedPanelView.swift | 411 ++---------------- Sources/Feed/FeedPanelViewModel.swift | 46 +- 8 files changed, 526 insertions(+), 404 deletions(-) create mode 100644 Sources/Feed/FeedAgentTreeController.swift create mode 100644 Sources/Feed/FeedKeyboardFocusBridge.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6daaa3a3df12..958d0a887f77 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -90,6 +90,8 @@ 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 */; }; @@ -527,6 +529,8 @@ 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 = ""; }; @@ -1019,7 +1023,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 */, @@ -1963,7 +1969,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/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index fad080d47ba4..57bab83c8099 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -196,7 +196,8 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childSource: childSource, childWorkspaceId: nil ) - } else if let spawn = SpawnRecord(item: item, metadata: metadata) { + } else if let spawn = SpawnRecord(item: item, metadata: metadata), + !hasResolvedChild(parentWorkstreamId: item.workstreamId, spawn: spawn) { pendingSpawnsByParent[item.workstreamId, default: []].append(spawn) } } @@ -270,6 +271,28 @@ public nonisolated enum WorkstreamAgentGraphBuilder { 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 } @@ -500,6 +523,38 @@ private struct SpawnRecord { children: [] ) } + + func resolutionScore(matching record: SessionRecord) -> Int { + var score = 0 + var hasMetadataMatch = false + if source == record.source { + score += 1 + } + if let workspaceId, + let recordWorkspaceId = record.workspaceId, + workspaceId == recordWorkspaceId { + score += 1 + } + if let subagentType, + let recordSubagentType = record.subagentType, + subagentType == recordSubagentType { + score += 4 + hasMetadataMatch = true + } + if let model, + let recordModel = record.model, + model == recordModel { + score += 3 + hasMetadataMatch = true + } + if let taskDescription, + let recordTaskDescription = record.taskDescription, + taskDescription == recordTaskDescription { + score += 5 + hasMetadataMatch = true + } + return hasMetadataMatch ? score : 0 + } } private struct AgentGraphMetadata { diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 4315964a769e..8b35aa7d58a5 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -142,6 +142,39 @@ struct WorkstreamAgentGraphTests { #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("Parent child metadata prunes matching pending spawn") func parentChildMetadataPrunesMatchingPendingSpawn() { let store = WorkstreamStore(ringCapacity: 10) diff --git a/Sources/Feed/FeedAgentTreeController.swift b/Sources/Feed/FeedAgentTreeController.swift new file mode 100644 index 000000000000..9d5c8aa4ec7f --- /dev/null +++ b/Sources/Feed/FeedAgentTreeController.swift @@ -0,0 +1,171 @@ +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 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 { + targetIndex = delta >= 0 ? 0 : nodeIds.count - 1 + } + return select(targets[targetIndex], focusFeed: false) + } + + func activate( + in targets: [FeedAgentTreeFocusTarget] + ) -> FeedAgentTreeSelectionEffect? { + guard let target = preferredTarget(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 + } +} + +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 index 9c409c59f4dc..4bc7cca0fe21 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -5,10 +5,11 @@ struct FeedAgentTreeView: View { let graph: WorkstreamAgentGraphSnapshot let rows: [FeedAgentTreeRow] let actions: FeedRowActions - @Binding var collapsedNodeIds: Set + let collapsedNodeIds: Set let selectedNodeId: String? let isKeyboardActive: Bool let scrollRequest: FeedAgentTreeScrollRequest? + let onToggle: (String) -> Void let onSelect: (WorkstreamAgentTreeNode) -> Void var body: some View { @@ -50,7 +51,7 @@ struct FeedAgentTreeView: View { isSelected: rowIsSelected, isFocusActive: isKeyboardActive && rowIsSelected, onToggle: { - toggle(nodeId) + onToggle(nodeId) }, onFocus: { onSelect(row.node) @@ -113,13 +114,6 @@ struct FeedAgentTreeView: View { ) } - private func toggle(_ id: String) { - if collapsedNodeIds.contains(id) { - collapsedNodeIds.remove(id) - } else { - collapsedNodeIds.insert(id) - } - } } struct FeedAgentTreeRow: Identifiable, Equatable { diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift new file mode 100644 index 000000000000..8d8ca02076d4 --- /dev/null +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -0,0 +1,192 @@ +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)? + + 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 + } +} diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 7dc267be7762..93269a0dcbad 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 { @@ -76,7 +70,7 @@ struct FeedPanelView: View { } @State private var filter: Filter = .actionable - @StateObject private var viewModel = FeedPanelViewModel() + @State private var viewModel = FeedPanelViewModel() var body: some View { VStack(spacing: 0) { @@ -174,18 +168,14 @@ private struct FeedListView: View { @State private var scrollRequest: FeedScrollRequest? @State private var scrollRequestSequence = 0 @State private var stopDrafts: [UUID: FeedStopDraft] = [:] - @State private var collapsedAgentTreeNodeIds: Set = [] - @State private var selectedAgentTreeNodeId: String? - @State private var agentTreeScrollRequest: FeedAgentTreeScrollRequest? - @State private var agentTreeScrollRequestSequence = 0 - @State private var agentTreeVisibleSnapshot = FeedAgentTreeVisibleSnapshot.empty + @State private var agentTreeController = FeedAgentTreeController() var body: some View { let rowActions = FeedRowActions.bound() let snapshots = filter == .agentTree ? [] : visibleSnapshots(items) let activityGroups = filter == .activity ? activitySnapshotGroups(snapshots) : nil let focusSnapshots = activityGroups?.ordered ?? snapshots - let visibleAgentTree = agentTreeVisibleSnapshot + let visibleAgentTree = agentTreeController.visibleSnapshot(from: agentGraphSnapshot) ScrollViewReader { proxy in Group { if filter == .agentTree { @@ -196,12 +186,17 @@ private struct FeedListView: View { graph: agentGraphSnapshot, rows: visibleAgentTree.rows, actions: rowActions, - collapsedNodeIds: $collapsedAgentTreeNodeIds, - selectedNodeId: selectedAgentTreeNodeId, + collapsedNodeIds: agentTreeController.collapsedNodeIds, + selectedNodeId: agentTreeController.selectedNodeId, isKeyboardActive: focusSnapshot.isKeyboardActive, - scrollRequest: agentTreeScrollRequest, + scrollRequest: agentTreeController.scrollRequest, + onToggle: { nodeId in + agentTreeController.toggle(nodeId) + }, onSelect: { node in - selectAgentTreeNode(node, focusFeed: false) + applyAgentTreeSelectionEffect( + agentTreeController.select(node, focusFeed: false) + ) } ) } @@ -232,21 +227,35 @@ private struct FeedListView: View { }, onMoveSelection: { delta in if filter == .agentTree { - moveAgentTreeSelection(in: visibleAgentTree.focusTargets, delta: delta) + if let effect = agentTreeController.move(in: visibleAgentTree.focusTargets, delta: delta) { + applyAgentTreeSelectionEffect(effect) + } } else { moveSelection(in: focusSnapshots, delta: delta) } }, onActivateSelection: { if filter == .agentTree { - activateAgentTreeSelection(in: visibleAgentTree.focusTargets, actions: rowActions) + 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: { if filter == .agentTree { - focusFirstAgentTreeItem(in: visibleAgentTree.focusTargets, focusHost: false) + if let effect = agentTreeController.focusFirst( + in: visibleAgentTree.focusTargets, + focusHost: false + ) { + applyAgentTreeSelectionEffect(effect) + } else { + updateAgentTreeFocusIntent(focusHost: false) + } } else { focusFirstVisibleItem(in: focusSnapshots, focusHost: false) } @@ -264,18 +273,6 @@ private struct FeedListView: View { ) .frame(width: 1, height: 1) ) - .onAppear { - refreshAgentTreeVisibleSnapshot() - } - .onChange(of: filter) { _, _ in - refreshAgentTreeVisibleSnapshot() - } - .onChange(of: agentGraphSnapshot) { _, _ in - refreshAgentTreeVisibleSnapshot() - } - .onChange(of: collapsedAgentTreeNodeIds) { _, _ in - refreshAgentTreeVisibleSnapshot() - } } } @@ -551,21 +548,6 @@ private struct FeedListView: View { scrollRequest = FeedScrollRequest(id: targetId, sequence: scrollRequestSequence) } - private func refreshAgentTreeVisibleSnapshot() { - let nextSnapshot: FeedAgentTreeVisibleSnapshot - if filter == .agentTree { - nextSnapshot = FeedAgentTreeVisibleSnapshot( - graph: agentGraphSnapshot, - collapsedNodeIds: collapsedAgentTreeNodeIds - ) - } else { - nextSnapshot = .empty - } - if agentTreeVisibleSnapshot != nextSnapshot { - agentTreeVisibleSnapshot = nextSnapshot - } - } - private func preferredFocusItemId(in snapshots: [FeedItemSnapshot]) -> UUID? { let ids = snapshots.map(\.id) let window = activeFeedWindow() @@ -584,28 +566,6 @@ private struct FeedListView: View { return ids.first } - private func focusFirstAgentTreeItem( - in targets: [FeedAgentTreeFocusTarget], - focusHost: Bool = true - ) { - guard let target = preferredAgentTreeFocusTarget(in: targets) else { - let window = activeFeedWindow() - updateAgentTreeFocusIntent(focusHost: focusHost, window: window) - return - } - selectAgentTreeNode(target, focusFeed: focusHost) - } - - private func preferredAgentTreeFocusTarget( - in targets: [FeedAgentTreeFocusTarget] - ) -> FeedAgentTreeFocusTarget? { - if let selectedAgentTreeNodeId, - let target = targets.first(where: { $0.nodeId == selectedAgentTreeNodeId }) { - return target - } - return targets.first - } - private func moveSelection(in snapshots: [FeedItemSnapshot], delta: Int) { guard !snapshots.isEmpty else { return } let ids = snapshots.map(\.id) @@ -634,22 +594,6 @@ private struct FeedListView: View { #endif } - private func moveAgentTreeSelection( - in targets: [FeedAgentTreeFocusTarget], - delta: Int - ) { - guard !targets.isEmpty else { return } - let nodeIds = targets.map(\.nodeId) - let targetIndex: Int - if let selectedAgentTreeNodeId, - let currentIndex = nodeIds.firstIndex(of: selectedAgentTreeNodeId) { - targetIndex = min(max(currentIndex + delta, 0), nodeIds.count - 1) - } else { - targetIndex = delta >= 0 ? 0 : nodeIds.count - 1 - } - selectAgentTreeNode(targets[targetIndex], focusFeed: false) - } - private func activateSelection( in snapshots: [FeedItemSnapshot], actions: FeedRowActions @@ -666,46 +610,14 @@ private struct FeedListView: View { actions.jump(snapshot.workstreamId) } - private func activateAgentTreeSelection( - in targets: [FeedAgentTreeFocusTarget], - actions: FeedRowActions - ) { - let target = targets.first(where: { $0.nodeId == selectedAgentTreeNodeId }) - ?? (selectedAgentTreeNodeId == nil ? targets.first : nil) - guard let target else { return } - selectAgentTreeNode(target, focusFeed: true) - actions.jump(target.focusWorkstreamId) - } - - private func selectAgentTreeNode( - _ node: WorkstreamAgentTreeNode, - focusFeed: Bool - ) { - guard let target = FeedAgentTreeFocusTarget(node: node) else { - selectedAgentTreeNodeId = node.id - updateAgentTreeFocusIntent(focusHost: focusFeed) - return - } - selectAgentTreeNode(target, focusFeed: focusFeed) - } - - private func selectAgentTreeNode( - _ target: FeedAgentTreeFocusTarget, - focusFeed: Bool - ) { - selectedAgentTreeNodeId = target.nodeId - updateAgentTreeFocusIntent(focusHost: focusFeed) - agentTreeScrollRequestSequence &+= 1 - agentTreeScrollRequest = FeedAgentTreeScrollRequest( - nodeId: target.nodeId, - sequence: agentTreeScrollRequestSequence - ) -#if DEBUG + private func applyAgentTreeSelectionEffect(_ effect: FeedAgentTreeSelectionEffect) { + updateAgentTreeFocusIntent(focusHost: effect.focusHost) + #if DEBUG dlog( - "feed.agentTree.focus.select node=\(target.nodeId.prefix(16)) " + - "focusFeed=\(focusFeed ? 1 : 0)" + "feed.agentTree.focus.select node=\(effect.nodeId.prefix(16)) " + + "focusFeed=\(effect.focusHost ? 1 : 0)" ) -#endif + #endif } private func updateAgentTreeFocusIntent(focusHost: Bool, window: NSWindow? = nil) { @@ -784,76 +696,6 @@ private struct FeedScrollRequest: Equatable { let sequence: Int } -private 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)) - if let target = FeedAgentTreeFocusTarget(node: node) { - focusTargets.append(target) - } - guard !collapsedNodeIds.contains(node.id) else { return } - for child in node.children { - append( - node: child, - depth: depth + 1, - collapsedNodeIds: collapsedNodeIds, - rows: &rows, - focusTargets: &focusTargets - ) - } - } -} - -private struct FeedAgentTreeFocusTarget: Equatable { - let nodeId: String - let focusWorkstreamId: String - - init?(node: WorkstreamAgentTreeNode) { - guard let focusWorkstreamId = node.focusWorkstreamId else { return nil } - self.nodeId = node.id - self.focusWorkstreamId = focusWorkstreamId - } -} - -struct FeedAgentTreeScrollRequest: Equatable { - let nodeId: String - let sequence: Int -} - struct FeedStopDraft: Equatable { var reply = "" @@ -954,189 +796,6 @@ 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 805c83c3d04e..bda717697558 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -3,22 +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 agentGraphSnapshot: WorkstreamAgentGraphSnapshot = .empty - @Published private(set) var hasMorePersistedItems = false - @Published private(set) var isLoadingOlderItems = false - private var storeInstalledObserver: NSObjectProtocol? - private let graphBuildWorker = FeedAgentGraphBuildWorker() - private var graphBuildTask: Task? - private var graphBuildSequence = 0 - private var pendingGraphBuildRequest: AgentGraphBuildRequest? - private var activeGraphBuildSequence: Int? - private var isAgentTreeActive = false +@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( @@ -35,6 +38,7 @@ final class FeedPanelViewModel: ObservableObject { deinit { graphBuildTask?.cancel() + loadOlderItemsTask?.cancel() if let storeInstalledObserver { NotificationCenter.default.removeObserver(storeInstalledObserver) } @@ -58,10 +62,16 @@ final class FeedPanelViewModel: ObservableObject { } } - nonisolated func loadOlderItems() { - Task { @MainActor [weak self] in - guard let self, !self.isLoadingOlderItems, self.hasMorePersistedItems else { return } + func loadOlderItems() { + guard !isLoadingOlderItems, hasMorePersistedItems else { return } + loadOlderItemsSequence &+= 1 + let sequence = loadOlderItemsSequence + loadOlderItemsTask?.cancel() + loadOlderItemsTask = Task { @MainActor [weak self, sequence] in + guard let self, !Task.isCancelled else { return } await FeedCoordinator.shared.store?.loadOlderItems() + guard self.loadOlderItemsSequence == sequence else { return } + self.loadOlderItemsTask = nil } } From d5c1f7121cd02e011f14de54be7445e8778fa565 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:00:08 -0700 Subject: [PATCH 23/42] fix: use app debug log for feed focus bridge --- Sources/Feed/FeedKeyboardFocusBridge.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift index 8d8ca02076d4..2935feb08a51 100644 --- a/Sources/Feed/FeedKeyboardFocusBridge.swift +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -54,7 +54,7 @@ final class FeedKeyboardFocusView: NSView { guard let window else { return } AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) #if DEBUG - dlog("feed.focus.host attach window=\(ObjectIdentifier(window))") + cmuxDebugLog("feed.focus.host attach window=\(ObjectIdentifier(window))") #endif } @@ -71,7 +71,7 @@ final class FeedKeyboardFocusView: NSView { override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.type == .keyDown, event.keyCode == 53 { #if DEBUG - dlog( + cmuxDebugLog( "feed.focus.host escape window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + "fr=\(feedDebugResponderSummary(window?.firstResponder))" ) @@ -89,7 +89,7 @@ final class FeedKeyboardFocusView: NSView { override func keyDown(with event: NSEvent) { #if DEBUG let chars = event.charactersIgnoringModifiers ?? "" - dlog( + cmuxDebugLog( "feed.focus.host keyDown key=\(event.keyCode) chars=\(chars) " + "fr=\(feedDebugResponderSummary(window?.firstResponder))" ) @@ -138,7 +138,7 @@ final class FeedKeyboardFocusView: NSView { onFocusChanged?(true) } #if DEBUG - dlog( + cmuxDebugLog( "feed.focus.host become result=\(result ? 1 : 0) " + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + "fr=\(feedDebugResponderSummary(window?.firstResponder))" @@ -153,7 +153,7 @@ final class FeedKeyboardFocusView: NSView { onFocusChanged?(false) } #if DEBUG - dlog( + cmuxDebugLog( "feed.focus.host resign result=\(result ? 1 : 0) " + "window=\(window.map { String(describing: ObjectIdentifier($0)) } ?? "nil") " + "fr=\(feedDebugResponderSummary(window?.firstResponder))" @@ -173,7 +173,7 @@ final class FeedKeyboardFocusView: NSView { #endif let result = window.makeFirstResponder(self) #if DEBUG - dlog( + cmuxDebugLog( "feed.focus.host request result=\(result ? 1 : 0) " + "window=\(ObjectIdentifier(window)) before=\(before) " + "after=\(feedDebugResponderSummary(window.firstResponder))" From 5dedfe8889884e2842eacffcdf72a1d8be96df3e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:40:28 -0700 Subject: [PATCH 24/42] fix: let agent tree page older feed history --- Sources/Feed/FeedAgentTreeView.swift | 34 ++++++++++++++++++++-- Sources/Feed/FeedPanelView.swift | 41 +++++++++++++-------------- Sources/Feed/FeedPanelViewModel.swift | 13 ++++++--- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index 4bc7cca0fe21..aa9a03d42265 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -9,6 +9,9 @@ struct FeedAgentTreeView: View { 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 @@ -35,8 +38,18 @@ struct FeedAgentTreeView: View { private var rowStack: some View { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(rows) { row in - rowView(for: row) + if rows.isEmpty { + emptyState + } else { + ForEach(rows) { row in + rowView(for: row) + } + } + if showsLoadMore { + FeedHistoryLoadMoreRow( + isLoading: isLoadingOlderItems, + action: onLoadOlderItems + ) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -114,6 +127,23 @@ struct FeedAgentTreeView: View { ) } + 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 { diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 93269a0dcbad..0316699e2927 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -179,27 +179,26 @@ private struct FeedListView: View { ScrollViewReader { proxy in Group { if filter == .agentTree { - if agentGraphSnapshot.isEmpty { - emptyState - } else { - FeedAgentTreeView( - graph: agentGraphSnapshot, - rows: visibleAgentTree.rows, - actions: rowActions, - collapsedNodeIds: agentTreeController.collapsedNodeIds, - selectedNodeId: agentTreeController.selectedNodeId, - isKeyboardActive: focusSnapshot.isKeyboardActive, - scrollRequest: agentTreeController.scrollRequest, - onToggle: { nodeId in - agentTreeController.toggle(nodeId) - }, - onSelect: { node in - applyAgentTreeSelectionEffect( - agentTreeController.select(node, focusFeed: false) - ) - } - ) - } + 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) + }, + onSelect: { node in + applyAgentTreeSelectionEffect( + agentTreeController.select(node, focusFeed: false) + ) + } + ) } else { if snapshots.isEmpty && !shouldShowActivityHistoryLoader { emptyState diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index bda717697558..95b764be8759 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -63,15 +63,20 @@ final class FeedPanelViewModel { } func loadOlderItems() { - guard !isLoadingOlderItems, hasMorePersistedItems else { return } + guard !isLoadingOlderItems, + hasMorePersistedItems, + loadOlderItemsTask == nil + else { return } loadOlderItemsSequence &+= 1 let sequence = loadOlderItemsSequence - loadOlderItemsTask?.cancel() 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() - guard self.loadOlderItemsSequence == sequence else { return } - self.loadOlderItemsTask = nil } } From e20963b30ee9d4696677a84ce0fb8dbd7f808f65 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:52:46 -0700 Subject: [PATCH 25/42] fix: remove duplicate agent tree empty state --- Sources/Feed/FeedPanelView.swift | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 0316699e2927..25dee9ddbfd6 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -655,28 +655,30 @@ private struct FeedListView: View { .frame(height: 1) } + @ViewBuilder private var emptyState: some View { - let title: String - let subtitle: String switch filter { case .actionable: - 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.") + 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: - 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.") + 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: - title = String(localized: "feed.empty.agentTree.title", - defaultValue: "No agent tree yet") - subtitle = String(localized: "feed.empty.agentTree.subtitle", - defaultValue: "Task-spawned subagents will appear here as parent and child activity arrives.") + EmptyView() } + } - return VStack(spacing: 4) { + private func emptyStateContent(title: String, subtitle: String) -> some View { + VStack(spacing: 4) { Text(title) .font(.system(size: 12)) .foregroundColor(.secondary) From e0d2bb3ae53147d41dd70a3a78bfa176191497c2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 14:02:11 -0700 Subject: [PATCH 26/42] fix: avoid duplicate agent metadata text --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 2 +- .../WorkstreamAgentGraphTests.swift | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 57bab83c8099..bb21c65a0f92 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -622,7 +622,7 @@ private struct AgentGraphMetadata { } var taskDescription: String? { - string(keys: ["task_description", "taskDescription", "prompt", "message", "description"]) + string(keys: ["task_description", "taskDescription", "prompt", "message"]) } func parentWorkstreamId(source: WorkstreamSource) -> String? { diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 8b35aa7d58a5..3a94cbfb34f4 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -83,6 +83,32 @@ struct WorkstreamAgentGraphTests { #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") From a86f82e06e32934be98e703988e9f6b5e379c28c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:09:41 -0700 Subject: [PATCH 27/42] fix: isolate feed store observation snapshot --- Sources/Feed/FeedPanelViewModel.swift | 32 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/Sources/Feed/FeedPanelViewModel.swift b/Sources/Feed/FeedPanelViewModel.swift index 95b764be8759..204cbcda907d 100644 --- a/Sources/Feed/FeedPanelViewModel.swift +++ b/Sources/Feed/FeedPanelViewModel.swift @@ -46,20 +46,28 @@ final class FeedPanelViewModel { private func arm() { guard let store = FeedCoordinator.shared.store else { return } - let previousItems = items - withObservationTracking { - let currentItems = store.items - items = currentItems - if currentItems != previousItems { - scheduleAgentGraphRebuildIfNeeded(from: currentItems) - } - 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) + } + + 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() { @@ -159,6 +167,12 @@ private struct AgentGraphBuildRequest { 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 } From 0e211567474b46ae46f7f230d99123f9afb477c0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:07:07 -0700 Subject: [PATCH 28/42] fix: align agent spawn resolution scoring --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 30 +++++++++----- .../WorkstreamAgentGraphTests.swift | 40 +++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index bb21c65a0f92..f2bcbd840c9f 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -237,27 +237,27 @@ public nonisolated enum WorkstreamAgentGraphBuilder { guard !shouldCancel() else { return nil } var score = 0 if spawn.source == childSource { - score += 2 + score += SpawnResolutionScore.linkedChildSource } if let childWorkspaceId, let workspaceId = spawn.workspaceId, childWorkspaceId == workspaceId { - score += 2 + score += SpawnResolutionScore.linkedChildWorkspace } if let subagentType = metadata.subagentType, let spawnSubagentType = spawn.subagentType, subagentType == spawnSubagentType { - score += 4 + score += SpawnResolutionScore.subagentType } if let model = metadata.model, let spawnModel = spawn.model, model == spawnModel { - score += 3 + score += SpawnResolutionScore.model } if let taskDescription = metadata.taskDescription, let spawnTaskDescription = spawn.taskDescription, taskDescription == spawnTaskDescription { - score += 4 + score += SpawnResolutionScore.taskDescription } guard score > 0 else { continue } if score > bestScore { @@ -369,6 +369,16 @@ public nonisolated enum WorkstreamAgentGraphBuilder { } } +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 @@ -528,29 +538,29 @@ private struct SpawnRecord { var score = 0 var hasMetadataMatch = false if source == record.source { - score += 1 + score += SpawnResolutionScore.existingChildSource } if let workspaceId, let recordWorkspaceId = record.workspaceId, workspaceId == recordWorkspaceId { - score += 1 + score += SpawnResolutionScore.existingChildWorkspace } if let subagentType, let recordSubagentType = record.subagentType, subagentType == recordSubagentType { - score += 4 + score += SpawnResolutionScore.subagentType hasMetadataMatch = true } if let model, let recordModel = record.model, model == recordModel { - score += 3 + score += SpawnResolutionScore.model hasMetadataMatch = true } if let taskDescription, let recordTaskDescription = record.taskDescription, taskDescription == recordTaskDescription { - score += 5 + score += SpawnResolutionScore.taskDescription hasMetadataMatch = true } return hasMetadataMatch ? score : 0 diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 3a94cbfb34f4..6cbc4ed6b4f0 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -201,6 +201,46 @@ struct WorkstreamAgentGraphTests { #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) From f4ec1f18660feecf2055f6bca6992d433860a808 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:18:56 -0700 Subject: [PATCH 29/42] fix: address agent tree review feedback --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 6 +--- .../WorkstreamAgentGraphTests.swift | 33 +++++++++++++++++++ Sources/Feed/FeedAgentTreeView.swift | 4 ++- Sources/Feed/FeedKeyboardFocusBridge.swift | 1 - 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index f2bcbd840c9f..440ecee90224 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -536,7 +536,6 @@ private struct SpawnRecord { func resolutionScore(matching record: SessionRecord) -> Int { var score = 0 - var hasMetadataMatch = false if source == record.source { score += SpawnResolutionScore.existingChildSource } @@ -549,21 +548,18 @@ private struct SpawnRecord { let recordSubagentType = record.subagentType, subagentType == recordSubagentType { score += SpawnResolutionScore.subagentType - hasMetadataMatch = true } if let model, let recordModel = record.model, model == recordModel { score += SpawnResolutionScore.model - hasMetadataMatch = true } if let taskDescription, let recordTaskDescription = record.taskDescription, taskDescription == recordTaskDescription { score += SpawnResolutionScore.taskDescription - hasMetadataMatch = true } - return hasMetadataMatch ? score : 0 + return score } } diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 6cbc4ed6b4f0..1e567b805884 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -201,6 +201,39 @@ struct WorkstreamAgentGraphTests { #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) diff --git a/Sources/Feed/FeedAgentTreeView.swift b/Sources/Feed/FeedAgentTreeView.swift index aa9a03d42265..5cbba130d1c6 100644 --- a/Sources/Feed/FeedAgentTreeView.swift +++ b/Sources/Feed/FeedAgentTreeView.swift @@ -17,7 +17,9 @@ struct FeedAgentTreeView: View { var body: some View { VStack(spacing: 0) { - summaryBar + if !graph.isEmpty { + summaryBar + } treeScrollView } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift index 2935feb08a51..0bd3d8f23e4a 100644 --- a/Sources/Feed/FeedKeyboardFocusBridge.swift +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -34,7 +34,6 @@ struct FeedKeyboardFocusBridge: NSViewRepresentable { nsView.onFocusFirstItemRequested = onFocusFirstItemRequested nsView.onFocusChanged = onFocusChanged nsView.onFocusSnapshotChanged = onFocusSnapshotChanged - nsView.registerWithKeyboardFocusCoordinatorIfNeeded() } } From bb23aad7dee1797c7993bd108386c1155780ef2e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:30:19 -0700 Subject: [PATCH 30/42] fix: make feed focus registration idempotent --- Sources/Feed/FeedKeyboardFocusBridge.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift index 0bd3d8f23e4a..5e89dbd6a98a 100644 --- a/Sources/Feed/FeedKeyboardFocusBridge.swift +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -34,6 +34,7 @@ struct FeedKeyboardFocusBridge: NSViewRepresentable { nsView.onFocusFirstItemRequested = onFocusFirstItemRequested nsView.onFocusChanged = onFocusChanged nsView.onFocusSnapshotChanged = onFocusSnapshotChanged + nsView.registerWithKeyboardFocusCoordinatorIfNeeded() } } @@ -44,6 +45,7 @@ final class FeedKeyboardFocusView: NSView { 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 } @@ -58,8 +60,13 @@ final class FeedKeyboardFocusView: NSView { } func registerWithKeyboardFocusCoordinatorIfNeeded() { - guard let window else { return } + guard let window else { + registeredWindow = nil + return + } + guard registeredWindow !== window else { return } AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) + registeredWindow = window } override func layout() { From a2cd5262448ec07da1c6dea08ef318d5d23d9059 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 16:45:07 -0700 Subject: [PATCH 31/42] fix: reuse feed focus registration path --- Sources/Feed/FeedKeyboardFocusBridge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift index 5e89dbd6a98a..46d1284c7f52 100644 --- a/Sources/Feed/FeedKeyboardFocusBridge.swift +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -52,8 +52,8 @@ final class FeedKeyboardFocusView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + registerWithKeyboardFocusCoordinatorIfNeeded() guard let window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) #if DEBUG cmuxDebugLog("feed.focus.host attach window=\(ObjectIdentifier(window))") #endif From 6baf83338a94e73dd108f157889112d88da7e98d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:12:03 -0700 Subject: [PATCH 32/42] test: cover sanitized agent prompt metadata --- .../WorkstreamAgentGraphTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 1e567b805884..4ff135ddad86 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -65,6 +65,36 @@ struct WorkstreamAgentGraphTests { #expect(child?.focusWorkstreamId == "claude-child") } + @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) From 0184e891b18de775974f25a5f97719666ba016f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:00:05 -0700 Subject: [PATCH 33/42] fix: preserve agent prompt metadata --- .../CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift index 79f20bd69ab3..956cb7b1ed9d 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamStore.swift @@ -224,7 +224,7 @@ public final class WorkstreamStore { "child_source", "childSource", "subagent_source", "subagentSource", "subagent_type", "subagentType", "agent_type", "agentType", "model", "subagent_model", "subagentModel", - "task_description", "taskDescription", "description", "title", "name", + "task_description", "taskDescription", "prompt", "message", "description", "title", "name", ] private static func sanitizedExtraFieldsJSON(_ rawJSON: String?) -> String? { From 200adc5eb318bac730bfd978c30156ce8ee18f75 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:39:15 -0700 Subject: [PATCH 34/42] test: cover subagent model priority --- .../Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 4ff135ddad86..6ce14b1f11e3 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -21,7 +21,8 @@ struct WorkstreamAgentGraphTests { source: "claude", workspaceId: "workspace-1", toolName: "Task", - toolInputJSON: #"{"description":"Explore settings","subagent_type":"explorer","prompt":"Map settings code paths"}"# + 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) @@ -31,6 +32,7 @@ struct WorkstreamAgentGraphTests { 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") } From e5ad63d93b6dcc948fc2ca5faca11e6134b04c5b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:43:46 -0700 Subject: [PATCH 35/42] fix: prefer spawn input model metadata --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 440ecee90224..7ea3bff0d4c2 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -616,7 +616,8 @@ private struct AgentGraphMetadata { } var model: String? { - string(keys: ["model", "subagent_model", "subagentModel"]) + toolInputString(keys: ["subagent_model", "subagentModel", "model"]) + ?? string(keys: ["subagent_model", "subagentModel", "model"]) } var subagentType: String? { @@ -663,6 +664,15 @@ private struct AgentGraphMetadata { 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), From 38da68bff6161792d0848632a03ff5baf1e1fcf7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:09:54 -0700 Subject: [PATCH 36/42] fix: keep spawn metadata off parent agent nodes --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 97 ++++++++++++++----- .../WorkstreamAgentGraphTests.swift | 4 + 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 7ea3bff0d4c2..8a93fcf9af41 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -137,7 +137,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { ) { updateRecord(childWorkstreamId) { record in record.parentWorkstreamId = parentWorkstreamId - record.merge(metadata: metadata) + record.mergeChild(metadata: metadata) } pruneResolvedSpawn( parentWorkstreamId: parentWorkstreamId, @@ -244,17 +244,17 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childWorkspaceId == workspaceId { score += SpawnResolutionScore.linkedChildWorkspace } - if let subagentType = metadata.subagentType, + if let subagentType = metadata.childSubagentType, let spawnSubagentType = spawn.subagentType, subagentType == spawnSubagentType { score += SpawnResolutionScore.subagentType } - if let model = metadata.model, + if let model = metadata.childModel, let spawnModel = spawn.model, model == spawnModel { score += SpawnResolutionScore.model } - if let taskDescription = metadata.taskDescription, + if let taskDescription = metadata.childTaskDescription, let spawnTaskDescription = spawn.taskDescription, taskDescription == spawnTaskDescription { score += SpawnResolutionScore.taskDescription @@ -402,7 +402,7 @@ private struct SessionRecord { } let resolvedMetadata = metadata ?? AgentGraphMetadata(item: item) - merge(metadata: resolvedMetadata) + mergeSession(metadata: resolvedMetadata) switch item.status { case .pending: @@ -423,15 +423,28 @@ private struct SessionRecord { } } - mutating func merge(metadata: AgentGraphMetadata) { - if let model = metadata.model, !model.isEmpty { + mutating func mergeSession(metadata: AgentGraphMetadata) { + if let model = metadata.sessionModel, !model.isEmpty { self.model = model } - if let subagentType = metadata.subagentType, !subagentType.isEmpty { + if let subagentType = metadata.sessionSubagentType, !subagentType.isEmpty { self.subagentType = subagentType } - mergeTaskDescription(metadata.taskDescription) - if let description = metadata.description, !description.isEmpty, title == nil { + mergeTaskDescription(metadata.sessionTaskDescription) + if let description = metadata.sessionDescription, !description.isEmpty, title == nil { + title = description + } + } + + mutating func mergeChild(metadata: AgentGraphMetadata) { + if let model = metadata.childModel, !model.isEmpty { + self.model = model + } + if let subagentType = metadata.childSubagentType, !subagentType.isEmpty { + self.subagentType = subagentType + } + mergeTaskDescription(metadata.childTaskDescription) + if let description = metadata.childDescription, !description.isEmpty, title == nil { title = description } } @@ -507,12 +520,12 @@ private struct SpawnRecord { self.id = "spawn:\(item.id.uuidString)" self.source = item.source self.workspaceId = item.workspaceId - self.title = metadata.description - ?? metadata.subagentType + self.title = metadata.childDescription + ?? metadata.childSubagentType ?? String(toolName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) - self.model = metadata.model - self.subagentType = metadata.subagentType - self.taskDescription = metadata.taskDescription + self.model = metadata.childModel + self.subagentType = metadata.childSubagentType + self.taskDescription = metadata.childTaskDescription self.createdAt = item.createdAt } @@ -567,6 +580,7 @@ private struct AgentGraphMetadata { let source: WorkstreamSource let extra: [String: Any] let toolInput: [String: Any] + let isSpawnTool: Bool init(item: WorkstreamItem) { let toolName: String? @@ -598,7 +612,8 @@ private struct AgentGraphMetadata { self.source = source self.extra = Self.dictionary(from: extraFieldsJSON) let parsedToolInput = Self.dictionary(from: toolInputJSON) - if let toolName, SessionRecord.isSpawnTool(toolName) { + self.isSpawnTool = toolName.map(SessionRecord.isSpawnTool) ?? false + if isSpawnTool { self.toolInput = parsedToolInput } else { self.toolInput = [:] @@ -615,21 +630,46 @@ private struct AgentGraphMetadata { .flatMap(WorkstreamSource.init(wireName:)) } - var model: String? { + 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"]) + } + + var childModel: String? { toolInputString(keys: ["subagent_model", "subagentModel", "model"]) - ?? string(keys: ["subagent_model", "subagentModel", "model"]) + ?? extraString(keys: ["subagent_model", "subagentModel", "model"]) } - var subagentType: String? { - string(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + var childSubagentType: String? { + toolInputString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + ?? extraString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) } - var description: String? { - string(keys: ["description", "title", "name"]) + var childDescription: String? { + toolInputString(keys: ["description", "title", "name"]) + ?? extraString(keys: ["description", "title", "name"]) } - var taskDescription: String? { - string(keys: ["task_description", "taskDescription", "prompt", "message"]) + var childTaskDescription: String? { + toolInputString(keys: ["task_description", "taskDescription", "prompt", "message"]) + ?? extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) } func parentWorkstreamId(source: WorkstreamSource) -> String? { @@ -664,6 +704,15 @@ private struct AgentGraphMetadata { 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]) { diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 6ce14b1f11e3..a54361aef5fb 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -29,6 +29,10 @@ struct WorkstreamAgentGraphTests { #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") From 50a2d5eacf334cc2e4e11eb4d35c09d4194bdddb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:45:10 -0700 Subject: [PATCH 37/42] fix: keep parent models out of child nodes --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 41 +++++++++++----- .../WorkstreamAgentGraphTests.swift | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 8a93fcf9af41..872b39ca1f84 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -132,16 +132,18 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childWorkstreamId: String, parentWorkstreamId: String, metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, childSource: WorkstreamSource, childWorkspaceId: String? ) { updateRecord(childWorkstreamId) { record in record.parentWorkstreamId = parentWorkstreamId - record.mergeChild(metadata: metadata) + record.mergeChild(metadata: metadata, scope: metadataScope) } pruneResolvedSpawn( parentWorkstreamId: parentWorkstreamId, metadata: metadata, + metadataScope: metadataScope, childSource: childSource, childWorkspaceId: childWorkspaceId ) @@ -174,6 +176,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childWorkstreamId: item.workstreamId, parentWorkstreamId: parentWorkstreamId, metadata: metadata, + metadataScope: .childSession, childSource: item.source, childWorkspaceId: item.workspaceId ) @@ -193,6 +196,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childWorkstreamId: childWorkstreamId, parentWorkstreamId: item.workstreamId, metadata: metadata, + metadataScope: .parentDeclaration, childSource: childSource, childWorkspaceId: nil ) @@ -205,6 +209,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { func pruneResolvedSpawn( parentWorkstreamId: String, metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, childSource: WorkstreamSource, childWorkspaceId: String? ) { @@ -213,6 +218,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { guard let index = bestResolvedSpawnIndex( in: spawns, metadata: metadata, + metadataScope: metadataScope, childSource: childSource, childWorkspaceId: childWorkspaceId ) else { return } @@ -227,6 +233,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { func bestResolvedSpawnIndex( in spawns: [SpawnRecord], metadata: AgentGraphMetadata, + metadataScope: AgentGraphChildMetadataScope, childSource: WorkstreamSource, childWorkspaceId: String? ) -> Int? { @@ -249,12 +256,12 @@ public nonisolated enum WorkstreamAgentGraphBuilder { subagentType == spawnSubagentType { score += SpawnResolutionScore.subagentType } - if let model = metadata.childModel, + if let model = metadata.childModel(scope: metadataScope), let spawnModel = spawn.model, model == spawnModel { score += SpawnResolutionScore.model } - if let taskDescription = metadata.childTaskDescription, + if let taskDescription = metadata.childTaskDescription(scope: metadataScope), let spawnTaskDescription = spawn.taskDescription, taskDescription == spawnTaskDescription { score += SpawnResolutionScore.taskDescription @@ -436,14 +443,14 @@ private struct SessionRecord { } } - mutating func mergeChild(metadata: AgentGraphMetadata) { - if let model = metadata.childModel, !model.isEmpty { + mutating func mergeChild(metadata: AgentGraphMetadata, scope: AgentGraphChildMetadataScope) { + if let model = metadata.childModel(scope: scope), !model.isEmpty { self.model = model } if let subagentType = metadata.childSubagentType, !subagentType.isEmpty { self.subagentType = subagentType } - mergeTaskDescription(metadata.childTaskDescription) + mergeTaskDescription(metadata.childTaskDescription(scope: scope)) if let description = metadata.childDescription, !description.isEmpty, title == nil { title = description } @@ -523,9 +530,9 @@ private struct SpawnRecord { self.title = metadata.childDescription ?? metadata.childSubagentType ?? String(toolName.trimmingCharacters(in: .whitespacesAndNewlines).prefix(40)) - self.model = metadata.childModel + self.model = metadata.childModel(scope: .parentDeclaration) self.subagentType = metadata.childSubagentType - self.taskDescription = metadata.childTaskDescription + self.taskDescription = metadata.childTaskDescription(scope: .parentDeclaration) self.createdAt = item.createdAt } @@ -652,9 +659,14 @@ private struct AgentGraphMetadata { return extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) } - var childModel: String? { - toolInputString(keys: ["subagent_model", "subagentModel", "model"]) - ?? extraString(keys: ["subagent_model", "subagentModel", "model"]) + 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"]) + } } var childSubagentType: String? { @@ -667,7 +679,7 @@ private struct AgentGraphMetadata { ?? extraString(keys: ["description", "title", "name"]) } - var childTaskDescription: String? { + func childTaskDescription(scope _: AgentGraphChildMetadataScope) -> String? { toolInputString(keys: ["task_description", "taskDescription", "prompt", "message"]) ?? extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) } @@ -743,3 +755,8 @@ private struct AgentGraphMetadata { return nil } } + +private enum AgentGraphChildMetadataScope { + case childSession + case parentDeclaration +} diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index a54361aef5fb..320da9679ce7 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -41,6 +41,33 @@ struct WorkstreamAgentGraphTests { #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) @@ -343,6 +370,26 @@ struct WorkstreamAgentGraphTests { #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) From 5f9e48d74a427b585e37be3efad5570f7e166572 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:56:41 -0700 Subject: [PATCH 38/42] fix: scope child spawn metadata --- .../CMUXWorkstream/WorkstreamAgentGraph.swift | 45 ++++++++++++------- .../WorkstreamAgentGraphTests.swift | 38 ++++++++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift index 872b39ca1f84..7d6cc35ff13d 100644 --- a/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift +++ b/Packages/CMUXWorkstream/Sources/CMUXWorkstream/WorkstreamAgentGraph.swift @@ -251,7 +251,7 @@ public nonisolated enum WorkstreamAgentGraphBuilder { childWorkspaceId == workspaceId { score += SpawnResolutionScore.linkedChildWorkspace } - if let subagentType = metadata.childSubagentType, + if let subagentType = metadata.childSubagentType(scope: metadataScope), let spawnSubagentType = spawn.subagentType, subagentType == spawnSubagentType { score += SpawnResolutionScore.subagentType @@ -447,11 +447,11 @@ private struct SessionRecord { if let model = metadata.childModel(scope: scope), !model.isEmpty { self.model = model } - if let subagentType = metadata.childSubagentType, !subagentType.isEmpty { + if let subagentType = metadata.childSubagentType(scope: scope), !subagentType.isEmpty { self.subagentType = subagentType } mergeTaskDescription(metadata.childTaskDescription(scope: scope)) - if let description = metadata.childDescription, !description.isEmpty, title == nil { + if let description = metadata.childDescription(scope: scope), !description.isEmpty, title == nil { title = description } } @@ -527,11 +527,11 @@ private struct SpawnRecord { self.id = "spawn:\(item.id.uuidString)" self.source = item.source self.workspaceId = item.workspaceId - self.title = metadata.childDescription - ?? metadata.childSubagentType + 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 + self.subagentType = metadata.childSubagentType(scope: .parentDeclaration) self.taskDescription = metadata.childTaskDescription(scope: .parentDeclaration) self.createdAt = item.createdAt } @@ -669,19 +669,34 @@ private struct AgentGraphMetadata { } } - var childSubagentType: String? { - toolInputString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) - ?? extraString(keys: ["subagent_type", "subagentType", "agent_type", "agentType"]) + 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"]) + } } - var childDescription: String? { - toolInputString(keys: ["description", "title", "name"]) - ?? extraString(keys: ["description", "title", "name"]) + 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? { - toolInputString(keys: ["task_description", "taskDescription", "prompt", "message"]) - ?? extraString(keys: ["task_description", "taskDescription", "prompt", "message"]) + 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? { diff --git a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift index 320da9679ce7..a2b07bffa9e1 100644 --- a/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift +++ b/Packages/CMUXWorkstream/Tests/CMUXWorkstreamTests/WorkstreamAgentGraphTests.swift @@ -98,6 +98,44 @@ struct WorkstreamAgentGraphTests { #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) From e6042cf67efd5d81e4d9b94b0ad502d67dfc0d1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:01:59 -0700 Subject: [PATCH 39/42] merge: keep feature submodule pointers --- ghostty | 2 +- vendor/bonsplit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghostty b/ghostty index ff6e1260d2e7..aef980e27b58 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit ff6e1260d2e7767de55b8d9307b328e4060545b7 +Subproject commit aef980e27b584a9d914f1ff0499b13c6ed1973e0 diff --git a/vendor/bonsplit b/vendor/bonsplit index b5dd1f03993b..e1d9954a027a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b5dd1f03993b70d3aa4b079e347d557323844e67 +Subproject commit e1d9954a027aa35afe003b0ebdbf81875258e171 From 57e8a30529dc5b75eaff7cfae88e90b6d1516033 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:06:56 -0700 Subject: [PATCH 40/42] fix: align bonsplit pointer with merged workspace API --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index e1d9954a027a..b5dd1f03993b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit e1d9954a027aa35afe003b0ebdbf81875258e171 +Subproject commit b5dd1f03993b70d3aa4b079e347d557323844e67 From e7e0678e39b776a68b4ada1055e03c3f9a11604c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:08:55 -0700 Subject: [PATCH 41/42] fix: clear stale agent tree selection --- Sources/Feed/FeedAgentTreeController.swift | 21 ++++++++++++++++++++- Sources/Feed/FeedPanelView.swift | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedAgentTreeController.swift b/Sources/Feed/FeedAgentTreeController.swift index 9d5c8aa4ec7f..f32748d60a2c 100644 --- a/Sources/Feed/FeedAgentTreeController.swift +++ b/Sources/Feed/FeedAgentTreeController.swift @@ -24,6 +24,13 @@ final class FeedAgentTreeController { ) } + 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 @@ -43,6 +50,9 @@ final class FeedAgentTreeController { 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) @@ -51,7 +61,7 @@ final class FeedAgentTreeController { func activate( in targets: [FeedAgentTreeFocusTarget] ) -> FeedAgentTreeSelectionEffect? { - guard let target = preferredTarget(in: targets), + guard let target = activationTarget(in: targets), let focusWorkstreamId = target.focusWorkstreamId else { return nil } var effect = select(target, focusFeed: true) @@ -94,6 +104,15 @@ final class FeedAgentTreeController { } 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 { diff --git a/Sources/Feed/FeedPanelView.swift b/Sources/Feed/FeedPanelView.swift index 25dee9ddbfd6..5eb48f621d92 100644 --- a/Sources/Feed/FeedPanelView.swift +++ b/Sources/Feed/FeedPanelView.swift @@ -192,6 +192,8 @@ private struct FeedListView: View { onLoadOlderItems: onLoadOlderItems, onToggle: { nodeId in agentTreeController.toggle(nodeId) + let updatedVisibleTree = agentTreeController.visibleSnapshot(from: agentGraphSnapshot) + agentTreeController.reconcileSelection(with: updatedVisibleTree) }, onSelect: { node in applyAgentTreeSelectionEffect( @@ -215,6 +217,10 @@ private struct FeedListView: View { 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: { From ab8f6a62a24fa8ab6086b8bbba0111428d21fb5a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 22 May 2026 12:58:56 -0700 Subject: [PATCH 42/42] fix: retry feed focus registration when coordinator is unavailable --- Sources/Feed/FeedKeyboardFocusBridge.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedKeyboardFocusBridge.swift b/Sources/Feed/FeedKeyboardFocusBridge.swift index 46d1284c7f52..be7c7e615205 100644 --- a/Sources/Feed/FeedKeyboardFocusBridge.swift +++ b/Sources/Feed/FeedKeyboardFocusBridge.swift @@ -65,7 +65,11 @@ final class FeedKeyboardFocusView: NSView { return } guard registeredWindow !== window else { return } - AppDelegate.shared?.keyboardFocusCoordinator(for: window)?.registerFeedHost(self) + guard let coordinator = AppDelegate.shared?.keyboardFocusCoordinator(for: window) else { + registeredWindow = nil + return + } + coordinator.registerFeedHost(self) registeredWindow = window }