From 4030cf4bfdbb948f0f9323dd40e070dc3d017733 Mon Sep 17 00:00:00 2001 From: fischer-create <264049687+fischer-create@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:08:25 -0500 Subject: [PATCH] fix(app): stabilize tool stream lifecycle --- AGENTS.md | 4 +- Sources/Halos/Models/RunToolRollup.swift | 60 ++++- .../Halos/Stores/MissionControlStore.swift | 229 ++++++++++++------ Sources/Halos/Views/ActivityRowView.swift | 37 ++- .../Halos/Views/Composer/ComposerView.swift | 15 +- Sources/Halos/Views/HalosGlyph.swift | 20 +- .../Halos/Views/Navigation/SidebarViews.swift | 2 +- .../Views/Sessions/CodeSessionsView.swift | 2 +- .../Services/HalosPermissionPrimer.swift | 29 +-- .../ComposerAttachmentTests.swift | 54 +++++ .../HalosUITests/GatewayStreamingTests.swift | 93 ++++++- 11 files changed, 411 insertions(+), 134 deletions(-) create mode 100644 Tests/HalosUITests/ComposerAttachmentTests.swift diff --git a/AGENTS.md b/AGENTS.md index b0338b6..56c0c5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,6 @@ cp -R dist/Halos.app ~/Applications/ - `main/` — shared trunk - `claude/` — Claude on `claude/workspace` - `codex/` — Codex on `codex/workspace` -- `clif/` — Clif (OpenClaw) on `clif/workspace` — no CLAUDE/AGENTS file +- `huma/` — Huma on `huma/workspace` -One agent per worktree. Don't use a sibling worktree as scratch. +One agent per worktree. Don't use a sibling worktree as scratch. Don't create additional worktrees unless Fischer explicitly asks. diff --git a/Sources/Halos/Models/RunToolRollup.swift b/Sources/Halos/Models/RunToolRollup.swift index 7f08a60..8a4e7d1 100644 --- a/Sources/Halos/Models/RunToolRollup.swift +++ b/Sources/Halos/Models/RunToolRollup.swift @@ -1,16 +1,27 @@ import Foundation +struct PluginToolDetail: Equatable { + let pluginName: String + let action: String + + var displayText: String { + "\(pluginName): \(action)" + } +} + struct RunToolRollup { var toolNamesByCallID: [String: String] = [:] var toolCategoriesByName: [String: GatewayStreamFormatter.ToolCategory] = [:] var toolCounts: [String: Int] = [:] var toolActionCounts: [String: Int] = [:] var compactActionCounts: [String: Int] = [:] + var activeToolCallIDsByName: [String: Set] = [:] + var activePluginCallIDsByName: [String: Set] = [:] var activeTools: Set = [] var failedTools: Set = [] var activePlugins: Set = [] - var latestEndedPlugin: String? - var pluginDetails: [String] = [] + var latestPluginName: String? + var pluginDetails: [PluginToolDetail] = [] var toolDetails: [String] = [] var latestSummary: String? @@ -20,6 +31,51 @@ struct RunToolRollup { || !pluginDetails.isEmpty || !toolCounts.isEmpty } + + mutating func markActive(toolCallID: String, title: String, category: GatewayStreamFormatter.ToolCategory) { + activeToolCallIDsByName[title, default: []].insert(toolCallID) + if category == .plugin { + activePluginCallIDsByName[title, default: []].insert(toolCallID) + } + refreshActiveNames() + } + + mutating func markComplete(toolCallID: String, title: String, category: GatewayStreamFormatter.ToolCategory) { + activeToolCallIDsByName[title]?.remove(toolCallID) + if activeToolCallIDsByName[title]?.isEmpty == true { + activeToolCallIDsByName.removeValue(forKey: title) + } + if category == .plugin { + activePluginCallIDsByName[title]?.remove(toolCallID) + if activePluginCallIDsByName[title]?.isEmpty == true { + activePluginCallIDsByName.removeValue(forKey: title) + } + } + refreshActiveNames() + } + + mutating func markAllComplete(title: String, category: GatewayStreamFormatter.ToolCategory) { + activeToolCallIDsByName.removeValue(forKey: title) + if category == .plugin { + activePluginCallIDsByName.removeValue(forKey: title) + } + refreshActiveNames() + } + + mutating func clearActiveCalls() { + activeToolCallIDsByName.removeAll() + activePluginCallIDsByName.removeAll() + refreshActiveNames() + } + + private mutating func refreshActiveNames() { + activeTools = Set(activeToolCallIDsByName.compactMap { name, callIDs in + callIDs.isEmpty ? nil : name + }) + activePlugins = Set(activePluginCallIDsByName.compactMap { name, callIDs in + callIDs.isEmpty ? nil : name + }) + } } struct LocalSessionTitleOverride: Codable, Equatable { diff --git a/Sources/Halos/Stores/MissionControlStore.swift b/Sources/Halos/Stores/MissionControlStore.swift index 4d00f86..cb7ba98 100644 --- a/Sources/Halos/Stores/MissionControlStore.swift +++ b/Sources/Halos/Stores/MissionControlStore.swift @@ -60,7 +60,7 @@ public final class MissionControlStore: ObservableObject { private var statusMessageIDsByRunID: [String: String] = [:] private var runtimeStatusMessageIDsByRunID: [String: String] = [:] private var workingMessageIDsByRunID: [String: String] = [:] - private var pluginMessageIDsByRunID: [String: String] = [:] + private var pluginMessageIDsByRunID: [String: [String: String]] = [:] private var runStartedAtByRunID: [String: Date] = [:] private var runToolRollupsByRunID: [String: RunToolRollup] = [:] private var responseStatusTask: Task? @@ -2048,6 +2048,7 @@ public final class MissionControlStore: ObservableObject { if state == "error" { finishRun(runID, state: .failed) clearStatus(runID: runID) + finalizeActiveToolSummaries(runID: runID, state: .failed) upsertWorkingSummary(runID: runID, latestSummary: "Stopped after an error.") append(ActivityMessage( id: "\(Date().timeIntervalSince1970)-chat-error-\(UUID().uuidString)", @@ -2065,6 +2066,7 @@ public final class MissionControlStore: ObservableObject { if state == "aborted" { finishRun(runID, state: .aborted) clearStatus(runID: runID) + finalizeActiveToolSummaries(runID: runID, state: .aborted) upsertWorkingSummary(runID: runID, latestSummary: "Stopped by request.") append(ActivityMessage( id: "\(Date().timeIntervalSince1970)-chat-aborted-\(UUID().uuidString)", @@ -2102,6 +2104,10 @@ public final class MissionControlStore: ObservableObject { clearStatus(runID: runID) upsertWorkingSummary(runID: runID, latestSummary: status.body) if phase == "end" || phase == "error" || phase == "abort" || phase == "aborted" { + finalizeActiveToolSummaries( + runID: runID, + state: phase == "end" ? .final : (phase == "error" ? .failed : .aborted) + ) finishResponseRun(runID: runID) } } @@ -2124,10 +2130,24 @@ public final class MissionControlStore: ObservableObject { title: display.name, category: display.category, body: body, - state: (data["isError"] as? Bool) == true ? .failed : (phase == "result" ? .final : .streaming) + state: toolStreamState(phase: phase, isError: (data["isError"] as? Bool) == true) ) } + private func toolStreamState(phase: String?, isError: Bool) -> MessageStreamState { + if isError { + return .failed + } + switch phase?.lowercased() { + case "result", "end", "ended", "complete", "completed", "success", "done": + return .final + case "error", "failed", "failure", "abort", "aborted": + return .failed + default: + return .streaming + } + } + private func extractText(from message: [String: Any]) -> String { GatewayStreamFormatter.extractText(from: message) } @@ -2782,24 +2802,23 @@ public final class MissionControlStore: ObservableObject { rollup.toolCategoriesByName[title] = category if state == .streaming { - rollup.activeTools.insert(title) - if category == .plugin { - rollup.activePlugins.insert(title) - } + rollup.markActive(toolCallID: toolCallID, title: title, category: category) } else { - rollup.activeTools.remove(title) - if category == .plugin { - rollup.activePlugins.remove(title) - rollup.latestEndedPlugin = title + rollup.markComplete(toolCallID: toolCallID, title: title, category: category) + if category == .plugin, title == "Computer Control", toolCallID == "lazuli-lifecycle" { + rollup.markAllComplete(title: title, category: category) } } if state == .failed { rollup.failedTools.insert(title) } + if category == .plugin { + rollup.latestPluginName = title + } if category == .plugin, !body.isEmpty, - !rollup.pluginDetails.contains(body) { - rollup.pluginDetails.append(body) + !rollup.pluginDetails.contains(where: { $0.pluginName == title && $0.action == body }) { + rollup.pluginDetails.append(PluginToolDetail(pluginName: title, action: body)) } if category == .tool && (state == .final || state == .failed) { rollup.toolActionCounts[body, default: 0] += 1 @@ -2811,7 +2830,7 @@ public final class MissionControlStore: ObservableObject { refreshActiveControlSurface() if category == .plugin { - upsertPluginSummary(runID: runID, latestSummary: body) + upsertPluginSummary(runID: runID, pluginName: title, latestSummary: body) } else { upsertWorkingSummary(runID: runID, latestSummary: body) } @@ -2854,11 +2873,7 @@ public final class MissionControlStore: ObservableObject { pendingRequests.removeAll() for runID in runToolRollupsByRunID.keys { - clearActiveToolsForDisconnect(runID: runID) - finalizeWorkingSummary(runID: runID) - finalizePluginSummary(runID: runID) - removeEmptyWorkingSummary(runID: runID) - removeEmptyPluginSummary(runID: runID) + finalizeActiveToolSummaries(runID: runID, state: .final) } if let activeRunID { @@ -2869,8 +2884,7 @@ public final class MissionControlStore: ObservableObject { private func clearActiveToolsForDisconnect(runID: String) { guard var rollup = runToolRollupsByRunID[runID] else { return } - rollup.activeTools.removeAll() - rollup.activePlugins.removeAll() + rollup.clearActiveCalls() runToolRollupsByRunID[runID] = rollup refreshActiveControlSurface() @@ -2888,20 +2902,29 @@ public final class MissionControlStore: ObservableObject { ) } - if let pluginID = pluginMessageIDsByRunID[runID], - let pluginIndex = messages.firstIndex(where: { $0.id == pluginID }) { - messages[pluginIndex] = ActivityMessage( - id: pluginID, - kind: .workSummary, - title: pluginSummaryTitle(for: runID), - body: pluginSummaryBody(for: runID), - createdAt: messages[pluginIndex].createdAt, - runId: runID, - streamState: messages[pluginIndex].streamState - ) + for (pluginName, pluginID) in pluginMessageIDsByRunID[runID] ?? [:] { + if let pluginIndex = messages.firstIndex(where: { $0.id == pluginID }) { + messages[pluginIndex] = ActivityMessage( + id: pluginID, + kind: .workSummary, + title: pluginSummaryTitle(for: runID, pluginName: pluginName), + body: pluginSummaryBody(for: runID, pluginName: pluginName), + createdAt: messages[pluginIndex].createdAt, + runId: runID, + streamState: messages[pluginIndex].streamState + ) + } } } + private func finalizeActiveToolSummaries(runID: String, state: MessageStreamState) { + clearActiveToolsForDisconnect(runID: runID) + finalizeWorkingSummary(runID: runID, state: state) + finalizePluginSummary(runID: runID, state: state) + removeEmptyWorkingSummary(runID: runID) + removeEmptyPluginSummary(runID: runID) + } + private func removeEmptyWorkingSummary(runID: String) { guard let id = workingMessageIDsByRunID[runID], let index = messages.firstIndex(where: { $0.id == id }), @@ -2912,15 +2935,22 @@ public final class MissionControlStore: ObservableObject { } private func removeEmptyPluginSummary(runID: String) { - guard let id = pluginMessageIDsByRunID[runID], - let index = messages.firstIndex(where: { $0.id == id }), - messages[index].body == "No plugins used yet." - else { return } - messages.remove(at: index) - pluginMessageIDsByRunID.removeValue(forKey: runID) + var idsByPlugin = pluginMessageIDsByRunID[runID] ?? [:] + for (pluginName, id) in idsByPlugin { + guard let index = messages.firstIndex(where: { $0.id == id }), + messages[index].body == "No plugins used yet." + else { continue } + messages.remove(at: index) + idsByPlugin.removeValue(forKey: pluginName) + } + if idsByPlugin.isEmpty { + pluginMessageIDsByRunID.removeValue(forKey: runID) + } else { + pluginMessageIDsByRunID[runID] = idsByPlugin + } } - private func finalizeWorkingSummary(runID: String) { + private func finalizeWorkingSummary(runID: String, state: MessageStreamState = .final) { guard let id = workingMessageIDsByRunID[runID], let index = messages.firstIndex(where: { $0.id == id }), messages[index].body != "No tools used yet." @@ -2933,25 +2963,25 @@ public final class MissionControlStore: ObservableObject { body: messages[index].body, createdAt: messages[index].createdAt, runId: runID, - streamState: .final + streamState: state ) } - private func finalizePluginSummary(runID: String) { - guard let id = pluginMessageIDsByRunID[runID], - let index = messages.firstIndex(where: { $0.id == id }), - messages[index].body != "No plugins used yet." - else { return } - - messages[index] = ActivityMessage( - id: id, - kind: .workSummary, - title: messages[index].title, - body: messages[index].body, - createdAt: messages[index].createdAt, - runId: runID, - streamState: .final - ) + private func finalizePluginSummary(runID: String, state: MessageStreamState = .final) { + for (pluginName, id) in pluginMessageIDsByRunID[runID] ?? [:] { + guard let index = messages.firstIndex(where: { $0.id == id }), + messages[index].body != "No plugins used yet." + else { continue } + messages[index] = ActivityMessage( + id: id, + kind: .workSummary, + title: finalPluginSummaryTitle(for: runID, pluginName: pluginName, state: state, fallback: messages[index].title), + body: messages[index].body, + createdAt: messages[index].createdAt, + runId: runID, + streamState: state + ) + } } private func upsertWorkingSummary(runID: String, latestSummary: String? = nil) { @@ -2999,7 +3029,7 @@ public final class MissionControlStore: ObservableObject { )) } - private func upsertPluginSummary(runID: String, latestSummary: String? = nil) { + private func upsertPluginSummary(runID: String, pluginName: String, latestSummary: String? = nil) { let startedAt = runStartedAtByRunID[runID] ?? Date() runStartedAtByRunID[runID] = startedAt @@ -3013,11 +3043,11 @@ public final class MissionControlStore: ObservableObject { updateResponseMarkerStatus(runID: runID, isActive: responseMarkerStatus.isActive) } - let body = pluginSummaryBody(for: runID) + let body = pluginSummaryBody(for: runID, pluginName: pluginName) guard body != "No plugins used yet." else { return } - let title = pluginSummaryTitle(for: runID) + let title = pluginSummaryTitle(for: runID, pluginName: pluginName) - if let id = pluginMessageIDsByRunID[runID], + if let id = pluginMessageIDsByRunID[runID]?[pluginName], let index = messages.firstIndex(where: { $0.id == id }) { messages[index] = ActivityMessage( id: id, @@ -3031,8 +3061,8 @@ public final class MissionControlStore: ObservableObject { return } - let id = "\(Date().timeIntervalSince1970)-plugins-\(runID)" - pluginMessageIDsByRunID[runID] = id + let id = "\(Date().timeIntervalSince1970)-plugins-\(runID)-\(pluginName)" + pluginMessageIDsByRunID[runID, default: [:]][pluginName] = id append(ActivityMessage( id: id, kind: .workSummary, @@ -3063,11 +3093,15 @@ public final class MissionControlStore: ObservableObject { return lines.isEmpty ? "No tools used yet." : lines.joined(separator: "\n") } - private func pluginSummaryBody(for runID: String) -> String { + private func pluginSummaryBody(for runID: String, pluginName: String? = nil) -> String { guard let rollup = runToolRollupsByRunID[runID], !rollup.pluginDetails.isEmpty else { return "No plugins used yet." } - return rollup.pluginDetails.joined(separator: "\n") + let details = rollup.pluginDetails.filter { detail in + pluginName == nil || detail.pluginName == pluginName + } + guard !details.isEmpty else { return "No plugins used yet." } + return details.map(\.displayText).joined(separator: "\n") } private func formattedToolActionDetails(from counts: [String: Int]) -> [String] { @@ -3096,17 +3130,57 @@ public final class MissionControlStore: ObservableObject { return "Worked for \(durationText(since: startedAt))" } - private func pluginSummaryTitle(for runID: String) -> String { + private func pluginSummaryTitle(for runID: String, pluginName: String? = nil) -> String { guard let rollup = runToolRollupsByRunID[runID] else { return "Used Plugins" } + if let pluginName { + if rollup.activePlugins.contains(pluginName) { + return "Using \(pluginName)" + } + return pluginName + } if !rollup.activePlugins.isEmpty { return "Using \(rollup.activePlugins.sorted().joined(separator: ", "))" } - if let endedPlugin = rollup.latestEndedPlugin { - return "\(endedPlugin) ended" + let pluginNames = pluginNames(in: rollup) + guard !pluginNames.isEmpty else { + return formattedToolCounts(in: rollup, category: .plugin) } - return formattedToolCounts(in: rollup, category: .plugin) + return rollup.latestPluginName ?? pluginNames.joined(separator: ", ") + } + + private func finalPluginSummaryTitle(for runID: String, pluginName: String? = nil, state: MessageStreamState, fallback: String) -> String { + guard let rollup = runToolRollupsByRunID[runID] else { + return fallback + } + let pluginNames = pluginNames(in: rollup) + guard !pluginNames.isEmpty else { + return fallback + } + let suffix: String + switch state { + case .failed, .aborted: + suffix = "stopped" + default: + suffix = "finished" + } + if let pluginName { + return "\(pluginName) \(suffix)" + } + if let latestPluginName = rollup.latestPluginName { + return "\(latestPluginName) \(suffix)" + } + if pluginNames.count == 1, let pluginName = pluginNames.first { + return "\(pluginName) \(suffix)" + } + return "Plugins \(suffix)" + } + + private func pluginNames(in rollup: RunToolRollup) -> [String] { + rollup.toolCounts.keys + .filter { rollup.toolCategoriesByName[$0] == .plugin } + .sorted() } private func compactActionKey(for detail: String) -> String { @@ -3247,17 +3321,18 @@ public final class MissionControlStore: ObservableObject { streamState: self.messages[index].streamState ) } - if let id = self.pluginMessageIDsByRunID[runID], - let index = self.messages.firstIndex(where: { $0.id == id }) { - self.messages[index] = ActivityMessage( - id: id, - kind: .workSummary, - title: self.pluginSummaryTitle(for: runID), - body: self.messages[index].body, - createdAt: self.messages[index].createdAt, - runId: runID, - streamState: self.messages[index].streamState - ) + for (pluginName, id) in self.pluginMessageIDsByRunID[runID] ?? [:] { + if let index = self.messages.firstIndex(where: { $0.id == id }) { + self.messages[index] = ActivityMessage( + id: id, + kind: .workSummary, + title: self.pluginSummaryTitle(for: runID, pluginName: pluginName), + body: self.messages[index].body, + createdAt: self.messages[index].createdAt, + runId: runID, + streamState: self.messages[index].streamState + ) + } } } } diff --git a/Sources/Halos/Views/ActivityRowView.swift b/Sources/Halos/Views/ActivityRowView.swift index 5ba039c..1187728 100644 --- a/Sources/Halos/Views/ActivityRowView.swift +++ b/Sources/Halos/Views/ActivityRowView.swift @@ -292,14 +292,19 @@ struct ActivityRowView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(message.title) + .accessibilityValue(isWorkSummaryExpanded ? "Expanded" : "Collapsed") + .accessibilityHint(isWorkSummaryExpanded ? "Collapse work details" : "Expand work details") if isWorkSummaryExpanded { VStack(alignment: .leading, spacing: 7) { - ForEach(workSummaryLines, id: \.self) { line in - if isPluginWorkSummary { - PluginWorkEventChip(pluginName: pluginSummaryName, action: line) + ForEach(workSummaryLines, id: \.raw) { line in + if let pluginLine = line.plugin { + PluginWorkEventChip(pluginName: pluginLine.name, action: pluginLine.action) + } else if isPluginWorkSummary { + PluginWorkEventChip(pluginName: pluginSummaryName, action: line.raw) } else { - markdownText(line) + markdownText(line.raw) .font(.system(size: 11.5, weight: .medium)) .foregroundStyle(HalosTheme.tertiaryText) .fixedSize(horizontal: false, vertical: true) @@ -318,10 +323,10 @@ struct ActivityRowView: View { .frame(maxWidth: .infinity, alignment: .leading) } - private var workSummaryLines: [String] { + private var workSummaryLines: [WorkSummaryLine] { message.body .split(separator: "\n", omittingEmptySubsequences: true) - .map(String.init) + .map { WorkSummaryLine(raw: String($0)) } } private var isPluginWorkSummary: Bool { @@ -355,6 +360,20 @@ struct ActivityRowView: View { } } +private struct WorkSummaryLine { + let raw: String + + var plugin: (name: String, action: String)? { + for name in ["Browser Control", "Computer Control", "Veyra"] { + let prefix = "\(name): " + if raw.hasPrefix(prefix) { + return (name, String(raw.dropFirst(prefix.count))) + } + } + return nil + } +} + private struct PluginWorkEventChip: View { let pluginName: String let action: String @@ -387,6 +406,8 @@ private struct PluginWorkEventChip: View { .stroke(HalosTheme.faintSeparator.opacity(0.7), lineWidth: 1) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(pluginName): \(action)") } private var symbolName: String { @@ -448,7 +469,7 @@ struct HalosThinkingEyesView: View { var body: some View { Group { if isActive && !reduceMotion { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in let elapsed = startDate.map { timeline.date.timeIntervalSince($0) } ?? 0 eyes(elapsed: elapsed) } @@ -626,7 +647,7 @@ struct HalosResponseMarkView: View { Group { if shouldAnimate { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in let elapsed = startDate.map { timeline.date.timeIntervalSince($0) } ?? 0 activeGlyph(elapsed: elapsed, date: timeline.date) } diff --git a/Sources/Halos/Views/Composer/ComposerView.swift b/Sources/Halos/Views/Composer/ComposerView.swift index 66a48aa..66fc336 100644 --- a/Sources/Halos/Views/Composer/ComposerView.swift +++ b/Sources/Halos/Views/Composer/ComposerView.swift @@ -212,7 +212,8 @@ struct ComposerView: View { } private var shouldShowSessionsPanel: Bool { - false + guard let query = slashQuery?.lowercased() else { return false } + return query == "session" || query == "sessions" } private var canSend: Bool { @@ -915,17 +916,17 @@ enum ComposerAttachment { return } - if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { - provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in - guard let url = fileURLFromPlainTextItem(item) else { return } + for type in dataTypes where provider.hasItemConformingToTypeIdentifier(type.identifier) { + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in + guard let data, let url = save(data: data, contentType: type) else { return } completion(url) } return } - for type in dataTypes where provider.hasItemConformingToTypeIdentifier(type.identifier) { - provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in - guard let data, let url = save(data: data, contentType: type) else { return } + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in + guard let url = fileURLFromPlainTextItem(item) else { return } completion(url) } return diff --git a/Sources/Halos/Views/HalosGlyph.swift b/Sources/Halos/Views/HalosGlyph.swift index 07a6c2e..988dff2 100644 --- a/Sources/Halos/Views/HalosGlyph.swift +++ b/Sources/Halos/Views/HalosGlyph.swift @@ -30,6 +30,10 @@ enum HalosGlyphKind { struct HalosGlyph: View { let kind: HalosGlyphKind + var showsShadow = true + var snapsToPixelGrid = false + + @Environment(\.displayScale) private var displayScale var body: some View { GeometryReader { proxy in @@ -50,17 +54,27 @@ struct HalosGlyph: View { private func settingsDots(size: CGFloat, radiusScale: CGFloat) -> some View { let r = HalosDotMarkGeometry.radius(in: size) * radiusScale let dots = HalosDotMarkGeometry.centers(in: size) + let diameter = snapsToPixelGrid ? snap(r * 2) : r * 2 + let shadowRadius = showsShadow ? size * 0.028 : 0 return ZStack { ForEach(Array(dots.enumerated()), id: \.offset) { _, point in Circle() - .frame(width: r * 2, height: r * 2) - .shadow(color: .white.opacity(0.14), radius: size * 0.028) - .offset(x: point.x, y: point.y) + .frame(width: diameter, height: diameter) + .shadow(color: .white.opacity(showsShadow ? 0.14 : 0), radius: shadowRadius) + .offset( + x: snapsToPixelGrid ? snap(point.x) : point.x, + y: snapsToPixelGrid ? snap(point.y) : point.y + ) } } } + private func snap(_ value: CGFloat) -> CGFloat { + let scale = max(displayScale, 1) + return (value * scale).rounded() / scale + } + private func automationOrbit(size: CGFloat) -> some View { let r = size * 0.115 return ZStack { diff --git a/Sources/Halos/Views/Navigation/SidebarViews.swift b/Sources/Halos/Views/Navigation/SidebarViews.swift index b7d84aa..a5ba4ba 100644 --- a/Sources/Halos/Views/Navigation/SidebarViews.swift +++ b/Sources/Halos/Views/Navigation/SidebarViews.swift @@ -188,7 +188,7 @@ struct RuntimeIcon: View { var body: some View { Group { if runtime == .openClaw { - HalosGlyph(kind: .settingsDots) + HalosGlyph(kind: .settingsDots, showsShadow: false, snapsToPixelGrid: true) } else { Image(systemName: runtime.symbolName) .font(.system(size: 13, weight: .semibold)) diff --git a/Sources/Halos/Views/Sessions/CodeSessionsView.swift b/Sources/Halos/Views/Sessions/CodeSessionsView.swift index 7146a8e..a7f077a 100644 --- a/Sources/Halos/Views/Sessions/CodeSessionsView.swift +++ b/Sources/Halos/Views/Sessions/CodeSessionsView.swift @@ -60,7 +60,7 @@ struct CodeSessionsView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { RuntimeIcon(runtime: .openClaw, color: HalosTheme.secondaryText) - .frame(width: 17, height: 17) + .frame(width: 16, height: 16) Text("Huma's Sessions") .font(.system(size: 14, weight: .semibold)) diff --git a/Sources/HalosApp/Services/HalosPermissionPrimer.swift b/Sources/HalosApp/Services/HalosPermissionPrimer.swift index 5144727..05af36c 100644 --- a/Sources/HalosApp/Services/HalosPermissionPrimer.swift +++ b/Sources/HalosApp/Services/HalosPermissionPrimer.swift @@ -1,33 +1,8 @@ -import ApplicationServices -import CoreGraphics import Foundation final class HalosPermissionPrimer { - private let automationDefaultsKey = "HalosDidPrimeAutomationPermission.v2" - func primeRequiredPermissionsIfNeeded() { - // Do not prompt for Accessibility or Screen Recording at launch. - // macOS will surface those when a real control/capture path needs them. - primeAutomationPermissionIfNeeded() - } - - private func primeAutomationPermissionIfNeeded() { - guard !UserDefaults.standard.bool(forKey: automationDefaultsKey) else { return } - UserDefaults.standard.set(true, forKey: automationDefaultsKey) - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - _ = Self.touchAppleEventsTarget(bundleID: "com.apple.Safari") - } - } - - private static func touchAppleEventsTarget(bundleID: String) -> Bool { - let source = """ - tell application id "\(bundleID)" - get name - end tell - """ - var error: NSDictionary? - NSAppleScript(source: source)?.executeAndReturnError(&error) - return error == nil + // Permissions should be requested by the feature path that needs them, + // not by a launch-time primer that can surprise the user. } } diff --git a/Tests/HalosUITests/ComposerAttachmentTests.swift b/Tests/HalosUITests/ComposerAttachmentTests.swift new file mode 100644 index 0000000..27638c6 --- /dev/null +++ b/Tests/HalosUITests/ComposerAttachmentTests.swift @@ -0,0 +1,54 @@ +import XCTest +import UniformTypeIdentifiers +@testable import HalosUI + +final class ComposerAttachmentTests: XCTestCase { + func testMixedPlainTextAndImageProviderLoadsImageData() throws { + let provider = NSItemProvider() + let pngData = Data([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, + ]) + + provider.registerObject("Copied Screenshot" as NSString, visibility: .all) + provider.registerDataRepresentation(forTypeIdentifier: UTType.png.identifier, visibility: .all) { completion in + completion(pngData, nil) + return nil + } + + let loaded = expectation(description: "image attachment loaded") + let loadedURL = LockedURL() + + ComposerAttachment.load(provider: provider) { url in + loadedURL.set(url) + loaded.fulfill() + } + + wait(for: [loaded], timeout: 2) + let url = try XCTUnwrap(loadedURL.value) + XCTAssertEqual(url.pathExtension, "png") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } +} + +private final class LockedURL: @unchecked Sendable { + private let lock = NSLock() + private var storedURL: URL? + + var value: URL? { + lock.withLock { storedURL } + } + + func set(_ url: URL) { + lock.withLock { + storedURL = url + } + } +} diff --git a/Tests/HalosUITests/GatewayStreamingTests.swift b/Tests/HalosUITests/GatewayStreamingTests.swift index e74bcc0..26dce13 100644 --- a/Tests/HalosUITests/GatewayStreamingTests.swift +++ b/Tests/HalosUITests/GatewayStreamingTests.swift @@ -408,14 +408,14 @@ final class GatewayStreamingTests: XCTestCase { store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-edoras", name: "edoras_safari__open_url")) summary = store.messages.first(where: { $0.kind == .workSummary }) XCTAssertNil(store.activeControlSurface) - XCTAssertEqual(summary?.title, "Browser Control ended") + XCTAssertEqual(summary?.title, "Browser Control") XCTAssertEqual(summary?.body.contains("Opened"), true) store.ingestLazuliEventForTesting(jsonString: #"{"type":"state","state":"active","at":1777050000000}"#) store.ingestLazuliEventForTesting(jsonString: #"{"type":"state","state":"active","at":1777050000100}"#) store.ingestLazuliEventForTesting(jsonString: #"{"type":"action","tool":"click","args":{"x":1},"expected_layer":2,"session_id":"abcdef123456","at":1777050000200}"#) - summary = store.messages.first(where: { $0.kind == .workSummary }) + summary = store.messages.first(where: { $0.kind == .workSummary && $0.title.contains("Computer Control") }) XCTAssertEqual(store.activeControlSurface, .computerControl) XCTAssertEqual(summary?.title, "Using Computer Control") XCTAssertEqual(summary?.body.components(separatedBy: "Enabled").count, 2) @@ -424,9 +424,9 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(summary?.body.contains("You can stop it"), false) store.ingestLazuliEventForTesting(jsonString: #"{"type":"state","state":"idle","at":1777050001000}"#) - summary = store.messages.first(where: { $0.kind == .workSummary }) + summary = store.messages.first(where: { $0.kind == .workSummary && $0.title.contains("Computer Control") }) XCTAssertNil(store.activeControlSurface) - XCTAssertEqual(summary?.title, "Computer Control ended") + XCTAssertEqual(summary?.title, "Computer Control") XCTAssertEqual(summary?.body.contains("Click"), true) XCTAssertEqual(summary?.body.contains("Plugins:"), false) XCTAssertEqual(summary?.body.contains("Browser Control completed"), false) @@ -445,7 +445,7 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(rows[0].0, .assistant) XCTAssertEqual(rows[0].2, "I’ll check the browser.") XCTAssertEqual(rows[1].0, .workSummary) - XCTAssertEqual(rows[1].1, "Browser Control ended") + XCTAssertEqual(rows[1].1, "Browser Control finished") XCTAssertEqual(rows[1].2.contains("Opened"), true) XCTAssertEqual(rows[1].2.contains("completed"), false) XCTAssertEqual(rows[1].3, .final) @@ -454,6 +454,87 @@ final class GatewayStreamingTests: XCTestCase { XCTAssertEqual(rows[2].3, .final) } + func testPluginSummaryDoesNotClaimFinishedBeforeAssistantRunFinishes() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll use the browser.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + + var summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Browser Control") + XCTAssertEqual(summary?.streamState, .streaming) + XCTAssertEqual(summary?.body.contains("Opened"), true) + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "final", text: "Done.")) + + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(summary?.title, "Browser Control finished") + XCTAssertEqual(summary?.streamState, .final) + } + + func testOverlappingBrowserControlCallsStayActiveUntilAllCallsFinish() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll inspect the page.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-open", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-shot", name: "plugin__browser_control__screenshot")) + + var summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(store.activeControlSurface, .browserControl) + XCTAssertEqual(summary?.title, "Using Browser Control") + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-open", name: "plugin__browser_control__open_url")) + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertEqual(store.activeControlSurface, .browserControl) + XCTAssertEqual(summary?.title, "Using Browser Control") + XCTAssertEqual(summary?.body.contains("Opened"), true) + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-shot", name: "plugin__browser_control__screenshot")) + summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertNil(store.activeControlSurface) + XCTAssertEqual(summary?.title, "Browser Control") + XCTAssertEqual(summary?.body.contains("Screenshot"), true) + } + + func testPluginSummaryKeepsPluginIdentityPerLine() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll use two surfaces.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-browser", name: "plugin__browser_control__screenshot")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "result", toolCallID: "tool-veyra", name: "plugin__veyra__publish")) + + let summaries = store.messages.filter { $0.kind == .workSummary } + XCTAssertEqual(summaries.count, 2) + XCTAssertEqual(summaries.first(where: { $0.title == "Browser Control" })?.body.contains("Browser Control: Screenshot"), true) + XCTAssertEqual(summaries.first(where: { $0.title == "Veyra" })?.body.contains("Veyra: Publish"), true) + } + + func testTerminalChatErrorFinalizesActivePluginSummary() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "delta", text: "I’ll open the page.")) + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "chat", payload: chatPayload(runID: "run-1", state: "error", text: "")) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertNil(store.activeControlSurface) + XCTAssertEqual(summary?.title, "Browser Control stopped") + XCTAssertEqual(summary?.streamState, .failed) + } + + func testLifecycleEndFinalizesActivePluginSummary() { + let store = makeStore() + + store.ingestGatewayEventForTesting(event: "agent", payload: toolPayload(runID: "run-1", phase: "start", toolCallID: "tool-browser", name: "plugin__browser_control__open_url")) + store.ingestGatewayEventForTesting(event: "agent", payload: lifecyclePayload(runID: "run-1", phase: "end")) + + let summary = store.messages.first(where: { $0.kind == .workSummary }) + XCTAssertNil(store.activeControlSurface) + XCTAssertEqual(summary?.title, "Browser Control finished") + XCTAssertEqual(summary?.streamState, .final) + } + func testPluginSummaryDoesNotCombineWithRanSummary() { let store = makeStore() @@ -465,7 +546,7 @@ final class GatewayStreamingTests: XCTestCase { let summaries = store.messages.filter { $0.kind == .workSummary } XCTAssertEqual(summaries.count, 2) - XCTAssertEqual(summaries[0].title, "Browser Control ended") + XCTAssertEqual(summaries[0].title, "Browser Control") XCTAssertEqual(summaries[0].body.contains("Screenshot"), true) XCTAssertEqual(summaries[0].body.contains("Ran swift test"), false) XCTAssertEqual(summaries[1].title, "Ran Command")