diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index a4612c02..b28cd518 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; }; E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; + DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; }; /* End PBXBuildFile section */ @@ -91,6 +92,7 @@ E6C001022F9B000100000001 /* Sparkle in Frameworks */, E6D001032FA0000100000001 /* RxCodeCore in Frameworks */, E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */, + DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -223,6 +225,7 @@ E6C001012F9B000100000001 /* Sparkle */, E6D001012FA0000100000001 /* RxCodeCore */, E6D001022FA0000100000001 /* RxCodeChatKit */, + DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */, ); productName = RxCode; productReference = E67335382F7356F600FD26C7 /* RxCode.app */; @@ -258,6 +261,7 @@ E6C001002F9B000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */, E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */, DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */, + DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ); preferredProjectObjectVersion = 77; productRefGroup = E67335392F7356F600FD26C7 /* Products */; @@ -659,6 +663,14 @@ minimumVersion = 2.0.0; }; }; + DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -685,6 +697,11 @@ package = E6C001002F9B000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { + isa = XCSwiftPackageProductDependency; + package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; + productName = WaterfallGrid; + }; E6D001012FA0000100000001 /* RxCodeCore */ = { isa = XCSwiftPackageProductDependency; package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 067ac86d..e91f9e7e 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8632ffca1a16af6f1b83e63eb95cf449d1902e96b275c0df9fe37cce485ca3d8", + "originHash" : "cd0f1869aec772d2717b6f2ddf4927d69ca9a55e0482f7b012a30bb999a17484", "pins" : [ { "identity" : "sparkle", @@ -36,6 +36,15 @@ "revision" : "e9a06346499a3a889165647e3f23f8a7b2609a1c", "version" : "0.10.3" } + }, + { + "identity" : "waterfallgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/paololeonardi/WaterfallGrid", + "state" : { + "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", + "version" : "1.1.0" + } } ], "version" : 3 diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index cbf62584..182b7f05 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -97,6 +97,7 @@ struct SessionStreamState { enum SummarizationProvider: String, CaseIterable, Identifiable { case selectedClient case openAI + case appleFoundationModel var id: String { rawValue } @@ -104,6 +105,22 @@ enum SummarizationProvider: String, CaseIterable, Identifiable { switch self { case .selectedClient: return "Thread Model" case .openAI: return "OpenAI-Compatible Endpoint" + case .appleFoundationModel: return "Apple Foundation Model" + } + } + + /// Returns the providers that should be offered to the user right now. + /// Apple Foundation Model is hidden when the device doesn't support it + /// (non-Apple-Silicon Mac, Apple Intelligence disabled, etc.). + @MainActor + static var availableCases: [SummarizationProvider] { + allCases.filter { provider in + switch provider { + case .appleFoundationModel: + return FoundationModelSummarizationService.isAvailable + case .selectedClient, .openAI: + return true + } } } } @@ -430,7 +447,13 @@ final class AppState { // MARK: - Summarization - var summarizationProvider: SummarizationProvider = .init(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient { + var summarizationProvider: SummarizationProvider = { + let stored = SummarizationProvider(rawValue: UserDefaults.standard.string(forKey: "summarizationProvider") ?? "") ?? .selectedClient + if stored == .appleFoundationModel, !FoundationModelSummarizationService.isAvailable { + return .selectedClient + } + return stored + }() { didSet { UserDefaults.standard.set(summarizationProvider.rawValue, forKey: "summarizationProvider") } } @@ -891,6 +914,7 @@ final class AppState { let acp: ACPService let acpRegistryService = ACPRegistryService() let openAISummarization = OpenAISummarizationService() + let foundationModelSummarization = FoundationModelSummarizationService() let persistence: PersistenceService let marketplace = MarketplaceService() let mcp: MCPService @@ -3776,6 +3800,8 @@ final class AppState { apiKey: openAISummarizationAPIKey, model: openAISummarizationModel ) + case .appleFoundationModel: + return await foundationModelSummarization.generateSessionTitle(firstUserMessage: firstUserMessage) } } @@ -3796,6 +3822,8 @@ final class AppState { apiKey: openAISummarizationAPIKey, model: openAISummarizationModel ) + case .appleFoundationModel: + return await foundationModelSummarization.generateResponseNotificationSummary(responseText: trimmedResponse) } } @@ -3894,6 +3922,12 @@ final class AppState { apiKey: openAISummarizationAPIKey, model: openAISummarizationModel ) + case .appleFoundationModel: + return await foundationModelSummarization.generateThreadSummary( + previousSummary: previousSummary, + userMessage: userMessage, + finalResponse: finalResponse + ) } } @@ -3919,6 +3953,8 @@ final class AppState { apiKey: openAISummarizationAPIKey, model: openAISummarizationModel ) + case .appleFoundationModel: + return await foundationModelSummarization.generateBranchBriefing(threadSummaries: threadSummaries) } } diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift new file mode 100644 index 00000000..a821a73c --- /dev/null +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -0,0 +1,150 @@ +import Foundation +import FoundationModels +import os + +/// On-device summarization powered by Apple's Foundation Models framework +/// (Apple Intelligence). Free, private, and runs locally — but only available +/// on Apple Silicon Macs with Apple Intelligence enabled. +actor FoundationModelSummarizationService { + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.claudework", + category: "FoundationModelSummarizationService" + ) + + /// Whether the on-device model is currently usable. Re-evaluated on each + /// call so toggling Apple Intelligence in System Settings is picked up + /// without a relaunch. + nonisolated static var isAvailable: Bool { + if case .available = SystemLanguageModel.default.availability { + return true + } + return false + } + + /// Human-readable reason the model is unavailable, or `nil` if available. + nonisolated static var unavailabilityReason: String? { + switch SystemLanguageModel.default.availability { + case .available: + return nil + case .unavailable(.deviceNotEligible): + return "This Mac doesn't support Apple Intelligence." + case .unavailable(.appleIntelligenceNotEnabled): + return "Apple Intelligence isn't enabled. Turn it on in System Settings." + case .unavailable(.modelNotReady): + return "The on-device model is still downloading. Try again later." + case .unavailable: + return "Apple Intelligence is unavailable on this device." + } + } + + func generateSessionTitle(firstUserMessage: String) async -> String? { + let trimmed = String(firstUserMessage.prefix(500)) + let prompt = """ + Summarize the following user message as a 3-6 word chat title. Reply with ONLY the title, no quotes, no markdown, no punctuation at the end. + + \(trimmed) + """ + let raw = await respond( + instructions: "You generate concise chat titles.", + prompt: prompt + ) + return cleanTitle(raw) + } + + func generateResponseNotificationSummary(responseText: String) async -> String? { + let trimmed = String(responseText.prefix(4000)) + let prompt = """ + Summarize the following assistant response for a macOS notification. Reply with one concise sentence under 180 characters. Mention the outcome and the most important result. No markdown. + + \(trimmed) + """ + let raw = await respond( + instructions: "You write concise notification summaries.", + prompt: prompt + ) + return cleanSummary(raw, limit: 180) + } + + func generateThreadSummary( + previousSummary: String?, + userMessage: String, + finalResponse: String + ) async -> String? { + let previous = previousSummary?.trimmingCharacters(in: .whitespacesAndNewlines) + let prompt = """ + Update the stored summary for one project thread. Use the previous summary, latest user request, and final assistant response. + Keep it factual and concise, 3-6 bullet points max. Include completed work, important decisions, files or areas touched, and unresolved follow-ups. + Reply with only the updated summary. + + Previous summary: + \((previous?.isEmpty == false) ? previous! : "None") + + Latest user request: + \(String(userMessage.prefix(2000))) + + Final assistant response: + \(String(finalResponse.prefix(4000))) + """ + let raw = await respond( + instructions: "You maintain concise local project summaries.", + prompt: prompt + ) + return cleanSummary(raw, limit: 1800) + } + + func generateBranchBriefing( + threadSummaries: [(title: String, summary: String)] + ) async -> String? { + guard !threadSummaries.isEmpty else { return nil } + let joined = threadSummaries.map { item -> String in + let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = String(item.summary.prefix(1500)).trimmingCharacters(in: .whitespacesAndNewlines) + return "### \(title.isEmpty ? "Untitled thread" : title)\n\(summary)" + }.joined(separator: "\n\n") + + let prompt = """ + Write a concise overall briefing for one git branch by synthesizing the per-thread summaries below into a single coherent overview. + Cover the main themes, completed work, important decisions, files or areas touched, and unresolved follow-ups across the whole branch. + Do not list threads individually — produce a unified summary. Use 4-8 short bullet points. Reply with only the briefing. + + Thread summaries (newest first): + + \(joined) + """ + let raw = await respond( + instructions: "You maintain concise local project summaries.", + prompt: prompt + ) + return cleanSummary(raw, limit: 1800) + } + + private func respond(instructions: String, prompt: String) async -> String? { + guard Self.isAvailable else { return nil } + do { + let session = LanguageModelSession(instructions: instructions) + let response = try await session.respond(to: prompt) + return response.content + } catch { + logger.warning("Foundation Models summarization failed: \(error.localizedDescription)") + return nil + } + } + + private func cleanTitle(_ raw: String?) -> String? { + guard let raw else { return nil } + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + return String(cleaned.prefix(80)) + } + + private func cleanSummary(_ raw: String?, limit: Int) -> String? { + guard let raw else { return nil } + let cleaned = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`")) + guard !cleaned.isEmpty else { return nil } + return String(cleaned.prefix(limit)) + } +} diff --git a/RxCode/Views/Chat/ThreadTitlePopoverButton.swift b/RxCode/Views/Chat/ThreadTitlePopoverButton.swift index adf3c236..08ce666a 100644 --- a/RxCode/Views/Chat/ThreadTitlePopoverButton.swift +++ b/RxCode/Views/Chat/ThreadTitlePopoverButton.swift @@ -26,26 +26,29 @@ struct ThreadTitlePopoverButton: View { var body: some View { let hasSummary = !trimmedSummary.isEmpty - Button { - if hasSummary { showingPopover.toggle() } - } label: { - HStack(spacing: 4) { - Text(title) - .foregroundStyle(ClaudeTheme.textPrimary) - if hasSummary { + if hasSummary { + Button { + showingPopover.toggle() + } label: { + HStack(spacing: 4) { + Text(title) + .foregroundStyle(ClaudeTheme.textPrimary) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) .foregroundStyle(ClaudeTheme.textTertiary) } + .padding(.trailing) + .contentShape(Rectangle()) } - .padding(.trailing) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!hasSummary) - .help(hasSummary ? "Show thread summary" : "") - .popover(isPresented: $showingPopover, arrowEdge: .bottom) { - ThreadSummaryPopoverContent(item: summaryItem) + .buttonStyle(.plain) + .help("Show thread summary") + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ThreadSummaryPopoverContent(item: summaryItem) + } + } else { + Text(title) + .foregroundStyle(ClaudeTheme.textPrimary) + .padding(.trailing) } } } diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 4e7ed174..2ec9112c 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -672,7 +672,7 @@ struct ChatSettingsTab: View { .foregroundStyle(.secondary) Picker("Provider", selection: $appState.summarizationProvider) { - ForEach(SummarizationProvider.allCases) { provider in + ForEach(SummarizationProvider.availableCases) { provider in Text(provider.displayName).tag(provider) } } @@ -690,6 +690,22 @@ struct ChatSettingsTab: View { .foregroundStyle(.secondary) case .openAI: openAISummarizationForm + case .appleFoundationModel: + appleFoundationModelStatus + } + } + } + + private var appleFoundationModelStatus: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Runs on-device with Apple Intelligence. Private, free, and offline.") + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(.secondary) + if let reason = FoundationModelSummarizationService.unavailabilityReason { + Text(reason) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/RxCode/Views/Sidebar/BriefingView.swift b/RxCode/Views/Sidebar/BriefingView.swift index 1e4d7391..a16332cd 100644 --- a/RxCode/Views/Sidebar/BriefingView.swift +++ b/RxCode/Views/Sidebar/BriefingView.swift @@ -1,6 +1,7 @@ import SwiftUI import Foundation import RxCodeCore +import WaterfallGrid struct BriefingView: View { @Environment(AppState.self) private var appState @@ -9,6 +10,19 @@ struct BriefingView: View { /// Selected project ids for filtering. Empty = show every project. @State private var selectedProjectIds: Set = [] + /// When false (default), only show briefings for each project's current branch. + @State private var showAllBranches: Bool = false + + /// Cached current branch per project path, refreshed when the project list changes. + @State private var currentBranchByProject: [UUID: String] = [:] + @State private var branchRefreshTask: Task? + + /// Group id whose copy button most recently fired; used for transient checkmark feedback. + @State private var recentlyCopiedGroupId: String? + + /// Container width tracked from the scroll content; drives the waterfall column count. + @State private var availableWidth: CGFloat = 800 + private struct BriefingGroup: Identifiable { let projectId: UUID let branch: String @@ -65,10 +79,24 @@ struct BriefingView: View { } .sorted { $0.updatedAt > $1.updatedAt } + let projectFiltered: [BriefingGroup] if selectedProjectIds.isEmpty { - return all + projectFiltered = all + } else { + projectFiltered = all.filter { selectedProjectIds.contains($0.projectId) } + } + + if showAllBranches { + return projectFiltered + } + return projectFiltered.filter { group in + guard let branch = currentBranchByProject[group.projectId] else { + // Branch not yet resolved — keep the group so the user isn't shown + // an empty state while git is still being queried. + return true + } + return group.branch == branch } - return all.filter { selectedProjectIds.contains($0.projectId) } } /// Projects that actually have at least one briefing or summary recorded. @@ -84,18 +112,48 @@ struct BriefingView: View { var body: some View { Group { - if groups.isEmpty { + if hasAnyData { + content + } else { emptyState( icon: "text.page", title: "No Briefings Yet", message: "Briefings appear after a thread finishes on a project branch." ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - content } } .background(ClaudeTheme.background) + .task(id: projectPathsKey) { + await refreshCurrentBranches() + } + } + + /// True when there is at least one briefing or thread summary persisted, regardless + /// of the active filters. Used to decide whether the filter bar should be shown. + private var hasAnyData: Bool { + _ = appState.branchBriefingRevision + _ = appState.threadSummaryRevision + return !appState.threadStore.allBranchBriefingItems().isEmpty + || !appState.threadStore.allThreadSummaryItems().isEmpty + } + + private var projectPathsKey: String { + appState.projects + .map { "\($0.id.uuidString):\($0.path)" } + .joined(separator: "|") + } + + private func refreshCurrentBranches() async { + var resolved: [UUID: String] = [:] + for project in appState.projects { + if let branch = await GitHelper.currentBranch(at: project.path) { + resolved[project.id] = branch + } + } + await MainActor.run { + currentBranchByProject = resolved + } } private var content: some View { @@ -104,14 +162,10 @@ struct BriefingView: View { hero filterBar - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 380, maximum: 560), spacing: 16, alignment: .top)], - alignment: .leading, - spacing: 16 - ) { - ForEach(groups) { group in - groupCard(group) - } + if groups.isEmpty { + filteredEmptyState + } else { + waterfall } } .padding(.horizontal, 28) @@ -119,9 +173,42 @@ struct BriefingView: View { .padding(.bottom, 40) .frame(maxWidth: 1400, alignment: .leading) .frame(maxWidth: .infinity, alignment: .topLeading) + .background( + GeometryReader { proxy in + Color.clear + .onChange(of: proxy.size.width, initial: true) { _, newValue in + availableWidth = newValue + } + } + ) } } + private var waterfall: some View { + WaterfallGrid(groups) { group in + groupCard(group) + } + .gridStyle( + columns: max(1, min(4, Int(availableWidth / 420))), + spacing: 16, + animation: .easeInOut(duration: 0.25) + ) + .scrollOptions(direction: .vertical) + } + + private var filteredEmptyState: some View { + let message = showAllBranches + ? "No briefings match the selected projects." + : "No briefings for the current branch. Switch to All branches to see other branches." + return emptyState( + icon: "line.3.horizontal.decrease.circle", + title: "Nothing to Show", + message: message + ) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } + // MARK: - Hero private var hero: some View { @@ -165,9 +252,62 @@ struct BriefingView: View { private var filterBar: some View { let projects = projectsWithData - return Group { - if projects.count > 1 { - Menu { + return HStack(spacing: 8) { + branchScopeChip + projectFilterMenu(projects: projects) + Spacer(minLength: 0) + } + } + + private var branchScopeChip: some View { + Menu { + Button { + showAllBranches = false + } label: { + Label("Current branch", systemImage: showAllBranches ? "" : "checkmark") + } + Button { + showAllBranches = true + } label: { + Label("All branches", systemImage: showAllBranches ? "checkmark" : "") + } + } label: { + HStack(spacing: 6) { + Image(systemName: showAllBranches ? "arrow.triangle.branch" : "arrow.triangle.branch.fill") + .font(.system(size: 11, weight: .semibold)) + Text(showAllBranches ? "All branches" : "Current branch") + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(showAllBranches ? ClaudeTheme.textOnAccent : ClaudeTheme.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(showAllBranches ? ClaudeTheme.accent : ClaudeTheme.surfaceSecondary) + ) + .overlay( + Capsule(style: .continuous) + .strokeBorder( + showAllBranches + ? ClaudeTheme.accent.opacity(0.4) + : ClaudeTheme.border.opacity(0.6), + lineWidth: 0.5 + ) + ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Choose which branches to show briefings for.") + } + + @ViewBuilder + private func projectFilterMenu(projects: [Project]) -> some View { + if projects.count > 1 { + Menu { Button { selectedProjectIds.removeAll() } label: { @@ -215,7 +355,6 @@ struct BriefingView: View { .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() - } } } @@ -295,12 +434,76 @@ struct BriefingView: View { Spacer(minLength: 0) + copyButton(for: group) + if let project { cardMenu(for: project) } } } + private func copyButton(for group: BriefingGroup) -> some View { + let copied = recentlyCopiedGroupId == group.id + return Button { + copyBriefing(group) + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(copied ? ClaudeTheme.accent : ClaudeTheme.textSecondary) + .frame(width: 24, height: 22) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ClaudeTheme.surfaceSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(ClaudeTheme.border.opacity(0.6), lineWidth: 0.5) + ) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .help(copied ? "Copied" : "Copy briefing text") + .disabled(group.briefing == nil && group.threadSummaries.isEmpty) + } + + private func copyBriefing(_ group: BriefingGroup) { + let text = renderBriefingText(group) + guard !text.isEmpty else { return } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + + let id = group.id + recentlyCopiedGroupId = id + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + if recentlyCopiedGroupId == id { + recentlyCopiedGroupId = nil + } + } + } + + private func renderBriefingText(_ group: BriefingGroup) -> String { + var lines: [String] = [] + let projectName = projectsById[group.projectId]?.name ?? "Unknown project" + lines.append("# \(projectName) — \(group.branch)") + lines.append("") + + if let briefing = group.briefing { + lines.append(briefing.briefing.trimmingCharacters(in: .whitespacesAndNewlines)) + lines.append("") + } + + if !group.threadSummaries.isEmpty { + lines.append("## Threads") + for thread in group.threadSummaries { + lines.append("- \(thread.title)") + } + } + + return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + private func cardMenu(for project: Project) -> some View { Menu { Button {