From dde9459fdd97b06cce47db8bf0db9ef400ea2b6e Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 22:50:40 -0700 Subject: [PATCH 01/41] Fix TableBlockView: darker borders, grip dots outside, selection clearing, + button hit targets --- .../Bugbook/Views/Editor/TableBlockView.swift | 126 +++++++++++++++--- 1 file changed, 106 insertions(+), 20 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/TableBlockView.swift b/Sources/Bugbook/Views/Editor/TableBlockView.swift index d40aed02..17d84a6e 100644 --- a/Sources/Bugbook/Views/Editor/TableBlockView.swift +++ b/Sources/Bugbook/Views/Editor/TableBlockView.swift @@ -33,16 +33,25 @@ struct TableBlockView: View { private var colCount: Int { rows.map(\.count).max() ?? 3 } private var rowCount: Int { rows.count } + /// Darker border color for better visibility (issue #1). + private var tableBorderColor: Color { + Color(light: Color(hex: "d4d4d0"), dark: Color(hex: "454545")) + } + var body: some View { HStack(alignment: .top, spacing: 0) { - // Main table + // Grip dots column — outside the table grid (issue #2) + gripDotsColumn + .opacity(isHovering ? 1 : 0) + + // Main table + add-row bar VStack(alignment: .leading, spacing: 0) { tableGrid .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) .overlay( RoundedRectangle(cornerRadius: Radius.xs) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + .stroke(tableBorderColor, lineWidth: 1) ) // Add row bar @@ -50,15 +59,34 @@ struct TableBlockView: View { .opacity(isHovering ? 1 : 0) } - // Add column button — outside the table, to the right + // Add column button — outside the table, to the right (issue #4) addColumnButton .opacity(isHovering ? 1 : 0) } - .contentShape(Rectangle()) - .onTapGesture { selectedCell = nil } .onAppear { initColumnWidths() } .onChange(of: colCount) { _, _ in initColumnWidths() } .onHover { isHovering = $0 } + // Use background NSView monitor to detect clicks outside cells (issue #3). + // The SwiftUI .onTapGesture on the container was being eaten by + // highPriorityGesture on cells, so we use an AppKit-level approach. + .background(TableClickOutsideMonitor(onClickOutside: { selectedCell = nil })) + } + + // MARK: - Grip Dots Column (outside table) + + /// Renders grip dots to the left of each row, aligned by row index. + /// Positioned outside the table grid so they don't create padding inside cells (issue #2). + private var gripDotsColumn: some View { + VStack(spacing: 0) { + ForEach(0.. 0 { + // Spacer matching the border line height + Color.clear.frame(height: 1) + } + rowDragHandle(rowIdx) + .frame(minHeight: 32) + } + } } // MARK: - Table Grid @@ -71,8 +99,8 @@ struct TableBlockView: View { Rectangle().fill(Color.dragIndicator).frame(height: 2) } else { Rectangle() - .fill(Color(nsColor: .separatorColor)) - .frame(height: 0.5) + .fill(tableBorderColor) + .frame(height: 1) } } tableRow(rowIdx) @@ -101,10 +129,6 @@ struct TableBlockView: View { let isHeader = block.hasHeaderRow && rowIdx == 0 HStack(spacing: 0) { - // Row drag handle - rowDragHandle(rowIdx) - .opacity(isHovering ? 1 : 0) - ForEach(0.. 0 { // Resize handle doubles as the column separator @@ -159,9 +183,8 @@ struct TableBlockView: View { private func columnResizeHandle(_ colIdx: Int) -> some View { Rectangle() - .fill(dragColumnIndex == colIdx ? Color.accentColor.opacity(0.5) : Color(nsColor: .separatorColor)) - .frame(width: dragColumnIndex == colIdx ? 2 : 0.5) - .padding(.horizontal, dragColumnIndex == colIdx ? 0 : 1.75) + .fill(dragColumnIndex == colIdx ? Color.accentColor.opacity(0.5) : tableBorderColor) + .frame(width: dragColumnIndex == colIdx ? 2 : 1) .contentShape(Rectangle().size(width: 8, height: .infinity)) .onHover { hovering in if hovering { NSCursor.resizeLeftRight.push() } @@ -201,16 +224,24 @@ struct TableBlockView: View { .help("Click to add a new row") } + /// Add-column button with generous hit target (issue #4). + /// The button fills a fixed-width column to the right of the table and uses + /// `.frame(maxHeight: .infinity)` plus `.contentShape(Rectangle())` to ensure + /// the entire area is clickable. private var addColumnButton: some View { Button { addColumn() } label: { - Image(systemName: "plus") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) + VStack { + Spacer(minLength: 0) + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Spacer(minLength: 0) + } + .frame(width: 28, height: 32) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(width: 24) + .frame(width: 28) .help("Add column") } @@ -370,6 +401,61 @@ struct TableBlockView: View { } } +// MARK: - Click-Outside Monitor (issue #3) +// +// Clears cell selection when the user clicks anywhere outside the table. +// Uses an AppKit local event monitor because SwiftUI's onTapGesture on the +// container is swallowed by the highPriorityGesture on individual cells. + +private struct TableClickOutsideMonitor: NSViewRepresentable { + var onClickOutside: () -> Void + + func makeNSView(context: Context) -> TableClickMonitorView { + let view = TableClickMonitorView() + view.onClickOutside = onClickOutside + return view + } + + func updateNSView(_ nsView: TableClickMonitorView, context: Context) { + nsView.onClickOutside = onClickOutside + } +} + +final class TableClickMonitorView: NSView { + var onClickOutside: (() -> Void)? + private var monitor: Any? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil && monitor == nil { + monitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self, let window = self.window, event.window === window else { return event } + // Check if the click is inside our parent table view's bounds + guard let tableParent = self.superview else { return event } + let locationInTable = tableParent.convert(event.locationInWindow, from: nil) + if !tableParent.bounds.contains(locationInTable) { + self.onClickOutside?() + } + return event + } + } else if window == nil, let m = monitor { + NSEvent.removeMonitor(m) + monitor = nil + } + } + + override func removeFromSuperview() { + if let m = monitor { NSEvent.removeMonitor(m); monitor = nil } + super.removeFromSuperview() + } + + deinit { + if let m = monitor { NSEvent.removeMonitor(m) } + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + // MARK: - Cell Text Field struct TableCellTextField: NSViewRepresentable { From 39562c57b6ad5989e36da7814f7dd77303b09936 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 22:50:41 -0700 Subject: [PATCH 02/41] Fix Cmd+K content search: use pane system for navigation instead of legacy tabs --- Sources/Bugbook/Views/ContentView.swift | 30 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..2523a26d 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -515,24 +515,24 @@ struct ContentView: View { appState: appState, isPresented: $appState.commandPaletteOpen, onSelectFile: { entry in - navigateToEntry(entry) + navigateToEntryInPane(entry) }, onSelectFileNewTab: { entry in - navigateToEntry(entry, inNewTab: true) + openEntryInNewWorkspaceTab(entry) }, onCreateFile: { name in createNewFileWithName(name) }, onSelectContentMatch: { entry, query in if appState.commandPaletteMode == .newTab { - navigateToEntry(entry, inNewTab: true) + openEntryInNewWorkspaceTab(entry) } else { - navigateToEntry(entry) + navigateToEntryInPane(entry) } - // Jump to the block containing the match - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let tab = appState.activeTab, - let doc = blockDocuments[tab.id] else { return } + // Jump to the block containing the match after content loads + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + guard let paneId = workspaceManager.activeWorkspace?.focusedPaneId, + let doc = blockDocuments[paneId] else { return } let lowerQuery = query.lowercased() if let block = doc.blocks.first(where: { $0.text.lowercased().contains(lowerQuery) @@ -1616,6 +1616,20 @@ struct ContentView: View { loadFileContentForPane(entry: entry, paneId: targetPaneId) } + /// Open a file entry in a new workspace tab (used by Cmd+K new-tab mode). + private func openEntryInNewWorkspaceTab(_ entry: FileEntry) { + appState.currentView = .editor + appState.showSettings = false + + let paneId = UUID() + let file = makeOpenFile(for: entry, id: paneId) + workspaceManager.addWorkspaceWith(content: .document(openFile: file)) + // addWorkspaceWith reassigns the pane id; use the actual focused pane id + if let actualPaneId = workspaceManager.activeWorkspace?.focusedPaneId { + loadFileContentForPane(entry: entry, paneId: actualPaneId) + } + } + /// Load file content from disk into a pane's BlockDocument. private func loadFileContentForPane(entry: FileEntry, paneId: UUID) { guard !entry.isDatabase, !entry.isDatabaseRow else { return } From c58781e477ed56cb082407532633892bd4736621 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:08:24 -0700 Subject: [PATCH 03/41] Callout block: neutral default, icon/color picker, flexible styling --- Sources/Bugbook/Lib/MarkdownBlockParser.swift | 40 +++- Sources/Bugbook/Models/Block.swift | 9 +- Sources/Bugbook/Models/BlockDocument.swift | 5 +- .../Views/Editor/CalloutBlockView.swift | 183 +++++++++++++++--- 4 files changed, 194 insertions(+), 43 deletions(-) diff --git a/Sources/Bugbook/Lib/MarkdownBlockParser.swift b/Sources/Bugbook/Lib/MarkdownBlockParser.swift index 7669e403..05852e56 100644 --- a/Sources/Bugbook/Lib/MarkdownBlockParser.swift +++ b/Sources/Bugbook/Lib/MarkdownBlockParser.swift @@ -248,7 +248,7 @@ enum MarkdownBlockParser { } // Callout block - if let calloutVariant = parseCalloutOpenComment(trimmed) { + if let calloutMeta = parseCalloutOpenComment(trimmed) { i += 1 let title = i < lines.count ? String(lines[i]) : "" i += 1 @@ -263,7 +263,8 @@ enum MarkdownBlockParser { } let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n")) var block = makeBlock(type: .callout, text: title, children: children) - block.calloutType = calloutVariant + block.calloutIcon = calloutMeta.icon + block.calloutColor = calloutMeta.color blocks.append(block) continue } @@ -678,7 +679,7 @@ enum MarkdownBlockParser { } case .callout: - lines.append("") + lines.append("") lines.append(block.text) if !block.children.isEmpty { lines.append(serialize(block.children, includeBlockIDComments: includeBlockIDComments)) @@ -954,15 +955,40 @@ enum MarkdownBlockParser { return level } - private static func parseCalloutOpenComment(_ trimmed: String) -> String? { + private static func parseCalloutOpenComment(_ trimmed: String) -> (icon: String, color: String)? { guard trimmed.hasPrefix("") else { return nil } let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) - // inner is like "callout info" or "callout warning" + // inner is like "callout icon:lightbulb color:default" or legacy "callout info" guard inner.hasPrefix("callout") else { return nil } let rest = inner.dropFirst("callout".count).trimmingCharacters(in: .whitespaces) + + // New format: key:value pairs + if rest.contains("icon:") || rest.contains("color:") { + var icon = "lightbulb" + var color = "default" + let parts = rest.split(separator: " ") + for part in parts { + if part.hasPrefix("icon:") { + icon = String(part.dropFirst("icon:".count)) + } else if part.hasPrefix("color:") { + color = String(part.dropFirst("color:".count)) + } + } + return (icon: icon, color: color) + } + + // Legacy format: "callout info", "callout warning", etc. + let legacyMap: [String: (String, String)] = [ + "info": ("lightbulb", "default"), + "warning": ("exclamationmark.triangle", "orange"), + "success": ("checkmark.circle", "green"), + "error": ("xmark.circle", "red"), + ] let variant = rest.isEmpty ? "info" : String(rest) - let validVariants = ["info", "warning", "success", "error"] - return validVariants.contains(variant) ? variant : "info" + if let mapped = legacyMap[variant] { + return (icon: mapped.0, color: mapped.1) + } + return (icon: "lightbulb", color: "default") } private static func parsePageLinkComment(_ line: String) -> String? { diff --git a/Sources/Bugbook/Models/Block.swift b/Sources/Bugbook/Models/Block.swift index eae78d86..6f7d76a8 100644 --- a/Sources/Bugbook/Models/Block.swift +++ b/Sources/Bugbook/Models/Block.swift @@ -70,7 +70,8 @@ struct Block: Identifiable, Equatable { var hasHeaderRow: Bool = false // Callout block properties - var calloutType: String = "info" + var calloutIcon: String = "lightbulb" + var calloutColor: String = "default" init( id: UUID = UUID(), @@ -98,7 +99,8 @@ struct Block: Identifiable, Equatable { meetingNotes: String = "", tableData: [[String]] = [], hasHeaderRow: Bool = false, - calloutType: String = "info" + calloutIcon: String = "lightbulb", + calloutColor: String = "default" ) { self.id = id self.type = type @@ -125,6 +127,7 @@ struct Block: Identifiable, Equatable { self.meetingNotes = meetingNotes self.tableData = tableData self.hasHeaderRow = hasHeaderRow - self.calloutType = calloutType + self.calloutIcon = calloutIcon + self.calloutColor = calloutColor } } diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index ecc1b793..17e237ce 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -1003,12 +1003,13 @@ class BlockDocument { return case let .blockType(type, headingLevel): - // Callout needs special handling — set calloutType and focus after + // Callout needs special handling — set icon/color defaults and focus after if type == .callout { saveUndo() updateBlockProperty(id: blockId) { block in block.type = .callout - block.calloutType = "info" + block.calloutIcon = "lightbulb" + block.calloutColor = "default" block.text = "" } focusOrInsertParagraphAfter(blockId: blockId) diff --git a/Sources/Bugbook/Views/Editor/CalloutBlockView.swift b/Sources/Bugbook/Views/Editor/CalloutBlockView.swift index 082a4d8a..42c178ce 100644 --- a/Sources/Bugbook/Views/Editor/CalloutBlockView.swift +++ b/Sources/Bugbook/Views/Editor/CalloutBlockView.swift @@ -1,56 +1,64 @@ import SwiftUI /// Highlighted callout container with icon, editable title, and nested child blocks. -/// Supports four variants: info (blue), warning (orange), success (green), error (red). +/// Default: neutral gray background with a lightbulb icon. +/// Click the icon to open a picker for changing color and icon. struct CalloutBlockView: View { var document: BlockDocument let block: Block var onTyping: (() -> Void)? = nil @State private var textHeight: CGFloat = 24 + @State private var showPicker = false - private var variant: String { block.calloutType } + private var calloutBlockColor: BlockColor { + BlockColor(rawValue: block.calloutColor) ?? .default + } - private var variantColor: Color { - switch variant { - case "warning": return .orange - case "success": return .green - case "error": return .red - default: return .blue + /// The accent color used for the left border and icon tint. + private var accentColor: Color { + switch calloutBlockColor { + case .default: return Color.fallbackTextSecondary + default: return calloutBlockColor.textColor } } - private var variantIcon: String { - switch variant { - case "warning": return "exclamationmark.triangle" - case "success": return "checkmark.circle" - case "error": return "xmark.circle" - default: return "info.circle" + /// The background fill for the callout container. + private var fillColor: Color { + switch calloutBlockColor { + case .default: return Color.primary.opacity(Opacity.subtle) + default: return calloutBlockColor.backgroundColor } } - private static let variantCycle = ["info", "warning", "success", "error"] - var body: some View { HStack(alignment: .top, spacing: 0) { // Left accent border RoundedRectangle(cornerRadius: 1.5) - .fill(variantColor) + .fill(accentColor.opacity(0.5)) .frame(width: 3) - .padding(.vertical, 2) + .padding(.vertical, 4) VStack(alignment: .leading, spacing: 4) { // Header: icon + editable title HStack(alignment: .top, spacing: 6) { Button { - cycleVariant() + showPicker.toggle() } label: { - Image(systemName: variantIcon) + Image(systemName: block.calloutIcon) .font(.system(size: 14, weight: .medium)) - .foregroundStyle(variantColor) + .foregroundStyle(accentColor) .frame(width: 20, height: 24) } .buttonStyle(.plain) .appCursor(.pointingHand) + .popover(isPresented: $showPicker, arrowEdge: .bottom) { + CalloutPickerView( + document: document, + blockId: block.id, + currentIcon: block.calloutIcon, + currentColor: block.calloutColor + ) + } BlockTextView( document: document, @@ -68,8 +76,10 @@ struct CalloutBlockView: View { // Children if !block.children.isEmpty { VStack(alignment: .leading, spacing: 0) { - ForEach(block.children) { child in - BlockCellView(document: document, block: child, onTyping: onTyping) + ForEach(Array(block.children.enumerated()), id: \.element.id) { idx, child in + let prevType = idx > 0 ? block.children[idx - 1].type : nil + let nextType = idx + 1 < block.children.count ? block.children[idx + 1].type : nil + BlockCellView(document: document, block: child, previousBlockType: prevType, nextBlockType: nextType, onTyping: onTyping) .padding(.vertical, 1) } } @@ -80,16 +90,127 @@ struct CalloutBlockView: View { .padding(.vertical, 8) } .background( - RoundedRectangle(cornerRadius: 6) - .fill(variantColor.opacity(0.08)) + RoundedRectangle(cornerRadius: Radius.sm) + .fill(fillColor) ) } +} + +// MARK: - Callout Picker + +/// Popover for choosing callout color and icon. +private struct CalloutPickerView: View { + var document: BlockDocument + let blockId: UUID + let currentIcon: String + let currentColor: String + + private static let colorOptions: [(String, String)] = [ + ("default", "Default"), + ("gray", "Gray"), + ("brown", "Brown"), + ("orange", "Orange"), + ("yellow", "Yellow"), + ("green", "Green"), + ("blue", "Blue"), + ("purple", "Purple"), + ("pink", "Pink"), + ("red", "Red"), + ] + + private static let iconOptions: [(String, String)] = [ + ("lightbulb", "Lightbulb"), + ("info.circle", "Info"), + ("exclamationmark.triangle", "Warning"), + ("checkmark.circle", "Success"), + ("xmark.circle", "Error"), + ("star", "Star"), + ("heart", "Heart"), + ("bolt", "Bolt"), + ("flag", "Flag"), + ("bookmark", "Bookmark"), + ("bell", "Bell"), + ("pin", "Pin"), + ("pencil", "Pencil"), + ("link", "Link"), + ("questionmark.circle", "Question"), + ("flame", "Fire"), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Color section + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(Color.fallbackTextSecondary) + + LazyVGrid(columns: Array(repeating: GridItem(.fixed(24), spacing: 6), count: 5), spacing: 6) { + ForEach(Self.colorOptions, id: \.0) { key, _ in + let blockColor = BlockColor(rawValue: key) ?? .default + let swatchColor: Color = key == "default" + ? Color.primary.opacity(Opacity.medium) + : blockColor.textColor + + Button { + setColor(key) + } label: { + Circle() + .fill(swatchColor) + .frame(width: 20, height: 20) + .overlay { + if currentColor == key { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + } + } + } + + Divider() + + // Icon section + VStack(alignment: .leading, spacing: 6) { + Text("Icon") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(Color.fallbackTextSecondary) + + LazyVGrid(columns: Array(repeating: GridItem(.fixed(28), spacing: 4), count: 4), spacing: 4) { + ForEach(Self.iconOptions, id: \.0) { symbol, _ in + Button { + setIcon(symbol) + } label: { + Image(systemName: symbol) + .font(.system(size: 13)) + .foregroundStyle(currentIcon == symbol ? Color.accentColor : Color.fallbackTextSecondary) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: Radius.xs) + .fill(currentIcon == symbol ? Color.accentColor.opacity(Opacity.medium) : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + } + } + .padding(12) + .frame(width: 180) + } - private func cycleVariant() { - guard let idx = document.index(for: block.id) else { return } - let cycle = Self.variantCycle - let currentIndex = cycle.firstIndex(of: variant) ?? 0 - let nextIndex = (currentIndex + 1) % cycle.count - document.blocks[idx].calloutType = cycle[nextIndex] + private func setColor(_ color: String) { + document.updateBlockProperty(id: blockId) { block in + block.calloutColor = color + } + } + + private func setIcon(_ icon: String) { + document.updateBlockProperty(id: blockId) { block in + block.calloutIcon = icon + } } } From 017b80d46875c14bfa608eb6daacc5e2bfa1eb76 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:08:26 -0700 Subject: [PATCH 04/41] Fix table row grip dots: add explicit frame for 6-dot (2x3) layout --- Sources/Bugbook/Views/Database/TableView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift index 73f1a060..11a48551 100644 --- a/Sources/Bugbook/Views/Database/TableView.swift +++ b/Sources/Bugbook/Views/Database/TableView.swift @@ -1040,6 +1040,7 @@ private struct RowDragHandleDots: View { var body: some View { GripDotsView() .fixedSize() + .frame(width: 12, height: 20) } } From cf665670d7877e5cf544d863ffdd1076fe55ccbf Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:08:27 -0700 Subject: [PATCH 05/41] Wire TranscriptionService to MeetingBlockView for live recording and audio levels --- Sources/Bugbook/Views/ContentView.swift | 5 +- .../Views/Editor/MeetingBlockView.swift | 48 ++++++++----------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..67b465eb 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -1299,9 +1299,11 @@ struct ContentView: View { lastVolatile = volatile var entries = segments if !volatile.isEmpty { entries.append(volatile) } + let fullText = entries.joined(separator: " ") doc?.updateBlockProperty(id: blockId) { block in block.transcriptEntries = entries - block.meetingTranscript = entries.joined(separator: " ") + block.meetingTranscript = fullText + block.text = fullText } doc?.meetingVolatileText = volatile } @@ -1319,6 +1321,7 @@ struct ContentView: View { doc.updateBlockProperty(id: blockId) { block in block.meetingState = .complete block.meetingTranscript = transcript + block.text = transcript } } doc.onDropPageFromSidebar = { [weak appState, weak doc] sourcePath, insertionIndex in diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..34519791 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -436,7 +436,7 @@ struct MeetingBlockView: View { }) { HStack(spacing: 8) { if showWaveform { - WaveformView(isActive: block.meetingState == .recording, phase: 0) + WaveformView(isActive: block.meetingState == .recording, audioLevel: document.meetingAudioLevel) .frame(width: 40, height: 16) } else { Text("Transcript") @@ -818,40 +818,34 @@ private struct PulsingDot: View { private struct WaveformView: View { var isActive: Bool - var phase: CGFloat + var audioLevel: Float - @State private var animating = false private let barCount = 5 + private let maxHeight: CGFloat = 14 + private let minHeight: CGFloat = 3 var body: some View { - HStack(spacing: 2) { - ForEach(0.. CGFloat { - if !isActive { return 3 } - let base: CGFloat = animating ? 14 : 3 - let variance: CGFloat = animating ? CGFloat(index % 3) * 3 : 0 - return max(3, base - variance) + private func barHeight(for index: Int, date: Date) -> CGFloat { + guard isActive else { return minHeight } + let level = CGFloat(audioLevel) + // Each bar gets a slightly different offset from the audio level for organic movement + let t = date.timeIntervalSinceReferenceDate + let freq = 2.5 + Double(index) * 1.3 + let jitter = CGFloat(sin(t * freq) * 0.15) + let height = minHeight + (maxHeight - minHeight) * (level + jitter) + return max(minHeight, min(maxHeight, height)) } } From 7a004ecb8dc29cca0397878bcaccf9b453c3424f Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:08:28 -0700 Subject: [PATCH 06/41] Fix Cmd+K navigation: defer to DispatchQueue.main.async after palette dismissal --- Sources/Bugbook/Views/ContentView.swift | 43 +++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..a69a148e 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -515,30 +515,39 @@ struct ContentView: View { appState: appState, isPresented: $appState.commandPaletteOpen, onSelectFile: { entry in - navigateToEntry(entry) + // Defer navigation until after palette dismissal completes + DispatchQueue.main.async { + navigateToEntryInPane(entry) + } }, onSelectFileNewTab: { entry in - navigateToEntry(entry, inNewTab: true) + DispatchQueue.main.async { + let paneId = UUID() + let file = makeOpenFile(for: entry, id: paneId) + if let newPaneId = workspaceManager.splitFocusedPane(axis: .horizontal, newContent: .document(openFile: file)) { + loadFileContentForPane(entry: entry, paneId: newPaneId) + } + } }, onCreateFile: { name in createNewFileWithName(name) }, onSelectContentMatch: { entry, query in - if appState.commandPaletteMode == .newTab { - navigateToEntry(entry, inNewTab: true) - } else { - navigateToEntry(entry) - } - // Jump to the block containing the match - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let tab = appState.activeTab, - let doc = blockDocuments[tab.id] else { return } - let lowerQuery = query.lowercased() - if let block = doc.blocks.first(where: { - $0.text.lowercased().contains(lowerQuery) - }) { - doc.focusedBlockId = block.id - doc.cursorPosition = 0 + // Defer navigation until after palette dismissal completes + DispatchQueue.main.async { + navigateToEntryInPane(entry) + // Jump to the block containing the match + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let focusedPaneId = workspaceManager.activeWorkspace?.focusedPaneId + guard let paneId = focusedPaneId, + let doc = blockDocuments[paneId] else { return } + let lowerQuery = query.lowercased() + if let block = doc.blocks.first(where: { + $0.text.lowercased().contains(lowerQuery) + }) { + doc.focusedBlockId = block.id + doc.cursorPosition = 0 + } } } } From 64f5c81db2ded8270b8b5f1917669668e3fe040f Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:20:28 -0700 Subject: [PATCH 07/41] Outline/TOC block: clickable heading navigation --- Sources/Bugbook/Views/Editor/OutlineBlockView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Bugbook/Views/Editor/OutlineBlockView.swift b/Sources/Bugbook/Views/Editor/OutlineBlockView.swift index d20fafe1..c8be4680 100644 --- a/Sources/Bugbook/Views/Editor/OutlineBlockView.swift +++ b/Sources/Bugbook/Views/Editor/OutlineBlockView.swift @@ -41,6 +41,7 @@ struct OutlineBlockView: View { return Button { document.focusedBlockId = entry.id + document.scrollToBlockId = entry.id } label: { HStack(spacing: 6) { Circle() From 68d8142e81dcca27dd2313cfeb418a9015112264 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:20:28 -0700 Subject: [PATCH 08/41] Meeting block: merge Summary/Notes toggle, neutral buttons --- .../Views/Editor/MeetingBlockView.swift | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..a987d555 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -13,18 +13,13 @@ struct MeetingBlockView: View { @State private var transcriptSearch = "" @State private var isSearchingTranscript = false @State private var isSummaryExpanded = false - @State private var activeTab: MeetingTab = .summary + @State private var showSummary = true @State private var isHovered = false private var hasVoiceActivity: Bool { document.meetingAudioLevel > 0.01 } @State private var processingStatus = "" @State private var showTranscriptSheet = false - enum MeetingTab { - case summary - case notes - } - init(document: BlockDocument, block: Block) { self.document = document self.block = block @@ -82,14 +77,14 @@ struct MeetingBlockView: View { HStack(spacing: 5) { Circle() .fill(Color.red) - .frame(width: 8, height: 8) + .frame(width: 6, height: 6) Text("Record") .font(.system(size: Typography.bodySmall, weight: .medium)) } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.red.opacity(Opacity.medium)) - .foregroundStyle(Color.red) + .background(Color.primary.opacity(Opacity.subtle)) + .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) @@ -126,15 +121,15 @@ struct MeetingBlockView: View { Button(action: stopRecording) { HStack(spacing: 5) { RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .frame(width: 8, height: 8) + .fill(Color.red) + .frame(width: 6, height: 6) Text("Stop") .font(.system(size: Typography.bodySmall, weight: .medium)) } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.red) - .foregroundStyle(.white) + .background(Color.primary.opacity(Opacity.subtle)) + .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) @@ -230,26 +225,35 @@ struct MeetingBlockView: View { .transition(.opacity) } - // Summary/Notes tab picker - Picker("", selection: $activeTab) { - Text("Summary").tag(MeetingTab.summary) - Text("Notes").tag(MeetingTab.notes) + // Toggle AI summary visibility + Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showSummary.toggle() } }) { + HStack(spacing: 4) { + Text("Summary") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(Color.fallbackTextSecondary) + Image(systemName: showSummary ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.fallbackTextSecondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) } - .pickerStyle(.segmented) - .frame(width: 140) + .buttonStyle(.borderless) Button(action: resumeRecording) { HStack(spacing: 5) { Circle() .fill(Color.red) - .frame(width: 8, height: 8) + .frame(width: 6, height: 6) Text("Resume") .font(.system(size: Typography.bodySmall, weight: .medium)) } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.red.opacity(Opacity.medium)) - .foregroundStyle(Color.red) + .background(Color.primary.opacity(Opacity.subtle)) + .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) @@ -257,13 +261,11 @@ struct MeetingBlockView: View { .padding(.horizontal, 14) .padding(.vertical, 12) - // Content area: Summary or Notes - switch activeTab { - case .summary: + // Combined content area: summary (toggleable) + notes + if showSummary { summaryView - case .notes: - notesView } + notesView Divider() @@ -827,7 +829,7 @@ private struct WaveformView: View { HStack(spacing: 2) { ForEach(0.. Date: Mon, 30 Mar 2026 23:20:28 -0700 Subject: [PATCH 09/41] Ask AI: open full chat view instead of side panel --- Sources/Bugbook/Views/AI/NotesChatView.swift | 7 +++++++ Sources/Bugbook/Views/ContentView.swift | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/Bugbook/Views/AI/NotesChatView.swift b/Sources/Bugbook/Views/AI/NotesChatView.swift index 5e08067d..c9ecb0c4 100644 --- a/Sources/Bugbook/Views/AI/NotesChatView.swift +++ b/Sources/Bugbook/Views/AI/NotesChatView.swift @@ -36,6 +36,13 @@ struct NotesChatView: View { selectedEngine = appState.settings.preferredAIEngine inputFocused = true ensureActiveThread() + if let prompt = appState.aiInitialPrompt, !prompt.isEmpty { + inputText = prompt + appState.aiInitialPrompt = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + sendMessage() + } + } } } diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..349b93e1 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -361,13 +361,14 @@ struct ContentView: View { view .onReceive(NotificationCenter.default.publisher(for: .openAIPanel)) { _ in ensureAiInitializedIfNeeded() - appState.toggleAiPanel() + appState.openNotesChat() } .onReceive(NotificationCenter.default.publisher(for: .askAI)) { notification in let prompt = notification.userInfo?["prompt"] as? String ?? notification.userInfo?["query"] as? String ensureAiInitializedIfNeeded() - appState.openAiPanel(prompt: prompt) + appState.aiInitialPrompt = prompt + appState.openNotesChat() } .onReceive(NotificationCenter.default.publisher(for: .blockTypeShortcut)) { notification in handleBlockTypeShortcut(notification.object as? String) From 278b3a301e779d10f72f733928aba49e22e018ba Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:20:29 -0700 Subject: [PATCH 10/41] Database row templates: create, select, edit per database --- .../Database/DatabaseRowFullPageView.swift | 27 +++++++++ .../Views/Database/DatabaseRowModalView.swift | 31 ++++++++++ .../Views/Database/DatabaseRowViewModel.swift | 25 +++++++- .../Views/Database/InlineRowPeekPanel.swift | 44 +++++++++++++- .../Bugbook/Views/Database/RowPageView.swift | 58 +++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift b/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift index 03b71dff..ecba7d26 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift @@ -29,6 +29,12 @@ struct DatabaseRowFullPageView: View { templates: vm.schema?.templates ?? [], onApplyTemplate: { template in applyTemplate(template) + }, + onNewTemplate: { + vm.createTemplate(name: "Untitled") + }, + onSaveAsTemplate: { + saveCurrentRowAsTemplate() } ) } else { @@ -60,4 +66,25 @@ struct DatabaseRowFullPageView: View { currentRow.body = template.body vm.debouncedSave(currentRow, schema: schema) } + + private func saveCurrentRowAsTemplate() { + guard let row = vm.row, let schema = vm.schema else { return } + var defaults: [String: PropertyValue] = [:] + for prop in schema.properties where prop.type != .title { + if let val = row.properties[prop.id], val != .empty { + defaults[prop.id] = val + } + } + let titleText: String + if let titleProp = schema.titleProperty, let val = row.properties[titleProp.id], case .text(let t) = val { + titleText = t + } else { + titleText = "Untitled" + } + vm.createTemplate( + name: "\(titleText) template", + defaultProperties: defaults, + body: row.body + ) + } } diff --git a/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift b/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift index 226250a8..a55d3ef1 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift @@ -58,6 +58,12 @@ struct DatabaseRowModalView: View { templates: vm.schema?.templates ?? [], onApplyTemplate: { template in applyTemplate(template) + }, + onNewTemplate: { + createNewTemplate() + }, + onSaveAsTemplate: { + saveCurrentRowAsTemplate() } ) } else { @@ -107,4 +113,29 @@ struct DatabaseRowModalView: View { currentRow.body = template.body vm.debouncedSave(currentRow, schema: schema) } + + private func createNewTemplate() { + vm.createTemplate(name: "Untitled") + } + + private func saveCurrentRowAsTemplate() { + guard let row = vm.row, let schema = vm.schema else { return } + var defaults: [String: PropertyValue] = [:] + for prop in schema.properties where prop.type != .title { + if let val = row.properties[prop.id], val != .empty { + defaults[prop.id] = val + } + } + let titleText: String + if let titleProp = schema.titleProperty, let val = row.properties[titleProp.id], case .text(let t) = val { + titleText = t + } else { + titleText = "Untitled" + } + vm.createTemplate( + name: "\(titleText) template", + defaultProperties: defaults, + body: row.body + ) + } } diff --git a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift index d5f6d541..6a71d484 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift @@ -276,6 +276,26 @@ final class DatabaseRowViewModel { .map { RelationDatabaseCandidate(id: $0.id, name: $0.name, path: $0.path) } } + @discardableResult + func createTemplate(name: String, defaultProperties: [String: PropertyValue] = [:], body: String = "") -> DatabaseTemplate { + let template = DatabaseTemplate( + id: "tmpl_\(UUID().uuidString.prefix(8).lowercased())", + name: name, + defaultProperties: defaultProperties, + body: body + ) + if schema?.templates == nil { schema?.templates = [] } + schema?.templates?.append(template) + if let s = schema { + Task { [weak self] in + guard let self else { return } + try? dbService.saveSchema(s, at: dbPath) + postChangeNotification() + } + } + return template + } + func setRelationTarget(_ propertyId: String, target: String) { guard var s = schema, let idx = s.properties.firstIndex(where: { $0.id == propertyId }) else { return } @@ -312,7 +332,7 @@ final class DatabaseRowViewModel { } @ViewBuilder - func rowPageView(onBack: @escaping () -> Void = {}, autoFocusTitle: Bool = false, fullWidth: Bool = false, workspacePath: String? = nil, templates: [DatabaseTemplate] = [], onApplyTemplate: ((DatabaseTemplate) -> Void)? = nil, onNewTemplate: (() -> Void)? = nil) -> some View { + func rowPageView(onBack: @escaping () -> Void = {}, autoFocusTitle: Bool = false, fullWidth: Bool = false, workspacePath: String? = nil, templates: [DatabaseTemplate] = [], onApplyTemplate: ((DatabaseTemplate) -> Void)? = nil, onNewTemplate: (() -> Void)? = nil, onSaveAsTemplate: (() -> Void)? = nil) -> some View { if let schema = schema, row != nil { RowPageView( schema: schema, @@ -338,7 +358,8 @@ final class DatabaseRowViewModel { dbPath: dbPath, templates: templates, onApplyTemplate: onApplyTemplate, - onNewTemplate: onNewTemplate + onNewTemplate: onNewTemplate, + onSaveAsTemplate: onSaveAsTemplate ) } } diff --git a/Sources/Bugbook/Views/Database/InlineRowPeekPanel.swift b/Sources/Bugbook/Views/Database/InlineRowPeekPanel.swift index 954114ee..121f5f9f 100644 --- a/Sources/Bugbook/Views/Database/InlineRowPeekPanel.swift +++ b/Sources/Bugbook/Views/Database/InlineRowPeekPanel.swift @@ -66,7 +66,15 @@ struct InlineRowPeekPanel: View { if let error = vm.error { RowLoadErrorView(message: error) { vm.loadData(rowId: rowId) } } else if vm.schema != nil, vm.row != nil { - vm.rowPageView(onBack: { onClose() }, fullWidth: true, workspacePath: workspacePath) + vm.rowPageView( + onBack: { onClose() }, + fullWidth: true, + workspacePath: workspacePath, + templates: vm.schema?.templates ?? [], + onApplyTemplate: { template in applyTemplate(template) }, + onNewTemplate: { vm.createTemplate(name: "Untitled") }, + onSaveAsTemplate: { saveCurrentRowAsTemplate() } + ) } else { Spacer() HStack { @@ -98,6 +106,10 @@ struct InlineRowPeekPanel: View { showKebabMenu = false copyFilePath() } + kebabButton(icon: "doc.badge.plus", label: "Save as template") { + showKebabMenu = false + saveCurrentRowAsTemplate() + } kebabButton(icon: "trash", label: "Delete", isDestructive: true) { showKebabMenu = false deleteCurrentRow() @@ -140,4 +152,34 @@ struct InlineRowPeekPanel: View { vm.deleteRow(row.id) onClose() } + + private func applyTemplate(_ template: DatabaseTemplate) { + guard var currentRow = vm.row, let schema = vm.schema else { return } + for (key, value) in template.defaultProperties { + currentRow.properties[key] = value + } + currentRow.body = template.body + vm.debouncedSave(currentRow, schema: schema) + } + + private func saveCurrentRowAsTemplate() { + guard let row = vm.row, let schema = vm.schema else { return } + var defaults: [String: PropertyValue] = [:] + for prop in schema.properties where prop.type != .title { + if let val = row.properties[prop.id], val != .empty { + defaults[prop.id] = val + } + } + let titleText: String + if let titleProp = schema.titleProperty, let val = row.properties[titleProp.id], case .text(let t) = val { + titleText = t + } else { + titleText = "Untitled" + } + vm.createTemplate( + name: "\(titleText) template", + defaultProperties: defaults, + body: row.body + ) + } } diff --git a/Sources/Bugbook/Views/Database/RowPageView.swift b/Sources/Bugbook/Views/Database/RowPageView.swift index 628b51e7..09daebd1 100644 --- a/Sources/Bugbook/Views/Database/RowPageView.swift +++ b/Sources/Bugbook/Views/Database/RowPageView.swift @@ -23,6 +23,7 @@ struct RowPageView: View { var templates: [DatabaseTemplate] = [] var onApplyTemplate: ((DatabaseTemplate) -> Void)? var onNewTemplate: (() -> Void)? + var onSaveAsTemplate: (() -> Void)? @Environment(\.workspacePath) private var workspacePath @State private var editingTitle: String = "" @@ -141,6 +142,12 @@ struct RowPageView: View { if !templates.isEmpty, isRowEmpty { templateSection + } else if isRowEmpty, onNewTemplate != nil { + newTemplateOnlySection + } + + if !isRowEmpty, onSaveAsTemplate != nil { + saveAsTemplateRow } } .padding(.vertical, 8) @@ -331,6 +338,57 @@ struct RowPageView: View { } } } + + /// Shown on empty rows when no templates exist yet, but creating is possible. + private var newTemplateOnlySection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Press \u{23CE} to continue with an empty page") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + .padding(.top, 8) + + Button { + onNewTemplate?() + } label: { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + Text("New template") + .font(.caption) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(templateHoveredId == "_new_only" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: Radius.xs)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in templateHoveredId = hovering ? "_new_only" : nil } + } + } + + /// "Save as template" shown on non-empty rows. + private var saveAsTemplateRow: some View { + Button { + onSaveAsTemplate?() + } label: { + HStack(spacing: 6) { + Image(systemName: "doc.badge.plus") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Save as template") + .font(.body) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } } // MARK: - Property Row with split hover zones From 57b1e342f81f73ab90ccbc0fb0d8560f3e44f713 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:20:43 -0700 Subject: [PATCH 11/41] Reduce vertical spacing between list items and heading-to-list gap --- .../Bugbook/Views/Editor/BlockCellView.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index 41432b7c..10da6c4a 100644 --- a/Sources/Bugbook/Views/Editor/BlockCellView.swift +++ b/Sources/Bugbook/Views/Editor/BlockCellView.swift @@ -71,14 +71,24 @@ struct BlockCellView: View { block.type == .paragraph && block.text.isEmpty } - /// Returns tighter vertical padding (1pt) when both the current block and its - /// neighbor are list items, keeping consecutive list items visually grouped. - /// Non-list blocks and list edges retain normal spacing (2pt). Horizontal rules - /// always use 1pt. + /// Returns tighter vertical padding when consecutive list items or + /// heading→list transitions should feel visually grouped. + /// - Consecutive list items: 1pt + /// - Heading ↔ list item boundary: 1pt (grouped feel) + /// - Horizontal rules: 1pt + /// - Everything else: 2pt private func listEdgePadding(neighbor: BlockType?) -> CGFloat { if block.type == .horizontalRule { return 1 } - guard block.type.isListItem, let neighbor, neighbor.isListItem else { return 2 } - return 1 + guard let neighbor else { return 2 } + let currentIsList = block.type.isListItem + let neighborIsList = neighbor.isListItem + let currentIsHeading = block.type == .heading || block.type == .headingToggle + let neighborIsHeading = neighbor == .heading || neighbor == .headingToggle + // Consecutive list items + if currentIsList && neighborIsList { return 1 } + // Heading → list or list → heading boundary + if (currentIsList && neighborIsHeading) || (currentIsHeading && neighborIsList) { return 1 } + return 2 } private var blockUsesOwnInteractions: Bool { From f591575a36b52b3866ddb118de63fea9186c3b8d Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:29:02 -0700 Subject: [PATCH 12/41] Wire heading toggles: BlockCellView routing and slash commands --- Sources/Bugbook/Models/BlockDocument.swift | 7 +++++-- Sources/Bugbook/Views/Editor/BlockCellView.swift | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index ecc1b793..bc70c851 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -616,7 +616,7 @@ class BlockDocument { } updateBlockProperty(id: id) { block in block.type = type - if type == .heading { + if type == .heading || type == .headingToggle { block.headingLevel = 1 } else { block.headingLevel = 0 @@ -626,7 +626,10 @@ class BlockDocument { func setHeadingLevel(id: UUID, level: Int) { updateBlockProperty(id: id) { block in - block.type = .heading + // Preserve the current type when it's already a heading variant + if block.type != .heading && block.type != .headingToggle { + block.type = .heading + } block.headingLevel = level } } diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index 41432b7c..996de465 100644 --- a/Sources/Bugbook/Views/Editor/BlockCellView.swift +++ b/Sources/Bugbook/Views/Editor/BlockCellView.swift @@ -103,7 +103,7 @@ struct BlockCellView: View { private var blockInteractionCursor: NSCursor { switch block.type { - case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .blockquote, .codeBlock, .toggle, .callout: + case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .blockquote, .codeBlock, .toggle, .headingToggle, .callout: return .iBeam default: return .arrow From 9cf304c5389ac8e087aef7e23f5de8da27177ac7 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:29:02 -0700 Subject: [PATCH 13/41] Floating recording indicator pill: NSPanel with audio bars --- Sources/Bugbook/App/AppState.swift | 1 + .../Components/FloatingRecordingPill.swift | 122 +++++++++++------- Sources/Bugbook/Views/ContentView.swift | 15 ++- 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 3efeb13d..14f72405 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -45,6 +45,7 @@ struct MCPServerInfo: Identifiable { var mcpServers: [MCPServerInfo] = [] var isRecording: Bool = false + var recordingBlockId: UUID? var flashcardReviewOpen: Bool = false @ObservationIgnored lazy var aiThreadStore = AiThreadStore() diff --git a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift index 1f7748a2..f3aef891 100644 --- a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift +++ b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift @@ -3,9 +3,9 @@ import SwiftUI // MARK: - Floating Recording Pill Panel -/// A small always-on-top pill that appears when a meeting is recording and Bugbook -/// loses focus. Shows animated green audio bars inside a dark capsule. -/// Clicking it brings Bugbook back to the front. +/// A small always-on-top pill that appears when a meeting is recording. +/// Shows animated green audio bars, a live duration counter, and a stop button. +/// Clicking the pill body brings Bugbook back to the front. final class FloatingRecordingPillPanel: NSPanel { private let hostingView: NSHostingView @@ -16,7 +16,7 @@ final class FloatingRecordingPillPanel: NSPanel { self.hostingView = NSHostingView(rootView: RecordingPillView()) super.init( - contentRect: NSRect(x: 0, y: 0, width: 60, height: 30), + contentRect: NSRect(x: 0, y: 0, width: 140, height: 30), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true @@ -32,8 +32,12 @@ final class FloatingRecordingPillPanel: NSPanel { contentView = hostingView } - func showPill() { - hostingView.rootView = RecordingPillView(isAnimating: true) + func showPill(startDate: Date, onStop: @escaping () -> Void) { + hostingView.rootView = RecordingPillView( + isAnimating: true, + recordingStart: startDate, + onStop: onStop + ) // Re-evaluate size and position each show (handles display changes) let size = hostingView.fittingSize @@ -58,17 +62,40 @@ final class FloatingRecordingPillPanel: NSPanel { private struct RecordingPillView: View { var isAnimating: Bool = true + var recordingStart: Date = .now + var onStop: (() -> Void)? var body: some View { HStack(spacing: 6) { - // App icon (small ladybug) - Image(systemName: "ladybug.fill") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.9)) - - // Animated audio bars - AudioBarsView(isAnimating: isAnimating) - .frame(width: 16, height: 14) + // Tappable area: icon + bars + duration → brings app to front + HStack(spacing: 6) { + Image(systemName: "ladybug.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.9)) + + AudioBarsView(isAnimating: isAnimating) + .frame(width: 16, height: 14) + + if isAnimating { + DurationLabel(since: recordingStart) + } + } + .contentShape(Capsule()) + .onTapGesture { + NSApplication.shared.activate(ignoringOtherApps: true) + } + + if isAnimating { + // Stop button + Button { + onStop?() + } label: { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 8, height: 8) + } + .buttonStyle(.borderless) + } } .padding(.horizontal, 10) .padding(.vertical, 6) @@ -77,9 +104,22 @@ private struct RecordingPillView: View { .fill(Color(hex: "1a1a1a")) .shadow(color: .black.opacity(0.3), radius: 4, y: 2) ) - .contentShape(Capsule()) - .onTapGesture { - NSApplication.shared.activate(ignoringOtherApps: true) + } +} + +// MARK: - Duration Label + +private struct DurationLabel: View { + let since: Date + + var body: some View { + TimelineView(.periodic(from: since, by: 1)) { context in + let elapsed = Int(context.date.timeIntervalSince(since)) + let m = elapsed / 60 + let s = elapsed % 60 + Text(String(format: "%d:%02d", m, s)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(Color.white.opacity(0.85)) } } } @@ -127,10 +167,9 @@ private struct AudioBar: View { /// Pseudo-random bar height derived from time + seed for organic movement. private func barHeight(date: Date, seed: Int) -> CGFloat { let t = date.timeIntervalSinceReferenceDate - // Different frequency per bar so they don't sync up let freq = 2.5 + Double(seed) * 1.3 - let raw = (sin(t * freq) + 1) / 2 // 0...1 - let jitter = sin(t * freq * 2.7) * 0.15 // small wobble + let raw = (sin(t * freq) + 1) / 2 + let jitter = sin(t * freq * 2.7) * 0.15 return max(minFraction, min(1.0, raw + jitter)) } } @@ -138,59 +177,42 @@ private struct AudioBar: View { // MARK: - Controller /// Manages the lifecycle of the floating recording pill. -/// Owns the panel and responds to app activation / recording state changes. +/// Owns the panel and responds to recording state changes. @MainActor final class FloatingRecordingPillController { private var panel: FloatingRecordingPillPanel? - private var activateObserver: NSObjectProtocol? - private var resignObserver: NSObjectProtocol? + private var recordingStart: Date? + + /// Called when the user taps the stop button on the pill. + var onStop: (() -> Void)? /// Whether recording is active. Set from outside; the controller handles show/hide. var isRecording: Bool = false { didSet { guard isRecording != oldValue else { return } + if isRecording { + recordingStart = .now + } updateVisibility() } } - init() { - activateObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in self?.updateVisibility() } - } - - resignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in self?.updateVisibility() } - } - } - - /// Tear down the panel and notification observers. Call from `.onDisappear` - /// so cleanup runs on MainActor (deinit is nonisolated and can't do this safely). func cleanup() { - if let o = activateObserver { NotificationCenter.default.removeObserver(o) } - if let o = resignObserver { NotificationCenter.default.removeObserver(o) } - activateObserver = nil - resignObserver = nil panel?.orderOut(nil) panel = nil } private func updateVisibility() { - let shouldShow = isRecording && !NSApplication.shared.isActive - if shouldShow { + if isRecording { if panel == nil { panel = FloatingRecordingPillPanel() } - panel?.showPill() + panel?.showPill(startDate: recordingStart ?? .now, onStop: { [weak self] in + Task { @MainActor in self?.onStop?() } + }) } else { panel?.hidePill() + recordingStart = nil } } } diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..823340be 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -154,6 +154,13 @@ struct ContentView: View { } } .onChange(of: appState.isRecording) { _, recording in + if recording, let blockId = appState.recordingBlockId { + // Find the document that owns this recording block + let doc = blockDocuments.values.first { $0.blocks.contains(where: { $0.id == blockId }) } + recordingPillController.onStop = { [weak doc] in + doc?.onStopMeeting?(blockId) + } + } recordingPillController.isRecording = recording } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in @@ -1276,9 +1283,11 @@ struct ContentView: View { } let ts = transcriptionService doc.transcriptionService = ts - doc.onStartMeeting = { [weak doc] blockId in + doc.onStartMeeting = { [weak appState, weak doc] blockId in Task { await ts.startRecording() + appState?.isRecording = true + appState?.recordingBlockId = blockId // Poll confirmed segments and audio level after recording starts var lastSegmentCount = 0 var lastVolatile = "" @@ -1312,8 +1321,10 @@ struct ContentView: View { doc?.meetingVolatileText = "" } } - doc.onStopMeeting = { [weak doc] blockId in + doc.onStopMeeting = { [weak appState, weak doc] blockId in _ = ts.stopRecording() + appState?.isRecording = false + appState?.recordingBlockId = nil guard let doc else { return } let transcript = ts.currentTranscript doc.updateBlockProperty(id: blockId) { block in From 6caefa96974d9eec95e8c94c708070004f2c15a8 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:29:03 -0700 Subject: [PATCH 14/41] Wire table block: verify editor routing and slash menu --- Sources/Bugbook/Models/BlockDocument.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index ecc1b793..c770b656 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -1030,12 +1030,12 @@ class BlockDocument { return } - // Table block — initialize with empty 3x2 grid + // Table block — initialize with empty 3x3 grid if type == .table { saveUndo() updateBlockProperty(id: blockId) { block in block.type = .table - block.tableData = Array(repeating: Array(repeating: "", count: 3), count: 2) + block.tableData = Array(repeating: Array(repeating: "", count: 3), count: 3) block.hasHeaderRow = false } focusOrInsertParagraphAfter(blockId: blockId) From 1325f02bb14b4e7455fb0b88da4163a79a098f92 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:29:03 -0700 Subject: [PATCH 15/41] Meeting block: reduce padding between title and notes area --- Sources/Bugbook/Views/Editor/MeetingBlockView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..8315bdb7 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -95,12 +95,14 @@ struct MeetingBlockView: View { .buttonStyle(.borderless) } .padding(.horizontal, 14) - .padding(.vertical, 12) + .padding(.top, 12) + .padding(.bottom, 4) meetingNotesChildBlocks .frame(minHeight: 80) .padding(.horizontal, 10) - .padding(.vertical, 8) + .padding(.top, 2) + .padding(.bottom, 8) } } From c062005a0bfdd53a55195f6c2fcff0e555cea502 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:29:03 -0700 Subject: [PATCH 16/41] Rename Meetings to AI Meeting Notes with chat button --- Sources/Bugbook/Views/Meetings/MeetingsView.swift | 13 ++++++++++++- Sources/Bugbook/Views/Sidebar/SidebarView.swift | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 8d68f6e4..9e95dea6 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -27,7 +27,7 @@ struct MeetingsView: View { private var header: some View { HStack(spacing: 8) { - Text("Meetings") + Text("AI Meeting Notes") .font(.system(size: 16, weight: .semibold)) .lineLimit(1) @@ -38,6 +38,17 @@ struct MeetingsView: View { .controlSize(.small) } + Button(action: { appState.openNotesChat() }) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 11)) + Text("Chat with all my notes") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + Button(action: rescan) { Image(systemName: "arrow.clockwise") .font(.system(size: 12)) diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index a499df47..bcda68ec 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -252,7 +252,7 @@ struct SidebarView: View { Image(systemName: "waveform") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) - Text("Meetings") + Text("AI Meeting Notes") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) Spacer() From e27e8ba269c7ef07c0febb40a223f820897d3967 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:36:16 -0700 Subject: [PATCH 17/41] Fix calculations footer: transparent until hover, aligned to columns --- .../Bugbook/Views/Database/TableView.swift | 79 +++++++++++-------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift index 73f1a060..f7c5bfdc 100644 --- a/Sources/Bugbook/Views/Database/TableView.swift +++ b/Sources/Bugbook/Views/Database/TableView.swift @@ -978,43 +978,51 @@ struct TableView: View { // MARK: - Calculations Footer - private var calculationsFooter: some View { - HStack(spacing: 0) { - Color.clear.frame(width: scaledRowControlsInset, height: 1) + /// Whether any calculation is actively configured (so we always show results). + private var hasActiveCalculations: Bool { + guard let calcs = viewConfig.calculations else { return false } + return !calcs.isEmpty + } + private var calculationsFooter: some View { + HoverRow { isFooterHovered in HStack(spacing: 0) { - // Title column calculation cell - if let titlePropId = schema.titleProperty?.id { - CalculationFooterCell( - propertyId: titlePropId, - propertyType: .title, - currentFunction: viewConfig.calculations?[titlePropId], - result: calculationResults[titlePropId], - onSetCalculation: onSetCalculation - ) - .frame(width: titleColumnWidth) - } else { - Color.clear.frame(width: titleColumnWidth) - } + Color.clear.frame(width: scaledRowControlsInset, height: 1) - ForEach(visibleProperties) { prop in - CalculationFooterCell( - propertyId: prop.id, - propertyType: prop.type, - currentFunction: viewConfig.calculations?[prop.id], - result: calculationResults[prop.id], - onSetCalculation: onSetCalculation - ) - .frame(width: columnWidth(for: prop)) + HStack(spacing: 0) { + // Title column calculation cell + if let titlePropId = schema.titleProperty?.id { + CalculationFooterCell( + propertyId: titlePropId, + propertyType: .title, + currentFunction: viewConfig.calculations?[titlePropId], + result: calculationResults[titlePropId], + rowHovered: isFooterHovered, + onSetCalculation: onSetCalculation + ) + .frame(width: titleColumnWidth) + } else { + Color.clear.frame(width: titleColumnWidth) + } + + ForEach(visibleProperties) { prop in + CalculationFooterCell( + propertyId: prop.id, + propertyType: prop.type, + currentFunction: viewConfig.calculations?[prop.id], + result: calculationResults[prop.id], + rowHovered: isFooterHovered, + onSetCalculation: onSetCalculation + ) + .frame(width: columnWidth(for: prop)) + } } + .padding(.horizontal, DatabaseZoomMetrics.size(4)) } - .padding(.horizontal, DatabaseZoomMetrics.size(4)) + .frame(height: compactHeaderHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isFooterHovered || hasActiveCalculations ? 1 : 0) } - .frame(height: compactHeaderHeight) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.secondary.opacity(0.04)) - .overlay(alignment: .top) { Divider() } - .overlay { columnDividers().allowsHitTesting(false) } } } @@ -1290,9 +1298,10 @@ private struct CalculationFooterCell: View { let propertyType: PropertyType let currentFunction: String? let result: String? + var rowHovered: Bool = false var onSetCalculation: ((String, String?) -> Void)? - @State private var isHovered = false + @State private var isCellHovered = false @State private var showPopover = false private var availableFunctions: [String] { @@ -1313,10 +1322,10 @@ private struct CalculationFooterCell: View { .font(DatabaseZoomMetrics.font(13)) .foregroundStyle(.secondary) } - } else if isHovered { + } else if rowHovered { Text("Calculate") .font(DatabaseZoomMetrics.font(13)) - .foregroundStyle(.tertiary) + .foregroundStyle(isCellHovered ? .secondary : .tertiary) } Spacer(minLength: 0) } @@ -1325,7 +1334,7 @@ private struct CalculationFooterCell: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .onHover { isHovered = $0 } + .onHover { isCellHovered = $0 } .floatingPopover(isPresented: $showPopover, arrowEdge: .top) { VStack(alignment: .leading, spacing: 4) { ForEach(availableFunctions, id: \.self) { fn in From 1580afc79def4d3ae36a024934b4266a1d1c79ea Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:36:16 -0700 Subject: [PATCH 18/41] Transcript modal: centered page-style instead of full-width sheet --- .../Views/Editor/MeetingBlockView.swift | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..76e3c234 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -55,11 +55,37 @@ struct MeetingBlockView: View { .contentShape(RoundedRectangle(cornerRadius: Radius.lg)) .onHover { isHovered = $0 } .padding(.vertical, 4) - .sheet(isPresented: $showTranscriptSheet) { - TranscriptBubbleView( - transcript: block.meetingTranscript, - meetingNotes: block.meetingNotes - ) + .overlay { + if showTranscriptSheet { + ZStack { + Rectangle() + .fill(Color.black.opacity(0.28)) + .contentShape(Rectangle()) + .onTapGesture { showTranscriptSheet = false } + + TranscriptBubbleView( + transcript: block.meetingTranscript, + meetingNotes: block.meetingNotes, + onClose: { showTranscriptSheet = false } + ) + .frame(maxWidth: 680, maxHeight: 600) + .background(Elevation.popoverBg) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(Elevation.popoverBorder, lineWidth: 0.5) + .allowsHitTesting(false) + } + .shadow( + color: Elevation.shadowColor.opacity(0.18), + radius: 24, + y: Elevation.shadowY * 2 + ) + .onTapGesture { } + .padding(32) + } + .transition(.opacity) + } } } @@ -860,6 +886,7 @@ private struct WaveformView: View { struct TranscriptBubbleView: View { let transcript: String let meetingNotes: String + var onClose: (() -> Void)? @Environment(\.dismiss) private var dismiss var body: some View { @@ -869,14 +896,15 @@ struct TranscriptBubbleView: View { .font(.system(size: 18, weight: .semibold)) Spacer() Button { - dismiss() + if let onClose { onClose() } else { dismiss() } } label: { Image(systemName: "xmark") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.secondary) - .frame(width: 24, height: 24) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) } - .buttonStyle(.borderless) + .buttonStyle(.plain) } .padding(.horizontal, 20) .padding(.vertical, 14) @@ -917,8 +945,6 @@ struct TranscriptBubbleView: View { .padding(20) } } - .frame(minWidth: 500, minHeight: 400) - .background(Color(nsColor: .windowBackgroundColor)) } private struct Bubble { From c145d2dd2fce1cfe961d225baf4ff898d978f5f1 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:36:17 -0700 Subject: [PATCH 19/41] Table view grouping: remove dividers, zero-height collapsed groups --- Sources/Bugbook/Views/Database/TableView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift index 73f1a060..b8611a8c 100644 --- a/Sources/Bugbook/Views/Database/TableView.swift +++ b/Sources/Bugbook/Views/Database/TableView.swift @@ -742,8 +742,6 @@ struct TableView: View { } .buttonStyle(.plain) - tableDivider.opacity(0.5) - // Group rows if !isCollapsed { let groupRowIds = Set(group.rows.map(\.id)) From faae1b4c17390ffc42c3d69737eb58b026d0102a Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:36:17 -0700 Subject: [PATCH 20/41] Allow taking notes in finished meeting block --- .../Views/Editor/MeetingBlockView.swift | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..e56f453a 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -398,28 +398,19 @@ struct MeetingBlockView: View { private var notesView: some View { VStack(alignment: .leading, spacing: 0) { - if block.children.isEmpty && block.meetingNotes.isEmpty { - Text("No notes recorded.") - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextMuted) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 20) - } else if block.children.isEmpty { + if block.children.isEmpty && !block.meetingNotes.isEmpty { // Legacy plain-text notes (backwards compat) Text(block.meetingNotes) .font(.system(size: Typography.bodySmall)) .foregroundStyle(Color.fallbackTextPrimary) .textSelection(.enabled) .padding(14) - } else { - VStack(alignment: .leading, spacing: 0) { - ForEach(block.children) { child in - BlockCellView(document: document, block: child) - .padding(.vertical, 1) - } - } - .padding(14) } + + meetingNotesChildBlocks + .frame(minHeight: 80) + .padding(.horizontal, 10) + .padding(.vertical, 8) } } From 2ca7d48a2f345796e5ab841289fc76a0c7aa9227 Mon Sep 17 00:00:00 2001 From: max4c Date: Mon, 30 Mar 2026 23:36:17 -0700 Subject: [PATCH 21/41] Style AI thread picker to match app popover design --- .../Bugbook/Views/AI/AiSidePanelView.swift | 60 +++---------- Sources/Bugbook/Views/AI/NotesChatView.swift | 60 +++---------- .../Bugbook/Views/AI/ThreadPickerRow.swift | 85 +++++++++++++++++++ 3 files changed, 111 insertions(+), 94 deletions(-) create mode 100644 Sources/Bugbook/Views/AI/ThreadPickerRow.swift diff --git a/Sources/Bugbook/Views/AI/AiSidePanelView.swift b/Sources/Bugbook/Views/AI/AiSidePanelView.swift index 39093e0d..dc712866 100644 --- a/Sources/Bugbook/Views/AI/AiSidePanelView.swift +++ b/Sources/Bugbook/Views/AI/AiSidePanelView.swift @@ -169,31 +169,18 @@ struct AiSidePanelView: View { } } .buttonStyle(.borderless) - .popover(isPresented: $showThreadPicker, arrowEdge: .bottom) { + .floatingPopover(isPresented: $showThreadPicker, arrowEdge: .bottom) { threadPickerContent + .popoverSurface() } } private var threadPickerContent: some View { VStack(alignment: .leading, spacing: 0) { - Button { + NewThreadButton { threadStore.createThread() showThreadPicker = false - } label: { - HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 13)) - .foregroundStyle(Color.accentColor) - Text("New Thread") - .font(.system(size: Typography.bodySmall, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .contentShape(Rectangle()) } - .buttonStyle(.plain) Divider() .padding(.horizontal, 8) @@ -212,39 +199,18 @@ struct AiSidePanelView: View { } private func threadRow(_ thread: AiThread) -> some View { - Button { - threadStore.switchTo(thread.id) - showThreadPicker = false - } label: { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 2) { - Text(thread.title) - .font(.system(size: Typography.bodySmall, weight: thread.id == threadStore.activeThreadId ? .semibold : .regular)) - .foregroundStyle(Color.fallbackTextPrimary) - .lineLimit(1) - Text(relativeTimestamp(thread.updatedAt)) - .font(.system(size: Typography.caption2)) - .foregroundStyle(.secondary) - } - Spacer() - if thread.id == threadStore.activeThreadId { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.accentColor) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { + ThreadRow( + thread: thread, + isActive: thread.id == threadStore.activeThreadId, + timestamp: relativeTimestamp(thread.updatedAt), + onSelect: { + threadStore.switchTo(thread.id) + showThreadPicker = false + }, + onDelete: { threadStore.deleteThread(thread.id) - } label: { - Label("Delete Thread", systemImage: "trash") } - } + ) } // MARK: - Message Bubble diff --git a/Sources/Bugbook/Views/AI/NotesChatView.swift b/Sources/Bugbook/Views/AI/NotesChatView.swift index 5e08067d..37378c63 100644 --- a/Sources/Bugbook/Views/AI/NotesChatView.swift +++ b/Sources/Bugbook/Views/AI/NotesChatView.swift @@ -110,31 +110,18 @@ struct NotesChatView: View { } } .buttonStyle(.borderless) - .popover(isPresented: $showThreadPicker, arrowEdge: .bottom) { + .floatingPopover(isPresented: $showThreadPicker, arrowEdge: .bottom) { threadPickerContent + .popoverSurface() } } private var threadPickerContent: some View { VStack(alignment: .leading, spacing: 0) { - Button { + NewThreadButton { threadStore.createThread() showThreadPicker = false - } label: { - HStack(spacing: 6) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 13)) - .foregroundStyle(Color.accentColor) - Text("New Thread") - .font(.system(size: Typography.bodySmall, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .contentShape(Rectangle()) } - .buttonStyle(.plain) Divider() .padding(.horizontal, 8) @@ -153,39 +140,18 @@ struct NotesChatView: View { } private func threadRow(_ thread: AiThread) -> some View { - Button { - threadStore.switchTo(thread.id) - showThreadPicker = false - } label: { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 2) { - Text(thread.title) - .font(.system(size: Typography.bodySmall, weight: thread.id == threadStore.activeThreadId ? .semibold : .regular)) - .foregroundStyle(Color.fallbackTextPrimary) - .lineLimit(1) - Text(relativeTimestamp(thread.updatedAt)) - .font(.system(size: Typography.caption2)) - .foregroundStyle(.secondary) - } - Spacer() - if thread.id == threadStore.activeThreadId { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Color.accentColor) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { + ThreadRow( + thread: thread, + isActive: thread.id == threadStore.activeThreadId, + timestamp: relativeTimestamp(thread.updatedAt), + onSelect: { + threadStore.switchTo(thread.id) + showThreadPicker = false + }, + onDelete: { threadStore.deleteThread(thread.id) - } label: { - Label("Delete Thread", systemImage: "trash") } - } + ) } @ViewBuilder diff --git a/Sources/Bugbook/Views/AI/ThreadPickerRow.swift b/Sources/Bugbook/Views/AI/ThreadPickerRow.swift new file mode 100644 index 00000000..5f0072dc --- /dev/null +++ b/Sources/Bugbook/Views/AI/ThreadPickerRow.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// Reusable thread row for AI thread picker popovers. +/// Provides hover highlight, active-thread checkmark, and delete context menu. +struct ThreadRow: View { + let thread: AiThread + let isActive: Bool + let timestamp: String + let onSelect: () -> Void + let onDelete: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(thread.title) + .font(.system(size: Typography.bodySmall, weight: isActive ? .semibold : .regular)) + .foregroundStyle(Color.fallbackTextPrimary) + .lineLimit(1) + Text(timestamp) + .font(.system(size: Typography.caption2)) + .foregroundStyle(.secondary) + } + Spacer() + if isActive { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(isActive || isHovered ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .onHover { hovering in + isHovered = hovering + } + .contextMenu { + Button(role: .destructive) { + onDelete() + } label: { + Label("Delete Thread", systemImage: "trash") + } + } + } +} + +/// "New Thread" button used at the top of thread picker popovers. +struct NewThreadButton: View { + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(Color.accentColor) + Text("New Thread") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(Color.fallbackTextPrimary) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .onHover { hovering in + isHovered = hovering + } + } +} From 98668a0b367487753741d5887af92a01a7d01d5b Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 08:57:21 -0700 Subject: [PATCH 22/41] Fix Cmd+K navigation: use @State + onChange instead of direct call from overlay closure --- Sources/Bugbook/Views/ContentView.swift | 57 ++++++++++++++++--------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..8dcf925c 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -51,6 +51,16 @@ struct ContentView: View { @State private var showPageOptionsMenu = false @State private var databaseRowFullWidth: [UUID: Bool] = [:] + // Cmd+K deferred navigation: set by palette closure, consumed by .onChange in ContentView's own cycle + @State private var pendingCmdKNavigation: CmdKNavRequest? + + private struct CmdKNavRequest: Equatable { + let entry: FileEntry + let inNewTab: Bool + let searchQuery: String? + let id: UUID // unique per request so repeated selections of the same entry still fire + } + var body: some View { configuredLayout } @@ -229,6 +239,9 @@ struct ContentView: View { private func applyPaneNotifications(to view: V) -> some View { view + .onChange(of: pendingCmdKNavigation) { _, request in + handlePendingCmdKNavigation(request) + } .onReceive(NotificationCenter.default.publisher(for: .splitPaneRight)) { _ in _ = workspaceManager.splitFocusedPane(axis: .horizontal, newContent: .terminal) } @@ -515,32 +528,17 @@ struct ContentView: View { appState: appState, isPresented: $appState.commandPaletteOpen, onSelectFile: { entry in - navigateToEntry(entry) + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: false, searchQuery: nil, id: UUID()) }, onSelectFileNewTab: { entry in - navigateToEntry(entry, inNewTab: true) + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: true, searchQuery: nil, id: UUID()) }, onCreateFile: { name in createNewFileWithName(name) }, onSelectContentMatch: { entry, query in - if appState.commandPaletteMode == .newTab { - navigateToEntry(entry, inNewTab: true) - } else { - navigateToEntry(entry) - } - // Jump to the block containing the match - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let tab = appState.activeTab, - let doc = blockDocuments[tab.id] else { return } - let lowerQuery = query.lowercased() - if let block = doc.blocks.first(where: { - $0.text.lowercased().contains(lowerQuery) - }) { - doc.focusedBlockId = block.id - doc.cursorPosition = 0 - } - } + let newTab = appState.commandPaletteMode == .newTab + pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: newTab, searchQuery: query, id: UUID()) } ) Spacer() @@ -1616,6 +1614,27 @@ struct ContentView: View { loadFileContentForPane(entry: entry, paneId: targetPaneId) } + /// Handle deferred Cmd+K navigation in ContentView's own render cycle. + private func handlePendingCmdKNavigation(_ request: CmdKNavRequest?) { + guard let request else { return } + pendingCmdKNavigation = nil + navigateToEntryInPane(request.entry) + // For content matches, jump to the matching block after content loads + if let query = request.searchQuery { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let ws = workspaceManager.activeWorkspace, + let doc = blockDocuments[ws.focusedPaneId] else { return } + let lowerQuery = query.lowercased() + if let block = doc.blocks.first(where: { + $0.text.lowercased().contains(lowerQuery) + }) { + doc.focusedBlockId = block.id + doc.cursorPosition = 0 + } + } + } + } + /// Load file content from disk into a pane's BlockDocument. private func loadFileContentForPane(entry: FileEntry, paneId: UUID) { guard !entry.isDatabase, !entry.isDatabaseRow else { return } From 1ef5e0efd36ae436f0e83c6cb9022210ff512741 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 09:04:57 -0700 Subject: [PATCH 23/41] Ask anything AI bar for meeting transcript queries --- .../Views/Editor/MeetingBlockView.swift | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..3f979f0a 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -20,6 +20,11 @@ struct MeetingBlockView: View { @State private var processingStatus = "" @State private var showTranscriptSheet = false + // Ask anything Q&A state (transient) + @State private var askQuestion = "" + @State private var askPairs: [(question: String, answer: String)] = [] + @State private var isAskLoading = false + enum MeetingTab { case summary case notes @@ -154,6 +159,8 @@ struct MeetingBlockView: View { if isTranscriptOpen { transcriptDrawer } + + askAnythingSection } } @@ -272,6 +279,8 @@ struct MeetingBlockView: View { if isTranscriptOpen { transcriptDrawer } + + askAnythingSection } } @@ -563,6 +572,81 @@ struct MeetingBlockView: View { )) } + // MARK: - Ask Anything + + private var askAnythingSection: some View { + VStack(spacing: 0) { + if !askPairs.isEmpty { + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(askPairs.enumerated()), id: \.offset) { _, pair in + VStack(alignment: .leading, spacing: 4) { + Text(pair.question) + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(Color.fallbackTextPrimary) + Text(pair.answer) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextSecondary) + .textSelection(.enabled) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .frame(maxHeight: 200) + } + + Divider() + + HStack(spacing: 8) { + if isAskLoading { + ProgressView() + .controlSize(.small) + } + TextField("Ask anything...", text: $askQuestion) + .textFieldStyle(.plain) + .font(.system(size: Typography.bodySmall)) + .onSubmit { submitAskQuestion() } + .disabled(isAskLoading) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.primary.opacity(Opacity.subtle)) + } + } + + private func submitAskQuestion() { + let question = askQuestion.trimmingCharacters(in: .whitespacesAndNewlines) + guard !question.isEmpty else { return } + askQuestion = "" + isAskLoading = true + + let transcript = block.meetingTranscript + let notes = block.children.isEmpty + ? block.meetingNotes + : block.children.map { $0.text }.joined(separator: "\n") + let summary = block.meetingSummary + + Task { + var context = "" + if !transcript.isEmpty { context += "Transcript:\n\(transcript)\n\n" } + if !notes.isEmpty { context += "Notes:\n\(notes)\n\n" } + if !summary.isEmpty { context += "Summary:\n\(summary)\n\n" } + + let prompt = """ + You are answering questions about a meeting. Be concise and specific. + + \(context)Question: \(question) + """ + + let answer = await runClaude(prompt: prompt) ?? "Could not generate an answer." + askPairs.append((question: question, answer: answer)) + isAskLoading = false + } + } + // MARK: - Ladybug AI Button private var ladybugButton: some View { From 490c978714e9156e838f27bbba1d27d709bf43d1 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 09:10:16 -0700 Subject: [PATCH 24/41] Post-meeting structured output: transcript cleaning, AI sections, chat bubbles --- Sources/Bugbook/Models/BlockDocument.swift | 8 ++ .../Views/Editor/MeetingBlockView.swift | 105 ++++++++++++++---- 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index ecc1b793..6781181f 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -161,6 +161,14 @@ class BlockDocument { updateBlockProperty(id: blockId) { $0.meetingTitle = title } } + func updateMeetingTranscript(blockId: UUID, transcript: String) { + updateBlockProperty(id: blockId) { $0.meetingTranscript = transcript } + } + + func updateMeetingActionItems(blockId: UUID, actionItems: String) { + updateBlockProperty(id: blockId) { $0.meetingActionItems = actionItems } + } + func updateMeetingState(blockId: UUID, state: MeetingBlockState) { updateBlockProperty(id: blockId) { $0.meetingState = state } } diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..72d7f8d8 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -523,7 +523,7 @@ struct MeetingBlockView: View { } ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { + LazyVStack(spacing: 6) { let allEntries = !block.transcriptEntries.isEmpty ? block.transcriptEntries : block.meetingTranscript.components(separatedBy: "\n").filter { !$0.isEmpty } @@ -532,13 +532,16 @@ struct MeetingBlockView: View { : allEntries.filter { $0.localizedCaseInsensitiveContains(transcriptSearch) } ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in - Text(entry) - .font(.system(size: Typography.caption2)) - .foregroundStyle(Color.fallbackTextPrimary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.primary.opacity(Opacity.light)) - .clipShape(RoundedRectangle(cornerRadius: Radius.md)) + HStack { + Text(entry) + .font(.system(size: Typography.caption2)) + .foregroundStyle(Color.fallbackTextPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + Spacer(minLength: 40) + } } if block.meetingState == .recording { @@ -650,22 +653,28 @@ struct MeetingBlockView: View { document.updateMeetingState(blockId: block.id, state: .processing) + var cleanedTranscript = transcript + if !transcript.isEmpty { processingStatus = "Cleaning transcript..." - let cleanedTranscript = await cleanTranscript(transcript) - let cleaned = cleanedTranscript ?? transcript - document.updateBlockText(id: block.id, text: cleaned) + if let result = await cleanTranscript(transcript) { + cleanedTranscript = result + document.updateMeetingTranscript(blockId: block.id, transcript: cleanedTranscript) + } + } + let hasContent = !cleanedTranscript.isEmpty || !userNotes.isEmpty + if hasContent { processingStatus = "Extracting meeting sections..." - let structured = await extractStructuredSections(transcript: cleaned, notes: userNotes) - if let structured { - document.updateMeetingSummary(blockId: block.id, summary: structured) - } - } else if !userNotes.isEmpty { - processingStatus = "Generating summary from notes..." - let structured = await extractStructuredSections(transcript: "", notes: userNotes) - if let structured { - document.updateMeetingSummary(blockId: block.id, summary: structured) + if let structured = await extractStructuredSections(transcript: cleanedTranscript, notes: userNotes) { + let parsed = parseAIResponse(structured) + if !parsed.title.isEmpty { + document.updateMeetingTitle(blockId: block.id, title: parsed.title) + } + if !parsed.actionItems.isEmpty { + document.updateMeetingActionItems(blockId: block.id, actionItems: parsed.actionItems) + } + document.updateMeetingSummary(blockId: block.id, summary: parsed.sections) } } @@ -673,6 +682,62 @@ struct MeetingBlockView: View { document.updateMeetingState(blockId: block.id, state: .complete) } + /// Parse the structured AI response into title, action items, and remaining sections. + private func parseAIResponse(_ response: String) -> (title: String, actionItems: String, sections: String) { + var title = "" + var actionLines: [String] = [] + var sectionLines: [String] = [] + var inActionItems = false + + for line in response.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Extract title from "## Title" section + if trimmed.hasPrefix("## Title") { + // The title value is on the next non-empty line; handled below + inActionItems = false + continue + } + + // Detect action items section + if trimmed == "## Action Items" || trimmed == "### Action Items" { + inActionItems = true + continue + } + + // Detect start of a new section (not Action Items) + if (trimmed.hasPrefix("## ") || trimmed.hasPrefix("### ")) && !trimmed.contains("Action Items") { + inActionItems = false + } + + // If we just saw "## Title" and this is a non-empty line, capture it as the title + if title.isEmpty && sectionLines.isEmpty && actionLines.isEmpty && !trimmed.isEmpty + && !trimmed.hasPrefix("##") && !trimmed.hasPrefix("- ") { + title = trimmed + continue + } + + if inActionItems { + if !trimmed.isEmpty { + actionLines.append(trimmed) + } + } else { + sectionLines.append(line) + } + } + + // Clean trailing empty lines from sections + while sectionLines.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { + sectionLines.removeLast() + } + + return ( + title: title, + actionItems: actionLines.joined(separator: "\n"), + sections: sectionLines.joined(separator: "\n") + ) + } + private func cleanTranscript(_ raw: String) async -> String? { let prompt = "Clean up this transcript: remove filler words (uh, um, like, you know), fix punctuation, add sentence breaks. Output only cleaned text:\n\n\(raw)" return await runClaude(prompt: prompt) From 788ff90c6124e71bcf799531ee78a6d9d9cbce99 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 09:31:53 -0700 Subject: [PATCH 25/41] Select option color: kebab menu on option rows with inline edit + color picker --- .../Views/Database/PropertyEditorView.swift | 93 ++++++++++++++----- 1 file changed, 69 insertions(+), 24 deletions(-) diff --git a/Sources/Bugbook/Views/Database/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index 6732d8f2..07567a32 100644 --- a/Sources/Bugbook/Views/Database/PropertyEditorView.swift +++ b/Sources/Bugbook/Views/Database/PropertyEditorView.swift @@ -239,7 +239,7 @@ struct PropertyEditorView: View { } Divider().padding(.vertical, 2) ForEach(options) { option in - optionButton(option.name, color: option.color, isActive: option.id == currentValue) { + optionButton(option.name, color: option.color, isActive: option.id == currentValue, option: option) { value = .select(option.id) showSelectPopover = false } @@ -319,7 +319,7 @@ struct PropertyEditorView: View { VStack(alignment: .leading, spacing: 0) { ForEach(options) { option in let isSelected = selectedIds.contains(option.id) - optionButton(option.name, color: option.color, isActive: isSelected) { + optionButton(option.name, color: option.color, isActive: isSelected, option: option) { var updated = selectedIds if isSelected { updated.removeAll { $0 == option.id } @@ -348,30 +348,22 @@ struct PropertyEditorView: View { // MARK: - Shared Option Button - private func optionButton(_ label: String, color: String? = nil, isActive: Bool = false, isAction: Bool = false, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 6) { - if let color = color { - Circle() - .fill(colorForName(color)) - .frame(width: 8, height: 8) - } - Text(label) - .font(.callout) - .foregroundStyle(isAction ? Color.secondary : Color.primary) - Spacer() - if isActive { - Image(systemName: "checkmark") - .font(.caption) - .foregroundStyle(.primary) + private func optionButton(_ label: String, color: String? = nil, isActive: Bool = false, isAction: Bool = false, option: SelectOption? = nil, action: @escaping () -> Void) -> some View { + OptionButtonRow( + label: label, + color: color.map { colorForName($0) }, + isActive: isActive, + isAction: isAction, + showKebab: option != nil, + onSelect: action, + onKebab: { + if let option = option { + editingOptionId = option.id + editingOptionName = option.name + editingOptionColor = option.color } } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 4).fill(Color.clear)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + ) } // MARK: - Option Context Menu @@ -1364,4 +1356,57 @@ private struct RelationFlowLayout: Layout { } } +// MARK: - Option Button Row (hover kebab) + +private struct OptionButtonRow: View { + let label: String + let color: Color? + let isActive: Bool + let isAction: Bool + let showKebab: Bool + let onSelect: () -> Void + let onKebab: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 6) { + if let color { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + Text(label) + .font(.callout) + .foregroundStyle(isAction ? Color.secondary : Color.primary) + Spacer() + if showKebab && isHovered { + Button { + onKebab() + } label: { + Text("···") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if isActive { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(.primary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + // MARK: - Relation Flow Layout From 3a4d31eb454e0bfc1d1f2079d347f75e7af7397a Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 09:36:29 -0700 Subject: [PATCH 26/41] Add page/database to sidebar via right-click context menu (replaces broken drag-and-drop) --- Sources/Bugbook/App/BugbookApp.swift | 1 + Sources/Bugbook/Views/ContentView.swift | 5 +++++ Sources/Bugbook/Views/Editor/BlockViews.swift | 9 +++++++++ Sources/Bugbook/Views/Editor/WikiLinkView.swift | 9 +++++++++ 4 files changed, 24 insertions(+) diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index b2bab353..a21829e5 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -374,6 +374,7 @@ extension Notification.Name { static let fileMoved = Notification.Name("fileMoved") static let movePage = Notification.Name("movePage") static let movePageToDir = Notification.Name("movePageToDir") + static let addToSidebar = Notification.Name("addToSidebar") // Pane/Workspace system static let splitPaneRight = Notification.Name("splitPaneRight") diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..dc0d700e 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -215,6 +215,11 @@ struct ContentView: View { performMovePage(from: sourcePath, toDirectory: destDir, insertIndex: insertIndex, siblingNames: siblingNames) } } + .onReceive(NotificationCenter.default.publisher(for: .addToSidebar)) { notification in + if let payload = notification.object as? SidebarReferenceDragPayload { + addSidebarReference(payload) + } + } } private func applyCommandNotifications(to view: V) -> some View { diff --git a/Sources/Bugbook/Views/Editor/BlockViews.swift b/Sources/Bugbook/Views/Editor/BlockViews.swift index c8eb7fa0..81995130 100644 --- a/Sources/Bugbook/Views/Editor/BlockViews.swift +++ b/Sources/Bugbook/Views/Editor/BlockViews.swift @@ -292,6 +292,15 @@ struct DatabaseEmbedBlockView: View { onOpenDatabase: { onOpenDatabaseTab?(dbPath) } ) .padding(.vertical, 4) + .contextMenu { + if let sidebarReferencePayload { + Button { + NotificationCenter.default.post(name: .addToSidebar, object: sidebarReferencePayload) + } label: { + Label("Add to Sidebar", systemImage: "sidebar.left") + } + } + } } private func sidebarDragHandle(payload: SidebarReferenceDragPayload) -> some View { diff --git a/Sources/Bugbook/Views/Editor/WikiLinkView.swift b/Sources/Bugbook/Views/Editor/WikiLinkView.swift index e7cc9b82..c68eac41 100644 --- a/Sources/Bugbook/Views/Editor/WikiLinkView.swift +++ b/Sources/Bugbook/Views/Editor/WikiLinkView.swift @@ -30,6 +30,15 @@ struct WikiLinkView: View { .contentShape(Rectangle()) .onTapGesture(perform: onNavigate) .appCursor(.pointingHand) + .contextMenu { + if let sidebarReferencePayload { + Button { + NotificationCenter.default.post(name: .addToSidebar, object: sidebarReferencePayload) + } label: { + Label("Add to Sidebar", systemImage: "sidebar.left") + } + } + } } private var dragPreview: some View { From 8370472329cd15a40a1a72c331c866025964621b Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 09:47:47 -0700 Subject: [PATCH 27/41] Skills viewer: scan ~/.claude/skills, render SKILL.md as read-only detail view --- Sources/Bugbook/Models/FileEntry.swift | 3 + Sources/Bugbook/Models/OpenFile.swift | 1 + .../Bugbook/Services/FileSystemService.swift | 65 ++++++++---- .../Bugbook/Views/Agent/SkillDetailView.swift | 99 +++++++++++++++++++ Sources/Bugbook/Views/ContentView.swift | 11 ++- 5 files changed, 156 insertions(+), 23 deletions(-) create mode 100644 Sources/Bugbook/Views/Agent/SkillDetailView.swift diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index 8c70142a..149b7bf5 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -6,12 +6,14 @@ enum TabKind: Equatable, Hashable, Codable { case calendar case meetings case graphView + case skill case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } var isCalendar: Bool { self == .calendar } var isMeetings: Bool { self == .meetings } var isGraphView: Bool { self == .graphView } + var isSkill: Bool { self == .skill } var isDatabaseRow: Bool { if case .databaseRow = self { return true }; return false } var databasePath: String? { if case .databaseRow(let p, _) = self { return p }; return nil } var databaseRowId: String? { if case .databaseRow(_, let r) = self { return r }; return nil } @@ -29,6 +31,7 @@ struct FileEntry: Identifiable, Hashable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } + var isSkill: Bool { kind.isSkill } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index cccdcd88..db3c179e 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -18,6 +18,7 @@ struct OpenFile: Identifiable, Equatable, Codable { var isCalendar: Bool { kind.isCalendar } var isMeetings: Bool { kind.isMeetings } var isGraphView: Bool { kind.isGraphView } + var isSkill: Bool { kind.isSkill } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index f2292d43..3ff7e2a0 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -703,35 +703,60 @@ class FileSystemService { // MARK: - Agent Skills - /// Scans ~/.claude/skills/ for skill subfolders containing a .md file. + /// Scans ~/.claude/skills/ and ~/.claude/agents/ for skill/agent subfolders containing a .md file. /// Returns one FileEntry per skill, pointing at the first .md file found. + /// Parses YAML frontmatter from each file to extract name and description. nonisolated func scanSkills() -> [FileEntry] { - let skillsRoot = (NSHomeDirectory() as NSString).appendingPathComponent(".claude/skills") + let home = NSHomeDirectory() as NSString + let skillsRoot = home.appendingPathComponent(".claude/skills") + let agentsRoot = home.appendingPathComponent(".claude/agents") let fm = FileManager.default - guard let subfolders = try? fm.contentsOfDirectory(atPath: skillsRoot) else { return [] } var entries: [FileEntry] = [] - for folder in subfolders.sorted() { - if folder.hasPrefix(".") { continue } - let folderPath = (skillsRoot as NSString).appendingPathComponent(folder) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: folderPath, isDirectory: &isDir), isDir.boolValue else { continue } - - guard let files = try? fm.contentsOfDirectory(atPath: folderPath) else { continue } - guard let mdFile = files.first(where: { $0.hasSuffix(".md") }) else { continue } - let mdPath = (folderPath as NSString).appendingPathComponent(mdFile) - - entries.append(FileEntry( - id: "skill:\(mdPath)", - name: folder, - path: mdPath, - isDirectory: false, - kind: .page - )) + + for root in [skillsRoot, agentsRoot] { + guard let subfolders = try? fm.contentsOfDirectory(atPath: root) else { continue } + for folder in subfolders.sorted() { + if folder.hasPrefix(".") { continue } + let folderPath = (root as NSString).appendingPathComponent(folder) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: folderPath, isDirectory: &isDir), isDir.boolValue else { continue } + + guard let files = try? fm.contentsOfDirectory(atPath: folderPath) else { continue } + guard let mdFile = files.first(where: { $0.hasSuffix(".md") }) else { continue } + let mdPath = (folderPath as NSString).appendingPathComponent(mdFile) + + let displayName = parseSkillFrontmatterName(at: mdPath) ?? folder + entries.append(FileEntry( + id: "skill:\(mdPath)", + name: displayName, + path: mdPath, + isDirectory: false, + kind: .skill, + icon: "sf:bolt.fill" + )) + } } return entries } + /// Extracts the `name` field from YAML frontmatter in a SKILL.md file. + nonisolated private func parseSkillFrontmatterName(at path: String) -> String? { + guard let data = FileManager.default.contents(atPath: path), + let content = String(data: data, encoding: .utf8) else { return nil } + guard content.hasPrefix("---") else { return nil } + let lines = content.components(separatedBy: "\n") + for line in lines.dropFirst() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { break } + if trimmed.hasPrefix("name:") { + let value = trimmed.dropFirst(5).trimmingCharacters(in: .whitespaces) + return value.isEmpty ? nil : value + } + } + return nil + } + func resolveFavorites(for workspacePath: String, fileTree: [FileEntry]) -> [FileEntry] { let storedPaths = favoritePaths(for: workspacePath) var resolvedPaths: [String] = [] diff --git a/Sources/Bugbook/Views/Agent/SkillDetailView.swift b/Sources/Bugbook/Views/Agent/SkillDetailView.swift new file mode 100644 index 00000000..62586ec6 --- /dev/null +++ b/Sources/Bugbook/Views/Agent/SkillDetailView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// Read-only view for agent skill files (SKILL.md). +/// Parses YAML frontmatter for name/description and renders the body as styled markdown. +struct SkillDetailView: View { + let filePath: String + let displayName: String + + @State private var markdownBody: String = "" + @State private var skillDescription: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 10) { + Image(systemName: "bolt.fill") + .font(.system(size: 20)) + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(displayName) + .font(.system(size: 24, weight: .bold)) + if let desc = skillDescription { + Text(desc) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + } + .padding(.bottom, 20) + + // Read-only badge + HStack(spacing: 6) { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + Text("Read-only skill file") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.primary.opacity(0.05)) + .clipShape(.rect(cornerRadius: 6)) + .padding(.bottom, 16) + + // Markdown content + Text(LocalizedStringKey(markdownBody)) + .font(.system(size: 14)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(40) + .frame(maxWidth: 720, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.fallbackEditorBg) + .onAppear { loadContent() } + .onChange(of: filePath) { _, _ in loadContent() } + } + + private func loadContent() { + guard let data = FileManager.default.contents(atPath: filePath), + let content = String(data: data, encoding: .utf8) else { + markdownBody = "Unable to read skill file." + return + } + let (desc, body) = Self.stripFrontmatter(content) + skillDescription = desc + markdownBody = body + } + + /// Strips YAML frontmatter and extracts the description field. + static func stripFrontmatter(_ content: String) -> (description: String?, body: String) { + guard content.hasPrefix("---") else { return (nil, content) } + let lines = content.components(separatedBy: "\n") + var description: String? + var endIndex = 0 + + for (i, line) in lines.dropFirst().enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == "---" { + endIndex = i + 2 // +1 for dropFirst offset, +1 to skip the closing --- + break + } + if trimmed.hasPrefix("description:") { + let value = trimmed.dropFirst(12).trimmingCharacters(in: .whitespaces) + if !value.isEmpty { description = value } + } + } + + if endIndex > 0, endIndex < lines.count { + let body = lines[endIndex...].joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return (description, body) + } + return (description, content) + } +} diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..f6fbb14e 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -936,7 +936,7 @@ struct ContentView: View { Spacer() - if !file.isEmptyTab && !file.isDatabase { + if !file.isEmptyTab && !file.isDatabase && !file.isSkill { Button { showPageOptionsMenu.toggle() } label: { @@ -1046,6 +1046,11 @@ struct ContentView: View { } ) } + } else if file.isSkill { + SkillDetailView( + filePath: file.path, + displayName: file.displayName ?? (file.path as NSString).lastPathComponent + ) } else if file.isDatabase { DatabaseFullPageView(dbPath: file.path, initialRowId: dbInitialRowId) .id(leaf.id) @@ -1618,7 +1623,7 @@ struct ContentView: View { /// Load file content from disk into a pane's BlockDocument. private func loadFileContentForPane(entry: FileEntry, paneId: UUID) { - guard !entry.isDatabase, !entry.isDatabaseRow else { return } + guard !entry.isDatabase, !entry.isDatabaseRow, !entry.isSkill else { return } let signpostState = Log.signpost.beginInterval("loadFileContent") defer { Log.signpost.endInterval("loadFileContent", signpostState) } formattingPanel?.hidePanel() @@ -1993,7 +1998,7 @@ struct ContentView: View { } private func loadFileContent(for entry: FileEntry) { - guard !entry.isDatabase, !entry.isDatabaseRow else { return } + guard !entry.isDatabase, !entry.isDatabaseRow, !entry.isSkill else { return } let signpostState = Log.signpost.beginInterval("loadFileContent") defer { Log.signpost.endInterval("loadFileContent", signpostState) } formattingPanel?.hidePanel() From d0352b9a7f5606b3f19a94b2ce67bfd8b9488c85 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 10:02:06 -0700 Subject: [PATCH 28/41] Fix Xcode build: link GhosttyKit xcframework + add missing SkillDetailView and ThreadPickerRow to pbxproj --- macos/Bugbook.xcodeproj/project.pbxproj | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 79b73651..28427468 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 736679A853BAC31DAE966F2D /* WorkspaceWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */; }; 73733159AA52E171C502A90A /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D16799A267ED55B558F74BD /* Agent.swift */; }; 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */; }; + E290413799A28A17CC50C957 /* SkillDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9376FE426E025E11737D6737 /* SkillDetailView.swift */; }; 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FDBEE7FFDAA06C8410D140 /* DatabaseDateValue.swift */; }; 76139D5A678EE0329AD1280B /* Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D399795586ADE5448AB72D1C /* Schema.swift */; }; 76E3F554517581E83297A297 /* TrashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33262B570A72E09F2E88ECF2 /* TrashView.swift */; }; @@ -120,6 +121,7 @@ B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */; }; BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */; }; BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7150DDE83E7ED425899B1 /* TableView.swift */; }; + A54F4BE359B93B383EA9A932 /* ThreadPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */; }; BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6639981D84F86643DBB91CD /* AISettingsView.swift */; }; BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */; }; C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */; }; @@ -326,6 +328,7 @@ D914C317DA10399836E4DECC /* DatabaseTemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplatePickerView.swift; sourceTree = ""; }; DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayView.swift; sourceTree = ""; }; DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiSidePanelView.swift; sourceTree = ""; }; + 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerRow.swift; sourceTree = ""; }; E071A97E9E6B0892FA767898 /* BacklinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BacklinkService.swift; sourceTree = ""; }; E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashCommandMenu.swift; sourceTree = ""; }; E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemService.swift; sourceTree = ""; }; @@ -337,6 +340,7 @@ EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarReferenceDragPayload.swift; sourceTree = ""; }; EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPopover.swift; sourceTree = ""; }; EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubView.swift; sourceTree = ""; }; + 9376FE426E025E11737D6737 /* SkillDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillDetailView.swift; sourceTree = ""; }; ED677E977CBBB33D0ECA74CE /* GraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphView.swift; sourceTree = ""; }; ED7530043DEEF28753C2CD6C /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = ""; }; EFF0F24D7EAF03362E0B0441 /* QmdService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QmdService.swift; sourceTree = ""; }; @@ -467,6 +471,7 @@ children = ( DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */, 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */, + 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */, ); path = AI; sourceTree = ""; @@ -475,6 +480,7 @@ isa = PBXGroup; children = ( EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */, + 9376FE426E025E11737D6737 /* SkillDetailView.swift */, ); path = Agent; sourceTree = ""; @@ -923,6 +929,8 @@ 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */, 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */, 0044A6D4562A8D96730E1E8E /* AgentsSettingsView.swift in Sources */, + E290413799A28A17CC50C957 /* SkillDetailView.swift in Sources */, + A54F4BE359B93B383EA9A932 /* ThreadPickerRow.swift in Sources */, C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */, D809F8300B119795D5F00A8B /* AiService.swift in Sources */, 7ED64068786FC4125769B869 /* AiSidePanelView.swift in Sources */, @@ -1187,10 +1195,13 @@ "$(inherited)", "@executable_path/../Frameworks", ); + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64"; MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = "-lghostty-fat"; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; PRODUCT_NAME = Bugbook; SDKROOT = macosx; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64/Headers"; }; name = Release; }; @@ -1208,10 +1219,13 @@ "$(inherited)", "@executable_path/../Frameworks", ); + LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64"; MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = "-lghostty-fat"; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; PRODUCT_NAME = Bugbook; SDKROOT = macosx; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64/Headers"; }; name = Debug; }; From 74182c246eb002d4c2037e6d7bb4dc9eae248433 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 10:14:15 -0700 Subject: [PATCH 29/41] Fix select option kebab: always visible (not hover-dependent), use onTapGesture instead of nested Button --- .../Views/Database/PropertyEditorView.swift | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/Sources/Bugbook/Views/Database/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index 07567a32..c2104b6c 100644 --- a/Sources/Bugbook/Views/Database/PropertyEditorView.swift +++ b/Sources/Bugbook/Views/Database/PropertyEditorView.swift @@ -1370,41 +1370,35 @@ private struct OptionButtonRow: View { @State private var isHovered = false var body: some View { - Button(action: onSelect) { - HStack(spacing: 6) { - if let color { - Circle() - .fill(color) - .frame(width: 8, height: 8) - } - Text(label) - .font(.callout) - .foregroundStyle(isAction ? Color.secondary : Color.primary) - Spacer() - if showKebab && isHovered { - Button { - onKebab() - } label: { - Text("···") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - if isActive { - Image(systemName: "checkmark") - .font(.caption) - .foregroundStyle(.primary) - } + HStack(spacing: 6) { + if let color { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + Text(label) + .font(.callout) + .foregroundStyle(isAction ? Color.secondary : Color.primary) + Spacer() + if showKebab { + Text("···") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(isHovered ? .primary : .tertiary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + .onTapGesture { onKebab() } + } + if isActive { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(.primary) } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 4).fill(Color.clear)) - .contentShape(Rectangle()) } - .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(RoundedRectangle(cornerRadius: 4).fill(isHovered ? Color.primary.opacity(0.06) : Color.clear)) + .contentShape(Rectangle()) + .onTapGesture { onSelect() } .onHover { isHovered = $0 } } } From cb39ae91b5142befebc26712a0c011220710c1fa Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 10:22:29 -0700 Subject: [PATCH 30/41] Select options: Notion-style colored pills, grip dots + kebab on hover, vertical color list with names --- .../Views/Database/PropertyEditorView.swift | 170 ++++++++++++------ 1 file changed, 111 insertions(+), 59 deletions(-) diff --git a/Sources/Bugbook/Views/Database/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index c2104b6c..7748e5ad 100644 --- a/Sources/Bugbook/Views/Database/PropertyEditorView.swift +++ b/Sources/Bugbook/Views/Database/PropertyEditorView.swift @@ -92,55 +92,54 @@ struct PropertyEditorView: View { } private func editOptionPopover(optionId: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Edit Option") - .font(.subheadline) - .fontWeight(.medium) - - TextField("Name", text: $editingOptionName) + VStack(alignment: .leading, spacing: 0) { + // Rename field + TextField("Option name", text: $editingOptionName) .textFieldStyle(.roundedBorder) - .frame(width: 200) - .onSubmit { - commitOptionEdit(optionId: optionId) - } + .font(.callout) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .onSubmit { commitOptionEdit(optionId: optionId) } - // Color picker grid - VStack(alignment: .leading, spacing: 4) { - Text("Color") - .font(.caption) - .foregroundStyle(.secondary) - LazyVGrid(columns: Array(repeating: GridItem(.fixed(22), spacing: 4), count: 5), spacing: 4) { - ForEach(Self.optionColors, id: \.self) { color in - Button { - editingOptionColor = color - } label: { - Circle() - .fill(colorForName(color)) - .frame(width: 18, height: 18) - .overlay { - if editingOptionColor == color { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white) - } - } - } - .buttonStyle(.plain) - } + // Delete + Button { + showDeleteConfirm = optionId + editingOptionId = nil + } label: { + HStack(spacing: 8) { + Image(systemName: "trash") + .font(.callout) + Text("Delete") + .font(.callout) + Spacer() } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) } + .buttonStyle(.plain) - HStack { - Spacer() - Button("Cancel") { editingOptionId = nil } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - Button("Save") { - commitOptionEdit(optionId: optionId) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(editingOptionName.trimmingCharacters(in: .whitespaces).isEmpty) + Divider() + .padding(.vertical, 4) + + // Color list (Notion-style: vertical list with color swatch + name) + Text("Colors") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.bottom, 4) + + ForEach(Self.optionColors, id: \.self) { color in + OptionColorRow( + name: color.capitalized, + color: colorForName(color), + isSelected: editingOptionColor == color, + onSelect: { + editingOptionColor = color + commitOptionEdit(optionId: optionId) + } + ) } } .padding(12) @@ -1356,7 +1355,7 @@ private struct RelationFlowLayout: Layout { } } -// MARK: - Option Button Row (hover kebab) +// MARK: - Option Button Row (Notion-style: pill + grip dots + kebab on hover) private struct OptionButtonRow: View { let label: String @@ -1370,33 +1369,86 @@ private struct OptionButtonRow: View { @State private var isHovered = false var body: some View { - HStack(spacing: 6) { - if let color { - Circle() - .fill(color) - .frame(width: 8, height: 8) + HStack(spacing: 4) { + // Grip dots (hover only, like Notion's drag handle) + if showKebab { + Image(systemName: "line.3.horizontal") + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .frame(width: 14) + .opacity(isHovered ? 1 : 0) } - Text(label) - .font(.callout) - .foregroundStyle(isAction ? Color.secondary : Color.primary) + + // Colored pill (tag style) + if let color, !isAction { + Text(label) + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + Text(label) + .font(.callout) + .foregroundStyle(isAction ? Color.secondary : Color.primary) + } + Spacer() + + // Kebab (hover only) if showKebab { Text("···") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(isHovered ? .primary : .tertiary) - .frame(width: 20, height: 20) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) .contentShape(Rectangle()) .onTapGesture { onKebab() } + .opacity(isHovered ? 1 : 0) } + if isActive { Image(systemName: "checkmark") .font(.caption) .foregroundStyle(.primary) } } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 4).fill(isHovered ? Color.primary.opacity(0.06) : Color.clear)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(RoundedRectangle(cornerRadius: 4).fill(isHovered ? Color.primary.opacity(0.04) : Color.clear)) + .contentShape(Rectangle()) + .onTapGesture { onSelect() } + .onHover { isHovered = $0 } + } +} + +// MARK: - Option Color Row (Notion-style: swatch + name + checkmark) + +private struct OptionColorRow: View { + let name: String + let color: Color + let isSelected: Bool + let onSelect: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 3) + .fill(color.opacity(0.2)) + .frame(width: 18, height: 18) + Text(name) + .font(.callout) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(.primary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(isHovered ? Color.primary.opacity(0.04) : Color.clear) .contentShape(Rectangle()) .onTapGesture { onSelect() } .onHover { isHovered = $0 } From 6784b60fe1bd0bd1410204f0f5654790726ff8c0 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 11:09:48 -0700 Subject: [PATCH 31/41] Fix page icon not syncing to sidebar: update file tree via pane system path, not just legacy tabs --- Sources/Bugbook/Views/ContentView.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 052f68e5..159b5f23 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -1084,12 +1084,22 @@ struct ContentView: View { icon: Binding( get: { document.icon }, set: { - document.icon = $0 + let newIcon = $0 + document.icon = newIcon markActiveEditorTabDirty() + // Update via pane system + if let ws = workspaceManager.activeWorkspace, + let leaf = ws.focusedLeaf, + case .document(let openFile) = leaf.content { + workspaceManager.updatePaneOpenFile(paneId: leaf.id) { file in + file.icon = newIcon + } + appState.updateFileTreeIcon(for: openFile.path, icon: newIcon) + } + // Legacy tab path (fallback) if appState.activeTabIndex < appState.openTabs.count { - let tab = appState.openTabs[appState.activeTabIndex] - appState.openTabs[appState.activeTabIndex].icon = $0 - appState.updateFileTreeIcon(for: tab.path, icon: $0) + appState.openTabs[appState.activeTabIndex].icon = newIcon + appState.updateFileTreeIcon(for: appState.openTabs[appState.activeTabIndex].path, icon: newIcon) } scheduleSave() } From 454a1e872d90ed63e4b5e5915c2d42ad38e61a0b Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 11:40:24 -0700 Subject: [PATCH 32/41] Configurable AI model: add Opus option, thread model through meeting summarization --- Sources/Bugbook/Models/AppSettings.swift | 2 ++ Sources/Bugbook/Services/AiService.swift | 5 +++-- Sources/Bugbook/Services/MeetingNoteService.swift | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift index dd59b4ba..24a247ef 100644 --- a/Sources/Bugbook/Models/AppSettings.swift +++ b/Sources/Bugbook/Models/AppSettings.swift @@ -16,11 +16,13 @@ enum PreferredAIEngine: String, Codable, CaseIterable { enum AnthropicModel: String, Codable, CaseIterable { case haiku = "claude-haiku-4-5-20251001" case sonnet = "claude-sonnet-4-20250514" + case opus = "claude-opus-4-20250514" var displayName: String { switch self { case .haiku: return "Haiku (fast)" case .sonnet: return "Sonnet (quality)" + case .opus: return "Opus (best)" } } } diff --git a/Sources/Bugbook/Services/AiService.swift b/Sources/Bugbook/Services/AiService.swift index 001c2be2..7c3d9db2 100644 --- a/Sources/Bugbook/Services/AiService.swift +++ b/Sources/Bugbook/Services/AiService.swift @@ -208,7 +208,7 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain let actionItems: String } - func summarizeTranscript(_ transcript: String, apiKey: String) async throws -> TranscriptSummary { + func summarizeTranscript(_ transcript: String, apiKey: String, model: AnthropicModel = .sonnet) async throws -> TranscriptSummary { guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } let systemPrompt = """ @@ -227,7 +227,8 @@ NEVER produce empty blocks or consecutive blank lines. Every block must contain apiKey: apiKey, systemPrompt: systemPrompt, userPrompt: "Summarize this meeting transcript:\n\n\(transcript)", - maxTokens: 2048 + maxTokens: 2048, + model: model ) // Split the AI response into summary and action items sections diff --git a/Sources/Bugbook/Services/MeetingNoteService.swift b/Sources/Bugbook/Services/MeetingNoteService.swift index 1bb79ff1..219b0fef 100644 --- a/Sources/Bugbook/Services/MeetingNoteService.swift +++ b/Sources/Bugbook/Services/MeetingNoteService.swift @@ -79,7 +79,8 @@ class MeetingNoteService { event: CalendarEvent?, workspace: String, aiService: AiService, - apiKey: String + apiKey: String, + model: AnthropicModel = .sonnet ) async -> String? { // If there's an event with an existing linked page, return it if let event, let existing = event.linkedPagePath, @@ -98,7 +99,7 @@ class MeetingNoteService { let summary: AiService.TranscriptSummary if !apiKey.isEmpty { do { - summary = try await aiService.summarizeTranscript(transcription.fullText, apiKey: apiKey) + summary = try await aiService.summarizeTranscript(transcription.fullText, apiKey: apiKey, model: model) } catch { self.error = "AI summary failed: \(error.localizedDescription)" summary = AiService.TranscriptSummary(summary: "_(AI summary unavailable)_", actionItems: "- [ ] ") From 6b919c5b390fcd59c6376e4644f4fd464d5b32bd Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 11:42:49 -0700 Subject: [PATCH 33/41] Fix ambiguous summarizeTranscript call: add explicit String type annotation --- Sources/Bugbook/Services/MeetingNoteService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Bugbook/Services/MeetingNoteService.swift b/Sources/Bugbook/Services/MeetingNoteService.swift index 219b0fef..e40b1cd4 100644 --- a/Sources/Bugbook/Services/MeetingNoteService.swift +++ b/Sources/Bugbook/Services/MeetingNoteService.swift @@ -186,7 +186,7 @@ class MeetingNoteService { // Try AI summary if API key available if !apiKey.isEmpty { let plainTranscript = segments.map { $0.text }.joined(separator: " ") - if let summary = try? await aiService.summarizeTranscript(plainTranscript, apiKey: apiKey, model: model) { + if let summary: String = try? await aiService.summarizeTranscript(plainTranscript, apiKey: apiKey, model: model) { content = content.replacingOccurrences(of: "## Summary\n\n_AI summary will appear here when an API key is configured._", with: "## Summary\n\n\(summary)") } } From 073c0974182923e1d3b39035dd444eb2f5584db4 Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 11:51:11 -0700 Subject: [PATCH 34/41] Add FilterGroup recursive data model: AND/OR filter groups with auto-migration from flat filters --- .../Views/Database/DatabaseViewHelpers.swift | 23 ++++ .../Views/Database/DatabaseViewState.swift | 12 +- Sources/BugbookCore/Model/View.swift | 109 +++++++++++++++++- 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift b/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift index 0a653515..751ab65b 100644 --- a/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift +++ b/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift @@ -82,6 +82,29 @@ func matchesFilter(_ value: PropertyValue, filter: FilterConfig) -> Bool { } } +func matchesFilterGroup(_ row: DatabaseRow, group: FilterGroup, schema: DatabaseSchema) -> Bool { + switch group.conjunction { + case .and: + return group.conditions.allSatisfy { condition in + matchesFilterCondition(row, condition: condition, schema: schema) + } + case .or: + return group.conditions.isEmpty || group.conditions.contains { condition in + matchesFilterCondition(row, condition: condition, schema: schema) + } + } +} + +private func matchesFilterCondition(_ row: DatabaseRow, condition: FilterCondition, schema: DatabaseSchema) -> Bool { + switch condition { + case .filter(let filterConfig): + let val = row.properties[filterConfig.property] ?? .empty + return matchesFilter(val, filter: filterConfig) + case .group(let nestedGroup): + return matchesFilterGroup(row, group: nestedGroup, schema: schema) + } +} + func compareValues(_ a: PropertyValue, _ b: PropertyValue) -> ComparisonResult { if case .number(let aNum) = a, case .number(let bNum) = b { if aNum < bNum { return .orderedAscending } diff --git a/Sources/Bugbook/Views/Database/DatabaseViewState.swift b/Sources/Bugbook/Views/Database/DatabaseViewState.swift index b3f29518..0c78cb6a 100644 --- a/Sources/Bugbook/Views/Database/DatabaseViewState.swift +++ b/Sources/Bugbook/Views/Database/DatabaseViewState.swift @@ -56,10 +56,16 @@ final class DatabaseViewState { guard let view = activeView else { return rows } var result = applyManualRowOrder(view.manualRowOrder, to: rows) - for filter in view.filters { + if let group = view.filterGroup, let s = schema { result = result.filter { row in - let val = row.properties[filter.property] ?? .empty - return matchesFilter(val, filter: filter) + matchesFilterGroup(row, group: group, schema: s) + } + } else { + for filter in view.filters { + result = result.filter { row in + let val = row.properties[filter.property] ?? .empty + return matchesFilter(val, filter: filter) + } } } diff --git a/Sources/BugbookCore/Model/View.swift b/Sources/BugbookCore/Model/View.swift index 8db6296b..cf288a31 100644 --- a/Sources/BugbookCore/Model/View.swift +++ b/Sources/BugbookCore/Model/View.swift @@ -44,12 +44,73 @@ public struct FilterConfig: Codable, Identifiable, Sendable { } } +public enum FilterConjunction: String, Codable, Sendable { + case and + case or +} + +public struct FilterGroup: Codable, Identifiable, Sendable { + public let id: String + public var conjunction: FilterConjunction + public var conditions: [FilterCondition] + + public init(id: String = UUID().uuidString, conjunction: FilterConjunction = .and, conditions: [FilterCondition] = []) { + self.id = id + self.conjunction = conjunction + self.conditions = conditions + } +} + +public enum FilterCondition: Codable, Identifiable, Sendable { + case filter(FilterConfig) + case group(FilterGroup) + + public var id: String { + switch self { + case .filter(let f): return f.id + case .group(let g): return g.id + } + } + + private enum CodingKeys: String, CodingKey { + case type, filter, group + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "filter": + let f = try container.decode(FilterConfig.self, forKey: .filter) + self = .filter(f) + case "group": + let g = try container.decode(FilterGroup.self, forKey: .group) + self = .group(g) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown FilterCondition type: \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .filter(let f): + try container.encode("filter", forKey: .type) + try container.encode(f, forKey: .filter) + case .group(let g): + try container.encode("group", forKey: .type) + try container.encode(g, forKey: .group) + } + } +} + public struct ViewConfig: Identifiable, Codable, Sendable { public let id: String public var name: String public var type: ViewType public var sorts: [SortConfig] public var filters: [FilterConfig] + public var filterGroup: FilterGroup? public var columnWidths: [String: Double]? public var hiddenColumns: [String]? public var wrapCellText: Bool? @@ -61,7 +122,8 @@ public struct ViewConfig: Identifiable, Codable, Sendable { public var calculations: [String: String]? public init(id: String, name: String, type: ViewType, sorts: [SortConfig] = [], - filters: [FilterConfig] = [], columnWidths: [String: Double]? = nil, + filters: [FilterConfig] = [], filterGroup: FilterGroup? = nil, + columnWidths: [String: Double]? = nil, hiddenColumns: [String]? = nil, wrapCellText: Bool? = nil, groupBy: String? = nil, dateProperty: String? = nil, manualRowOrder: [String]? = nil, subGroupBy: String? = nil, @@ -71,6 +133,7 @@ public struct ViewConfig: Identifiable, Codable, Sendable { self.type = type self.sorts = sorts self.filters = filters + self.filterGroup = filterGroup self.columnWidths = columnWidths self.hiddenColumns = hiddenColumns self.wrapCellText = wrapCellText @@ -84,6 +147,7 @@ public struct ViewConfig: Identifiable, Codable, Sendable { enum CodingKeys: String, CodingKey { case id, name, type, sorts, filters, calculations + case filterGroup = "filter_group" case columnWidths = "column_widths" case hiddenColumns = "hidden_columns" case wrapCellText = "wrap_cell_text" @@ -93,4 +157,47 @@ public struct ViewConfig: Identifiable, Codable, Sendable { case subGroupBy = "sub_group_by" case hideTitle = "hide_title" } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + type = try container.decode(ViewType.self, forKey: .type) + sorts = try container.decodeIfPresent([SortConfig].self, forKey: .sorts) ?? [] + filters = try container.decodeIfPresent([FilterConfig].self, forKey: .filters) ?? [] + filterGroup = try container.decodeIfPresent(FilterGroup.self, forKey: .filterGroup) + columnWidths = try container.decodeIfPresent([String: Double].self, forKey: .columnWidths) + hiddenColumns = try container.decodeIfPresent([String].self, forKey: .hiddenColumns) + wrapCellText = try container.decodeIfPresent(Bool.self, forKey: .wrapCellText) + groupBy = try container.decodeIfPresent(String.self, forKey: .groupBy) + dateProperty = try container.decodeIfPresent(String.self, forKey: .dateProperty) + manualRowOrder = try container.decodeIfPresent([String].self, forKey: .manualRowOrder) + subGroupBy = try container.decodeIfPresent(String.self, forKey: .subGroupBy) + hideTitle = try container.decodeIfPresent(Bool.self, forKey: .hideTitle) + calculations = try container.decodeIfPresent([String: String].self, forKey: .calculations) + + // Migration: wrap legacy flat filters into an AND group + if filterGroup == nil && !filters.isEmpty { + filterGroup = FilterGroup(conjunction: .and, conditions: filters.map { .filter($0) }) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(type, forKey: .type) + try container.encode(sorts, forKey: .sorts) + try container.encode(filters, forKey: .filters) + try container.encodeIfPresent(filterGroup, forKey: .filterGroup) + try container.encodeIfPresent(columnWidths, forKey: .columnWidths) + try container.encodeIfPresent(hiddenColumns, forKey: .hiddenColumns) + try container.encodeIfPresent(wrapCellText, forKey: .wrapCellText) + try container.encodeIfPresent(groupBy, forKey: .groupBy) + try container.encodeIfPresent(dateProperty, forKey: .dateProperty) + try container.encodeIfPresent(manualRowOrder, forKey: .manualRowOrder) + try container.encodeIfPresent(subGroupBy, forKey: .subGroupBy) + try container.encodeIfPresent(hideTitle, forKey: .hideTitle) + try container.encodeIfPresent(calculations, forKey: .calculations) + } } From 5b8f9ba54ad17cfdc3d2353ea4caf2cc26e6c44b Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 12:25:05 -0700 Subject: [PATCH 35/41] Mention picker: @ trigger in text blocks, filtered page list, inserts @[[Page Name]] --- Sources/Bugbook/Models/BlockDocument.swift | 57 ++++++++ .../Bugbook/Views/Editor/BlockCellView.swift | 21 +++ .../Bugbook/Views/Editor/BlockTextView.swift | 79 +++++++++++ .../Views/Editor/MentionPickerView.swift | 123 ++++++++++++++++++ macos/Bugbook.xcodeproj/project.pbxproj | 4 + 5 files changed, 284 insertions(+) create mode 100644 Sources/Bugbook/Views/Editor/MentionPickerView.swift diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index ecc1b793..85c09369 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -41,6 +41,11 @@ class BlockDocument { var pagePickerSearch: String = "" var pagePickerSelectedIndex: Int = 0 var showTemplatePicker: Bool = false + var mentionPickerBlockId: UUID? + var mentionPickerFilter: String = "" + var mentionPickerSelectedIndex: Int = 0 + /// Character index in the block text where '@' was typed. + var mentionPickerAnchorPos: Int = 0 var aiPromptBlockId: UUID? var aiPromptText: String = "" var isAiGenerating: Bool = false @@ -1147,6 +1152,58 @@ class BlockDocument { slashMenuSelectedIndex = 0 } + // MARK: - Mention Picker (@-mention) + + @ObservationIgnored private var _mentionPickerCache: (search: String, entries: [FileEntry])? + + var filteredMentionEntries: [FileEntry] { + if let cache = _mentionPickerCache, cache.search == mentionPickerFilter { + return cache.entries + } + let flat = flattenEntries(availablePages) + .filter { !$0.isDirectory && ($0.name.hasSuffix(".md") || $0.isDatabase) } + let result = mentionPickerFilter.isEmpty + ? flat + : flat.filter { $0.name.localizedCaseInsensitiveContains(mentionPickerFilter) } + _mentionPickerCache = (search: mentionPickerFilter, entries: result) + return result + } + + func executeMentionPicker() { + let items = filteredMentionEntries + guard !items.isEmpty else { return } + let idx = min(mentionPickerSelectedIndex, items.count - 1) + guard idx >= 0 else { dismissMentionPicker(); return } + let name = items[idx].name.replacingOccurrences(of: ".md", with: "") + insertMention(name: name) + } + + func insertMention(name: String) { + guard let blockId = mentionPickerBlockId, + blockLocation(for: blockId) != nil else { + dismissMentionPicker() + return + } + let searchToken = "@" + mentionPickerFilter + let mention = "@[[" + name + "]]" + saveUndo() + updateBlockProperty(id: blockId) { block in + // Find the @ token nearest to the anchor and replace it. + if let range = block.text.range(of: searchToken) { + block.text.replaceSubrange(range, with: mention) + } + } + dismissMentionPicker() + } + + func dismissMentionPicker() { + mentionPickerBlockId = nil + mentionPickerFilter = "" + mentionPickerSelectedIndex = 0 + mentionPickerAnchorPos = 0 + _mentionPickerCache = nil + } + // MARK: - Inline AI Prompt func showAiPrompt(blockId: UUID) { diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index 41432b7c..e835981d 100644 --- a/Sources/Bugbook/Views/Editor/BlockCellView.swift +++ b/Sources/Bugbook/Views/Editor/BlockCellView.swift @@ -18,6 +18,7 @@ struct BlockCellView: View { @State private var showSlashMenu = false @State private var showBlockMenu = false @State private var showPagePicker = false + @State private var showMentionPicker = false @State private var showAiPrompt = false var body: some View { @@ -30,6 +31,7 @@ struct BlockCellView: View { showSlashMenu: $showSlashMenu, showBlockMenu: $showBlockMenu, showPagePicker: $showPagePicker, + showMentionPicker: $showMentionPicker, showAiPrompt: $showAiPrompt )) } @@ -292,12 +294,14 @@ private struct PopoverSyncModifier: ViewModifier { @Binding var showSlashMenu: Bool @Binding var showBlockMenu: Bool @Binding var showPagePicker: Bool + @Binding var showMentionPicker: Bool @Binding var showAiPrompt: Bool /// Whether this block is the target of any popover right now. private var isSlashTarget: Bool { document.slashMenuBlockId == block.id } private var isBlockMenuTarget: Bool { document.blockMenuBlockId == block.id } private var isPagePickerTarget: Bool { document.showPagePicker && document.pagePickerBlockId == block.id } + private var isMentionPickerTarget: Bool { document.mentionPickerBlockId == block.id } private var isAiPromptTarget: Bool { document.aiPromptBlockId == block.id } func body(content: Content) -> some View { @@ -306,6 +310,7 @@ private struct PopoverSyncModifier: ViewModifier { showSlashMenu = isSlashTarget showBlockMenu = isBlockMenuTarget showPagePicker = isPagePickerTarget + showMentionPicker = isMentionPickerTarget showAiPrompt = isAiPromptTarget } .onChange(of: document.slashMenuBlockId) { _, newVal in @@ -339,6 +344,15 @@ private struct PopoverSyncModifier: ViewModifier { document.dismissPagePicker() } } + .onChange(of: document.mentionPickerBlockId) { _, newVal in + let shouldShow = (newVal == block.id) + if showMentionPicker != shouldShow { showMentionPicker = shouldShow } + } + .onChange(of: showMentionPicker) { _, show in + if !show && document.mentionPickerBlockId == block.id { + document.dismissMentionPicker() + } + } .onChange(of: document.aiPromptBlockId) { _, newVal in let shouldShow = (newVal == block.id) if showAiPrompt != shouldShow { showAiPrompt = shouldShow } @@ -384,6 +398,13 @@ private struct PopoverSyncModifier: ViewModifier { } } } + .background { + if isMentionPickerTarget { + Color.clear.floatingPopover(isPresented: $showMentionPicker, arrowEdge: .bottom) { + MentionPickerView(document: document) + } + } + } .background { if isAiPromptTarget { Color.clear.floatingPopover(isPresented: $showAiPrompt, arrowEdge: .bottom) { diff --git a/Sources/Bugbook/Views/Editor/BlockTextView.swift b/Sources/Bugbook/Views/Editor/BlockTextView.swift index 4b14779c..d84390d1 100644 --- a/Sources/Bugbook/Views/Editor/BlockTextView.swift +++ b/Sources/Bugbook/Views/Editor/BlockTextView.swift @@ -738,6 +738,11 @@ struct BlockTextView: NSViewRepresentable { parent.document.dismissSlashMenu() } + // Mention picker detection: look for '@' before cursor + if !isTitleBlock { + detectMentionTrigger(textView) + } + // Auto-detect markdown prefixes (e.g. "## ", "- ", "- [ ] ", "> ") // Skip title block to avoid converting heading to other types if !isTitleBlock { @@ -788,6 +793,55 @@ struct BlockTextView: NSViewRepresentable { // MARK: - Markdown shortcut auto-detection + /// Detects '@' before cursor and activates/updates the mention picker. + private func detectMentionTrigger(_ textView: NSTextView) { + let text = textView.string + let cursorLoc = textView.selectedRange().location + + // If mention picker is already active for this block, update filter + if parent.document.mentionPickerBlockId == parent.blockId { + let anchor = parent.document.mentionPickerAnchorPos + if anchor < text.count, cursorLoc > anchor { + let startIdx = text.index(text.startIndex, offsetBy: anchor) + if text[startIdx] == "@" { + let filterStart = text.index(after: startIdx) + let filterEnd = text.index(text.startIndex, offsetBy: cursorLoc, limitedBy: text.endIndex) ?? text.endIndex + let filter = String(text[filterStart.. 0, cursorLoc <= text.count else { return } + let charBeforeCursor = text[text.index(text.startIndex, offsetBy: cursorLoc - 1)] + guard charBeforeCursor == "@" else { return } + + // '@' must be at start of text or preceded by whitespace + if cursorLoc > 1 { + let charBeforeAt = text[text.index(text.startIndex, offsetBy: cursorLoc - 2)] + guard charBeforeAt == " " || charBeforeAt == "\t" || charBeforeAt == "\n" else { return } + } + + parent.document.mentionPickerBlockId = parent.blockId + parent.document.mentionPickerAnchorPos = cursorLoc - 1 + parent.document.mentionPickerFilter = "" + parent.document.mentionPickerSelectedIndex = 0 + } + /// Detects markdown prefixes typed at the start of a paragraph block /// and auto-converts the block type (like Notion does). private func autoDetectMarkdownPrefix(_ textView: NSTextView) { @@ -1052,6 +1106,31 @@ struct BlockTextView: NSViewRepresentable { } } + // Mention picker intercepts (when active) + if parent.document.mentionPickerBlockId == parent.blockId { + if commandSelector == #selector(NSResponder.moveUp(_:)) { + if parent.document.mentionPickerSelectedIndex > 0 { + parent.document.mentionPickerSelectedIndex -= 1 + } + return true + } + if commandSelector == #selector(NSResponder.moveDown(_:)) { + let count = min(parent.document.filteredMentionEntries.count, 8) + if parent.document.mentionPickerSelectedIndex < count - 1 { + parent.document.mentionPickerSelectedIndex += 1 + } + return true + } + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.document.executeMentionPicker() + return true + } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + parent.document.dismissMentionPicker() + return true + } + } + // Enter — split block if commandSelector == #selector(NSResponder.insertNewline(_:)) { if parent.isMultiline { return false } diff --git a/Sources/Bugbook/Views/Editor/MentionPickerView.swift b/Sources/Bugbook/Views/Editor/MentionPickerView.swift new file mode 100644 index 00000000..31a34297 --- /dev/null +++ b/Sources/Bugbook/Views/Editor/MentionPickerView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// Mention picker popup triggered by typing '@' in a text block. +/// Shows a filtered list of pages; selecting one inserts @[[Page Name]]. +struct MentionPickerView: View { + var document: BlockDocument + @State private var debouncedEntries: [FileEntry] = [] + @State private var debounceTask: Task? + + var body: some View { + let visible = Array(debouncedEntries.prefix(8)) + VStack(alignment: .leading, spacing: 0) { + Text("Mention a page") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + + // Display search text + HStack(spacing: 0) { + Text("@") + .foregroundStyle(.secondary) + Text(document.mentionPickerFilter.isEmpty ? "Search pages..." : document.mentionPickerFilter) + .foregroundStyle(document.mentionPickerFilter.isEmpty ? .secondary : .primary) + if !document.mentionPickerFilter.isEmpty { + Rectangle().fill(Color.accentColor).frame(width: 1, height: 14) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + + Divider() + + if debouncedEntries.isEmpty { + Text("No pages found") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(visible.enumerated()), id: \.element.id) { index, entry in + Button { + let name = entry.name.replacingOccurrences(of: ".md", with: "") + document.insertMention(name: name) + } label: { + HStack(spacing: 8) { + pageIcon(entry) + Text(entry.name.replacingOccurrences(of: ".md", with: "")) + .foregroundStyle(.primary) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + index == document.mentionPickerSelectedIndex + ? Color.accentColor.opacity(0.1) + : Color.clear + ) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 240) + } + } + .frame(width: 240) + .popoverSurface() + .onAppear { + debouncedEntries = document.filteredMentionEntries + } + .onChange(of: document.mentionPickerFilter) { _, _ in + debounceTask?.cancel() + debounceTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 120_000_000) + guard !Task.isCancelled else { return } + debouncedEntries = document.filteredMentionEntries + } + } + .onDisappear { + debounceTask?.cancel() + debounceTask = nil + } + } + + @ViewBuilder + private func pageIcon(_ entry: FileEntry) -> some View { + if let icon = entry.icon, !icon.isEmpty { + if icon.hasPrefix("custom:") { + let path = String(icon.dropFirst(7)) + if let nsImage = NSImage(contentsOfFile: path) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + } else { + defaultIcon(for: entry) + } + } else if icon.hasPrefix("sf:") { + Image(systemName: String(icon.dropFirst(3))) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else if icon.unicodeScalars.first?.properties.isEmoji == true { + Text(icon).font(.system(size: 13)) + } else { + defaultIcon(for: entry) + } + } else { + defaultIcon(for: entry) + } + } + + private func defaultIcon(for entry: FileEntry) -> some View { + Image(systemName: entry.isDatabase ? "tablecells" : "doc.text") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 0fece574..39503228 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */; }; 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */; }; 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */; }; + A1B2C3D4E5F60718293A4B5C /* MentionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */; }; 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */; }; 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4358B9082C37BE1854810696 /* PaneContentView.swift */; }; 1364B73C31B34B4A59420743 /* Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47EF03A6E9A932800D5A95E /* Row.swift */; }; @@ -329,6 +330,7 @@ DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayView.swift; sourceTree = ""; }; DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiSidePanelView.swift; sourceTree = ""; }; E071A97E9E6B0892FA767898 /* BacklinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BacklinkService.swift; sourceTree = ""; }; + D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionPickerView.swift; sourceTree = ""; }; E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashCommandMenu.swift; sourceTree = ""; }; E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemService.swift; sourceTree = ""; }; E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovePagePickerView.swift; sourceTree = ""; }; @@ -454,6 +456,7 @@ 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */, FB1D2F73F8536CB22D1959D7 /* OutlineBlockView.swift */, 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */, + D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */, F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */, E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */, D5E1AF8805EE73E377D8DF0E /* TableBlockView.swift */, @@ -1011,6 +1014,7 @@ 08B960587DA4D305E70BDF7E /* OutlineBlockView.swift in Sources */, D029F4E3DFFC61628D54BE90 /* PageHeaderView.swift in Sources */, F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* MentionPickerView.swift in Sources */, A017A5DD5FB8198FEEBAC1D1 /* PagePickerView.swift in Sources */, 7822807DA20A9A6954D3ABBC /* PaneContent.swift in Sources */, 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */, From c1ed44ef287147a2bab46b0c65eb2377904a694e Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 12:53:21 -0700 Subject: [PATCH 36/41] Native Gateway dashboard: live ticket counts, quick links, database grid with status bars --- Sources/Bugbook/App/BugbookApp.swift | 6 + Sources/Bugbook/Models/FileEntry.swift | 2 + Sources/Bugbook/Models/OpenFile.swift | 1 + Sources/Bugbook/Models/PaneContent.swift | 9 + .../Bugbook/ViewModels/GatewayViewModel.swift | 75 +++++ .../Views/Components/WorkspaceTabBar.swift | 4 + Sources/Bugbook/Views/ContentView.swift | 32 +- .../Bugbook/Views/Gateway/GatewayView.swift | 289 ++++++++++++++++++ .../Bugbook/Views/Panes/PaneContentView.swift | 1 + .../Bugbook/Views/Sidebar/SidebarView.swift | 21 +- macos/Bugbook.xcodeproj/project.pbxproj | 16 + 11 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 Sources/Bugbook/ViewModels/GatewayViewModel.swift create mode 100644 Sources/Bugbook/Views/Gateway/GatewayView.swift diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index b2bab353..b11cad42 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -96,6 +96,11 @@ struct BugbookApp: App { } .keyboardShortcut("y", modifiers: [.command, .shift]) + Button("Gateway") { + NotificationCenter.default.post(name: .openGateway, object: nil) + } + .keyboardShortcut("0", modifiers: [.command, .shift]) + Button("Toggle Theme") { NotificationCenter.default.post(name: .toggleTheme, object: nil) } @@ -370,6 +375,7 @@ extension Notification.Name { static let editorZoomReset = Notification.Name("editorZoomReset") static let openCalendar = Notification.Name("openCalendar") static let openMeetings = Notification.Name("openMeetings") + static let openGateway = Notification.Name("openGateway") static let fileDeleted = Notification.Name("fileDeleted") static let fileMoved = Notification.Name("fileMoved") static let movePage = Notification.Name("movePage") diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index 8c70142a..c6ba16fe 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -6,12 +6,14 @@ enum TabKind: Equatable, Hashable, Codable { case calendar case meetings case graphView + case gateway case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } var isCalendar: Bool { self == .calendar } var isMeetings: Bool { self == .meetings } var isGraphView: Bool { self == .graphView } + var isGateway: Bool { self == .gateway } var isDatabaseRow: Bool { if case .databaseRow = self { return true }; return false } var databasePath: String? { if case .databaseRow(let p, _) = self { return p }; return nil } var databaseRowId: String? { if case .databaseRow(_, let r) = self { return r }; return nil } diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index cccdcd88..13bb5570 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -18,6 +18,7 @@ struct OpenFile: Identifiable, Equatable, Codable { var isCalendar: Bool { kind.isCalendar } var isMeetings: Bool { kind.isMeetings } var isGraphView: Bool { kind.isGraphView } + var isGateway: Bool { kind.isGateway } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/PaneContent.swift b/Sources/Bugbook/Models/PaneContent.swift index f71324c7..3466e697 100644 --- a/Sources/Bugbook/Models/PaneContent.swift +++ b/Sources/Bugbook/Models/PaneContent.swift @@ -34,6 +34,15 @@ enum PaneContent: Codable, Equatable { )) } + /// A gateway dashboard pane. + static func gatewayDocument() -> PaneContent { + let id = UUID() + return .document(openFile: OpenFile( + id: id, path: "bugbook://gateway", content: "", isDirty: false, isEmptyTab: false, + kind: .gateway, displayName: "Gateway", icon: "square.grid.2x2" + )) + } + /// A meetings pane. static func meetingsDocument() -> PaneContent { let id = UUID() diff --git a/Sources/Bugbook/ViewModels/GatewayViewModel.swift b/Sources/Bugbook/ViewModels/GatewayViewModel.swift new file mode 100644 index 00000000..f37982a5 --- /dev/null +++ b/Sources/Bugbook/ViewModels/GatewayViewModel.swift @@ -0,0 +1,75 @@ +import Foundation +import BugbookCore + +/// Scans workspace databases and aggregates summary stats for the Gateway dashboard. +@MainActor +@Observable +class GatewayViewModel { + struct DatabaseSummary: Identifiable { + let id: String + let name: String + let path: String + let rowCount: Int + let statusCounts: [String: Int] // option name -> count + } + + struct TicketSummary { + var total: Int = 0 + var statusCounts: [String: Int] = [:] // option name -> count + } + + private(set) var databases: [DatabaseSummary] = [] + private(set) var ticketSummary = TicketSummary() + private(set) var isLoading = false + private(set) var recentFiles: [String] = [] // file names + + private let dbStore = DatabaseStore() + private let dbService = DatabaseService() + + func scan(workspacePath: String) { + isLoading = true + let infos = dbStore.listDatabases(in: workspacePath) + + var summaries: [DatabaseSummary] = [] + var aggregateTickets = TicketSummary() + + for info in infos { + guard let (schema, rows) = try? dbService.loadDatabase(at: info.path) else { continue } + + // Find the first select property (typically "Status") + var statusCounts: [String: Int] = [:] + if let statusProp = schema.properties.first(where: { $0.type == .select }), + let options = statusProp.options { + let optionMap = Dictionary(uniqueKeysWithValues: options.map { ($0.id, $0.name) }) + for row in rows { + if case .select(let optId) = row.properties[statusProp.id] { + let name = optionMap[optId] ?? optId + statusCounts[name, default: 0] += 1 + } else { + statusCounts["No Status", default: 0] += 1 + } + } + } + + summaries.append(DatabaseSummary( + id: info.id, + name: info.name, + path: info.path, + rowCount: rows.count, + statusCounts: statusCounts + )) + + // Aggregate ticket-like databases (ones with status properties) + if !statusCounts.isEmpty { + aggregateTickets.total += rows.count + for (status, count) in statusCounts { + aggregateTickets.statusCounts[status, default: 0] += count + } + } + } + + databases = summaries + ticketSummary = aggregateTickets + isLoading = false + } +} diff --git a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift index e122726f..91fda494 100644 --- a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift +++ b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift @@ -158,6 +158,10 @@ private struct NewPanePopover: View { workspaceManager.addWorkspaceWith(content: .meetingsDocument()) dismiss() } + contentRow(icon: "square.grid.2x2", label: "Gateway") { + workspaceManager.addWorkspaceWith(content: .gatewayDocument()) + dismiss() + } Divider().padding(.vertical, 4) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..589a2fe5 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -333,6 +333,11 @@ struct ContentView: View { appState.showSettings = false openContentInFocusedPane(.meetingsDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .openGateway)) { _ in + appState.currentView = .editor + appState.showSettings = false + openContentInFocusedPane(.gatewayDocument()) + } .onReceive(NotificationCenter.default.publisher(for: .newDatabase)) { _ in createNewDatabase() } @@ -814,7 +819,8 @@ struct ContentView: View { private var activeTabLeadingPadding: CGFloat { let isCalendar = appState.activeTab?.isCalendar ?? false let isMeetings = appState.activeTab?.isMeetings ?? false - if isCalendar || isMeetings { return 0 } + let isGateway = appState.activeTab?.isGateway ?? false + if isCalendar || isMeetings || isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -926,7 +932,7 @@ struct ContentView: View { @ViewBuilder private func paneDocumentContent(leaf: PaneNode.Leaf, file: OpenFile) -> some View { VStack(spacing: 0) { - if !file.isEmptyTab && !file.isCalendar && !file.isMeetings { + if !file.isEmptyTab && !file.isCalendar && !file.isMeetings && !file.isGateway { HStack { BreadcrumbView( items: breadcrumbs(for: file), @@ -970,7 +976,7 @@ struct ContentView: View { } private func paneLeadingPadding(for file: OpenFile) -> CGFloat { - if file.isCalendar || file.isMeetings { return 0 } + if file.isCalendar || file.isMeetings || file.isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -1046,6 +1052,26 @@ struct ContentView: View { } ) } + } else if file.isGateway { + GatewayView( + appState: appState, + workspacePath: appState.workspacePath, + onNavigateToFile: { path in + navigateToFilePath(path) + }, + onOpenGatewayLink: { link in + switch link { + case .calendar: + openContentInFocusedPane(.calendarDocument()) + case .graph: + openContentInFocusedPane(.graphDocument()) + case .meetings: + openContentInFocusedPane(.meetingsDocument()) + case .database(let path): + navigateToFilePath(path) + } + } + ) } else if file.isDatabase { DatabaseFullPageView(dbPath: file.path, initialRowId: dbInitialRowId) .id(leaf.id) diff --git a/Sources/Bugbook/Views/Gateway/GatewayView.swift b/Sources/Bugbook/Views/Gateway/GatewayView.swift new file mode 100644 index 00000000..392c66d0 --- /dev/null +++ b/Sources/Bugbook/Views/Gateway/GatewayView.swift @@ -0,0 +1,289 @@ +import SwiftUI +import BugbookCore + +/// Navigation targets for Gateway quick links. +enum GatewayLink { + case calendar + case graph + case meetings + case database(path: String) +} + +/// Native mission-control dashboard — the home screen of Bugbook. +/// Shows live workspace state: databases with status breakdowns, ticket counts, and quick-nav links. +struct GatewayView: View { + var appState: AppState + var workspacePath: String? + var onNavigateToFile: (String) -> Void + var onOpenGatewayLink: (GatewayLink) -> Void + + @State private var viewModel = GatewayViewModel() + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + ticketOverview + quickLinks + databasesGrid + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.fallbackEditorBg) + .onAppear { + if let workspace = workspacePath { + viewModel.scan(workspacePath: workspace) + } + } + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text("Gateway") + .font(.system(size: Typography.title2, weight: .semibold)) + + Spacer() + + Text(formattedDate) + .font(.system(size: Typography.body)) + .foregroundStyle(.secondary) + + if viewModel.isLoading { + ProgressView() + .controlSize(.small) + } + + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.bottom, 4) + } + + // MARK: - Ticket Overview + + @ViewBuilder + private var ticketOverview: some View { + if !viewModel.ticketSummary.statusCounts.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Tickets") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + statBadge( + label: "Total", + value: "\(viewModel.ticketSummary.total)", + color: .primary + ) + + ForEach(sortedStatuses, id: \.0) { status, count in + statBadge( + label: status, + value: "\(count)", + color: colorForStatus(status) + ) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.md)) + } + } + + private var sortedStatuses: [(String, Int)] { + viewModel.ticketSummary.statusCounts.sorted { a, b in + statusOrder(a.key) < statusOrder(b.key) + } + } + + // MARK: - Quick Links + + private var quickLinks: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Links") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + quickLinkButton(icon: "calendar", label: "Today") { + NotificationCenter.default.post(name: .openDailyNote, object: nil) + } + quickLinkButton(icon: "calendar.badge.clock", label: "Calendar") { + onOpenGatewayLink(.calendar) + } + quickLinkButton(icon: "point.3.connected.trianglepath.dotted", label: "Graph") { + onOpenGatewayLink(.graph) + } + quickLinkButton(icon: "waveform", label: "Meetings") { + onOpenGatewayLink(.meetings) + } + } + } + } + + private func quickLinkButton(icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12)) + Text(label) + .font(.system(size: Typography.bodySmall)) + } + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + } + .buttonStyle(.plain) + } + + // MARK: - Databases Grid + + @ViewBuilder + private var databasesGrid: some View { + if !viewModel.databases.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Databases") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + + LazyVGrid(columns: columns, spacing: 12) { + ForEach(viewModel.databases) { db in + databaseCard(db) + } + } + } + } + } + + private func databaseCard(_ db: GatewayViewModel.DatabaseSummary) -> some View { + Button { + onOpenGatewayLink(.database(path: db.path)) + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(db.name) + .font(.system(size: Typography.body, weight: .medium)) + .lineLimit(1) + Spacer() + Text("\(db.rowCount)") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(Capsule()) + } + + if !db.statusCounts.isEmpty { + statusBar(db.statusCounts) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.md)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: - Status Bar + + private func statusBar(_ counts: [String: Int]) -> some View { + let total = counts.values.reduce(0, +) + guard total > 0 else { return AnyView(EmptyView()) } + + let sorted = counts.sorted { statusOrder($0.key) < statusOrder($1.key) } + + return AnyView( + GeometryReader { geo in + HStack(spacing: 1) { + ForEach(sorted, id: \.key) { status, count in + let fraction = CGFloat(count) / CGFloat(total) + RoundedRectangle(cornerRadius: 2) + .fill(colorForStatus(status)) + .frame(width: max(4, geo.size.width * fraction)) + } + } + } + .frame(height: 4) + .clipShape(Capsule()) + ) + } + + // MARK: - Stat Badge + + private func statBadge(label: String, value: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(color) + Text(label) + .font(.system(size: Typography.caption)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + // MARK: - Helpers + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMM d" + return formatter.string(from: Date()) + } + + private func refresh() { + if let workspace = workspacePath { + viewModel.scan(workspacePath: workspace) + } + } + + private func colorForStatus(_ status: String) -> Color { + let lower = status.lowercased() + if lower.contains("done") || lower.contains("complete") || lower.contains("closed") { + return StatusColor.success + } + if lower.contains("progress") || lower.contains("doing") || lower.contains("active") || lower.contains("review") { + return StatusColor.active + } + if lower.contains("block") || lower.contains("stuck") { + return StatusColor.blocked + } + if lower.contains("cancel") || lower.contains("wont") { + return StatusColor.cancelled + } + if lower.contains("todo") || lower.contains("backlog") || lower.contains("queued") || lower.contains("not started") { + return StatusColor.info + } + return StatusColor.neutral + } + + private func statusOrder(_ status: String) -> Int { + let lower = status.lowercased() + if lower.contains("progress") || lower.contains("doing") || lower.contains("active") { return 0 } + if lower.contains("review") { return 1 } + if lower.contains("block") || lower.contains("stuck") { return 2 } + if lower.contains("todo") || lower.contains("backlog") || lower.contains("queued") || lower.contains("not started") { return 3 } + if lower.contains("done") || lower.contains("complete") || lower.contains("closed") { return 4 } + if lower.contains("cancel") { return 5 } + return 3 + } +} diff --git a/Sources/Bugbook/Views/Panes/PaneContentView.swift b/Sources/Bugbook/Views/Panes/PaneContentView.swift index 4a2a9d75..c71b8984 100644 --- a/Sources/Bugbook/Views/Panes/PaneContentView.swift +++ b/Sources/Bugbook/Views/Panes/PaneContentView.swift @@ -71,6 +71,7 @@ struct PaneContentView: View { Button("Calendar") { action(.calendarDocument()) } Button("Meetings") { action(.meetingsDocument()) } Button("Graph View") { action(.graphDocument()) } + Button("Gateway") { action(.gatewayDocument()) } } @ViewBuilder diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index a499df47..c2d69ea7 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -187,9 +187,28 @@ struct SidebarView: View { .padding(.horizontal, sectionHorizontalPadding) .padding(.vertical, sectionVerticalPadding) - // Daily note & Graph (hidden in compact/peek mode) + // Gateway, Daily note & Graph (hidden in compact/peek mode) if !isCompact { VStack(spacing: sectionSpacing) { + Button(action: { invokeAction { NotificationCenter.default.post(name: .openGateway, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "square.grid.2x2") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Gateway") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "gateway" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "gateway" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openDailyNote, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { Image(systemName: "calendar") diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 0fece574..a2e429ee 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4358B9082C37BE1854810696 /* PaneContentView.swift */; }; 1364B73C31B34B4A59420743 /* Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47EF03A6E9A932800D5A95E /* Row.swift */; }; 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */; }; + A1B2C3D4E5F60718A9B0C1D2 /* GatewayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */; }; + A1B2C3D4E5F60718A9B0C1D3 /* GatewayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */; }; 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2262869710B320C9D232D7B /* CalendarEventStore.swift */; }; 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED677E977CBBB33D0ECA74CE /* GraphView.swift */; }; 1E82C25E8134A1038E2D96A6 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */; }; @@ -208,6 +210,8 @@ 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubViewModel.swift; sourceTree = ""; }; 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiThreadStore.swift; sourceTree = ""; }; 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsView.swift; sourceTree = ""; }; + D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayView.swift; sourceTree = ""; }; + D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayViewModel.swift; sourceTree = ""; }; 189FFD8CFB64645025DB5876 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; 1A49740409C913AF7DE543D6 /* QueryEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngine.swift; sourceTree = ""; }; 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeaderView.swift; sourceTree = ""; }; @@ -555,6 +559,7 @@ B16E9F553D2EDB723B3CDFF8 /* Database */, 2579331778CC5EC6DB286690 /* Editor */, C575E14DD4F6A30BEA81C1B4 /* Graph */, + D1E2F3A4B5C6071829A0B1C4 /* Gateway */, F3EE2A2FCDD080B2CD99613C /* Meetings */, D6B6A42068B2184566F971A5 /* Panes */, 3B7E87FA2AD5CC2E60AC7EF4 /* Settings */, @@ -580,6 +585,7 @@ 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */, 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */, 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */, + D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */, 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */, 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */, ); @@ -766,6 +772,14 @@ path = Meetings; sourceTree = ""; }; + D1E2F3A4B5C6071829A0B1C4 /* Gateway */ = { + isa = PBXGroup; + children = ( + D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */, + ); + path = Gateway; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1002,6 +1016,8 @@ 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */, 7EDA4964EE97D79FFE09EF59 /* MeetingNoteService.swift in Sources */, E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */, + A1B2C3D4E5F60718A9B0C1D2 /* GatewayView.swift in Sources */, + A1B2C3D4E5F60718A9B0C1D3 /* GatewayViewModel.swift in Sources */, 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */, 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */, D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */, From 61a4d453a66e34eeab6359afd7f34d48280cda4b Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 13:13:50 -0700 Subject: [PATCH 37/41] FormulaEngine: recursive descent parser for database computed properties --- .../BugbookCore/Engine/FormulaEngine.swift | 192 ++++++++++++++++++ macos/Bugbook.xcodeproj/project.pbxproj | 4 + 2 files changed, 196 insertions(+) create mode 100644 Sources/BugbookCore/Engine/FormulaEngine.swift diff --git a/Sources/BugbookCore/Engine/FormulaEngine.swift b/Sources/BugbookCore/Engine/FormulaEngine.swift new file mode 100644 index 00000000..0669f38f --- /dev/null +++ b/Sources/BugbookCore/Engine/FormulaEngine.swift @@ -0,0 +1,192 @@ +import Foundation + +public struct FormulaEngine { + + // MARK: - Public + + public enum FormulaError: Error, LocalizedError { + case unexpectedCharacter(Character) + case unexpectedToken(String) + case undefinedProperty(String) + case divisionByZero + case unexpectedEnd + + public var errorDescription: String? { + switch self { + case .unexpectedCharacter(let c): return "Unexpected character: '\(c)'" + case .unexpectedToken(let t): return "Unexpected token: '\(t)'" + case .undefinedProperty(let p): return "Undefined property: '\(p)'" + case .divisionByZero: return "Division by zero" + case .unexpectedEnd: return "Unexpected end of expression" + } + } + } + + /// Evaluate a formula expression, resolving property references from the values dictionary. + /// + /// Supports: `+`, `-`, `*`, `/`, parentheses, number literals, and property names (e.g. `prop_price`). + public static func evaluate(expression: String, values: [String: Double]) throws -> Double { + var parser = Parser(tokens: try tokenize(expression), values: values) + let result = try parser.parseExpression() + guard parser.isAtEnd else { + throw FormulaError.unexpectedToken(String(describing: parser.currentToken)) + } + return result + } + + // MARK: - Tokenizer + + private enum Token: CustomStringConvertible { + case number(Double) + case identifier(String) + case plus, minus, star, slash + case leftParen, rightParen + + var description: String { + switch self { + case .number(let n): return "\(n)" + case .identifier(let s): return s + case .plus: return "+" + case .minus: return "-" + case .star: return "*" + case .slash: return "/" + case .leftParen: return "(" + case .rightParen: return ")" + } + } + } + + private static func tokenize(_ expression: String) throws -> [Token] { + var tokens: [Token] = [] + var i = expression.startIndex + + while i < expression.endIndex { + let c = expression[i] + + if c.isWhitespace { + i = expression.index(after: i) + continue + } + + switch c { + case "+": tokens.append(.plus); i = expression.index(after: i) + case "-": tokens.append(.minus); i = expression.index(after: i) + case "*": tokens.append(.star); i = expression.index(after: i) + case "/": tokens.append(.slash); i = expression.index(after: i) + case "(": tokens.append(.leftParen); i = expression.index(after: i) + case ")": tokens.append(.rightParen); i = expression.index(after: i) + default: + if c.isNumber || c == "." { + let start = i + while i < expression.endIndex && (expression[i].isNumber || expression[i] == ".") { + i = expression.index(after: i) + } + guard let value = Double(expression[start..= tokens.count } + var currentToken: Token? { pos < tokens.count ? tokens[pos] : nil } + + mutating func advance() { pos += 1 } + + // expression = term (('+' | '-') term)* + mutating func parseExpression() throws -> Double { + var result = try parseTerm() + while let token = currentToken { + switch token { + case .plus: + advance() + result += try parseTerm() + case .minus: + advance() + result -= try parseTerm() + default: + return result + } + } + return result + } + + // term = factor (('*' | '/') factor)* + mutating func parseTerm() throws -> Double { + var result = try parseFactor() + while let token = currentToken { + switch token { + case .star: + advance() + result *= try parseFactor() + case .slash: + advance() + let divisor = try parseFactor() + guard divisor != 0 else { throw FormulaError.divisionByZero } + result /= divisor + default: + return result + } + } + return result + } + + // factor = ('+' | '-') factor | primary + mutating func parseFactor() throws -> Double { + guard let token = currentToken else { throw FormulaError.unexpectedEnd } + switch token { + case .plus: + advance() + return try parseFactor() + case .minus: + advance() + return -(try parseFactor()) + default: + return try parsePrimary() + } + } + + // primary = NUMBER | IDENTIFIER | '(' expression ')' + mutating func parsePrimary() throws -> Double { + guard let token = currentToken else { throw FormulaError.unexpectedEnd } + switch token { + case .number(let n): + advance() + return n + case .identifier(let name): + advance() + guard let value = values[name] else { throw FormulaError.undefinedProperty(name) } + return value + case .leftParen: + advance() + let result = try parseExpression() + guard case .rightParen = currentToken else { + throw FormulaError.unexpectedToken(currentToken.map(String.init(describing:)) ?? "end") + } + advance() + return result + default: + throw FormulaError.unexpectedToken(String(describing: token)) + } + } + } +} diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 0fece574..18de7c51 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */; }; 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */; }; 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */; }; + 0FA1B2C3D4E5F60718293A4B /* FormulaEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */; }; 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */; }; 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */; }; 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4358B9082C37BE1854810696 /* PaneContentView.swift */; }; @@ -253,6 +254,7 @@ 5C6265630F0923203C1F9764 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D16799A267ED55B558F74BD /* Agent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregationEngine.swift; sourceTree = ""; }; + A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormulaEngine.swift; sourceTree = ""; }; 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineRowPeekPanel.swift; sourceTree = ""; }; 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 661BA33DC8D61D2884B32072 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -590,6 +592,7 @@ isa = PBXGroup; children = ( 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */, + A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */, 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */, 1A49740409C913AF7DE543D6 /* QueryEngine.swift */, 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */, @@ -899,6 +902,7 @@ ABA26E21B59748870EB509CE /* AgentWorkspaceStore.swift in Sources */, 694EBF21623BCEA74940150D /* AgentWorkspaceTemplate.swift in Sources */, 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */, + 0FA1B2C3D4E5F60718293A4B /* FormulaEngine.swift in Sources */, BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */, 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */, 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */, From 7650980297d9900414825f8e51f225070a226d6e Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 13:34:19 -0700 Subject: [PATCH 38/41] Import audio recordings: NSOpenPanel + existing transcription pipeline in MeetingsView --- Sources/Bugbook/Views/ContentView.swift | 3 ++ .../Bugbook/Views/Meetings/MeetingsView.swift | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 6336f965..4e7a81ca 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -1031,6 +1031,9 @@ struct ContentView: View { MeetingsView( appState: appState, viewModel: meetingsVM, + meetingNoteService: meetingNoteService, + transcriptionService: transcriptionService, + aiService: aiService, onNavigateToFile: { path in navigateToFilePath(path) } diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 8d68f6e4..85fbf6f3 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -1,10 +1,17 @@ import SwiftUI +import AppKit +import UniformTypeIdentifiers struct MeetingsView: View { var appState: AppState @Bindable var viewModel: MeetingsViewModel + var meetingNoteService: MeetingNoteService + var transcriptionService: TranscriptionService + var aiService: AiService var onNavigateToFile: (String) -> Void + @State private var isImporting = false + var body: some View { VStack(spacing: 0) { header @@ -38,6 +45,15 @@ struct MeetingsView: View { .controlSize(.small) } + Button(action: importRecording) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(isImporting) + .help("Import Recording") + Button(action: rescan) { Image(systemName: "arrow.clockwise") .font(.system(size: 12)) @@ -142,6 +158,36 @@ struct MeetingsView: View { // MARK: - Helpers + private func importRecording() { + let panel = NSOpenPanel() + panel.title = "Import Recording" + panel.allowedContentTypes = [.audio] + panel.allowsMultipleSelection = false + + guard panel.runModal() == .OK, let url = panel.url else { return } + guard TranscriptionService.isSupportedAudioFile(url) else { return } + + isImporting = true + Task { + defer { isImporting = false } + guard let workspace = appState.workspacePath else { return } + let apiKey = appState.settings.anthropicApiKey + let model = appState.settings.anthropicModel + + if let path = await meetingNoteService.importRecording( + fileURL: url, + workspace: workspace, + transcriptionService: transcriptionService, + aiService: aiService, + apiKey: apiKey, + model: model + ) { + onNavigateToFile(path) + rescan() + } + } + } + private func rescan() { guard let workspace = appState.workspacePath else { return } viewModel.scan(workspace: workspace) From be445948dc911720727993aa0a6cf31aa814eedb Mon Sep 17 00:00:00 2001 From: max4c Date: Tue, 31 Mar 2026 17:18:57 -0700 Subject: [PATCH 39/41] Pre-/go: import removal, queue.json, stashed changes from session --- .go/progress.md | 34 +- .go/queue.json | 17 +- Package.swift | 1 + Sources/Bugbook/App/AppState.swift | 15 + Sources/Bugbook/App/BugbookApp.swift | 6 + Sources/Bugbook/Models/AppSettings.swift | 159 +++- Sources/Bugbook/Models/FileEntry.swift | 3 + Sources/Bugbook/Models/MailModels.swift | 160 ++++ Sources/Bugbook/Models/OpenFile.swift | 1 + Sources/Bugbook/Models/PaneContent.swift | 9 + .../Bugbook/Services/AppSettingsStore.swift | 46 + .../Bugbook/Services/CalendarService.swift | 338 ++++---- .../Bugbook/Services/FileSystemService.swift | 12 +- .../Bugbook/Services/GoogleAuthService.swift | 275 ++++++ Sources/Bugbook/Services/Logger.swift | 2 + Sources/Bugbook/Services/MailService.swift | 792 ++++++++++++++++++ .../Calendar/WorkspaceCalendarView.swift | 229 ++++- .../Views/Components/CommandPaletteView.swift | 3 + Sources/Bugbook/Views/ContentView.swift | 38 +- Sources/Bugbook/Views/Mail/MailPaneView.swift | 557 ++++++++++++ .../Bugbook/Views/Meetings/MeetingsView.swift | 48 +- .../Bugbook/Views/Panes/PaneContentView.swift | 2 + .../Views/Settings/CalendarSettingsView.swift | 126 --- .../Views/Settings/GoogleSettingsView.swift | 217 +++++ .../Bugbook/Views/Settings/SettingsView.swift | 6 +- .../Bugbook/Views/Sidebar/SidebarView.swift | 21 +- Tests/BugbookTests/CalendarFeatureTests.swift | 72 ++ Tests/BugbookTests/MailFeatureTests.swift | 265 ++++++ macos/Bugbook.xcodeproj/project.pbxproj | 179 ++-- macos/project.yml | 1 + 30 files changed, 3139 insertions(+), 495 deletions(-) create mode 100644 Sources/Bugbook/Models/MailModels.swift create mode 100644 Sources/Bugbook/Services/AppSettingsStore.swift create mode 100644 Sources/Bugbook/Services/GoogleAuthService.swift create mode 100644 Sources/Bugbook/Services/MailService.swift create mode 100644 Sources/Bugbook/Views/Mail/MailPaneView.swift delete mode 100644 Sources/Bugbook/Views/Settings/CalendarSettingsView.swift create mode 100644 Sources/Bugbook/Views/Settings/GoogleSettingsView.swift create mode 100644 Tests/BugbookTests/CalendarFeatureTests.swift create mode 100644 Tests/BugbookTests/MailFeatureTests.swift diff --git a/.go/progress.md b/.go/progress.md index f75f6921..912390cc 100644 --- a/.go/progress.md +++ b/.go/progress.md @@ -1,31 +1,5 @@ -# Go Run — 2026-03-31 (evening) - -Started: 10:30 PM +# Go Run — 2026-03-31 +Started: 05:18 PM Time budget: 8h -Approach: Sequential v2 with /prep queue - -## Completed (6 tickets, all verified via build) -- [x] Configurable AI model (row_ek1o0u) — added Opus, threaded model through summarization. Build PASS. -- [x] FilterGroup recursive data model (row_0dib4n) — AND/OR groups, auto-migration, matchesFilterGroup. Build PASS. -- [x] Mention picker @ trigger (row_dimm5g) — popup on @, filtered page list, inserts @[[Page Name]]. Build PASS. -- [x] Native Gateway dashboard (row_xsiof2) — live ticket counts, quick links, database grid. Build PASS. -- [x] Restructure Gateway 8.0 (row_zwx9a6) — Values page, Horizon property, updated links. Done via CLI. -- [x] Formula expression parser (row_m0b19c) — recursive descent, arithmetic + property refs. Build PASS. - -## In Progress -- [ ] Import audio recordings — API 529, retrying - -## Queue remaining (split from large tickets) -- Import audio recordings for offline transcription - -## Blocked -- API 529 overloaded errors — pausing until recovery - -## Discoveries -- AiService has two summarizeTranscript overloads with same params, different return types. Adding a model param caused ambiguity — fixed with explicit type annotation. -- The slash menu pattern (BlockDocument trigger detection + floating popover) extends cleanly to @ mention detection. -- Gateway view wired into 11 files — sidebar, content routing, pane system, tab bar, context menus. - -## How to Review -git checkout dev -swift build && .build/arm64-apple-macosx/debug/Bugbook +Queue: 12 tickets from .go/queue.json +Worker mix: 9 Codex, 3 Claude agent diff --git a/.go/queue.json b/.go/queue.json index 0cd61524..866551f2 100644 --- a/.go/queue.json +++ b/.go/queue.json @@ -1,7 +1,14 @@ [ - {"position": 1, "row_id": "row_ek1o0u", "name": "Configurable AI model for meeting summaries", "files": ["AiService.swift", "AISettingsView.swift"], "eval_type": "interaction", "risk": "low"}, - {"position": 2, "row_id": "row_0dib4n", "name": "Add FilterGroup recursive data model", "files": ["View.swift", "DatabaseViewHelpers.swift", "DatabaseViewState.swift"], "eval_type": "cli-only", "risk": "high"}, - {"position": 3, "row_id": "row_dimm5g", "name": "Mention picker: trigger @ and show page list", "files": ["BlockTextView.swift", "BlockDocument.swift"], "eval_type": "interaction", "risk": "medium"}, - {"position": 4, "row_id": "row_xsiof2", "name": "Build native Gateway interface", "files": ["explore"], "eval_type": "visual", "risk": "high"}, - {"position": 5, "row_id": "row_zwx9a6", "name": "Restructure Gateway 8.0", "files": ["explore"], "eval_type": "data", "risk": "medium"} + {"position": 1, "row_id": "row_wtl5f9", "name": "Ship Bugbook as standalone .app binary", "files": ["macos/Bugbook.xcodeproj/project.pbxproj", "macos/project.yml", "Package.swift", "Sources/Bugbook/App/BugbookApp.swift"], "worker": "claude", "note": "Complex build system work — needs xcode project investigation, GhosttyKit resolution, signing. Claude agent better suited."}, + {"position": 2, "row_id": "row_xsiof2", "name": "Gateway redesign", "files": ["Sources/Bugbook/Views/Gateway/GatewayView.swift", "Sources/Bugbook/Views/ContentView.swift"], "worker": "codex", "note": "Has full design spec in Bugbook. Progressive disclosure, greeting+date, quick nav, todays focus, recent activity."}, + {"position": 3, "row_id": "row_wcsgow", "name": "Rename Meetings → Meetings", "files": ["Sources/Bugbook/Views/Meetings/MeetingsView.swift", "Sources/Bugbook/Views/Sidebar/SidebarView.swift"], "worker": "codex", "note": "Simple rename. Sidebar + view title → Meetings. Keep chat button."}, + {"position": 4, "row_id": "row_k1pfpn", "name": "Meeting padding fix", "files": ["Sources/Bugbook/Views/Editor/MeetingBlockView.swift"], "worker": "codex", "note": "Reduce title-to-notes gap to 4pt max."}, + {"position": 5, "row_id": "row_fnmxx9", "name": "Callout block fix", "files": ["Sources/Bugbook/Views/Editor/CalloutBlockView.swift", "Sources/Bugbook/Views/Editor/BlockCellView.swift"], "worker": "codex", "note": "Remove left border. Just rounded gray bg + icon. Match TOC styling."}, + {"position": 6, "row_id": "row_u9mndd", "name": "Outline/TOC fix", "files": ["Sources/Bugbook/Views/Editor/OutlineBlockView.swift", "Sources/Bugbook/Views/Editor/BlockCellView.swift"], "worker": "codex", "note": "Grey text links not bullets/stars. Match callout container. Click scrolls to heading."}, + {"position": 7, "row_id": "row_b7h2vl", "name": "Heading toggles behavior", "files": ["Sources/Bugbook/Views/Editor/BlockCellView.swift", "Sources/Bugbook/Models/BlockDocument.swift", "Sources/BugbookCLI/PageBlockHelpers.swift"], "worker": "claude", "note": "Complex: Cmd+Shift+Enter toggle, Enter exits, auto-nest smaller headings. Needs multi-file reasoning."}, + {"position": 8, "row_id": "row_srmgse", "name": "TableBlockView fix", "files": ["Sources/Bugbook/Views/Editor/TableBlockView.swift"], "worker": "codex", "note": "Attempt 3. Fix grip dots duplication, alignment, flush with content area."}, + {"position": 9, "row_id": "row_0lsztg", "name": "Kebab menu fix", "files": ["Sources/Bugbook/Views/Database/SelectOptionViews.swift"], "worker": "codex", "note": "Larger hit area, popover positioning beside not on top."}, + {"position": 10, "row_id": "row_qm7iyh", "name": "Chat redesign", "files": ["Sources/Bugbook/Views/AI/AiSidePanelView.swift", "Sources/Bugbook/Views/AI/NotesChatView.swift", "Sources/Bugbook/Views/ContentView.swift", "Sources/Bugbook/Views/Sidebar/SidebarView.swift"], "worker": "codex", "note": "Rename Ask AI → Chat, remove X, Cmd+I opens sidebar panel."}, + {"position": 11, "row_id": "row_dimm5g", "name": "Mention picker link styling", "files": ["Sources/Bugbook/Views/Editor/BlockTextView.swift", "Sources/Bugbook/Views/Editor/WikiLinkView.swift"], "worker": "codex", "note": "Style @[[Page Name]] as visible link (pill or colored text), clickable."}, + {"position": 12, "row_id": "row_uqw8vz", "name": "Cmd+K search navigation fix", "files": ["Sources/Bugbook/Views/ContentView.swift", "Sources/Bugbook/Views/Components/CommandPaletteView.swift"], "worker": "claude", "note": "Attempt 3. navigateToEntryInPane works from sidebar but not command palette. Deep nav system issue."} ] diff --git a/Package.swift b/Package.swift index 3fa35ac4..e79bf029 100644 --- a/Package.swift +++ b/Package.swift @@ -71,6 +71,7 @@ let package = Package( .linkedFramework("IOSurface"), .linkedFramework("Metal"), .linkedFramework("QuartzCore"), + .linkedFramework("WebKit"), .linkedLibrary("c++"), .linkedLibrary("z"), ] diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 14f72405..b63156df 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -242,6 +242,21 @@ struct MCPServerInfo: Identifiable { } private func resolveEntry(for path: String) -> FileEntry { + switch path { + case "bugbook://mail": + return FileEntry(id: path, name: "Mail", path: path, isDirectory: false, kind: .mail, icon: "envelope") + case "bugbook://calendar": + return FileEntry(id: path, name: "Calendar", path: path, isDirectory: false, kind: .calendar, icon: "calendar.badge.clock") + case "bugbook://meetings": + return FileEntry(id: path, name: "Meetings", path: path, isDirectory: false, kind: .meetings, icon: "person.2") + case "bugbook://graph": + return FileEntry(id: path, name: "Graph View", path: path, isDirectory: false, kind: .graphView, icon: "sf:point.3.connected.trianglepath.dotted") + case "bugbook://gateway": + return FileEntry(id: path, name: "Gateway", path: path, isDirectory: false, kind: .gateway, icon: "square.grid.2x2") + default: + break + } + if let row = DatabaseRowNavigationPath.parse(path) { return FileEntry( id: path, diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index be51decc..1e780606 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -91,6 +91,11 @@ struct BugbookApp: App { } .keyboardShortcut("g", modifiers: [.command, .shift]) + Button("Mail") { + NotificationCenter.default.post(name: .openMail, object: nil) + } + .keyboardShortcut("m", modifiers: [.command, .shift]) + Button("Calendar") { NotificationCenter.default.post(name: .openCalendar, object: nil) } @@ -370,6 +375,7 @@ extension Notification.Name { static let navigateForward = Notification.Name("navigateForward") static let openDailyNote = Notification.Name("openDailyNote") static let openGraphView = Notification.Name("openGraphView") + static let openMail = Notification.Name("openMail") static let editorZoomIn = Notification.Name("editorZoomIn") static let editorZoomOut = Notification.Name("editorZoomOut") static let editorZoomReset = Notification.Name("editorZoomReset") diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift index 24a247ef..39f80e27 100644 --- a/Sources/Bugbook/Models/AppSettings.swift +++ b/Sources/Bugbook/Models/AppSettings.swift @@ -33,7 +33,7 @@ enum ExecutionPolicy: String, Codable, CaseIterable { case denyAll = "Deny All" } -struct AppSettings: Codable { +struct AppSettings: Codable, Equatable { var theme: ThemeMode var focusModeOnType: Bool var preferredAIEngine: PreferredAIEngine @@ -46,12 +46,14 @@ struct AppSettings: Codable { /// Path to the page opened for new/empty tabs. Empty string = default Bugbook landing page. var defaultNewTabPage: String - // Google Calendar - var googleCalendarRefreshToken: String - var googleCalendarAccessToken: String - var googleCalendarTokenExpiry: Double - var googleCalendarConnectedEmail: String - var googleCalendarBannerDismissed: Bool + // Shared Google account + var googleClientID: String + var googleClientSecret: String + var googleRefreshToken: String + var googleAccessToken: String + var googleTokenExpiry: Double + var googleConnectedEmail: String + var googleGrantedScopes: [String] static let `default` = AppSettings( theme: .system, @@ -64,14 +66,42 @@ struct AppSettings: Codable { anthropicApiKey: "", anthropicModel: .sonnet, defaultNewTabPage: "", - googleCalendarRefreshToken: "", - googleCalendarAccessToken: "", - googleCalendarTokenExpiry: 0, - googleCalendarConnectedEmail: "", - googleCalendarBannerDismissed: false + googleClientID: "", + googleClientSecret: "", + googleRefreshToken: "", + googleAccessToken: "", + googleTokenExpiry: 0, + googleConnectedEmail: "", + googleGrantedScopes: [] ) - // Backward-compatible decoding — new fields default gracefully + private enum CodingKeys: String, CodingKey { + case theme + case focusModeOnType + case preferredAIEngine + case executionPolicy + case bugbookSkillEnabled + case agentsMdContent + case qmdSearchMode + case anthropicApiKey + case anthropicModel + case defaultNewTabPage + case googleClientID + case googleClientSecret + case googleRefreshToken + case googleAccessToken + case googleTokenExpiry + case googleConnectedEmail + case googleGrantedScopes + + // Legacy calendar-only auth keys. + case googleCalendarRefreshToken + case googleCalendarAccessToken + case googleCalendarTokenExpiry + case googleCalendarConnectedEmail + } + + // Backward-compatible decoding — new fields default gracefully. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) theme = try container.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system @@ -84,11 +114,17 @@ struct AppSettings: Codable { anthropicApiKey = try container.decodeIfPresent(String.self, forKey: .anthropicApiKey) ?? "" anthropicModel = try container.decodeIfPresent(AnthropicModel.self, forKey: .anthropicModel) ?? .sonnet defaultNewTabPage = try container.decodeIfPresent(String.self, forKey: .defaultNewTabPage) ?? "" - googleCalendarRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarRefreshToken) ?? "" - googleCalendarAccessToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarAccessToken) ?? "" - googleCalendarTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleCalendarTokenExpiry) ?? 0 - googleCalendarConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleCalendarConnectedEmail) ?? "" - googleCalendarBannerDismissed = try container.decodeIfPresent(Bool.self, forKey: .googleCalendarBannerDismissed) ?? false + googleClientID = try container.decodeIfPresent(String.self, forKey: .googleClientID) ?? "" + googleClientSecret = try container.decodeIfPresent(String.self, forKey: .googleClientSecret) ?? "" + let legacyRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarRefreshToken) + let legacyAccessToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarAccessToken) + let legacyTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleCalendarTokenExpiry) + let legacyConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleCalendarConnectedEmail) + googleRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleRefreshToken) ?? legacyRefreshToken ?? "" + googleAccessToken = try container.decodeIfPresent(String.self, forKey: .googleAccessToken) ?? legacyAccessToken ?? "" + googleTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleTokenExpiry) ?? legacyTokenExpiry ?? 0 + googleConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleConnectedEmail) ?? legacyConnectedEmail ?? "" + googleGrantedScopes = try container.decodeIfPresent([String].self, forKey: .googleGrantedScopes) ?? [] } init( @@ -102,11 +138,13 @@ struct AppSettings: Codable { anthropicApiKey: String, anthropicModel: AnthropicModel = .sonnet, defaultNewTabPage: String, - googleCalendarRefreshToken: String = "", - googleCalendarAccessToken: String = "", - googleCalendarTokenExpiry: Double = 0, - googleCalendarConnectedEmail: String = "", - googleCalendarBannerDismissed: Bool = false + googleClientID: String = "", + googleClientSecret: String = "", + googleRefreshToken: String = "", + googleAccessToken: String = "", + googleTokenExpiry: Double = 0, + googleConnectedEmail: String = "", + googleGrantedScopes: [String] = [] ) { self.theme = theme self.focusModeOnType = focusModeOnType @@ -118,10 +156,75 @@ struct AppSettings: Codable { self.anthropicApiKey = anthropicApiKey self.anthropicModel = anthropicModel self.defaultNewTabPage = defaultNewTabPage - self.googleCalendarRefreshToken = googleCalendarRefreshToken - self.googleCalendarAccessToken = googleCalendarAccessToken - self.googleCalendarTokenExpiry = googleCalendarTokenExpiry - self.googleCalendarConnectedEmail = googleCalendarConnectedEmail - self.googleCalendarBannerDismissed = googleCalendarBannerDismissed + self.googleClientID = googleClientID + self.googleClientSecret = googleClientSecret + self.googleRefreshToken = googleRefreshToken + self.googleAccessToken = googleAccessToken + self.googleTokenExpiry = googleTokenExpiry + self.googleConnectedEmail = googleConnectedEmail + self.googleGrantedScopes = googleGrantedScopes + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(theme, forKey: .theme) + try container.encode(focusModeOnType, forKey: .focusModeOnType) + try container.encode(preferredAIEngine, forKey: .preferredAIEngine) + try container.encode(executionPolicy, forKey: .executionPolicy) + try container.encode(bugbookSkillEnabled, forKey: .bugbookSkillEnabled) + try container.encode(agentsMdContent, forKey: .agentsMdContent) + try container.encode(qmdSearchMode, forKey: .qmdSearchMode) + try container.encode(anthropicApiKey, forKey: .anthropicApiKey) + try container.encode(anthropicModel, forKey: .anthropicModel) + try container.encode(defaultNewTabPage, forKey: .defaultNewTabPage) + try container.encode(googleClientID, forKey: .googleClientID) + try container.encode(googleClientSecret, forKey: .googleClientSecret) + try container.encode(googleRefreshToken, forKey: .googleRefreshToken) + try container.encode(googleAccessToken, forKey: .googleAccessToken) + try container.encode(googleTokenExpiry, forKey: .googleTokenExpiry) + try container.encode(googleConnectedEmail, forKey: .googleConnectedEmail) + try container.encode(googleGrantedScopes, forKey: .googleGrantedScopes) + } + + var googleConfigured: Bool { + !googleClientID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !googleClientSecret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var googleConnected: Bool { + !googleRefreshToken.isEmpty + } + + var googleToken: GoogleOAuthToken? { + guard googleConnected else { return nil } + return GoogleOAuthToken( + accessToken: googleAccessToken, + refreshToken: googleRefreshToken, + expiresAt: Date(timeIntervalSince1970: googleTokenExpiry), + grantedScopes: googleGrantedScopes + ) + } + + mutating func applyGoogleAuthResult(_ result: GoogleOAuthResult) { + googleAccessToken = result.accessToken + googleRefreshToken = result.refreshToken + googleTokenExpiry = result.expiresAt.timeIntervalSince1970 + googleConnectedEmail = result.email + googleGrantedScopes = result.grantedScopes + } + + mutating func updateGoogleToken(_ token: GoogleOAuthToken) { + googleAccessToken = token.accessToken + googleRefreshToken = token.refreshToken + googleTokenExpiry = token.expiresAt.timeIntervalSince1970 + googleGrantedScopes = token.grantedScopes + } + + mutating func disconnectGoogle() { + googleRefreshToken = "" + googleAccessToken = "" + googleTokenExpiry = 0 + googleConnectedEmail = "" + googleGrantedScopes = [] } } diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index cd705f8a..7564e12e 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -3,6 +3,7 @@ import Foundation enum TabKind: Equatable, Hashable, Codable { case page case database + case mail case calendar case meetings case graphView @@ -11,6 +12,7 @@ enum TabKind: Equatable, Hashable, Codable { case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } + var isMail: Bool { self == .mail } var isCalendar: Bool { self == .calendar } var isMeetings: Bool { self == .meetings } var isGraphView: Bool { self == .graphView } @@ -33,6 +35,7 @@ struct FileEntry: Identifiable, Hashable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } + var isMail: Bool { kind.isMail } var isSkill: Bool { kind.isSkill } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } diff --git a/Sources/Bugbook/Models/MailModels.swift b/Sources/Bugbook/Models/MailModels.swift new file mode 100644 index 00000000..e91396ad --- /dev/null +++ b/Sources/Bugbook/Models/MailModels.swift @@ -0,0 +1,160 @@ +import Foundation + +enum MailMailbox: String, CaseIterable, Codable, Identifiable { + case inbox + case sent + case drafts + case starred + case trash + + var id: String { rawValue } + + var displayName: String { + switch self { + case .inbox: return "Inbox" + case .sent: return "Sent" + case .drafts: return "Drafts" + case .starred: return "Starred" + case .trash: return "Trash" + } + } + + var systemImage: String { + switch self { + case .inbox: return "tray.full" + case .sent: return "paperplane" + case .drafts: return "square.and.pencil" + case .starred: return "star" + case .trash: return "trash" + } + } + + var gmailLabelIDs: [String] { + switch self { + case .inbox: return ["INBOX"] + case .sent: return ["SENT"] + case .drafts: return ["DRAFT"] + case .starred: return ["STARRED"] + case .trash: return ["TRASH"] + } + } +} + +struct MailMessageRecipient: Codable, Equatable, Hashable, Identifiable { + var name: String? + var email: String + + var id: String { "\(email.lowercased())|\(name ?? "")" } + + var displayName: String { + let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedName.isEmpty ? email : "\(trimmedName) <\(email)>" + } +} + +enum MailComposerMode: String, Codable, Equatable { + case newMessage + case reply + case replyAll +} + +struct MailDraft: Codable, Equatable { + var mode: MailComposerMode = .newMessage + var to: String = "" + var cc: String = "" + var bcc: String = "" + var subject: String = "" + var body: String = "" + var threadId: String? + var replyToMessageID: String? + var referencesHeader: String? + + var isEmpty: Bool { + to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct MailSearchState: Codable, Equatable { + var query: String = "" + + var isActive: Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +struct MailMessage: Identifiable, Codable, Equatable { + let id: String + var threadId: String + var subject: String + var snippet: String + var labelIds: [String] + var from: MailMessageRecipient? + var to: [MailMessageRecipient] + var cc: [MailMessageRecipient] + var bcc: [MailMessageRecipient] + var date: Date? + var plainBody: String + var htmlBody: String? + var messageIDHeader: String? + var referencesHeader: String? + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isDraft: Bool { labelIds.contains("DRAFT") } + + var bodyText: String { + if !plainBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return plainBody + } + return htmlBody ?? "" + } +} + +struct MailThreadSummary: Identifiable, Codable, Equatable { + let id: String + var mailbox: MailMailbox? + var subject: String + var snippet: String + var participants: [String] + var date: Date? + var messageCount: Int + var labelIds: [String] + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isStarred: Bool { labelIds.contains("STARRED") } +} + +struct MailThreadDetail: Identifiable, Codable, Equatable { + let id: String + var mailbox: MailMailbox? + var subject: String + var snippet: String + var participants: [String] + var messages: [MailMessage] + var labelIds: [String] + var historyId: String? + + var isUnread: Bool { labelIds.contains("UNREAD") } + var isStarred: Bool { labelIds.contains("STARRED") } + + var lastDate: Date? { + messages.compactMap(\.date).max() + } +} + +struct MailCacheSnapshot: Codable, Equatable { + var mailboxThreads: [MailMailbox: [MailThreadSummary]] + var threadDetails: [String: MailThreadDetail] + var savedAt: Date +} + +enum MailThreadAction: Equatable { + case archive + case trash + case untrash + case setStarred(Bool) + case setUnread(Bool) +} diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index 04924918..fdbc59a9 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -15,6 +15,7 @@ struct OpenFile: Identifiable, Equatable, Codable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } + var isMail: Bool { kind.isMail } var isCalendar: Bool { kind.isCalendar } var isMeetings: Bool { kind.isMeetings } var isGraphView: Bool { kind.isGraphView } diff --git a/Sources/Bugbook/Models/PaneContent.swift b/Sources/Bugbook/Models/PaneContent.swift index 3466e697..af920c69 100644 --- a/Sources/Bugbook/Models/PaneContent.swift +++ b/Sources/Bugbook/Models/PaneContent.swift @@ -16,6 +16,15 @@ enum PaneContent: Codable, Equatable { return .document(openFile: OpenFile(id: id, path: "", content: "", isDirty: false, isEmptyTab: true)) } + /// A mail pane. + static func mailDocument() -> PaneContent { + let id = UUID() + return .document(openFile: OpenFile( + id: id, path: "bugbook://mail", content: "", isDirty: false, isEmptyTab: false, + kind: .mail, displayName: "Mail", icon: "envelope" + )) + } + /// A calendar pane. static func calendarDocument() -> PaneContent { let id = UUID() diff --git a/Sources/Bugbook/Services/AppSettingsStore.swift b/Sources/Bugbook/Services/AppSettingsStore.swift new file mode 100644 index 00000000..6c51cead --- /dev/null +++ b/Sources/Bugbook/Services/AppSettingsStore.swift @@ -0,0 +1,46 @@ +import Foundation + +struct AppSettingsStore { + private let fileManager: FileManager + private let fileURL: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(fileManager: FileManager = .default, fileURL: URL? = nil) { + self.fileManager = fileManager + self.fileURL = fileURL ?? Self.defaultFileURL(fileManager: fileManager) + } + + func load() -> AppSettings { + guard let data = try? Data(contentsOf: fileURL), + let settings = try? decoder.decode(AppSettings.self, from: data) else { + return .default + } + return settings + } + + func save(_ settings: AppSettings) { + do { + try ensureParentDirectoryExists() + let data = try encoder.encode(settings) + try data.write(to: fileURL, options: .atomic) + } catch { + Log.app.error("Failed to save app settings: \(error.localizedDescription)") + } + } + + private func ensureParentDirectoryExists() throws { + let parent = fileURL.deletingLastPathComponent() + guard !fileManager.fileExists(atPath: parent.path) else { return } + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + } + + private static func defaultFileURL(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("Settings", isDirectory: true) + .appendingPathComponent("app-settings.json") + } +} diff --git a/Sources/Bugbook/Services/CalendarService.swift b/Sources/Bugbook/Services/CalendarService.swift index 4680de59..967cf191 100644 --- a/Sources/Bugbook/Services/CalendarService.swift +++ b/Sources/Bugbook/Services/CalendarService.swift @@ -1,142 +1,112 @@ import Foundation -import AuthenticationServices import BugbookCore enum CalendarError: LocalizedError { case notAuthenticated case apiError(String) - case tokenRefreshFailed - case oauthFailed(String) var errorDescription: String? { switch self { case .notAuthenticated: return "Not signed in to Google Calendar." case .apiError(let msg): return msg - case .tokenRefreshFailed: return "Failed to refresh Google Calendar token. Try signing in again." - case .oauthFailed(let msg): return "Google sign-in failed: \(msg)" } } } -// MARK: - Google OAuth Token - -struct GoogleOAuthToken: Codable { - var accessToken: String - var refreshToken: String - var expiresAt: Date - - var isExpired: Bool { Date() >= expiresAt } -} +struct CalendarEventDraft: Equatable { + var title: String + var startDate: Date + var endDate: Date + var isAllDay: Bool + var location: String + var notes: String + var calendarId: String + + init( + title: String = "", + startDate: Date, + endDate: Date, + isAllDay: Bool = false, + location: String = "", + notes: String = "", + calendarId: String = "primary" + ) { + self.title = title + self.startDate = startDate + self.endDate = endDate + self.isAllDay = isAllDay + self.location = location + self.notes = notes + self.calendarId = calendarId + } -// MARK: - Google OAuth Browser Flow + func normalized(calendar: Calendar = .current) -> CalendarEventDraft { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines) -struct GoogleOAuthResult { - var accessToken: String - var refreshToken: String - var expiresAt: Date - var email: String -} + if isAllDay { + let normalizedStart = calendar.startOfDay(for: startDate) + let normalizedEnd = max(calendar.startOfDay(for: endDate), normalizedStart) + return CalendarEventDraft( + title: trimmedTitle, + startDate: normalizedStart, + endDate: normalizedEnd, + isAllDay: true, + location: trimmedLocation, + notes: trimmedNotes, + calendarId: calendarId + ) + } -enum GoogleOAuthFlow { - // Register a "Desktop app" OAuth client in Google Cloud Console with the Calendar API enabled. - // For installed/desktop apps, Google documents that the client ID and secret are not truly secret. - // Replace these with your registered credentials. - static let clientID = "YOUR_CLIENT_ID_HERE" - static let clientSecret = "YOUR_CLIENT_SECRET_HERE" - private static let redirectURI = "http://127.0.0.1" - private static let scopes = "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/userinfo.email" - - @MainActor - static func signIn() async throws -> GoogleOAuthResult { - let authCode = try await requestAuthCode() - let tokenResult = try await exchangeCode(authCode) - let email = try await fetchUserEmail(accessToken: tokenResult.0) - return GoogleOAuthResult( - accessToken: tokenResult.0, - refreshToken: tokenResult.1, - expiresAt: tokenResult.2, - email: email + let normalizedEnd = endDate > startDate ? endDate : startDate.addingTimeInterval(3600) + return CalendarEventDraft( + title: trimmedTitle, + startDate: startDate, + endDate: normalizedEnd, + isAllDay: false, + location: trimmedLocation, + notes: trimmedNotes, + calendarId: calendarId ) } +} - @MainActor - private static func requestAuthCode() async throws -> String { - var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! - components.queryItems = [ - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: scopes), - URLQueryItem(name: "access_type", value: "offline"), - URLQueryItem(name: "prompt", value: "consent"), +enum GoogleCalendarEventRequestEncoder { + static func requestBody(for draft: CalendarEventDraft, timeZone: TimeZone = .current) throws -> Data { + let normalized = draft.normalized() + let eventTitle = normalized.title.isEmpty ? "Untitled event" : normalized.title + var payload: [String: Any] = [ + "summary": eventTitle, ] - return try await withCheckedThrowingContinuation { continuation in - let session = ASWebAuthenticationSession( - url: components.url!, - callbackURLScheme: "http" - ) { callbackURL, error in - if let error { - continuation.resume(throwing: CalendarError.oauthFailed(error.localizedDescription)) - return - } - guard let callbackURL, - let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, - let code = items.first(where: { $0.name == "code" })?.value else { - continuation.resume(throwing: CalendarError.oauthFailed("No authorization code received.")) - return - } - continuation.resume(returning: code) - } - session.prefersEphemeralWebBrowserSession = true - session.presentationContextProvider = OAuthPresentationContext.shared - session.start() - } - } - private static func exchangeCode(_ code: String) async throws -> (String, String, Date) { - var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - var body = URLComponents() - body.queryItems = [ - URLQueryItem(name: "code", value: code), - URLQueryItem(name: "client_id", value: clientID), - URLQueryItem(name: "client_secret", value: clientSecret), - URLQueryItem(name: "redirect_uri", value: redirectURI), - URLQueryItem(name: "grant_type", value: "authorization_code"), - ] - request.httpBody = body.query?.data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - let msg = String(data: data, encoding: .utf8) ?? "" - throw CalendarError.oauthFailed("Token exchange failed: \(msg)") + if !normalized.location.isEmpty { + payload["location"] = normalized.location } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let accessToken = json["access_token"] as? String, - let refreshToken = json["refresh_token"] as? String, - let expiresIn = json["expires_in"] as? Int else { - throw CalendarError.oauthFailed("Unexpected token response format.") + if !normalized.notes.isEmpty { + payload["description"] = normalized.notes } - return (accessToken, refreshToken, Date().addingTimeInterval(TimeInterval(expiresIn))) - } - private static func fetchUserEmail(accessToken: String) async throws -> String { - var request = URLRequest(url: URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!) - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200, - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let email = json["email"] as? String else { - return "" + if normalized.isAllDay { + let exclusiveEnd = Calendar.current.date(byAdding: .day, value: 1, to: normalized.endDate) ?? normalized.endDate.addingTimeInterval(86400) + payload["start"] = [ + "date": CalendarFormatters.allDay.string(from: normalized.startDate), + ] + payload["end"] = [ + "date": CalendarFormatters.allDay.string(from: exclusiveEnd), + ] + } else { + payload["start"] = [ + "dateTime": CalendarFormatters.isoFallback.string(from: normalized.startDate), + "timeZone": timeZone.identifier, + ] + payload["end"] = [ + "dateTime": CalendarFormatters.isoFallback.string(from: normalized.endDate), + "timeZone": timeZone.identifier, + ] } - return email - } -} -private class OAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { - static let shared = OAuthPresentationContext() - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - NSApplication.shared.keyWindow ?? ASPresentationAnchor() + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) } } @@ -189,13 +159,8 @@ class CalendarService { defer { isSyncing = false } do { - var currentToken = token - if currentToken.isExpired { - currentToken = try await refreshToken(currentToken) - } - let syncToken = store.loadSyncToken(in: workspace) - let result = try await fetchGoogleEvents(token: currentToken, syncToken: syncToken) + let result = try await fetchGoogleEvents(token: token, syncToken: syncToken) try store.upsertEvents(result.events, in: workspace) if let newSyncToken = result.nextSyncToken { @@ -205,27 +170,46 @@ class CalendarService { events = store.loadEvents(in: workspace) lastSyncDate = Date() - let calendars = try await fetchGoogleCalendarList(token: currentToken) - let existingSources = store.loadSources(in: workspace) - let existingVisibility: [String: Bool] = Dictionary( - existingSources.map { ($0.id, $0.isVisible) }, - uniquingKeysWith: { first, _ in first } - ) - let mergedSources = calendars.map { cal in - CalendarSource( - id: cal.id, - name: cal.name, - color: cal.color, - isVisible: existingVisibility[cal.id] ?? true - ) - } - try store.saveSources(mergedSources, in: workspace) - sources = mergedSources + let calendars = try await fetchGoogleCalendarList(token: token) + try persistSources(calendars, in: workspace) } catch { self.error = error.localizedDescription } } + func createGoogleEvent(workspace: String, token: GoogleOAuthToken, draft: CalendarEventDraft) async throws -> CalendarEvent { + let normalizedDraft = draft.normalized() + var request = URLRequest(url: googleEventsURL(calendarId: normalizedDraft.calendarId)) + request.httpMethod = "POST" + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try GoogleCalendarEventRequestEncoder.requestBody(for: normalizedDraft) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CalendarError.apiError("No response from Google Calendar API") + } + guard (200...299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw CalendarError.apiError("Google Calendar create error \(http.statusCode): \(body)") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let event = parseGoogleEvent(json, calendarId: normalizedDraft.calendarId) else { + throw CalendarError.apiError("Google Calendar returned an unreadable event response.") + } + + try store.upsertEvents([event], in: workspace) + events = store.loadEvents(in: workspace) + lastSyncDate = Date() + + try ensureLocalSourceExists(for: event.calendarId, in: workspace) + if let calendars = try? await fetchGoogleCalendarList(token: token) { + try? persistSources(calendars, in: workspace, ensuringVisible: event.calendarId) + } + + return event + } + // MARK: - Database Overlay Items func loadDatabaseOverlayItems(workspace: String) async { @@ -413,6 +397,59 @@ class CalendarService { } } + private func persistSources(_ calendars: [CalendarSource], in workspace: String, ensuringVisible ensuredSourceID: String? = nil) throws { + let existingSources = store.loadSources(in: workspace) + let existingVisibility: [String: Bool] = Dictionary( + existingSources.map { ($0.id, $0.isVisible) }, + uniquingKeysWith: { first, _ in first } + ) + var mergedSources = calendars.map { cal in + CalendarSource( + id: cal.id, + name: cal.name, + color: cal.color, + isVisible: existingVisibility[cal.id] ?? true + ) + } + + if let ensuredSourceID, + !mergedSources.contains(where: { $0.id == ensuredSourceID }) { + let fallbackName = ensuredSourceID == "primary" ? "Primary" : ensuredSourceID + mergedSources.append( + CalendarSource( + id: ensuredSourceID, + name: fallbackName, + color: "#4285F4", + isVisible: existingVisibility[ensuredSourceID] ?? true + ) + ) + } + + try store.saveSources(mergedSources, in: workspace) + sources = mergedSources + } + + private func ensureLocalSourceExists(for calendarId: String, in workspace: String) throws { + guard !sources.contains(where: { $0.id == calendarId }) else { return } + var updatedSources = sources + updatedSources.append( + CalendarSource( + id: calendarId, + name: calendarId == "primary" ? "Primary" : calendarId, + color: "#4285F4", + isVisible: true + ) + ) + try store.saveSources(updatedSources, in: workspace) + sources = updatedSources + } + + private func googleEventsURL(calendarId: String) -> URL { + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + let encodedCalendarID = calendarId.addingPercentEncoding(withAllowedCharacters: allowed) ?? calendarId + return URL(string: "https://www.googleapis.com/calendar/v3/calendars/\(encodedCalendarID)/events")! + } + private func parseGoogleEvent(_ json: [String: Any], calendarId: String = "primary") -> CalendarEvent? { guard let id = json["id"] as? String, let summary = json["summary"] as? String else { return nil } @@ -471,39 +508,4 @@ class CalendarService { htmlLink: json["htmlLink"] as? String ) } - - // MARK: - Token Refresh - - private func refreshToken(_ token: GoogleOAuthToken) async throws -> GoogleOAuthToken { - var components = URLComponents(string: "https://oauth2.googleapis.com/token")! - components.queryItems = [ - URLQueryItem(name: "client_id", value: GoogleOAuthFlow.clientID), - URLQueryItem(name: "client_secret", value: GoogleOAuthFlow.clientSecret), - URLQueryItem(name: "refresh_token", value: token.refreshToken), - URLQueryItem(name: "grant_type", value: "refresh_token"), - ] - - var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = components.query?.data(using: .utf8) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { - throw CalendarError.tokenRefreshFailed - } - - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let accessToken = json["access_token"] as? String, - let expiresIn = json["expires_in"] as? Int else { - throw CalendarError.tokenRefreshFailed - } - - return GoogleOAuthToken( - accessToken: accessToken, - refreshToken: token.refreshToken, - expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) - ) - } - } diff --git a/Sources/Bugbook/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index 3ff7e2a0..ca940e50 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -267,14 +267,6 @@ class FileSystemService { userInfo: [NSLocalizedDescriptionKey: "Cannot move a page into its own sub-pages."]) } - // Prevent moving a database folder into a new companion folder — databases - // should only be moved via explicit user actions, not nested inside pages. - let sourceIsDatabase = isDatabaseFolder(at: sourcePath) - if sourceIsDatabase && !fileManager.fileExists(atPath: destDir) { - throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteInvalidFileNameError, - userInfo: [NSLocalizedDescriptionKey: "Cannot move a database into a new sub-page folder."]) - } - // Create destination directory if needed (e.g. companion folder for a parent page) let createdDestDir = !fileManager.fileExists(atPath: destDir) if createdDestDir { @@ -806,7 +798,7 @@ class FileSystemService { // MARK: - Path Rewriting - func rewritePathsInFile(at filePath: String, oldBase: String, newBase: String) { + nonisolated func rewritePathsInFile(at filePath: String, oldBase: String, newBase: String) { guard filePath.hasSuffix(".md"), oldBase != newBase, var content = try? String(contentsOfFile: filePath, encoding: .utf8), @@ -815,7 +807,7 @@ class FileSystemService { try? content.write(toFile: filePath, atomically: true, encoding: .utf8) } - func rewritePathsRecursively(in directory: String, oldBase: String, newBase: String) { + nonisolated func rewritePathsRecursively(in directory: String, oldBase: String, newBase: String) { guard oldBase != newBase, FileManager.default.fileExists(atPath: directory) else { return } guard let items = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return } diff --git a/Sources/Bugbook/Services/GoogleAuthService.swift b/Sources/Bugbook/Services/GoogleAuthService.swift new file mode 100644 index 00000000..b35f8acc --- /dev/null +++ b/Sources/Bugbook/Services/GoogleAuthService.swift @@ -0,0 +1,275 @@ +import Foundation +import AuthenticationServices +import AppKit + +enum GoogleAuthError: LocalizedError { + case missingClientConfiguration + case notAuthenticated + case missingScopes([String]) + case oauthFailed(String) + case tokenRefreshFailed + + var errorDescription: String? { + switch self { + case .missingClientConfiguration: + return "Add your Google OAuth client ID and client secret in Settings before connecting." + case .notAuthenticated: + return "Sign in to Google before using Mail or Calendar." + case .missingScopes(let scopes): + return "Google access is missing required scopes: \(scopes.joined(separator: ", ")). Sign in again to grant access." + case .oauthFailed(let message): + return "Google sign-in failed: \(message)" + case .tokenRefreshFailed: + return "Failed to refresh the Google access token. Sign in again." + } + } +} + +struct GoogleOAuthToken: Codable, Equatable { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var grantedScopes: [String] + + var isExpired: Bool { Date() >= expiresAt } +} + +struct GoogleOAuthResult: Equatable { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var email: String + var grantedScopes: [String] +} + +enum GoogleScopeSet { + static let userEmail = "https://www.googleapis.com/auth/userinfo.email" + static let calendarReadonly = "https://www.googleapis.com/auth/calendar.readonly" + static let calendarEvents = "https://www.googleapis.com/auth/calendar.events" + static let calendarListReadonly = "https://www.googleapis.com/auth/calendar.calendarlist.readonly" + static let gmailModify = "https://www.googleapis.com/auth/gmail.modify" + static let gmailSend = "https://www.googleapis.com/auth/gmail.send" + + static let calendar = [ + calendarEvents, + calendarListReadonly, + userEmail, + ] + + static let mail = [ + gmailModify, + gmailSend, + userEmail, + ] + + static let calendarAndMail = Array(Set(calendar + mail)).sorted() +} + +enum GoogleAuthService { + private static let redirectURI = "http://127.0.0.1" + + @MainActor + static func signIn(using settings: AppSettings, scopes: [String]) async throws -> GoogleOAuthResult { + let clientID = settings.googleClientID.trimmingCharacters(in: .whitespacesAndNewlines) + let clientSecret = settings.googleClientSecret.trimmingCharacters(in: .whitespacesAndNewlines) + guard !clientID.isEmpty, !clientSecret.isEmpty else { + throw GoogleAuthError.missingClientConfiguration + } + + let normalizedScopes = normalized(scopeList: scopes) + let authCode = try await requestAuthCode(clientID: clientID, scopes: normalizedScopes) + let tokenResult = try await exchangeCode( + authCode, + clientID: clientID, + clientSecret: clientSecret, + scopes: normalizedScopes + ) + let email = try await fetchUserEmail(accessToken: tokenResult.accessToken) + + return GoogleOAuthResult( + accessToken: tokenResult.accessToken, + refreshToken: tokenResult.refreshToken, + expiresAt: tokenResult.expiresAt, + email: email, + grantedScopes: normalizedScopes + ) + } + + static func validToken(using settings: inout AppSettings, requiredScopes: [String]) async throws -> GoogleOAuthToken { + guard settings.googleConfigured else { + throw GoogleAuthError.missingClientConfiguration + } + guard var token = settings.googleToken else { + throw GoogleAuthError.notAuthenticated + } + + let normalizedScopes = normalized(scopeList: requiredScopes) + let granted = Set(token.grantedScopes) + let missingScopes = normalizedScopes.filter { !granted.contains($0) } + guard missingScopes.isEmpty else { + throw GoogleAuthError.missingScopes(missingScopes) + } + + if token.isExpired { + token = try await refreshToken( + token, + clientID: settings.googleClientID, + clientSecret: settings.googleClientSecret + ) + settings.updateGoogleToken(token) + } + + return token + } + + private static func normalized(scopeList: [String]) -> [String] { + Array(Set(scopeList.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })).sorted() + } + + @MainActor + private static func requestAuthCode(clientID: String, scopes: [String]) async throws -> String { + var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: scopes.joined(separator: " ")), + URLQueryItem(name: "access_type", value: "offline"), + URLQueryItem(name: "prompt", value: "consent"), + ] + + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "http" + ) { callbackURL, error in + if let error { + continuation.resume(throwing: GoogleAuthError.oauthFailed(error.localizedDescription)) + return + } + + guard let callbackURL, + let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, + let code = items.first(where: { $0.name == "code" })?.value else { + continuation.resume(throwing: GoogleAuthError.oauthFailed("No authorization code received.")) + return + } + + continuation.resume(returning: code) + } + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = GoogleAuthPresentationContext.shared + session.start() + } + } + + private static func exchangeCode( + _ code: String, + clientID: String, + clientSecret: String, + scopes: [String] + ) async throws -> GoogleOAuthToken { + var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "grant_type", value: "authorization_code"), + ] + request.httpBody = body.query?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "" + throw GoogleAuthError.oauthFailed("Token exchange failed: \(message)") + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + let expiresIn = json["expires_in"] as? Int else { + throw GoogleAuthError.oauthFailed("Unexpected token response format.") + } + + return GoogleOAuthToken( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + grantedScopes: normalizedGrantedScopes(from: json["scope"] as? String, fallback: scopes) + ) + } + + private static func fetchUserEmail(accessToken: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let email = json["email"] as? String else { + throw GoogleAuthError.oauthFailed("Unable to fetch the connected Google account email.") + } + + return email + } + + private static func refreshToken( + _ token: GoogleOAuthToken, + clientID: String, + clientSecret: String + ) async throws -> GoogleOAuthToken { + var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "refresh_token", value: token.refreshToken), + URLQueryItem(name: "grant_type", value: "refresh_token"), + ] + request.httpBody = body.query?.data(using: .utf8) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw GoogleAuthError.tokenRefreshFailed + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let expiresIn = json["expires_in"] as? Int else { + throw GoogleAuthError.tokenRefreshFailed + } + + return GoogleOAuthToken( + accessToken: accessToken, + refreshToken: token.refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), + grantedScopes: normalizedGrantedScopes(from: json["scope"] as? String, fallback: token.grantedScopes) + ) + } + + private static func normalizedGrantedScopes(from scopeString: String?, fallback: [String]) -> [String] { + guard let scopeString else { + return normalized(scopeList: fallback) + } + let scopes = scopeString + .split(separator: " ") + .map(String.init) + return normalized(scopeList: scopes.isEmpty ? fallback : scopes) + } +} + +private final class GoogleAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = GoogleAuthPresentationContext() + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + NSApplication.shared.keyWindow ?? ASPresentationAnchor() + } +} diff --git a/Sources/Bugbook/Services/Logger.swift b/Sources/Bugbook/Services/Logger.swift index e3bf1d93..963d7e4e 100644 --- a/Sources/Bugbook/Services/Logger.swift +++ b/Sources/Bugbook/Services/Logger.swift @@ -20,6 +20,8 @@ enum Log { static let agent = Logger(subsystem: subsystem, category: "Agent") /// Audio capture and transcription static let transcription = Logger(subsystem: subsystem, category: "Transcription") + /// Gmail sync, thread actions, and compose/send flows + static let mail = Logger(subsystem: subsystem, category: "Mail") /// General app lifecycle static let app = Logger(subsystem: subsystem, category: "App") diff --git a/Sources/Bugbook/Services/MailService.swift b/Sources/Bugbook/Services/MailService.swift new file mode 100644 index 00000000..273111e8 --- /dev/null +++ b/Sources/Bugbook/Services/MailService.swift @@ -0,0 +1,792 @@ +import Foundation + +@MainActor +@Observable +final class MailService { + var mailboxThreads: [MailMailbox: [MailThreadSummary]] = [:] + var threadDetails: [String: MailThreadDetail] = [:] + var selectedMailbox: MailMailbox = .inbox + var selectedThreadID: String? + var searchState = MailSearchState() + var searchResults: [MailThreadSummary] = [] + var composer = MailDraft() + var isComposing = false + var isLoadingMailbox = false + var isLoadingThread = false + var isSearching = false + var isSending = false + var error: String? + var lastSyncDate: Date? + + @ObservationIgnored private let cacheStore: MailCacheStore + @ObservationIgnored private var activeAccountEmail: String? + + init(cacheStore: MailCacheStore = MailCacheStore()) { + self.cacheStore = cacheStore + } + + var visibleThreads: [MailThreadSummary] { + searchState.isActive ? searchResults : (mailboxThreads[selectedMailbox] ?? []) + } + + var selectedThread: MailThreadDetail? { + guard let selectedThreadID else { return nil } + return threadDetails[selectedThreadID] + } + + func loadCachedData(accountEmail: String) { + activeAccountEmail = accountEmail + mailboxThreads = [:] + threadDetails = [:] + searchState = MailSearchState() + searchResults = [] + selectedThreadID = nil + lastSyncDate = nil + + guard let snapshot = cacheStore.load(accountEmail: accountEmail) else { return } + mailboxThreads = snapshot.mailboxThreads + threadDetails = snapshot.threadDetails + lastSyncDate = snapshot.savedAt + if selectedThreadID == nil { + selectedThreadID = mailboxThreads[selectedMailbox]?.first?.id + } + } + + func selectMailbox(_ mailbox: MailMailbox) { + selectedMailbox = mailbox + searchState = MailSearchState() + searchResults = [] + selectedThreadID = mailboxThreads[mailbox]?.first?.id + error = nil + } + + func clearSearch() { + searchState = MailSearchState() + searchResults = [] + } + + func presentNewComposer() { + composer = MailDraft() + isComposing = true + } + + func dismissComposer() { + composer = MailDraft() + isComposing = false + } + + func prepareReplyDraft(thread: MailThreadDetail, connectedEmail: String, replyAll: Bool) { + guard let source = thread.messages.last(where: { !$0.isDraft }) ?? thread.messages.last else { return } + let sourceEmail = connectedEmail.lowercased() + let toRecipients = source.from.map { [$0.email] } ?? [] + let ccRecipients: [String] + + if replyAll { + let replyAllAddresses = (source.to + source.cc) + .map(\.email) + .filter { $0.caseInsensitiveCompare(sourceEmail) != .orderedSame } + .filter { !toRecipients.contains($0) } + ccRecipients = uniqueStrings(replyAllAddresses) + } else { + ccRecipients = [] + } + + composer = MailDraft( + mode: replyAll ? .replyAll : .reply, + to: toRecipients.joined(separator: ", "), + cc: ccRecipients.joined(separator: ", "), + bcc: "", + subject: MailService.replySubject(for: thread.subject), + body: "", + threadId: thread.id, + replyToMessageID: source.messageIDHeader, + referencesHeader: source.referencesHeader ?? source.messageIDHeader + ) + isComposing = true + } + + func loadMailbox(_ mailbox: MailMailbox, token: GoogleOAuthToken, forceRefresh: Bool = false) async { + if !forceRefresh, let cached = mailboxThreads[mailbox], !cached.isEmpty { + selectedThreadID = selectedThreadID ?? cached.first?.id + return + } + + isLoadingMailbox = true + error = nil + defer { isLoadingMailbox = false } + + do { + let threadIDs = try await GmailMailAPI.listThreadIDs( + labelIDs: mailbox.gmailLabelIDs, + query: nil, + token: token + ) + let summaries = try await GmailMailAPI.fetchThreadSummaries( + ids: threadIDs, + mailbox: mailbox, + token: token + ) + mailboxThreads[mailbox] = summaries.sorted(by: MailService.threadSort) + if selectedMailbox == mailbox { + selectedThreadID = mailboxThreads[mailbox]?.first?.id + } + persistSnapshot() + } catch { + self.error = error.localizedDescription + } + } + + func refreshSelectedMailbox(token: GoogleOAuthToken) async { + await loadMailbox(selectedMailbox, token: token, forceRefresh: true) + } + + func performSearch(query: String, token: GoogleOAuthToken) async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchState.query = query + guard !trimmed.isEmpty else { + searchResults = [] + return + } + + isSearching = true + error = nil + defer { isSearching = false } + + do { + let threadIDs = try await GmailMailAPI.listThreadIDs(labelIDs: [], query: trimmed, token: token) + let summaries = try await GmailMailAPI.fetchThreadSummaries(ids: threadIDs, mailbox: nil, token: token) + searchResults = summaries.sorted(by: MailService.threadSort) + selectedThreadID = searchResults.first?.id + } catch { + self.error = error.localizedDescription + } + } + + func loadThread(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken, forceRefresh: Bool = false) async { + if !forceRefresh, threadDetails[id] != nil { + selectedThreadID = id + return + } + + isLoadingThread = true + error = nil + defer { isLoadingThread = false } + + do { + let detail = try await GmailMailAPI.fetchThreadDetail(id: id, mailbox: mailbox, token: token) + threadDetails[id] = detail + selectedThreadID = id + updateSummary(for: detail) + persistSnapshot() + } catch { + self.error = error.localizedDescription + } + } + + func apply(action: MailThreadAction, to threadID: String, token: GoogleOAuthToken) async { + isLoadingThread = true + error = nil + defer { isLoadingThread = false } + + let previousMailboxThreads = mailboxThreads + let previousSearchResults = searchResults + let previousDetail = threadDetails[threadID] + let mailboxHint = previousDetail?.mailbox ?? selectedMailbox + applyLocal(action: action, threadID: threadID) + + do { + try await GmailMailAPI.apply(action: action, threadID: threadID, token: token) + let refreshed = try await GmailMailAPI.fetchThreadDetail(id: threadID, mailbox: mailboxHint, token: token) + threadDetails[threadID] = refreshed + updateSummary(for: refreshed) + persistSnapshot() + } catch { + mailboxThreads = previousMailboxThreads + searchResults = previousSearchResults + if let previousDetail { + threadDetails[threadID] = previousDetail + } + self.error = error.localizedDescription + } + } + + func sendComposer(connectedEmail: String, token: GoogleOAuthToken) async -> Bool { + let draft = composer + guard !draft.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "At least one recipient is required." + return false + } + guard !draft.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + error = "Email body cannot be empty." + return false + } + + isSending = true + error = nil + defer { isSending = false } + + do { + _ = try await GmailMailAPI.send(draft: draft, connectedEmail: connectedEmail, token: token) + dismissComposer() + + // Refresh mailbox data opportunistically so the thread list stays close to Gmail. + await loadMailbox(.sent, token: token, forceRefresh: true) + if selectedMailbox == .inbox || selectedMailbox == .drafts { + await loadMailbox(selectedMailbox, token: token, forceRefresh: true) + } + if let threadID = draft.threadId { + await loadThread(id: threadID, mailbox: selectedMailbox, token: token, forceRefresh: true) + } + return true + } catch { + self.error = error.localizedDescription + return false + } + } + + private func updateSummary(for detail: MailThreadDetail) { + let updated = MailThreadSummary( + id: detail.id, + mailbox: detail.mailbox, + subject: detail.subject, + snippet: detail.snippet, + participants: detail.participants, + date: detail.lastDate, + messageCount: detail.messages.count, + labelIds: detail.labelIds + ) + + for mailbox in MailMailbox.allCases { + var list = mailboxThreads[mailbox] ?? [] + let matches = MailThreadLabelReducer.mailbox(mailbox, contains: detail.labelIds) + + if let index = list.firstIndex(where: { $0.id == detail.id }) { + if matches { + var mailboxSummary = updated + mailboxSummary.mailbox = mailbox + list[index] = mailboxSummary + } else { + list.remove(at: index) + } + } else if matches { + var mailboxSummary = updated + mailboxSummary.mailbox = mailbox + list.insert(mailboxSummary, at: 0) + } + + mailboxThreads[mailbox] = list.sorted(by: MailService.threadSort) + } + + if let index = searchResults.firstIndex(where: { $0.id == detail.id }) { + searchResults[index] = updated + searchResults.sort(by: MailService.threadSort) + } + } + + private func applyLocal(action: MailThreadAction, threadID: String) { + for mailbox in MailMailbox.allCases { + guard var list = mailboxThreads[mailbox], + let index = list.firstIndex(where: { $0.id == threadID }) else { continue } + let nextLabels = MailThreadLabelReducer.mutatedLabels(list[index].labelIds, action: action) + if MailThreadLabelReducer.mailbox(mailbox, contains: nextLabels) { + list[index].labelIds = nextLabels + mailboxThreads[mailbox] = list + } else { + list.remove(at: index) + mailboxThreads[mailbox] = list + } + } + + if let index = searchResults.firstIndex(where: { $0.id == threadID }) { + searchResults[index].labelIds = MailThreadLabelReducer.mutatedLabels(searchResults[index].labelIds, action: action) + } + + if var detail = threadDetails[threadID] { + detail.labelIds = MailThreadLabelReducer.mutatedLabels(detail.labelIds, action: action) + for index in detail.messages.indices { + detail.messages[index].labelIds = MailThreadLabelReducer.mutatedLabels(detail.messages[index].labelIds, action: action) + } + threadDetails[threadID] = detail + } + } + + private func persistSnapshot() { + guard let activeAccountEmail else { return } + let snapshot = MailCacheSnapshot( + mailboxThreads: mailboxThreads, + threadDetails: threadDetails, + savedAt: Date() + ) + cacheStore.save(snapshot, accountEmail: activeAccountEmail) + lastSyncDate = snapshot.savedAt + } + + private static func threadSort(lhs: MailThreadSummary, rhs: MailThreadSummary) -> Bool { + let lhsDate = lhs.date ?? .distantPast + let rhsDate = rhs.date ?? .distantPast + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.subject.localizedCaseInsensitiveCompare(rhs.subject) == .orderedAscending + } + + private static func replySubject(for subject: String) -> String { + let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.lowercased().hasPrefix("re:") else { return trimmed } + return trimmed.isEmpty ? "Re:" : "Re: \(trimmed)" + } + + private func uniqueStrings(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0.lowercased()).inserted } + } +} + +enum MailThreadLabelReducer { + static func mutatedLabels(_ labels: [String], action: MailThreadAction) -> [String] { + var next = Set(labels) + switch action { + case .archive: + next.remove("INBOX") + case .trash: + next.insert("TRASH") + case .untrash: + next.remove("TRASH") + next.insert("INBOX") + case .setStarred(let starred): + if starred { + next.insert("STARRED") + } else { + next.remove("STARRED") + } + case .setUnread(let unread): + if unread { + next.insert("UNREAD") + } else { + next.remove("UNREAD") + } + } + return Array(next).sorted() + } + + static func mailbox(_ mailbox: MailMailbox, contains labels: [String]) -> Bool { + let labelSet = Set(labels) + return mailbox.gmailLabelIDs.allSatisfy(labelSet.contains) + } +} + +enum MailComposerEncoder { + static func buildRawMessage(draft: MailDraft, connectedEmail: String) -> String { + var lines: [String] = [ + "From: \(connectedEmail)", + "To: \(draft.to)", + ] + + if !draft.cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Cc: \(draft.cc)") + } + if !draft.bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.append("Bcc: \(draft.bcc)") + } + lines.append("Subject: \(draft.subject)") + if let replyToMessageID = draft.replyToMessageID, !replyToMessageID.isEmpty { + lines.append("In-Reply-To: \(replyToMessageID)") + } + if let referencesHeader = draft.referencesHeader, !referencesHeader.isEmpty { + lines.append("References: \(referencesHeader)") + } + lines.append("MIME-Version: 1.0") + lines.append("Content-Type: text/plain; charset=utf-8") + lines.append("Content-Transfer-Encoding: 8bit") + lines.append("") + lines.append(draft.body) + + let message = lines.joined(separator: "\r\n") + let data = Data(message.utf8) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private enum GmailMailAPI { + private static let baseURL = URL(string: "https://gmail.googleapis.com/gmail/v1/users/me")! + private static let listMaxResults = 25 + + static func listThreadIDs(labelIDs: [String], query: String?, token: GoogleOAuthToken) async throws -> [String] { + var components = URLComponents(url: baseURL.appendingPathComponent("threads"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "maxResults", value: "\(listMaxResults)")] + queryItems.append(contentsOf: labelIDs.map { URLQueryItem(name: "labelIds", value: $0) }) + if let query, !query.isEmpty { + queryItems.append(URLQueryItem(name: "q", value: query)) + } + components.queryItems = queryItems + + let json = try await requestJSON(url: components.url!, token: token) + let threads = json["threads"] as? [[String: Any]] ?? [] + return threads.compactMap { $0["id"] as? String } + } + + static func fetchThreadSummaries(ids: [String], mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> [MailThreadSummary] { + if ids.isEmpty { return [] } + return try await withThrowingTaskGroup(of: MailThreadSummary.self) { group in + for id in ids { + group.addTask { + try await fetchThreadSummary(id: id, mailbox: mailbox, token: token) + } + } + + var summaries: [MailThreadSummary] = [] + for try await summary in group { + summaries.append(summary) + } + return summaries + } + } + + static func fetchThreadSummary(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> MailThreadSummary { + let thread = try await fetchThreadResource( + id: id, + queryItems: [ + URLQueryItem(name: "format", value: "metadata"), + URLQueryItem(name: "metadataHeaders", value: "Subject"), + URLQueryItem(name: "metadataHeaders", value: "From"), + URLQueryItem(name: "metadataHeaders", value: "Date"), + ], + token: token + ) + + let messages = thread["messages"] as? [[String: Any]] ?? [] + let newestMessage = messages.last ?? messages.first ?? [:] + let newestHeaders = headerMap(from: newestMessage) + let participants = uniqueValues(messages.compactMap { + recipientDisplayName(from: headerMap(from: $0)["From"]) + }) + + return MailThreadSummary( + id: id, + mailbox: mailbox, + subject: newestHeaders["Subject"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(No Subject)", + snippet: (thread["snippet"] as? String) ?? "", + participants: participants, + date: parseRFC2822Date(newestHeaders["Date"]), + messageCount: messages.count, + labelIds: thread["labelIds"] as? [String] ?? [] + ) + } + + static func fetchThreadDetail(id: String, mailbox: MailMailbox?, token: GoogleOAuthToken) async throws -> MailThreadDetail { + let thread = try await fetchThreadResource( + id: id, + queryItems: [URLQueryItem(name: "format", value: "full")], + token: token + ) + + let rawMessages = thread["messages"] as? [[String: Any]] ?? [] + let messages = rawMessages.compactMap { parseMessage($0, threadID: id) } + let participants = uniqueValues(messages.compactMap { $0.from?.displayName }) + let subject = messages.last?.subject + ?? messages.first?.subject + ?? "(No Subject)" + + return MailThreadDetail( + id: id, + mailbox: mailbox, + subject: subject, + snippet: (thread["snippet"] as? String) ?? "", + participants: participants, + messages: messages, + labelIds: thread["labelIds"] as? [String] ?? [], + historyId: thread["historyId"] as? String + ) + } + + static func apply(action: MailThreadAction, threadID: String, token: GoogleOAuthToken) async throws { + switch action { + case .trash: + _ = try await postJSON(path: "threads/\(threadID)/trash", body: [:], token: token) + case .untrash: + _ = try await postJSON(path: "threads/\(threadID)/untrash", body: [:], token: token) + case .archive: + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: ["removeLabelIds": ["INBOX"]], + token: token + ) + case .setStarred(let starred): + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: starred ? ["addLabelIds": ["STARRED"]] : ["removeLabelIds": ["STARRED"]], + token: token + ) + case .setUnread(let unread): + _ = try await postJSON( + path: "threads/\(threadID)/modify", + body: unread ? ["addLabelIds": ["UNREAD"]] : ["removeLabelIds": ["UNREAD"]], + token: token + ) + } + } + + static func send(draft: MailDraft, connectedEmail: String, token: GoogleOAuthToken) async throws -> String { + let rawMessage = MailComposerEncoder.buildRawMessage(draft: draft, connectedEmail: connectedEmail) + var body: [String: Any] = ["raw": rawMessage] + if let threadID = draft.threadId, !threadID.isEmpty { + body["threadId"] = threadID + } + let response = try await postJSON(path: "messages/send", body: body, token: token) + return response["id"] as? String ?? "" + } + + private static func fetchThreadResource( + id: String, + queryItems: [URLQueryItem], + token: GoogleOAuthToken + ) async throws -> [String: Any] { + var components = URLComponents(url: baseURL.appendingPathComponent("threads/\(id)"), resolvingAgainstBaseURL: false)! + components.queryItems = queryItems + return try await requestJSON(url: components.url!, token: token) + } + + private static func requestJSON(url: URL, token: GoogleOAuthToken) async throws -> [String: Any] { + var request = URLRequest(url: url) + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MailServiceError.apiError("No response from Gmail.") + } + guard http.statusCode == 200 else { + let message = String(data: data, encoding: .utf8) ?? "" + throw MailServiceError.apiError("Gmail error \(http.statusCode): \(message)") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw MailServiceError.apiError("Invalid Gmail response.") + } + return json + } + + private static func postJSON(path: String, body: [String: Any], token: GoogleOAuthToken) async throws -> [String: Any] { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MailServiceError.apiError("No response from Gmail.") + } + guard (200..<300).contains(http.statusCode) else { + let message = String(data: data, encoding: .utf8) ?? "" + throw MailServiceError.apiError("Gmail error \(http.statusCode): \(message)") + } + guard !data.isEmpty else { return [:] } + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + private static func headerMap(from message: [String: Any]) -> [String: String] { + let payload = message["payload"] as? [String: Any] ?? [:] + let headers = payload["headers"] as? [[String: Any]] ?? [] + return Dictionary(uniqueKeysWithValues: headers.compactMap { header in + guard let name = header["name"] as? String, + let value = header["value"] as? String else { return nil } + return (name, value) + }) + } + + private static func parseMessage(_ json: [String: Any], threadID: String) -> MailMessage? { + guard let id = json["id"] as? String else { return nil } + let headers = headerMap(from: json) + let payload = json["payload"] as? [String: Any] ?? [:] + let body = extractBodies(from: payload) + + return MailMessage( + id: id, + threadId: threadID, + subject: headers["Subject"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(No Subject)", + snippet: json["snippet"] as? String ?? "", + labelIds: json["labelIds"] as? [String] ?? [], + from: parseSingleRecipient(from: headers["From"]), + to: parseRecipients(from: headers["To"]), + cc: parseRecipients(from: headers["Cc"]), + bcc: parseRecipients(from: headers["Bcc"]), + date: parseRFC2822Date(headers["Date"]), + plainBody: body.plain, + htmlBody: body.html, + messageIDHeader: headers["Message-ID"], + referencesHeader: headers["References"] + ) + } + + private static func extractBodies(from payload: [String: Any]) -> (plain: String, html: String?) { + var plainBody: String? + var htmlBody: String? + + func visit(_ part: [String: Any]) { + let mimeType = (part["mimeType"] as? String ?? "").lowercased() + let filename = part["filename"] as? String ?? "" + let body = part["body"] as? [String: Any] ?? [:] + let encodedData = body["data"] as? String + + if filename.isEmpty, let encodedData, let decoded = decodeBase64URL(encodedData) { + if mimeType == "text/plain" { + plainBody = plainBody ?? decoded + } else if mimeType == "text/html" { + htmlBody = htmlBody ?? decoded + } else if mimeType.isEmpty { + plainBody = plainBody ?? decoded + } + } + + let parts = part["parts"] as? [[String: Any]] ?? [] + for child in parts { + visit(child) + } + } + + visit(payload) + return (plainBody ?? "", htmlBody) + } + + private static func parseSingleRecipient(from header: String?) -> MailMessageRecipient? { + parseRecipients(from: header).first + } + + private static func parseRecipients(from header: String?) -> [MailMessageRecipient] { + guard let header, !header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + return header + .split(separator: ",") + .compactMap { chunk in + let value = chunk.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + + if let start = value.lastIndex(of: "<"), + let end = value.lastIndex(of: ">"), + start < end { + let name = value[.. String? { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + if padding > 0 { + normalized += String(repeating: "=", count: 4 - padding) + } + guard let data = Data(base64Encoded: normalized) else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func parseRFC2822Date(_ value: String?) -> Date? { + guard let value else { return nil } + for formatter in MailDateParsers.all { + if let date = formatter.date(from: value) { + return date + } + } + return nil + } + + private static func recipientDisplayName(from header: String?) -> String? { + parseSingleRecipient(from: header)?.displayName + } + + private static func uniqueValues(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0.lowercased()).inserted } + } +} + +enum MailServiceError: LocalizedError { + case apiError(String) + + var errorDescription: String? { + switch self { + case .apiError(let message): + return message + } + } +} + +private enum MailDateParsers { + static let all: [DateFormatter] = { + let formats = [ + "EEE, d MMM yyyy HH:mm:ss Z", + "d MMM yyyy HH:mm:ss Z", + "EEE, d MMM yyyy HH:mm:ss z", + "d MMM yyyy HH:mm:ss z", + ] + + return formats.map { format in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = format + return formatter + } + }() +} + +struct MailCacheStore { + private let fileManager: FileManager + private let baseDirectory: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(fileManager: FileManager = .default, directoryURL: URL? = nil) { + self.fileManager = fileManager + self.baseDirectory = directoryURL ?? Self.defaultDirectory(fileManager: fileManager) + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + func load(accountEmail: String) -> MailCacheSnapshot? { + let fileURL = cacheFileURL(for: accountEmail) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? decoder.decode(MailCacheSnapshot.self, from: data) + } + + func save(_ snapshot: MailCacheSnapshot, accountEmail: String) { + do { + try ensureBaseDirectoryExists() + let data = try encoder.encode(snapshot) + try data.write(to: cacheFileURL(for: accountEmail), options: .atomic) + } catch { + Log.mail.error("Failed to save mail cache: \(error.localizedDescription)") + } + } + + private func ensureBaseDirectoryExists() throws { + guard !fileManager.fileExists(atPath: baseDirectory.path) else { return } + try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + } + + private func cacheFileURL(for accountEmail: String) -> URL { + let sanitized = accountEmail + .lowercased() + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + let filename = sanitized.isEmpty ? "mail-cache" : sanitized + return baseDirectory.appendingPathComponent("\(filename).json") + } + + private static func defaultDirectory(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("MailCache", isDirectory: true) + } +} diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift index bf84aa67..7f2287a3 100644 --- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift +++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift @@ -12,6 +12,13 @@ struct WorkspaceCalendarView: View { @State private var transcriptionService = TranscriptionService() @State private var showImportRecording = false + @State private var showCreateEventSheet = false + @State private var createEventDraft = CalendarEventDraft( + startDate: Date(), + endDate: Date().addingTimeInterval(3600) + ) + @State private var isCreatingEvent = false + @State private var createEventError: String? var body: some View { VStack(spacing: 0) { @@ -23,6 +30,19 @@ struct WorkspaceCalendarView: View { .ignoresSafeArea(.container, edges: .top) .background(Color.fallbackEditorBg) .animation(.easeInOut(duration: 0.15), value: calendarVM.viewMode) + .sheet(isPresented: $showCreateEventSheet) { + CalendarEventComposerSheet( + draft: $createEventDraft, + connectedEmail: appState.settings.googleConnectedEmail, + isSaving: isCreatingEvent, + errorMessage: createEventError, + onCancel: { + showCreateEventSheet = false + createEventError = nil + }, + onSave: createCalendarEvent + ) + } .onAppear { if let workspace = appState.workspacePath { calendarService.loadCachedData(workspace: workspace) @@ -145,6 +165,15 @@ struct WorkspaceCalendarView: View { ) } + // Create event button + Button(action: handleCreateEventButton) { + Image(systemName: "plus.circle") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(appState.settings.googleConnected ? "Create Google Calendar event" : "Connect Google Calendar to create events") + // Import recording button Button(action: { showImportRecording = true }) { Image(systemName: "waveform.badge.plus") @@ -233,26 +262,93 @@ struct WorkspaceCalendarView: View { private func syncCalendar() { guard let workspace = appState.workspacePath else { return } - let token = loadGoogleToken() - guard let token else { - calendarService.error = "No Google Calendar credentials configured. Go to Settings > Calendar." + Task { + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.calendar) + appState.settings = settings + await calendarService.syncGoogleCalendar(workspace: workspace, token: token) + await calendarService.loadDatabaseOverlayItems(workspace: workspace) + } catch { + calendarService.error = error.localizedDescription + } + } + } + + private func handleCreateEventButton() { + guard appState.settings.googleConfigured, appState.settings.googleConnected else { + appState.showSettings = true + appState.selectedSettingsTab = "google" return } + + createEventDraft = makeCreateEventDraft() + createEventError = nil + showCreateEventSheet = true + } + + private func createCalendarEvent() { + guard let workspace = appState.workspacePath, !isCreatingEvent else { return } + createEventError = nil + isCreatingEvent = true + Task { - await calendarService.syncGoogleCalendar(workspace: workspace, token: token) - await calendarService.loadDatabaseOverlayItems(workspace: workspace) + defer { isCreatingEvent = false } + + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.calendar) + let createdEvent = try await calendarService.createGoogleEvent( + workspace: workspace, + token: token, + draft: createEventDraft + ) + appState.settings = settings + calendarVM.selectedDate = createdEvent.startDate + createEventDraft = makeCreateEventDraft() + showCreateEventSheet = false + } catch { + createEventError = error.localizedDescription + } } } - private func loadGoogleToken() -> GoogleOAuthToken? { - let settings = appState.settings - guard !settings.googleCalendarRefreshToken.isEmpty else { return nil } - return GoogleOAuthToken( - accessToken: settings.googleCalendarAccessToken, - refreshToken: settings.googleCalendarRefreshToken, - expiresAt: Date(timeIntervalSince1970: settings.googleCalendarTokenExpiry) + private func makeCreateEventDraft() -> CalendarEventDraft { + let calendar = Calendar.current + let selectedDayStart = calendar.startOfDay(for: calendarVM.selectedDate) + let now = Date() + + var startDate: Date + switch calendarVM.viewMode { + case .month: + startDate = calendar.date(byAdding: .hour, value: 9, to: selectedDayStart) ?? selectedDayStart + case .day, .week: + let candidate = max(now, calendarVM.selectedDate) + startDate = alignedToNextHalfHour(candidate) + if calendar.isDate(startDate, inSameDayAs: selectedDayStart) == false { + startDate = calendar.date(byAdding: .hour, value: 9, to: selectedDayStart) ?? selectedDayStart + } + } + + return CalendarEventDraft( + startDate: startDate, + endDate: startDate.addingTimeInterval(3600), + calendarId: "primary" ) } + + private func alignedToNextHalfHour(_ date: Date) -> Date { + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) + let minute = components.minute ?? 0 + let remainder = minute % 30 + if remainder == 0, let exact = calendar.date(from: components) { + return exact + } + components.minute = minute + (30 - remainder) + components.second = 0 + return calendar.date(from: components) ?? date + } } // MARK: - Source Picker @@ -332,3 +428,112 @@ struct CalendarSourcePicker: View { return TagColor.color(for: hex) } } + +struct CalendarEventComposerSheet: View { + @Binding var draft: CalendarEventDraft + let connectedEmail: String + let isSaving: Bool + let errorMessage: String? + var onCancel: () -> Void + var onSave: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + Text("New Event") + .font(.system(size: 18, weight: .semibold)) + + if !connectedEmail.isEmpty { + Label(connectedEmail, systemImage: "calendar.badge.plus") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("Title") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextField("Planning review", text: $draft.title) + .textFieldStyle(.roundedBorder) + } + + Toggle("All-day", isOn: $draft.isAllDay) + .toggleStyle(.switch) + + if draft.isAllDay { + DatePicker("Starts", selection: $draft.startDate, displayedComponents: [.date]) + DatePicker("Ends", selection: $draft.endDate, displayedComponents: [.date]) + + Text("All-day events end on the selected day in Bugbook and are sent to Google Calendar with the correct exclusive end date.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + DatePicker("Starts", selection: $draft.startDate) + DatePicker("Ends", selection: $draft.endDate) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Location") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextField("Conference room or link", text: $draft.location) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + TextEditor(text: $draft.notes) + .font(.system(size: 13)) + .frame(minHeight: 110) + .padding(8) + .background(Color.primary.opacity(0.04)) + .clipShape(.rect(cornerRadius: 8)) + } + } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Spacer() + + Button("Cancel", action: onCancel) + .buttonStyle(.borderless) + + Button(action: onSave) { + HStack(spacing: 8) { + if isSaving { + ProgressView() + .controlSize(.small) + } + Text("Create Event") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(isSaving) + } + } + .padding(20) + .frame(width: 460) + .onChange(of: draft.isAllDay) { _, isAllDay in + guard isAllDay else { + if draft.endDate <= draft.startDate { + draft.endDate = draft.startDate.addingTimeInterval(3600) + } + return + } + + let calendar = Calendar.current + draft.startDate = calendar.startOfDay(for: draft.startDate) + draft.endDate = calendar.startOfDay(for: max(draft.startDate, draft.endDate)) + } + } +} diff --git a/Sources/Bugbook/Views/Components/CommandPaletteView.swift b/Sources/Bugbook/Views/Components/CommandPaletteView.swift index 25e2a1d5..fd9e8862 100644 --- a/Sources/Bugbook/Views/Components/CommandPaletteView.swift +++ b/Sources/Bugbook/Views/Components/CommandPaletteView.swift @@ -413,6 +413,9 @@ struct CommandPaletteView: View { PaletteCommand(id: "toggle_theme", name: "Toggle Theme", icon: "circle.lefthalf.filled", shortcut: nil) { NotificationCenter.default.post(name: .toggleTheme, object: nil) }, + PaletteCommand(id: "open_mail", name: "Open Mail", icon: "envelope", shortcut: "Cmd+Shift+M") { + NotificationCenter.default.post(name: .openMail, object: nil) + }, ] } diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 12e13db3..d55cc944 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -10,9 +10,11 @@ struct ContentView: View { private let editorDraftStore = EditorDraftStore() @State private var appState = AppState() + @State private var appSettingsStore = AppSettingsStore() @State private var fileSystem = FileSystemService() @State private var aiService = AiService() @State private var calendarService = CalendarService() + @State private var mailService = MailService() @State private var calendarVM = CalendarViewModel() @State private var meetingNoteService = MeetingNoteService() @State private var transcriptionService = TranscriptionService() @@ -89,7 +91,9 @@ struct ContentView: View { private var configuredLayout: some View { applyDatabaseNotifications( to: applyCommandNotifications( - to: applyLifecycle(to: baseLayout) + to: applyWorkspaceNotifications( + to: applyLifecycle(to: baseLayout) + ) ) ) } @@ -99,11 +103,15 @@ struct ContentView: View { .ignoresSafeArea() .frame(minWidth: 800, minHeight: 500) .task { + loadAppSettings() initializeWorkspace() applyTheme(appState.settings.theme) editorZoomScale = clampedEditorZoomScale(editorZoomScale) editorUI.focusModeEnabled = appState.settings.focusModeOnType } + .onChange(of: appState.settings) { _, newSettings in + appSettingsStore.save(newSettings) + } .onChange(of: appState.settings.theme) { _, newTheme in applyTheme(newTheme) } @@ -181,6 +189,7 @@ struct ContentView: View { } .onDisappear { flushDirtyTabs() + appSettingsStore.save(appState.settings) terminalManager.shutdown() aiInitTask?.cancel() aiInitTask = nil @@ -189,6 +198,10 @@ struct ContentView: View { workspaceWatcher?.stop() recordingPillController.cleanup() } + } + + private func applyWorkspaceNotifications(to view: V) -> some View { + view .onReceive(NotificationCenter.default.publisher(for: .fileDeleted)) { notification in if let path = notification.object as? String { saveTask?.cancel() @@ -348,6 +361,11 @@ struct ContentView: View { appState.showSettings = false openContentInFocusedPane(.graphDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .openMail)) { _ in + appState.currentView = .editor + appState.showSettings = false + openContentInFocusedPane(.mailDocument()) + } .onReceive(NotificationCenter.default.publisher(for: .openCalendar)) { _ in appState.currentView = .editor appState.showSettings = false @@ -828,10 +846,11 @@ struct ContentView: View { } private var activeTabLeadingPadding: CGFloat { + let isMail = appState.activeTab?.isMail ?? false let isCalendar = appState.activeTab?.isCalendar ?? false let isMeetings = appState.activeTab?.isMeetings ?? false let isGateway = appState.activeTab?.isGateway ?? false - if isCalendar || isMeetings || isGateway { return 0 } + if isMail || isCalendar || isMeetings || isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -943,7 +962,7 @@ struct ContentView: View { @ViewBuilder private func paneDocumentContent(leaf: PaneNode.Leaf, file: OpenFile) -> some View { VStack(spacing: 0) { - if !file.isEmptyTab && !file.isCalendar && !file.isMeetings && !file.isGateway { + if !file.isEmptyTab && !file.isMail && !file.isCalendar && !file.isMeetings && !file.isGateway { HStack { BreadcrumbView( items: breadcrumbs(for: file), @@ -987,7 +1006,7 @@ struct ContentView: View { } private func paneLeadingPadding(for file: OpenFile) -> CGFloat { - if file.isCalendar || file.isMeetings || file.isGateway { return 0 } + if file.isMail || file.isCalendar || file.isMeetings || file.isGateway { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -1033,6 +1052,11 @@ struct ContentView: View { fullWidth: databaseRowFullWidth[leaf.id, default: false] ) .id(leaf.id) + } else if file.isMail { + MailPaneView( + appState: appState, + mailService: mailService + ) } else if file.isCalendar { WorkspaceCalendarView( appState: appState, @@ -1048,9 +1072,6 @@ struct ContentView: View { MeetingsView( appState: appState, viewModel: meetingsVM, - meetingNoteService: meetingNoteService, - transcriptionService: transcriptionService, - aiService: aiService, onNavigateToFile: { path in navigateToFilePath(path) } @@ -1879,6 +1900,9 @@ struct ContentView: View { return !isDir.boolValue } + private func loadAppSettings() { + appState.settings = appSettingsStore.load() + } private func initializeWorkspace() { // Restore the most recently used workspace, falling back to the default diff --git a/Sources/Bugbook/Views/Mail/MailPaneView.swift b/Sources/Bugbook/Views/Mail/MailPaneView.swift new file mode 100644 index 00000000..c0955845 --- /dev/null +++ b/Sources/Bugbook/Views/Mail/MailPaneView.swift @@ -0,0 +1,557 @@ +import SwiftUI +import WebKit + +struct MailPaneView: View { + var appState: AppState + @Bindable var mailService: MailService + + @State private var searchText = "" + + var body: some View { + VStack(spacing: 0) { + header + Divider() + + if !appState.settings.googleConfigured { + setupState( + title: "Configure Google access", + message: "Add your Google OAuth client ID and secret in Settings before connecting Mail." + ) + } else if !appState.settings.googleConnected { + setupState( + title: "Connect Gmail", + message: "Sign in once and Bugbook will use that Google account for both Mail and Calendar." + ) + } else { + HStack(spacing: 0) { + mailboxRail + .frame(width: 180) + Divider() + threadList + .frame(width: 320) + Divider() + detailPane + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Color.fallbackEditorBg) + .onAppear { + searchText = mailService.searchState.query + if let accountEmail = configuredAccountEmail { + mailService.loadCachedData(accountEmail: accountEmail) + if (mailService.mailboxThreads[mailService.selectedMailbox] ?? []).isEmpty { + refreshSelectedMailbox(force: false) + } + } + } + .onChange(of: appState.settings.googleConnectedEmail) { _, newEmail in + guard !newEmail.isEmpty else { return } + mailService.loadCachedData(accountEmail: newEmail) + refreshSelectedMailbox(force: false) + } + } + + private var header: some View { + HStack(spacing: 10) { + Text("Mail") + .font(.system(size: 16, weight: .semibold)) + + Spacer() + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search Gmail", text: $searchText) + .textFieldStyle(.plain) + .onSubmit { submitSearch() } + + if !searchText.isEmpty { + Button { + searchText = "" + mailService.clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.05)) + .clipShape(.rect(cornerRadius: 8)) + .frame(width: 320) + + if mailService.isSearching || mailService.isLoadingMailbox || mailService.isLoadingThread || mailService.isSending { + ProgressView() + .controlSize(.small) + } + + if let accountEmail = configuredAccountEmail { + Label(accountEmail, systemImage: "person.crop.circle") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Button(action: { refreshSelectedMailbox(force: true) }) { + Image(systemName: "arrow.clockwise") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!appState.settings.googleConnected || mailService.isLoadingMailbox) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var mailboxRail: some View { + VStack(alignment: .leading, spacing: 6) { + Button(action: { mailService.presentNewComposer() }) { + Label("Compose", systemImage: "square.and.pencil") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.accentColor.opacity(0.12)) + .clipShape(.rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + + ForEach(MailMailbox.allCases) { mailbox in + Button { + mailService.selectMailbox(mailbox) + refreshSelectedMailbox(force: false) + } label: { + HStack(spacing: 10) { + Image(systemName: mailbox.systemImage) + .frame(width: 18) + Text(mailbox.displayName) + .font(.system(size: 13, weight: .medium)) + Spacer() + let count = mailService.mailboxThreads[mailbox]?.count ?? 0 + if count > 0 { + Text("\(count)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(mailService.selectedMailbox == mailbox ? Color.primary.opacity(0.08) : Color.clear) + .clipShape(.rect(cornerRadius: 8)) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + Spacer() + + Button { + appState.showSettings = true + appState.selectedSettingsTab = "google" + } label: { + Label("Google Settings", systemImage: "person.badge.key") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(12) + } + + private var threadList: some View { + VStack(spacing: 0) { + if let error = mailService.error, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.top, 10) + } + + if mailService.visibleThreads.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "No threads" : "No search results", + systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", + description: Text(searchText.isEmpty ? "Refresh Gmail to load messages for \(mailService.selectedMailbox.displayName)." : "Try a different Gmail query.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(mailService.visibleThreads) { thread in + Button { + openThread(thread) + } label: { + threadRow(thread) + } + .buttonStyle(.plain) + } + } + } + } + } + .background(Color.fallbackEditorBg) + } + + @ViewBuilder + private var detailPane: some View { + if mailService.isComposing && mailService.composer.threadId == nil { + composeView(title: "New Message") + } else if let thread = mailService.selectedThread { + VStack(spacing: 0) { + threadToolbar(thread) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ForEach(thread.messages) { message in + messageCard(message) + } + + if mailService.isComposing, mailService.composer.threadId == thread.id { + composeView(title: mailService.composer.mode == .replyAll ? "Reply All" : "Reply") + } + } + .padding(20) + } + } + } else if mailService.isLoadingThread { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ContentUnavailableView( + "Select a thread", + systemImage: "envelope.open", + description: Text("Choose a message from \(mailService.selectedMailbox.displayName) to read or reply.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func setupState(title: String, message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "envelope.badge") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(title) + .font(.system(size: 20, weight: .semibold)) + Text(message) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 360) + Button("Open Google Settings") { + appState.showSettings = true + appState.selectedSettingsTab = "google" + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func threadRow(_ thread: MailThreadSummary) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(thread.participants.first ?? "(Unknown Sender)") + .font(.system(size: 12, weight: thread.isUnread ? .semibold : .regular)) + .lineLimit(1) + + Spacer() + + if thread.isStarred { + Image(systemName: "star.fill") + .font(.system(size: 10)) + .foregroundStyle(.yellow) + } + + if let date = thread.date { + Text(date, style: .date) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + Text(thread.subject) + .font(.system(size: 13, weight: thread.isUnread ? .semibold : .medium)) + .lineLimit(1) + + Text(thread.snippet) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(mailService.selectedThreadID == thread.id ? Color.primary.opacity(0.08) : Color.clear) + .overlay(alignment: .leading) { + if thread.isUnread { + Circle() + .fill(Color.accentColor) + .frame(width: 6, height: 6) + .padding(.leading, 4) + } + } + } + + private func threadToolbar(_ thread: MailThreadDetail) -> some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 4) { + Text(thread.subject) + .font(.system(size: 16, weight: .semibold)) + Text(thread.participants.joined(separator: ", ")) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Button("Reply") { + mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: false) + } + .buttonStyle(.bordered) + + Button("Reply All") { + mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: true) + } + .buttonStyle(.bordered) + + Button(action: { applyThreadAction(thread.isStarred ? .setStarred(false) : .setStarred(true), threadID: thread.id) }) { + Image(systemName: thread.isStarred ? "star.fill" : "star") + .foregroundStyle(thread.isStarred ? .yellow : .secondary) + } + .buttonStyle(.plain) + + Button(action: { applyThreadAction(thread.isUnread ? .setUnread(false) : .setUnread(true), threadID: thread.id) }) { + Image(systemName: thread.isUnread ? "envelope.open" : "envelope.badge") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Button(action: { applyThreadAction(thread.mailbox == .trash ? .untrash : .trash, threadID: thread.id) }) { + Image(systemName: thread.mailbox == .trash ? "arrow.uturn.left.circle" : "trash") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + if thread.mailbox != .trash { + Button(action: { applyThreadAction(.archive, threadID: thread.id) }) { + Image(systemName: "archivebox") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private func messageCard(_ message: MailMessage) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(Color.primary.opacity(0.08)) + .frame(width: 32, height: 32) + .overlay( + Text(String((message.from?.name ?? message.from?.email ?? "?").prefix(1)).uppercased()) + .font(.system(size: 13, weight: .semibold)) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(message.from?.displayName ?? "(Unknown Sender)") + .font(.system(size: 13, weight: .semibold)) + Text(recipientLine(for: message)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + if let date = message.date { + Text(date.formatted(date: .abbreviated, time: .shortened)) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + Spacer() + } + + if let htmlBody = message.htmlBody, + !htmlBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MailHTMLView(html: htmlBody) + .frame(minHeight: 220) + .clipShape(.rect(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + ) + } else { + Text(message.bodyText.isEmpty ? message.snippet : message.bodyText) + .font(.system(size: 13)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .background(Color.primary.opacity(0.03)) + .clipShape(.rect(cornerRadius: 14)) + } + + private func composeView(title: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.system(size: 15, weight: .semibold)) + Spacer() + Button("Discard") { + mailService.dismissComposer() + } + .buttonStyle(.borderless) + } + + composeField("To", text: $mailService.composer.to) + composeField("Cc", text: $mailService.composer.cc) + composeField("Bcc", text: $mailService.composer.bcc) + composeField("Subject", text: $mailService.composer.subject) + + TextEditor(text: $mailService.composer.body) + .font(.system(size: 13)) + .frame(minHeight: 180) + .padding(8) + .background(Color.primary.opacity(0.04)) + .clipShape(.rect(cornerRadius: 10)) + + HStack { + Spacer() + Button("Send") { + sendComposer() + } + .buttonStyle(.borderedProminent) + .disabled(mailService.isSending) + } + } + .padding(20) + } + + private func composeField(_ label: String, text: Binding) -> some View { + HStack(spacing: 10) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 52, alignment: .leading) + TextField(label, text: text) + .textFieldStyle(.roundedBorder) + } + } + + private func recipientLine(for message: MailMessage) -> String { + let toLine = message.to.map(\.displayName).joined(separator: ", ") + if toLine.isEmpty { + return "No recipients" + } + return "To: \(toLine)" + } + + private var configuredAccountEmail: String? { + let value = appState.settings.googleConnectedEmail.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func submitSearch() { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + mailService.clearSearch() + return + } + withMailToken { token in + await mailService.performSearch(query: trimmed, token: token) + } + } + + private func refreshSelectedMailbox(force: Bool) { + withMailToken { token in + await mailService.loadMailbox(mailService.selectedMailbox, token: token, forceRefresh: force) + } + } + + private func openThread(_ thread: MailThreadSummary) { + withMailToken { token in + await mailService.loadThread(id: thread.id, mailbox: thread.mailbox, token: token) + } + } + + private func applyThreadAction(_ action: MailThreadAction, threadID: String) { + withMailToken { token in + await mailService.apply(action: action, to: threadID, token: token) + } + } + + private func sendComposer() { + withMailToken { token in + _ = await mailService.sendComposer( + connectedEmail: appState.settings.googleConnectedEmail, + token: token + ) + } + } + + private func withMailToken(_ operation: @escaping (GoogleOAuthToken) async -> Void) { + Task { + do { + var settings = appState.settings + let token = try await GoogleAuthService.validToken(using: &settings, requiredScopes: GoogleScopeSet.mail) + appState.settings = settings + await operation(token) + } catch { + mailService.error = error.localizedDescription + } + } + } +} + +private struct MailHTMLView: NSViewRepresentable { + let html: String + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.suppressesIncrementalRendering = false + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.setValue(false, forKey: "drawsBackground") + webView.isInspectable = true + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.loadHTMLString(wrappedHTML(html), baseURL: nil) + } + + private func wrappedHTML(_ body: String) -> String { + """ + + + + + + \(body) + + """ + } +} diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 4970edb2..376f4274 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -1,17 +1,10 @@ import SwiftUI -import AppKit -import UniformTypeIdentifiers struct MeetingsView: View { var appState: AppState @Bindable var viewModel: MeetingsViewModel - var meetingNoteService: MeetingNoteService - var transcriptionService: TranscriptionService - var aiService: AiService var onNavigateToFile: (String) -> Void - @State private var isImporting = false - var body: some View { VStack(spacing: 0) { header @@ -31,6 +24,8 @@ struct MeetingsView: View { } // MARK: - Header + // NOTE: Audio import (NSOpenPanel → TranscriptionService) was removed 2026-03-31. + // The plumbing exists in MeetingNoteService.importRecording if we revisit later. private var header: some View { HStack(spacing: 8) { @@ -45,15 +40,6 @@ struct MeetingsView: View { .controlSize(.small) } - Button(action: importRecording) { - Image(systemName: "square.and.arrow.down") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .disabled(isImporting) - .help("Import Recording") - Button(action: { appState.openNotesChat() }) { HStack(spacing: 4) { Image(systemName: "sparkles") @@ -169,36 +155,6 @@ struct MeetingsView: View { // MARK: - Helpers - private func importRecording() { - let panel = NSOpenPanel() - panel.title = "Import Recording" - panel.allowedContentTypes = [.audio] - panel.allowsMultipleSelection = false - - guard panel.runModal() == .OK, let url = panel.url else { return } - guard TranscriptionService.isSupportedAudioFile(url) else { return } - - isImporting = true - Task { - defer { isImporting = false } - guard let workspace = appState.workspacePath else { return } - let apiKey = appState.settings.anthropicApiKey - let model = appState.settings.anthropicModel - - if let path = await meetingNoteService.importRecording( - fileURL: url, - workspace: workspace, - transcriptionService: transcriptionService, - aiService: aiService, - apiKey: apiKey, - model: model - ) { - onNavigateToFile(path) - rescan() - } - } - } - private func rescan() { guard let workspace = appState.workspacePath else { return } viewModel.scan(workspace: workspace) diff --git a/Sources/Bugbook/Views/Panes/PaneContentView.swift b/Sources/Bugbook/Views/Panes/PaneContentView.swift index c71b8984..e990bc07 100644 --- a/Sources/Bugbook/Views/Panes/PaneContentView.swift +++ b/Sources/Bugbook/Views/Panes/PaneContentView.swift @@ -68,6 +68,7 @@ struct PaneContentView: View { private func paneTypeMenu(action: @escaping (PaneContent) -> Void) -> some View { Button("Terminal") { action(.terminal) } Button("Empty Page") { action(.emptyDocument()) } + Button("Mail") { action(.mailDocument()) } Button("Calendar") { action(.calendarDocument()) } Button("Meetings") { action(.meetingsDocument()) } Button("Graph View") { action(.graphDocument()) } @@ -163,6 +164,7 @@ private struct PaneActionBar: View { Menu { Button("Terminal") { action(.terminal) } Button("Empty Page") { action(.emptyDocument()) } + Button("Mail") { action(.mailDocument()) } Button("Calendar") { action(.calendarDocument()) } Button("Meetings") { action(.meetingsDocument()) } Button("Graph View") { action(.graphDocument()) } diff --git a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift b/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift deleted file mode 100644 index f2fe8d30..00000000 --- a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift +++ /dev/null @@ -1,126 +0,0 @@ -import SwiftUI -import BugbookCore - -struct CalendarSettingsView: View { - @Bindable var appState: AppState - @State private var overlays: [CalendarOverlay] = [] - @State private var isSigningIn = false - @State private var signInError: String? - - private let store = CalendarEventStore() - - var body: some View { - VStack(alignment: .leading, spacing: 24) { - SettingsSection("Google Calendar") { - if isConnected { - HStack(spacing: 10) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 16)) - - VStack(alignment: .leading, spacing: 2) { - Text("Connected to Google Calendar") - .font(.system(size: 13, weight: .medium)) - if !appState.settings.googleCalendarConnectedEmail.isEmpty { - Text(appState.settings.googleCalendarConnectedEmail) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Button("Disconnect") { - disconnect() - } - .buttonStyle(.borderless) - .foregroundStyle(.red) - .font(.system(size: 13)) - } - } else { - VStack(alignment: .leading, spacing: 8) { - Button { - Task { await signIn() } - } label: { - HStack(spacing: 8) { - if isSigningIn { - ProgressView().controlSize(.small) - } else { - Image(systemName: "person.badge.key") - } - Text("Sign in with Google") - } - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - .disabled(isSigningIn) - - if let signInError { - Text(signInError) - .font(.caption) - .foregroundStyle(.red) - } - } - } - } - - SettingsSection("Database Overlays") { - VStack(alignment: .leading, spacing: 8) { - Text("Show database rows with date properties on your calendar. Add overlays from the calendar view's filter menu.") - .font(.caption) - .foregroundStyle(.secondary) - - if overlays.isEmpty { - Text("No overlays configured yet.") - .font(.system(size: 13)) - .foregroundStyle(.tertiary) - } else { - ForEach(overlays) { overlay in - HStack { - Circle() - .fill(TagColor.color(for: overlay.color)) - .frame(width: 8, height: 8) - Text("\(overlay.databaseName) — \(overlay.datePropertyName)") - .font(.system(size: 13)) - Spacer() - } - } - } - } - } - } - .onAppear { - if let workspace = appState.workspacePath { - overlays = store.loadOverlays(in: workspace) - } - } - } - - private func signIn() async { - isSigningIn = true - signInError = nil - defer { isSigningIn = false } - - do { - let result = try await GoogleOAuthFlow.signIn() - appState.settings.googleCalendarAccessToken = result.accessToken - appState.settings.googleCalendarRefreshToken = result.refreshToken - appState.settings.googleCalendarTokenExpiry = result.expiresAt.timeIntervalSince1970 - appState.settings.googleCalendarConnectedEmail = result.email - appState.settings.googleCalendarBannerDismissed = false - } catch { - signInError = error.localizedDescription - } - } - - private func disconnect() { - appState.settings.googleCalendarAccessToken = "" - appState.settings.googleCalendarRefreshToken = "" - appState.settings.googleCalendarTokenExpiry = 0 - appState.settings.googleCalendarConnectedEmail = "" - } - - private var isConnected: Bool { - !appState.settings.googleCalendarRefreshToken.isEmpty - } -} diff --git a/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift b/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift new file mode 100644 index 00000000..01c3eb55 --- /dev/null +++ b/Sources/Bugbook/Views/Settings/GoogleSettingsView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import BugbookCore + +struct GoogleSettingsView: View { + @Bindable var appState: AppState + @State private var overlays: [CalendarOverlay] = [] + @State private var isSigningIn = false + @State private var signInError: String? + @State private var showClientSecret = false + + private let store = CalendarEventStore() + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + SettingsSection("OAuth App") { + VStack(alignment: .leading, spacing: 10) { + TextField("Google OAuth Client ID", text: $appState.settings.googleClientID) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + + HStack(spacing: 8) { + Group { + if showClientSecret { + TextField("Google OAuth Client Secret", text: $appState.settings.googleClientSecret) + } else { + SecureField("Google OAuth Client Secret", text: $appState.settings.googleClientSecret) + } + } + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + + Button { + showClientSecret.toggle() + } label: { + Image(systemName: showClientSecret ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + + Text("Use a Google desktop OAuth client. Bugbook uses this one account for both Mail and Calendar.") + .font(.caption) + .foregroundStyle(.secondary) + + Text("Enable the Gmail API and Google Calendar API in the same project, then use that desktop client here.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + SettingsSection("Google Account") { + if isConnected { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16)) + + VStack(alignment: .leading, spacing: 2) { + Text("Connected to Google") + .font(.system(size: 13, weight: .medium)) + if !appState.settings.googleConnectedEmail.isEmpty { + Text(appState.settings.googleConnectedEmail) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 12) { + Text(scopeSummary) + .font(.caption) + .foregroundStyle(.secondary) + + Button("Disconnect") { + disconnect() + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + .font(.system(size: 13)) + } + } + } else { + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await signIn() } + } label: { + HStack(spacing: 8) { + if isSigningIn { + ProgressView().controlSize(.small) + } else { + Image(systemName: "person.badge.key") + } + Text("Sign in with Google") + } + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(isSigningIn || !appState.settings.googleConfigured) + + if let signInError { + Text(signInError) + .font(.caption) + .foregroundStyle(.red) + } + + if !appState.settings.googleConfigured { + Text("Add your Google OAuth client ID and secret above before signing in.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let reconnectMessage { + Text(reconnectMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + SettingsSection("Used By") { + VStack(alignment: .leading, spacing: 8) { + Label("Mail uses Gmail threads, search, compose, and thread actions.", systemImage: "envelope") + .font(.system(size: 13)) + Label("Calendar uses Google Calendar sync, event creation, and database overlays.", systemImage: "calendar.badge.plus") + .font(.system(size: 13)) + } + } + + SettingsSection("Database Overlays") { + VStack(alignment: .leading, spacing: 8) { + Text("Show database rows with date properties on your calendar. Add overlays from the calendar view's filter menu.") + .font(.caption) + .foregroundStyle(.secondary) + + if overlays.isEmpty { + Text("No overlays configured yet.") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + } else { + ForEach(overlays) { overlay in + HStack { + Circle() + .fill(TagColor.color(for: overlay.color)) + .frame(width: 8, height: 8) + Text("\(overlay.databaseName) — \(overlay.datePropertyName)") + .font(.system(size: 13)) + Spacer() + } + } + } + } + } + } + .onAppear { + if let workspace = appState.workspacePath { + overlays = store.loadOverlays(in: workspace) + } + } + } + + private func signIn() async { + isSigningIn = true + signInError = nil + defer { isSigningIn = false } + + do { + let result = try await GoogleAuthService.signIn(using: appState.settings, scopes: GoogleScopeSet.calendarAndMail) + appState.settings.applyGoogleAuthResult(result) + } catch { + signInError = error.localizedDescription + } + } + + private func disconnect() { + appState.settings.disconnectGoogle() + } + + private var isConnected: Bool { + appState.settings.googleConnected + } + + private var scopeSummary: String { + guard !appState.settings.googleGrantedScopes.isEmpty else { return "Scopes not recorded" } + + let grantedScopes = Set(appState.settings.googleGrantedScopes) + var descriptions: [String] = [] + if grantedScopes.contains(GoogleScopeSet.gmailModify) { + descriptions.append("Gmail read/modify") + } + if grantedScopes.contains(GoogleScopeSet.gmailSend) { + descriptions.append("Gmail send") + } + if grantedScopes.contains(GoogleScopeSet.calendarEvents) { + descriptions.append("Calendar create/edit") + } else if grantedScopes.contains(GoogleScopeSet.calendarReadonly) { + descriptions.append("Calendar read") + } + if grantedScopes.contains(GoogleScopeSet.calendarListReadonly) && !grantedScopes.contains(GoogleScopeSet.calendarEvents) { + descriptions.append("Calendar list") + } + return descriptions.joined(separator: " • ") + } + + private var reconnectMessage: String? { + guard isConnected else { return nil } + + let missingScopes = GoogleScopeSet.calendarAndMail.filter { !appState.settings.googleGrantedScopes.contains($0) } + guard !missingScopes.isEmpty else { return nil } + + if missingScopes.contains(GoogleScopeSet.calendarEvents) { + return "Reconnect Google access to grant calendar event creation." + } + return "Reconnect Google access if Mail or Calendar starts reporting missing scopes." + } +} diff --git a/Sources/Bugbook/Views/Settings/SettingsView.swift b/Sources/Bugbook/Views/Settings/SettingsView.swift index 593a5759..689c74e5 100644 --- a/Sources/Bugbook/Views/Settings/SettingsView.swift +++ b/Sources/Bugbook/Views/Settings/SettingsView.swift @@ -8,7 +8,7 @@ struct SettingsView: View { case "general": return "General" case "appearance": return "Appearance" case "ai": return "AI" - case "calendar": return "Calendar" + case "google", "calendar": return "Google" case "agents": return "Agents" case "search": return "Search" case "shortcuts": return "Shortcuts" @@ -32,8 +32,8 @@ struct SettingsView: View { AppearanceSettingsView(appState: appState) case "ai": AISettingsView(appState: appState) - case "calendar": - CalendarSettingsView(appState: appState) + case "google", "calendar": + GoogleSettingsView(appState: appState) case "agents": AgentsSettingsView(appState: appState) case "search": diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index 595520db..85c58126 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -30,7 +30,7 @@ struct SidebarView: View { ("general", "General", "gearshape"), ("appearance", "Appearance", "paintbrush"), ("ai", "AI", "cpu"), - ("calendar", "Calendar", "calendar"), + ("google", "Google", "person.badge.key"), ("agents", "Agents", "person.2"), ("search", "Search", "magnifyingglass"), ("shortcuts", "Shortcuts", "keyboard"), @@ -247,6 +247,25 @@ struct SidebarView: View { .buttonStyle(.plain) .onHover { hovering in hoveredButton = hovering ? "graph" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openMail, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "envelope") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Mail") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "mail" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "mail" : nil } + Button(action: { invokeAction { NotificationCenter.default.post(name: .openCalendar, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { Image(systemName: "calendar.badge.clock") diff --git a/Tests/BugbookTests/CalendarFeatureTests.swift b/Tests/BugbookTests/CalendarFeatureTests.swift new file mode 100644 index 00000000..da631ac7 --- /dev/null +++ b/Tests/BugbookTests/CalendarFeatureTests.swift @@ -0,0 +1,72 @@ +import Foundation +import XCTest +@testable import Bugbook + +final class CalendarFeatureTests: XCTestCase { + func testGoogleScopeSetCalendarIncludesEventWriteAndCalendarListRead() { + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarEvents)) + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarListReadonly)) + XCTAssertTrue(GoogleScopeSet.calendar.contains(GoogleScopeSet.userEmail)) + XCTAssertFalse(GoogleScopeSet.calendar.contains(GoogleScopeSet.calendarReadonly)) + } + + func testGoogleCalendarEventRequestEncoderBuildsTimedBody() throws { + let startDate = Date(timeIntervalSince1970: 1_700_000_000) + let endDate = startDate.addingTimeInterval(5400) + let draft = CalendarEventDraft( + title: "Roadmap Review", + startDate: startDate, + endDate: endDate, + isAllDay: false, + location: "https://meet.example.com/room", + notes: "Agenda and open questions." + ) + + let data = try GoogleCalendarEventRequestEncoder.requestBody( + for: draft, + timeZone: TimeZone(identifier: "America/Los_Angeles") ?? .current + ) + let json = try decodedJSON(data) + + XCTAssertEqual(json["summary"] as? String, "Roadmap Review") + XCTAssertEqual(json["location"] as? String, "https://meet.example.com/room") + XCTAssertEqual(json["description"] as? String, "Agenda and open questions.") + + let start = try XCTUnwrap(json["start"] as? [String: Any]) + let end = try XCTUnwrap(json["end"] as? [String: Any]) + XCTAssertEqual(start["timeZone"] as? String, "America/Los_Angeles") + XCTAssertEqual(end["timeZone"] as? String, "America/Los_Angeles") + XCTAssertNotNil(start["dateTime"] as? String) + XCTAssertNotNil(end["dateTime"] as? String) + XCTAssertNil(start["date"] as? String) + XCTAssertNil(end["date"] as? String) + } + + func testGoogleCalendarEventRequestEncoderBuildsAllDayBodyWithExclusiveEndDate() throws { + let calendar = Calendar(identifier: .gregorian) + let startDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 2))! + let endDate = calendar.date(from: DateComponents(year: 2026, month: 4, day: 3))! + let draft = CalendarEventDraft( + title: "Offsite", + startDate: startDate, + endDate: endDate, + isAllDay: true, + notes: "Bring notes." + ) + + let data = try GoogleCalendarEventRequestEncoder.requestBody(for: draft) + let json = try decodedJSON(data) + let start = try XCTUnwrap(json["start"] as? [String: Any]) + let end = try XCTUnwrap(json["end"] as? [String: Any]) + + XCTAssertEqual(start["date"] as? String, "2026-04-02") + XCTAssertEqual(end["date"] as? String, "2026-04-04") + XCTAssertNil(start["dateTime"] as? String) + XCTAssertNil(end["dateTime"] as? String) + } + + private func decodedJSON(_ data: Data) throws -> [String: Any] { + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) + } +} diff --git a/Tests/BugbookTests/MailFeatureTests.swift b/Tests/BugbookTests/MailFeatureTests.swift new file mode 100644 index 00000000..ec21a920 --- /dev/null +++ b/Tests/BugbookTests/MailFeatureTests.swift @@ -0,0 +1,265 @@ +import Foundation +import XCTest +@testable import Bugbook + +@MainActor +final class MailFeatureTests: XCTestCase { + func testMailDocumentFactoryProducesMailOpenFile() throws { + let content = PaneContent.mailDocument() + + guard case .document(let openFile) = content else { + XCTFail("Expected a document pane.") + return + } + + XCTAssertEqual(openFile.kind, .mail) + XCTAssertTrue(openFile.isMail) + XCTAssertEqual(openFile.path, "bugbook://mail") + XCTAssertEqual(openFile.displayName, "Mail") + XCTAssertEqual(openFile.icon, "envelope") + XCTAssertFalse(openFile.isEmptyTab) + } + + func testTabKindMailFlags() { + XCTAssertTrue(TabKind.mail.isMail) + XCTAssertFalse(TabKind.mail.isCalendar) + XCTAssertFalse(TabKind.mail.isMeetings) + XCTAssertFalse(TabKind.mail.isDatabase) + } + + func testAppSettingsDecodesLegacyCalendarGoogleFields() throws { + let json = """ + { + "googleClientID": "client-id", + "googleClientSecret": "client-secret", + "googleCalendarRefreshToken": "legacy-refresh", + "googleCalendarAccessToken": "legacy-access", + "googleCalendarTokenExpiry": 1234, + "googleCalendarConnectedEmail": "legacy@example.com" + } + """ + + let settings = try JSONDecoder().decode(AppSettings.self, from: Data(json.utf8)) + + XCTAssertEqual(settings.googleClientID, "client-id") + XCTAssertEqual(settings.googleClientSecret, "client-secret") + XCTAssertEqual(settings.googleRefreshToken, "legacy-refresh") + XCTAssertEqual(settings.googleAccessToken, "legacy-access") + XCTAssertEqual(settings.googleTokenExpiry, 1234) + XCTAssertEqual(settings.googleConnectedEmail, "legacy@example.com") + } + + func testAppSettingsGoogleTokenHelpers() throws { + var settings = AppSettings.default + let result = GoogleOAuthResult( + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSince1970: 1_000), + email: "user@example.com", + grantedScopes: GoogleScopeSet.calendarAndMail + ) + + settings.applyGoogleAuthResult(result) + + XCTAssertTrue(settings.googleConfigured == false) + XCTAssertTrue(settings.googleConnected) + XCTAssertEqual(settings.googleConnectedEmail, "user@example.com") + XCTAssertEqual(settings.googleGrantedScopes, GoogleScopeSet.calendarAndMail) + + let initialToken = try XCTUnwrap(settings.googleToken) + XCTAssertEqual(initialToken.accessToken, "access-token") + XCTAssertEqual(initialToken.refreshToken, "refresh-token") + XCTAssertEqual(initialToken.expiresAt, Date(timeIntervalSince1970: 1_000)) + + let refreshedToken = GoogleOAuthToken( + accessToken: "new-access", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSince1970: 2_000), + grantedScopes: GoogleScopeSet.mail + ) + settings.updateGoogleToken(refreshedToken) + + let updatedToken = try XCTUnwrap(settings.googleToken) + XCTAssertEqual(updatedToken.accessToken, "new-access") + XCTAssertEqual(updatedToken.expiresAt, Date(timeIntervalSince1970: 2_000)) + XCTAssertEqual(updatedToken.grantedScopes, GoogleScopeSet.mail) + + settings.disconnectGoogle() + XCTAssertFalse(settings.googleConnected) + XCTAssertNil(settings.googleToken) + XCTAssertEqual(settings.googleConnectedEmail, "") + XCTAssertTrue(settings.googleGrantedScopes.isEmpty) + } + + func testAppSettingsStorePersistsSharedGoogleSettings() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = AppSettingsStore(fileURL: directoryURL.appendingPathComponent("app-settings.json")) + var settings = AppSettings.default + settings.googleClientID = "client-id" + settings.googleClientSecret = "client-secret" + settings.googleAccessToken = "access-token" + settings.googleRefreshToken = "refresh-token" + settings.googleTokenExpiry = 9_999 + settings.googleConnectedEmail = "user@example.com" + settings.googleGrantedScopes = GoogleScopeSet.calendarAndMail + + store.save(settings) + let loaded = store.load() + + XCTAssertEqual(loaded, settings) + } + + func testMailCacheStoreRoundTripsSnapshot() throws { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = MailCacheStore(directoryURL: directoryURL) + let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 4_242)) + + store.save(snapshot, accountEmail: "Test.User+alias@gmail.com") + let loaded = try XCTUnwrap(store.load(accountEmail: "Test.User+alias@gmail.com")) + + XCTAssertEqual(loaded, snapshot) + } + + func testMailServiceLoadCachedDataClearsPreviousStateWithoutSnapshot() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let service = MailService(cacheStore: MailCacheStore(directoryURL: directoryURL)) + let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 200)) + service.mailboxThreads = snapshot.mailboxThreads + service.threadDetails = snapshot.threadDetails + service.selectedThreadID = "thread-1" + service.searchState = MailSearchState(query: "old") + service.searchResults = snapshot.mailboxThreads[.inbox] ?? [] + service.lastSyncDate = snapshot.savedAt + + service.loadCachedData(accountEmail: "new-account@example.com") + + XCTAssertTrue(service.mailboxThreads.isEmpty) + XCTAssertTrue(service.threadDetails.isEmpty) + XCTAssertEqual(service.searchState, MailSearchState()) + XCTAssertTrue(service.searchResults.isEmpty) + XCTAssertNil(service.selectedThreadID) + XCTAssertNil(service.lastSyncDate) + } + + func testMailComposerEncoderBuildsBase64URLReplyPayload() throws { + let draft = MailDraft( + mode: .replyAll, + to: "alice@example.com", + cc: "bob@example.com", + bcc: "carol@example.com", + subject: "Re: Project", + body: "Thanks for the update.", + threadId: "thread-1", + replyToMessageID: "", + referencesHeader: " " + ) + + let encoded = MailComposerEncoder.buildRawMessage(draft: draft, connectedEmail: "me@example.com") + let decoded = try XCTUnwrap(decodeBase64URL(encoded)) + + XCTAssertFalse(encoded.contains("+")) + XCTAssertFalse(encoded.contains("/")) + XCTAssertFalse(encoded.contains("=")) + XCTAssertTrue(decoded.contains("From: me@example.com\r\n")) + XCTAssertTrue(decoded.contains("To: alice@example.com\r\n")) + XCTAssertTrue(decoded.contains("Cc: bob@example.com\r\n")) + XCTAssertTrue(decoded.contains("Bcc: carol@example.com\r\n")) + XCTAssertTrue(decoded.contains("In-Reply-To: \r\n")) + XCTAssertTrue(decoded.contains("References: \r\n")) + XCTAssertTrue(decoded.hasSuffix("\r\n\r\nThanks for the update.")) + } + + func testMailThreadLabelReducerArchiveAndUnreadMutations() { + let initialLabels = ["INBOX", "STARRED", "UNREAD"] + + let archived = MailThreadLabelReducer.mutatedLabels(initialLabels, action: .archive) + XCTAssertFalse(archived.contains("INBOX")) + XCTAssertTrue(archived.contains("STARRED")) + XCTAssertTrue(archived.contains("UNREAD")) + XCTAssertFalse(MailThreadLabelReducer.mailbox(.inbox, contains: archived)) + XCTAssertTrue(MailThreadLabelReducer.mailbox(.starred, contains: archived)) + + let markedRead = MailThreadLabelReducer.mutatedLabels(archived, action: .setUnread(false)) + XCTAssertFalse(markedRead.contains("UNREAD")) + + let trashed = MailThreadLabelReducer.mutatedLabels(markedRead, action: .trash) + XCTAssertTrue(trashed.contains("TRASH")) + XCTAssertTrue(MailThreadLabelReducer.mailbox(.trash, contains: trashed)) + + let restored = MailThreadLabelReducer.mutatedLabels(trashed, action: .untrash) + XCTAssertTrue(restored.contains("INBOX")) + XCTAssertFalse(restored.contains("TRASH")) + } + + private func sampleSnapshot(savedAt: Date) -> MailCacheSnapshot { + let sender = MailMessageRecipient(name: "Alice", email: "alice@example.com") + let message = MailMessage( + id: "message-1", + threadId: "thread-1", + subject: "Hello", + snippet: "Test snippet", + labelIds: ["INBOX", "UNREAD"], + from: sender, + to: [MailMessageRecipient(name: "Me", email: "me@example.com")], + cc: [], + bcc: [], + date: Date(timeIntervalSince1970: 100), + plainBody: "Hello world", + htmlBody: nil, + messageIDHeader: "", + referencesHeader: nil + ) + + let detail = MailThreadDetail( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + messages: [message], + labelIds: ["INBOX", "UNREAD"], + historyId: "history-1" + ) + + let summary = MailThreadSummary( + id: "thread-1", + mailbox: .inbox, + subject: "Hello", + snippet: "Test snippet", + participants: [sender.displayName], + date: Date(timeIntervalSince1970: 100), + messageCount: 1, + labelIds: ["INBOX", "UNREAD"] + ) + + return MailCacheSnapshot( + mailboxThreads: [.inbox: [summary]], + threadDetails: ["thread-1": detail], + savedAt: savedAt + ) + } + + private func temporaryDirectory() -> URL { + let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } + + private func decodeBase64URL(_ value: String) -> String? { + var normalized = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + if padding > 0 { + normalized += String(repeating: "=", count: 4 - padding) + } + guard let data = Data(base64Encoded: normalized) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 6df8b3fc..ec5ae78e 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -15,26 +15,25 @@ 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */; }; 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */; }; 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */; }; - 0FA1B2C3D4E5F60718293A4B /* FormulaEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */; }; 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */; }; - A1B2C3D4E5F60718293A4B5C /* MentionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */; }; - 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */; }; + 12F9C2F7025065DBF7872E5F /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5B26920E522337E8BD55A4 /* MailModels.swift */; }; 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4358B9082C37BE1854810696 /* PaneContentView.swift */; }; 1364B73C31B34B4A59420743 /* Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47EF03A6E9A932800D5A95E /* Row.swift */; }; 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */; }; - A1B2C3D4E5F60718A9B0C1D2 /* GatewayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */; }; - A1B2C3D4E5F60718A9B0C1D3 /* GatewayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */; }; 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2262869710B320C9D232D7B /* CalendarEventStore.swift */; }; 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED677E977CBBB33D0ECA74CE /* GraphView.swift */; }; + 1D7E279DFDE3F1EF429518E0 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C225B558AE138387C4A5A434 /* Metal.framework */; }; 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */; }; 203CE38A21215FE7D2ED90C2 /* FloatingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */; }; 208879A6F607FAA57195F686 /* ShellZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */; }; 209D01AA836D71692841F67F /* BlockEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D38DE480A02B3136213525 /* BlockEditorView.swift */; }; + 215DC599267BF8F4A44B11D2 /* MentionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4ED842193C600650451387 /* MentionPickerView.swift */; }; 240EB5675B13CEE875BD0E72 /* Query.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7530043DEEF28753C2CD6C /* Query.swift */; }; 264D0B95D6702BC01DDA5B34 /* SidebarReferenceDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */; }; 26885DC83160B601DCBB61BB /* DatabaseInlineEmbedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF7FE7EFE7F26CAA875F2754 /* DatabaseInlineEmbedView.swift */; }; 26EDC26A3C6E5D61E5852773 /* BlockDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */; }; 279A6420F93CE53298E2AA56 /* SidebarDragPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */; }; + 28B792819ED4E4EEEE5F64E2 /* GoogleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */; }; 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D74F02ED2A5522822B783A /* GripDotsView.swift */; }; 290D6457E202795F776249B1 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E66496064A29930C3F3B17 /* AppSettings.swift */; }; 2AD1B1200C4938F37EFF82CA /* ViewModePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */; }; @@ -43,6 +42,7 @@ 31319D254BD21B3105E098D8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF3076E2CD78CCA495DCDCB /* Logger.swift */; }; 316D6517CC93BFFDDAC5F7A6 /* MutationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */; }; 349CC754CCA1FEBE8A961431 /* Color+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DF0CADED8EC505FAAC6252 /* Color+Theme.swift */; }; + 3587299AB024CEF71130E759 /* SkillDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33892ABFB220B57504C88B44 /* SkillDetailView.swift */; }; 38F21365F5B64137505F3CFB /* DatabasePointerCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */; }; 391868D020C69C0C614E7B8B /* PaneNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1B04557D2E66868F56829B /* PaneNode.swift */; }; 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */; }; @@ -55,13 +55,16 @@ 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */; }; 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */; }; 4F1134DFB5BF17CC4D9E671A /* ColumnBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780A13E34CC19B07D99EFF46 /* ColumnBlockView.swift */; }; + 50052B736C1AF0B92EDD87DD /* GoogleAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */; }; 5071CA6077BA5B0723C3AFE1 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9365350F95628047D144D9F7 /* FormattingToolbar.swift */; }; 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */; }; 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2421DB7DD227294C11A1041 /* KanbanView.swift */; }; + 51E63A339AE5D85FDAFF3C51 /* FormulaEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D402516F42FFD4940DB38 /* FormulaEngine.swift */; }; 5475FA131B72603118B5EE61 /* BugbookApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47A85AD47A48232C52EB55B /* BugbookApp.swift */; }; 55300AEF3CA2B3AABA0FF66D /* DatabaseZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */; }; 55AED705FAC0BD8F729640FD /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 189FFD8CFB64645025DB5876 /* FileEntry.swift */; }; 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */; }; + 57F11FACF4E5C3025AE96A31 /* IOSurface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 977F61568113A500928E2313 /* IOSurface.framework */; }; 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */; }; 5AC81CC4AEA7FE4D4424DEE4 /* RowSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */; }; 60473B1C3084B649F5458D8D /* Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398B466A4F5F82ACEA4874F6 /* Block.swift */; }; @@ -78,7 +81,6 @@ 736679A853BAC31DAE966F2D /* WorkspaceWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */; }; 73733159AA52E171C502A90A /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D16799A267ED55B558F74BD /* Agent.swift */; }; 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */; }; - E290413799A28A17CC50C957 /* SkillDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9376FE426E025E11737D6737 /* SkillDetailView.swift */; }; 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FDBEE7FFDAA06C8410D140 /* DatabaseDateValue.swift */; }; 76139D5A678EE0329AD1280B /* Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D399795586ADE5448AB72D1C /* Schema.swift */; }; 76E3F554517581E83297A297 /* TrashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33262B570A72E09F2E88ECF2 /* TrashView.swift */; }; @@ -95,10 +97,14 @@ 7EF33F80D6BB6B2F0A6CBD74 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF7A98A5ECBDDCDEA159DDBE /* Assets.xcassets */; }; 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71023CB4011C219BC7514109 /* InlineStyle.swift */; }; 80083188D4B8A228121759CD /* DatabaseEmbedPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */; }; + 8121BFA67D4FBF6DE190E808 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6ADD216C50E87964622BD9C8 /* Carbon.framework */; }; 84F54BA37F46B7DD22EC20D9 /* TextBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E33F7472EFFA78D901306D /* TextBlockView.swift */; }; 85B4001C6EF6E27F2017560E /* BugbookUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */; }; 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */; }; + 8666FC986F5D189ADE7D49AE /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */; }; 868E88F7B10FE2B5E3928632 /* BlockColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */; }; + 87C881CF884C042ACBB9275D /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */; }; + 89894179425F2C21F8DBAA2A /* MailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D326464530C4745129644F6 /* MailService.swift */; }; 8C96674A0DB8992FC7B22444 /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */; }; 8EC461617F98B3FDD8069B3E /* DatabaseRowNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */; }; 90DDA2F7FC61E7CCE3304A2A /* FileTreeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */; }; @@ -118,6 +124,7 @@ AB8B8319C66ACD0F141B3EEE /* BlockViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9D73232AA7B54B9A6C8C58 /* BlockViews.swift */; }; ABA26E21B59748870EB509CE /* AgentWorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */; }; ACFC9D5DD251C31F2B07021B /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 337D684DD11298A704DC254F /* TerminalSession.swift */; }; + AE2E7BA3FF90E319B3E4E820 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23E7105242180B0C6CA782A1 /* WebKit.framework */; }; AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99E3355E0062163300B5893F /* BugbookCore.framework */; }; B62BD30B7E98CA7476B89377 /* WorkspaceCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */; }; B77203F53F96C299D954EC1F /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */; }; @@ -125,9 +132,9 @@ B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */; }; BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */; }; BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7150DDE83E7ED425899B1 /* TableView.swift */; }; - A54F4BE359B93B383EA9A932 /* ThreadPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */; }; BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6639981D84F86643DBB91CD /* AISettingsView.swift */; }; BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */; }; + C00B25FE68F8B8172E1A00E7 /* MailPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B03D29C957B51154565736 /* MailPaneView.swift */; }; C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */; }; C3D3D68199CF617D60D3A773 /* BlockTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE400CA9718CD5697976941B /* BlockTextView.swift */; }; C5D349DF0EBC35FC62C43471 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8535862722D5936666055431 /* Workspace.swift */; }; @@ -142,14 +149,15 @@ D18BF53C15726EC0D2EC10B6 /* WorkspaceTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2E691CB7B90DC79363BC7B /* WorkspaceTabBar.swift */; }; D1C4FAB8A0DA90CAD2C9409B /* DatabaseRowModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7E110D3A8E8C645366B20E /* DatabaseRowModalView.swift */; }; D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */; }; + D2B9ECA5C7304C1A09E2DF36 /* ThreadPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */; }; D33327707F2E0FFCE3EDFB12 /* SchemaValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27404EBA915A3E0AEE0D7483 /* SchemaValidator.swift */; }; D3925CE4995D456C8362FD41 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7712E15B025309AF9EAF67D /* WelcomeView.swift */; }; D5404002070C02A7C49FAD38 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1036E491194645E064E4D08B /* SidebarView.swift */; }; D5A7B7D7582EC21C39092E60 /* DatabaseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */; }; D76DEEE7E4AF655BCC15E804 /* RenderLoopDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */; }; D809F8300B119795D5F00A8B /* AiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68748E44202E7C8D638C24B /* AiService.swift */; }; + D9AC923C602E5B918B9171D6 /* GatewayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */; }; DA7A9136A777D4A1E7E649A8 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37262953A2E00C21609C6836 /* SearchSettingsView.swift */; }; - DB652669C66A472349133778 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */; }; DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */; }; DC399F897D16862221AE55FF /* RowPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89D0872A52B79E2B456963C /* RowPageView.swift */; }; DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */; }; @@ -159,16 +167,20 @@ E45345FAE65412E3DFFB0272 /* DatabaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */; }; E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */; }; E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */; }; + E7CF6114C033056C7B567615 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */; }; E89317C6440E0357A8F3D179 /* BlockCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */; }; EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */; }; EB44EC9D598AA910AE80CE79 /* PaneFocusIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */; }; + EEEDB744704B49619ADEF9FF /* GatewayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E1991E13BFF7530974AEF84 /* GatewayView.swift */; }; EF9C6DDF179DD89F6FADB062 /* PropertyEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFF32AEDA72E376BBC436395 /* PropertyEditorView.swift */; }; F1D183B0EC41EE0853B0913F /* DatabaseTemplateEditorModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C636CE45E2D5E20C53C703 /* DatabaseTemplateEditorModal.swift */; }; + F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AC347581598E6C53507F712 /* CoreGraphics.framework */; }; F6196917751CF468444068C8 /* RowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC779B217122291C509E482 /* RowStore.swift */; }; F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B553FF577D3D25B20DA5DAC5 /* FormattingToolbarPanel.swift */; }; F65C1F27B0D5F2963A1BAAB7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6265630F0923203C1F9764 /* ContentView.swift */; }; F7250EE969D2D3094CD9172E /* RelationResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */; }; F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B384938321E8648F5447AE4D /* PageIcon.swift */; }; + FFCF132F09098A60325158E6 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 476358F19D676EC12E2AC59A /* CoreText.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -206,6 +218,7 @@ 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingToggleBlockView.swift; sourceTree = ""; }; 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarMonthView.swift; sourceTree = ""; }; 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingRecordingPill.swift; sourceTree = ""; }; + 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayViewModel.swift; sourceTree = ""; }; 0F534031257A5C2CBDA600E6 /* OpenFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenFile.swift; sourceTree = ""; }; 1036E491194645E064E4D08B /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatePickerView.swift; sourceTree = ""; }; @@ -213,13 +226,13 @@ 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubViewModel.swift; sourceTree = ""; }; 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiThreadStore.swift; sourceTree = ""; }; 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsView.swift; sourceTree = ""; }; - D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayView.swift; sourceTree = ""; }; - D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayViewModel.swift; sourceTree = ""; }; 189FFD8CFB64645025DB5876 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; + 18B03D29C957B51154565736 /* MailPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailPaneView.swift; sourceTree = ""; }; 1A49740409C913AF7DE543D6 /* QueryEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngine.swift; sourceTree = ""; }; 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeaderView.swift; sourceTree = ""; }; 21D7150DDE83E7ED425899B1 /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; 22439903C988ACDC2263371A /* OnboardingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingService.swift; sourceTree = ""; }; + 23E7105242180B0C6CA782A1 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 247B9DED846DAF0568FF163E /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = ""; }; 27404EBA915A3E0AEE0D7483 /* SchemaValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaValidator.swift; sourceTree = ""; }; @@ -229,12 +242,14 @@ 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringConverter.swift; sourceTree = ""; }; 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceWatcher.swift; sourceTree = ""; }; 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockColor.swift; sourceTree = ""; }; + 2D326464530C4745129644F6 /* MailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailService.swift; sourceTree = ""; }; 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDocument.swift; sourceTree = ""; }; 2F67AB5BB131FE8C947973D2 /* DatabaseViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseViewState.swift; sourceTree = ""; }; 31C778C966E4AA3C20B1A3EC /* PaneContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneContent.swift; sourceTree = ""; }; 33262B570A72E09F2E88ECF2 /* TrashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashView.swift; sourceTree = ""; }; 337D684DD11298A704DC254F /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = ""; }; + 33892ABFB220B57504C88B44 /* SkillDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillDetailView.swift; sourceTree = ""; }; 37262953A2E00C21609C6836 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingBlockView.swift; sourceTree = ""; }; 398B466A4F5F82ACEA4874F6 /* Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Block.swift; sourceTree = ""; }; @@ -243,13 +258,18 @@ 3B66941CAB290DD322DB303C /* WorkspaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManager.swift; sourceTree = ""; }; 3CF5BCA96AF59718B60774D2 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationResolver.swift; sourceTree = ""; }; + 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthService.swift; sourceTree = ""; }; 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationEngine.swift; sourceTree = ""; }; 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModePickerButton.swift; sourceTree = ""; }; 4358B9082C37BE1854810696 /* PaneContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneContentView.swift; sourceTree = ""; }; + 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerRow.swift; sourceTree = ""; }; + 476358F19D676EC12E2AC59A /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkspaceStore.swift; sourceTree = ""; }; 4A3E7869C11972E917693DA0 /* WorkspaceKnowledgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceKnowledgeService.swift; sourceTree = ""; }; + 4AC347581598E6C53507F712 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesChatView.swift; sourceTree = ""; }; 4CF3076E2CD78CCA495DCDCB /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 4E4ED842193C600650451387 /* MentionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionPickerView.swift; sourceTree = ""; }; 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowNavigation.swift; sourceTree = ""; }; 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseZoomMetrics.swift; sourceTree = ""; }; 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; @@ -259,12 +279,13 @@ 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingKnowledgeView.swift; sourceTree = ""; }; 5C6265630F0923203C1F9764 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D16799A267ED55B558F74BD /* Agent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; + 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = ""; }; 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregationEngine.swift; sourceTree = ""; }; - A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormulaEngine.swift; sourceTree = ""; }; 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineRowPeekPanel.swift; sourceTree = ""; }; 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; 661BA33DC8D61D2884B32072 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookUITests.swift; sourceTree = ""; }; + 6ADD216C50E87964622BD9C8 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesEditor.swift; sourceTree = ""; }; 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFullPageView.swift; sourceTree = ""; }; @@ -289,21 +310,25 @@ 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragPreview.swift; sourceTree = ""; }; 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneFocusIndicator.swift; sourceTree = ""; }; 8535862722D5936666055431 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../Frameworks/GhosttyKit.xcframework; sourceTree = ""; }; 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPeekState.swift; sourceTree = ""; }; 8754B7A6F668B3311F152557 /* TerminalPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneView.swift; sourceTree = ""; }; - 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarWeekView.swift; sourceTree = ""; }; 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = ""; }; 9365350F95628047D144D9F7 /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; 968A3076C851ABC04C18EC51 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + 977F61568113A500928E2313 /* IOSurface.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOSurface.framework; path = System/Library/Frameworks/IOSurface.framework; sourceTree = SDKROOT; }; 99E3355E0062163300B5893F /* BugbookCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BugbookCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9E1991E13BFF7530974AEF84 /* GatewayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayView.swift; sourceTree = ""; }; 9E2E691CB7B90DC79363BC7B /* WorkspaceTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabBar.swift; sourceTree = ""; }; + 9E5B26920E522337E8BD55A4 /* MailModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailModels.swift; sourceTree = ""; }; 9FC779B217122291C509E482 /* RowStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowStore.swift; sourceTree = ""; }; A2C165B4FFDB916884D4293C /* FileTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeView.swift; sourceTree = ""; }; A2E6CAEA3C923DDA24C7C36A /* ToggleBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleBlockView.swift; sourceTree = ""; }; A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; A52CFB20317770DA9DB4D732 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseService.swift; sourceTree = ""; }; + A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A7712E15B025309AF9EAF67D /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePointerCursor.swift; sourceTree = ""; }; AE400CA9718CD5697976941B /* BlockTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockTextView.swift; sourceTree = ""; }; @@ -318,6 +343,8 @@ B6639981D84F86643DBB91CD /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = ""; }; BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseStore.swift; sourceTree = ""; }; BF65B82523BECFB622E38CCA /* BreadcrumbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbView.swift; sourceTree = ""; }; + C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSettingsView.swift; sourceTree = ""; }; + C225B558AE138387C4A5A434 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; C2262869710B320C9D232D7B /* CalendarEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventStore.swift; sourceTree = ""; }; C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNoteService.swift; sourceTree = ""; }; C68748E44202E7C8D638C24B /* AiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiService.swift; sourceTree = ""; }; @@ -333,11 +360,10 @@ D3F977D14DD3400DC5FF3583 /* BugbookUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BugbookUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5E1AF8805EE73E377D8DF0E /* TableBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableBlockView.swift; sourceTree = ""; }; D914C317DA10399836E4DECC /* DatabaseTemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplatePickerView.swift; sourceTree = ""; }; + DB9D402516F42FFD4940DB38 /* FormulaEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormulaEngine.swift; sourceTree = ""; }; DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayView.swift; sourceTree = ""; }; DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiSidePanelView.swift; sourceTree = ""; }; - 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerRow.swift; sourceTree = ""; }; E071A97E9E6B0892FA767898 /* BacklinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BacklinkService.swift; sourceTree = ""; }; - D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionPickerView.swift; sourceTree = ""; }; E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashCommandMenu.swift; sourceTree = ""; }; E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemService.swift; sourceTree = ""; }; E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovePagePickerView.swift; sourceTree = ""; }; @@ -348,7 +374,6 @@ EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarReferenceDragPayload.swift; sourceTree = ""; }; EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPopover.swift; sourceTree = ""; }; EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubView.swift; sourceTree = ""; }; - 9376FE426E025E11737D6737 /* SkillDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillDetailView.swift; sourceTree = ""; }; ED677E977CBBB33D0ECA74CE /* GraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphView.swift; sourceTree = ""; }; ED7530043DEEF28753C2CD6C /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = ""; }; EFF0F24D7EAF03362E0B0441 /* QmdService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QmdService.swift; sourceTree = ""; }; @@ -373,13 +398,28 @@ AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */, DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */, 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */, - DB652669C66A472349133778 /* SwiftTerm in Frameworks */, + 8666FC986F5D189ADE7D49AE /* GhosttyKit.xcframework in Frameworks */, + 8121BFA67D4FBF6DE190E808 /* Carbon.framework in Frameworks */, + 1D7E279DFDE3F1EF429518E0 /* Metal.framework in Frameworks */, + F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */, + FFCF132F09098A60325158E6 /* CoreText.framework in Frameworks */, + E7CF6114C033056C7B567615 /* QuartzCore.framework in Frameworks */, + 57F11FACF4E5C3025AE96A31 /* IOSurface.framework in Frameworks */, + AE2E7BA3FF90E319B3E4E820 /* WebKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 05582B553CC59B536CDD007D /* Gateway */ = { + isa = PBXGroup; + children = ( + 9E1991E13BFF7530974AEF84 /* GatewayView.swift */, + ); + path = Gateway; + sourceTree = ""; + }; 0E0596AE897FCCE0A018DAA8 /* Bugbook */ = { isa = PBXGroup; children = ( @@ -409,6 +449,7 @@ 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */, 189FFD8CFB64645025DB5876 /* FileEntry.swift */, 71023CB4011C219BC7514109 /* InlineStyle.swift */, + 9E5B26920E522337E8BD55A4 /* MailModels.swift */, 0F534031257A5C2CBDA600E6 /* OpenFile.swift */, B384938321E8648F5447AE4D /* PageIcon.swift */, 31C778C966E4AA3C20B1A3EC /* PaneContent.swift */, @@ -462,9 +503,9 @@ 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */, 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */, 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */, + 4E4ED842193C600650451387 /* MentionPickerView.swift */, FB1D2F73F8536CB22D1959D7 /* OutlineBlockView.swift */, 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */, - D6E7F8091A2B3C4D5E6F7081 /* MentionPickerView.swift */, F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */, E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */, D5E1AF8805EE73E377D8DF0E /* TableBlockView.swift */, @@ -480,7 +521,7 @@ children = ( DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */, 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */, - 61331AF78FDF197552FCAC84 /* ThreadPickerRow.swift */, + 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */, ); path = AI; sourceTree = ""; @@ -489,7 +530,7 @@ isa = PBXGroup; children = ( EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */, - 9376FE426E025E11737D6737 /* SkillDetailView.swift */, + 33892ABFB220B57504C88B44 /* SkillDetailView.swift */, ); path = Agent; sourceTree = ""; @@ -530,8 +571,8 @@ 57BA5285DC6913D9E172BA99 /* AgentsSettingsView.swift */, B6639981D84F86643DBB91CD /* AISettingsView.swift */, C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */, - 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */, C72383D5865CC78156A21463 /* GeneralSettingsView.swift */, + C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */, 37262953A2E00C21609C6836 /* SearchSettingsView.swift */, 661BA33DC8D61D2884B32072 /* SettingsView.swift */, 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */, @@ -566,8 +607,9 @@ 3586EF5D600822B9681D1866 /* Components */, B16E9F553D2EDB723B3CDFF8 /* Database */, 2579331778CC5EC6DB286690 /* Editor */, + 05582B553CC59B536CDD007D /* Gateway */, C575E14DD4F6A30BEA81C1B4 /* Graph */, - D1E2F3A4B5C6071829A0B1C4 /* Gateway */, + 9CE5FB4F5FE35247A228ACC7 /* Mail */, F3EE2A2FCDD080B2CD99613C /* Meetings */, D6B6A42068B2184566F971A5 /* Panes */, 3B7E87FA2AD5CC2E60AC7EF4 /* Settings */, @@ -593,7 +635,7 @@ 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */, 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */, 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */, - D1E2F3A4B5C6071829A0B1C3 /* GatewayViewModel.swift */, + 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */, 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */, 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */, ); @@ -604,7 +646,7 @@ isa = PBXGroup; children = ( 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */, - A1B2C3D4E5F6071829304A5B /* FormulaEngine.swift */, + DB9D402516F42FFD4940DB38 /* FormulaEngine.swift */, 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */, 1A49740409C913AF7DE543D6 /* QueryEngine.swift */, 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */, @@ -618,12 +660,15 @@ children = ( C68748E44202E7C8D638C24B /* AiService.swift */, 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */, + 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */, E071A97E9E6B0892FA767898 /* BacklinkService.swift */, 59394152B4415173A37EE498 /* CalendarService.swift */, A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */, 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */, E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */, + 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */, 4CF3076E2CD78CCA495DCDCB /* Logger.swift */, + 2D326464530C4745129644F6 /* MailService.swift */, C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */, 22439903C988ACDC2263371A /* OnboardingService.swift */, EFF0F24D7EAF03362E0B0441 /* QmdService.swift */, @@ -646,6 +691,14 @@ path = App; sourceTree = ""; }; + 9CE5FB4F5FE35247A228ACC7 /* Mail */ = { + isa = PBXGroup; + children = ( + 18B03D29C957B51154565736 /* MailPaneView.swift */, + ); + path = Mail; + sourceTree = ""; + }; 9FAA7A86768FCC279708E6D2 /* Sidebar */ = { isa = PBXGroup; children = ( @@ -731,10 +784,26 @@ 0E0596AE897FCCE0A018DAA8 /* Bugbook */, B9ACAF37F4AD4DE19AB6BDF4 /* BugbookCore */, E790AFC40FB904B5B41C0C85 /* BugbookUITests */, + CACAA46E8989ABA8D0566C04 /* Frameworks */, D0BD45773DE8E09C582B07E9 /* Products */, ); sourceTree = ""; }; + CACAA46E8989ABA8D0566C04 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6ADD216C50E87964622BD9C8 /* Carbon.framework */, + 4AC347581598E6C53507F712 /* CoreGraphics.framework */, + 476358F19D676EC12E2AC59A /* CoreText.framework */, + 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */, + 977F61568113A500928E2313 /* IOSurface.framework */, + C225B558AE138387C4A5A434 /* Metal.framework */, + A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */, + 23E7105242180B0C6CA782A1 /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; D0BD45773DE8E09C582B07E9 /* Products */ = { isa = PBXGroup; children = ( @@ -781,14 +850,6 @@ path = Meetings; sourceTree = ""; }; - D1E2F3A4B5C6071829A0B1C4 /* Gateway */ = { - isa = PBXGroup; - children = ( - D1E2F3A4B5C6071829A0B1C2 /* GatewayView.swift */, - ); - path = Gateway; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -828,7 +889,6 @@ packageProductDependencies = ( 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */, 093BE9A2C3A2769A90DE0579 /* Sentry */, - 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */, ); productName = BugbookApp; productReference = C7935F81E8213A92201CFAA6 /* BugbookApp.app */; @@ -881,7 +941,6 @@ packageReferences = ( CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */, - D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */, ); preferredProjectObjectVersion = 77; projectDirPath = ""; @@ -922,11 +981,11 @@ ABA26E21B59748870EB509CE /* AgentWorkspaceStore.swift in Sources */, 694EBF21623BCEA74940150D /* AgentWorkspaceTemplate.swift in Sources */, 0F9D9E5E8F389B93AE0D3A98 /* AggregationEngine.swift in Sources */, - 0FA1B2C3D4E5F60718293A4B /* FormulaEngine.swift in Sources */, BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */, 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */, 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */, D5A7B7D7582EC21C39092E60 /* DatabaseStore.swift in Sources */, + 51E63A339AE5D85FDAFF3C51 /* FormulaEngine.swift in Sources */, 9E7E6AC9222DBAAC08FAD6BF /* IndexManager.swift in Sources */, 316D6517CC93BFFDDAC5F7A6 /* MutationEngine.swift in Sources */, 240EB5675B13CEE875BD0E72 /* Query.swift in Sources */, @@ -950,13 +1009,12 @@ 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */, 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */, 0044A6D4562A8D96730E1E8E /* AgentsSettingsView.swift in Sources */, - E290413799A28A17CC50C957 /* SkillDetailView.swift in Sources */, - A54F4BE359B93B383EA9A932 /* ThreadPickerRow.swift in Sources */, C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */, D809F8300B119795D5F00A8B /* AiService.swift in Sources */, 7ED64068786FC4125769B869 /* AiSidePanelView.swift in Sources */, D152B548059890320AA5F1F5 /* AiThreadStore.swift in Sources */, 290D6457E202795F776249B1 /* AppSettings.swift in Sources */, + 87C881CF884C042ACBB9275D /* AppSettingsStore.swift in Sources */, 7C8D7191B8B1FF31105C4608 /* AppState.swift in Sources */, EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */, 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */, @@ -975,7 +1033,6 @@ C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */, 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */, CBB52CB1DEEB63DEF5B95C42 /* CalendarService.swift in Sources */, - 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */, 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */, 79B94AC24F342F11305894B8 /* CalendarViewModel.swift in Sources */, 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */, @@ -1013,7 +1070,11 @@ 5071CA6077BA5B0723C3AFE1 /* FormattingToolbar.swift in Sources */, F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */, E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */, + EEEDB744704B49619ADEF9FF /* GatewayView.swift in Sources */, + D9AC923C602E5B918B9171D6 /* GatewayViewModel.swift in Sources */, 045B1A2FD850725900D6FC22 /* GeneralSettingsView.swift in Sources */, + 50052B736C1AF0B92EDD87DD /* GoogleAuthService.swift in Sources */, + 28B792819ED4E4EEEE5F64E2 /* GoogleSettingsView.swift in Sources */, 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */, 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */, 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */, @@ -1022,16 +1083,18 @@ 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */, 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */, 31319D254BD21B3105E098D8 /* Logger.swift in Sources */, + 12F9C2F7025065DBF7872E5F /* MailModels.swift in Sources */, + C00B25FE68F8B8172E1A00E7 /* MailPaneView.swift in Sources */, + 89894179425F2C21F8DBAA2A /* MailService.swift in Sources */, 3D9B4F3DBA52CA2B67CFBBB8 /* MarkdownBlockParser.swift in Sources */, BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */, 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */, 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */, 7EDA4964EE97D79FFE09EF59 /* MeetingNoteService.swift in Sources */, E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */, - A1B2C3D4E5F60718A9B0C1D2 /* GatewayView.swift in Sources */, - A1B2C3D4E5F60718A9B0C1D3 /* GatewayViewModel.swift in Sources */, 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */, 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */, + 215DC599267BF8F4A44B11D2 /* MentionPickerView.swift in Sources */, D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */, 3AAA59CAFE542617EFFD989E /* NotesChatView.swift in Sources */, CBDA6D4C212A797378E7C062 /* OnboardingService.swift in Sources */, @@ -1039,7 +1102,6 @@ 08B960587DA4D305E70BDF7E /* OutlineBlockView.swift in Sources */, D029F4E3DFFC61628D54BE90 /* PageHeaderView.swift in Sources */, F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */, - A1B2C3D4E5F60718293A4B5C /* MentionPickerView.swift in Sources */, A017A5DD5FB8198FEEBAC1D1 /* PagePickerView.swift in Sources */, 7822807DA20A9A6954D3ABBC /* PaneContent.swift in Sources */, 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */, @@ -1058,6 +1120,7 @@ 45ABE1F981E46B748D9C724A /* SidebarPeekState.swift in Sources */, 264D0B95D6702BC01DDA5B34 /* SidebarReferenceDragPayload.swift in Sources */, D5404002070C02A7C49FAD38 /* SidebarView.swift in Sources */, + 3587299AB024CEF71130E759 /* SkillDetailView.swift in Sources */, 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */, A395087E03AB1A889F4620AE /* SplitDividerView.swift in Sources */, 6AED4644678A23789357E98F /* TableBlockView.swift in Sources */, @@ -1067,6 +1130,7 @@ 7ADBA4F0047957F15F4CDD43 /* TerminalPaneView.swift in Sources */, ACFC9D5DD251C31F2B07021B /* TerminalSession.swift in Sources */, 84F54BA37F46B7DD22EC20D9 /* TextBlockView.swift in Sources */, + D2B9ECA5C7304C1A09E2DF36 /* ThreadPickerRow.swift in Sources */, AA958FA1B1E64E24C676E3B8 /* ToggleBlockView.swift in Sources */, A5C00BCC3313FC7DADD70BFA /* TranscriptionService.swift in Sources */, 76E3F554517581E83297A297 /* TrashView.swift in Sources */, @@ -1214,18 +1278,23 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64"; MACOSX_DEPLOYMENT_TARGET = 14.0; - OTHER_LDFLAGS = "-lghostty-fat"; + OTHER_LDFLAGS = ( + "-lz", + "-lc++", + ); PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; PRODUCT_NAME = Bugbook; SDKROOT = macosx; - SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64/Headers"; }; name = Release; }; @@ -1238,18 +1307,23 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../Frameworks\"", + ); INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64"; MACOSX_DEPLOYMENT_TARGET = 14.0; - OTHER_LDFLAGS = "-lghostty-fat"; + OTHER_LDFLAGS = ( + "-lz", + "-lc++", + ); PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; PRODUCT_NAME = Bugbook; SDKROOT = macosx; - SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/../Frameworks/GhosttyKit.xcframework/macos-arm64/Headers"; }; name = Debug; }; @@ -1410,14 +1484,6 @@ minimumVersion = 8.40.0; }; }; - D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.2.5; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1431,11 +1497,6 @@ package = 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - 99B83A30D46E5E76D5F88FB6 /* SwiftTerm */ = { - isa = XCSwiftPackageProductDependency; - package = D3A14895B9034DEEE3EA1AC4 /* XCRemoteSwiftPackageReference "SwiftTerm" */; - productName = SwiftTerm; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 748DCF6AC60831FC7058F9CD /* Project object */; diff --git a/macos/project.yml b/macos/project.yml index ccfbd42b..ba59e273 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -54,6 +54,7 @@ targets: - sdk: CoreText.framework - sdk: QuartzCore.framework - sdk: IOSurface.framework + - sdk: WebKit.framework info: path: App/Info.plist properties: From 1adaa37ff53663bfd29996c1e19f5f83d5431cef Mon Sep 17 00:00:00 2001 From: max4c Date: Wed, 1 Apr 2026 20:11:08 -0700 Subject: [PATCH 40/41] Ship meeting block: FluidAudio transcription, live transcript UI, AI summary - Switch transcription to FluidAudio (Whisper) with 5s chunked recording via AVAudioEngine + AsrManager; keep SFSpeechRecognizer as #else fallback - Add floating recording pill (always-on-top NSPanel) with animated bars, duration counter, stop button, and tap-to-navigate - Transcript drawer with right-aligned chat bubbles, search, copy-with-checkmark feedback, and scroll-to-bottom on open - AI summary via claude CLI: cleanTranscript + extractStructuredSections, result injected as editable Block children via MarkdownBlockParser.parse - afterStateView: editable title (axis: .vertical), ladybug Generate button, generate guard/disable, summary content in lighter gray vs user notes - Sidebar chat icon updated to bubble.left.and.bubble.right - Simplify pass: extract rmsLevel helper, incremental transcript join (O(1) append vs O(N2) rejoin), timer cleanup on chunk error, parseSections computed once per render, transcriptBottomAnchorID constant, markdownToBlocks uses MarkdownBlockParser Co-Authored-By: Claude Sonnet 4.6 --- .../Services/TranscriptionService.swift | 475 ++++++++++++--- .../Components/FloatingRecordingPill.swift | 15 +- Sources/Bugbook/Views/ContentView.swift | 18 + .../Views/Editor/MeetingBlockView.swift | 565 +++++++----------- .../Bugbook/Views/Sidebar/SidebarView.swift | 86 +-- macos/App/Info.plist | 2 - macos/Bugbook.xcodeproj/project.pbxproj | 18 + macos/project.yml | 5 +- 8 files changed, 718 insertions(+), 466 deletions(-) diff --git a/Sources/Bugbook/Services/TranscriptionService.swift b/Sources/Bugbook/Services/TranscriptionService.swift index 4795acb6..6c6d7b3d 100644 --- a/Sources/Bugbook/Services/TranscriptionService.swift +++ b/Sources/Bugbook/Services/TranscriptionService.swift @@ -2,6 +2,8 @@ import Foundation import AVFoundation #if canImport(FluidAudio) import FluidAudio +#else +import Speech #endif /// A segment of transcribed speech attributed to a speaker. @@ -12,12 +14,137 @@ struct TranscriptSegment: Identifiable { let timestamp: TimeInterval // seconds from start } +#if canImport(FluidAudio) +private final class FluidAudioChunkRecorder { + private let lock = NSLock() + private let fileManager = FileManager.default + + private var sessionDirectoryURL: URL? + private var recordingFormat: AVAudioFormat? + private var currentChunkURL: URL? + private var currentChunkFile: AVAudioFile? + private var currentChunkFrameCount: AVAudioFramePosition = 0 + private var chunkIndex = 0 + + func start(format: AVAudioFormat) throws { + lock.lock() + defer { lock.unlock() } + + resetLocked() + + let sessionDirectoryURL = fileManager.temporaryDirectory + .appendingPathComponent("bugbook-live-transcription-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: sessionDirectoryURL, withIntermediateDirectories: true) + + self.sessionDirectoryURL = sessionDirectoryURL + self.recordingFormat = format + try openNextChunkLocked() + } + + func append(_ buffer: AVAudioPCMBuffer) throws { + lock.lock() + defer { lock.unlock() } + + guard let currentChunkFile else { return } + try currentChunkFile.write(from: buffer) + currentChunkFrameCount += AVAudioFramePosition(buffer.frameLength) + } + + func rotateChunk() throws -> URL? { + lock.lock() + defer { lock.unlock() } + + guard recordingFormat != nil else { return nil } + let completedChunkURL = currentChunkFrameCount > 0 ? currentChunkURL : nil + try openNextChunkLocked() + return completedChunkURL + } + + func finish() -> URL? { + lock.lock() + defer { lock.unlock() } + + let completedChunkURL = currentChunkFrameCount > 0 ? currentChunkURL : nil + currentChunkURL = nil + currentChunkFile = nil + currentChunkFrameCount = 0 + recordingFormat = nil + chunkIndex = 0 + return completedChunkURL + } + + func reset() { + lock.lock() + defer { lock.unlock() } + resetLocked() + } + + func cleanupChunk(at url: URL) { + try? fileManager.removeItem(at: url) + + lock.lock() + defer { lock.unlock() } + + guard let sessionDirectoryURL else { return } + if let contents = try? fileManager.contentsOfDirectory(at: sessionDirectoryURL, includingPropertiesForKeys: nil), + contents.isEmpty { + try? fileManager.removeItem(at: sessionDirectoryURL) + self.sessionDirectoryURL = nil + } + } + + private func openNextChunkLocked() throws { + guard let sessionDirectoryURL, let recordingFormat else { return } + + currentChunkURL = nil + currentChunkFile = nil + currentChunkFrameCount = 0 + + let chunkURL = sessionDirectoryURL.appendingPathComponent("chunk-\(chunkIndex).caf") + chunkIndex += 1 + currentChunkFile = try AVAudioFile( + forWriting: chunkURL, + settings: recordingFormat.settings, + commonFormat: recordingFormat.commonFormat, + interleaved: recordingFormat.isInterleaved + ) + currentChunkURL = chunkURL + } + + private func resetLocked() { + currentChunkURL = nil + currentChunkFile = nil + currentChunkFrameCount = 0 + recordingFormat = nil + chunkIndex = 0 + + if let sessionDirectoryURL { + try? fileManager.removeItem(at: sessionDirectoryURL) + } + sessionDirectoryURL = nil + } +} + +private enum FluidChunkOutcome { + case text(String) + case empty +} +#endif + @MainActor @Observable class TranscriptionService { // MARK: - Live Recording State var currentTranscript: String = "" var confirmedSegments: [String] = [] + + private static func rmsLevel(from buffer: AVAudioPCMBuffer) -> Float { + guard let channelData = buffer.floatChannelData?[0] else { return 0 } + let frameCount = Int(buffer.frameLength) + var sum: Float = 0 + for i in 0..? private static let supportedExtensions: Set = ["m4a", "mp3", "wav", "caf", "aac", "aiff"] + #if canImport(FluidAudio) + private let chunkDuration: TimeInterval = 5 + #endif static func isSupportedAudioFile(_ url: URL) -> Bool { supportedExtensions.contains(url.pathExtension.lowercased()) @@ -49,7 +189,17 @@ class TranscriptionService { } } - // MARK: - Live Recording (FluidAudio / Whisper) + #if !canImport(FluidAudio) + private func requestSpeechPermission() async -> Bool { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + } + #endif + + // MARK: - Live Recording func startRecording() async { guard !isRecording else { return } @@ -60,58 +210,72 @@ class TranscriptionService { return } + #if canImport(FluidAudio) + do { + try await startFluidAudioRecording() + } catch { + self.error = "Failed to start live transcription: \(error.localizedDescription)" + fluidChunkRecorder.reset() + fluidChunkTimer?.invalidate() + fluidChunkTimer = nil + fluidCapturingAudio = false + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + audioLevel = 0 + } + #else + let speechGranted = await requestSpeechPermission() + guard speechGranted else { + error = "Speech recognition permission denied. Enable in System Settings > Privacy > Speech Recognition." + return + } + + guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")), + recognizer.isAvailable else { + error = "Speech recognizer not available." + return + } + error = nil currentTranscript = "" confirmedSegments = [] volatileText = "" audioLevel = 0 - #if canImport(FluidAudio) - // Configure chunk sizes so the total window (left + chunk + right) = 15s = 240000 samples, - // matching the parakeet-tdt-0.6b-v3 CoreML model's fixed input shape (1 x 240000). - // Default config uses chunkSeconds=15 + rightContext=2 = 17s = 272000 samples, which - // exceeds the model limit and causes a shape mismatch error. - let manager = StreamingAsrManager(config: StreamingAsrConfig( - chunkSeconds: 11.0, - hypothesisChunkSeconds: 1.0, - leftContextSeconds: 2.0, - rightContextSeconds: 2.0, - minContextForConfirmation: 3.0, - confirmationThreshold: 0.40 - )) - self.streamingManager = manager + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + self.recognitionRequest = request - do { - try await manager.start(source: .microphone) - } catch { - self.error = "Failed to start speech recognition: \(error.localizedDescription)" - return + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, taskError in + Task { @MainActor [weak self] in + guard let self else { return } + if let result { + let text = result.bestTranscription.formattedString + if result.isFinal { + self.confirmedSegments = [text] + self.currentTranscript = text + self.volatileText = "" + } else { + self.volatileText = text + self.currentTranscript = text + } + } + // Error code 1110 = "no speech detected" — not a real error + if let taskError, (taskError as NSError).code != 1110 { + self.error = taskError.localizedDescription + } + } } - #else - self.error = "Speech recognition unavailable (FluidAudio not linked)" - return - #endif let engine = AVAudioEngine() let inputNode = engine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 4096, format: recordingFormat) { [weak self] buffer, _ in - #if canImport(FluidAudio) - Task { [weak self] in - await self?.streamingManager?.streamAudio(buffer) - } - #endif - - guard let channelData = buffer.floatChannelData?[0] else { return } - let frameCount = Int(buffer.frameLength) - var sum: Float = 0 - for i in 0.. String { guard isRecording else { return currentTranscript } + #if canImport(FluidAudio) + fluidChunkTimer?.invalidate() + fluidChunkTimer = nil + + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + + fluidCapturingAudio = false + audioLevel = 0 + volatileText = "" + + flushFluidChunk(final: true) + completeFluidStopIfPossible() + #else audioEngine?.inputNode.removeTap(onBus: 0) audioEngine?.stop() audioEngine = nil isRecording = false audioLevel = 0 - updateTask?.cancel() - updateTask = nil + recognitionRequest?.endAudio() + recognitionRequest = nil + recognitionTask?.cancel() + recognitionTask = nil - // Include any volatile text that never got confirmed + // Capture any in-progress volatile text if !volatileText.isEmpty { - confirmedSegments.append(volatileText) - currentTranscript = confirmedSegments.joined(separator: " ") + currentTranscript = volatileText + confirmedSegments = [currentTranscript] volatileText = "" } - #if canImport(FluidAudio) - let manager = streamingManager - streamingManager = nil - // Fire-and-forget finalization -- transcript already captured above - Task { - let finalText = try? await manager?.finish() - await MainActor.run { [weak self] in - if let finalText, !finalText.isEmpty { - self?.currentTranscript = finalText - } - } - } #endif return currentTranscript } @@ -229,6 +381,173 @@ class TranscriptionService { } } +#if canImport(FluidAudio) +extension TranscriptionService { + func startFluidAudioRecording() async throws { + _ = try await prepareFluidAsrManager() + + error = nil + currentTranscript = "" + confirmedSegments = [] + volatileText = "" + audioLevel = 0 + + liveRecordingSessionID = UUID() + fluidPendingChunkTranscriptions = 0 + fluidCapturingAudio = true + fluidCompletedChunkOutcomes = [:] + fluidNextChunkIndexToCommit = 0 + fluidNextChunkSequence = 0 + fluidChunkRecorder.reset() + + let engine = AVAudioEngine() + let inputNode = engine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + try fluidChunkRecorder.start(format: recordingFormat) + + let recorder = fluidChunkRecorder + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] buffer, _ in + do { + try recorder.append(buffer) + } catch { + Task { @MainActor [weak self] in + self?.error = "Failed to write live audio chunk: \(error.localizedDescription)" + } + } + + let normalized = TranscriptionService.rmsLevel(from: buffer) + Task { @MainActor [weak self] in + self?.audioLevel = normalized + } + } + + do { + engine.prepare() + try engine.start() + } catch { + inputNode.removeTap(onBus: 0) + fluidChunkRecorder.reset() + throw error + } + + audioEngine = engine + isRecording = true + startFluidChunkTimer() + } + + func prepareFluidAsrManager() async throws -> AsrManager { + if let fluidAsrManager { + return fluidAsrManager + } + + let models = try await AsrModels.downloadAndLoad() + let asrManager = AsrManager(config: .default) + try await asrManager.initialize(models: models) + fluidAsrManager = asrManager + return asrManager + } + + func startFluidChunkTimer() { + fluidChunkTimer?.invalidate() + let timer = Timer.scheduledTimer(withTimeInterval: chunkDuration, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.flushFluidChunk(final: false) + } + } + RunLoop.main.add(timer, forMode: .common) + fluidChunkTimer = timer + } + + func flushFluidChunk(final: Bool) { + let sequence = fluidNextChunkSequence + let chunkURL: URL? + + do { + chunkURL = final ? fluidChunkRecorder.finish() : try fluidChunkRecorder.rotateChunk() + } catch { + self.error = "Failed to finalize live audio chunk: \(error.localizedDescription)" + if final { + isRecording = false + fluidChunkRecorder.reset() + } else { + fluidChunkTimer?.invalidate() + fluidChunkTimer = nil + } + return + } + + guard let chunkURL else { return } + + fluidNextChunkSequence += 1 + fluidPendingChunkTranscriptions += 1 + let sessionID = liveRecordingSessionID + + Task { [weak self] in + guard let self else { return } + + let outcome: FluidChunkOutcome + do { + let text = try await self.transcribeLiveFluidChunk(at: chunkURL) + .trimmingCharacters(in: .whitespacesAndNewlines) + outcome = text.isEmpty ? .empty : .text(text) + } catch { + await MainActor.run { + if self.liveRecordingSessionID == sessionID { + self.error = "Live transcription failed: \(error.localizedDescription)" + } + } + outcome = .empty + } + + await MainActor.run { + self.fluidChunkRecorder.cleanupChunk(at: chunkURL) + + guard self.liveRecordingSessionID == sessionID else { + self.fluidPendingChunkTranscriptions = max(0, self.fluidPendingChunkTranscriptions - 1) + self.completeFluidStopIfPossible() + return + } + + self.fluidCompletedChunkOutcomes[sequence] = outcome + self.commitCompletedFluidChunks() + self.fluidPendingChunkTranscriptions = max(0, self.fluidPendingChunkTranscriptions - 1) + self.completeFluidStopIfPossible() + } + } + } + + func transcribeLiveFluidChunk(at url: URL) async throws -> String { + guard let fluidAsrManager else { + throw TranscriptionError.transcriptionFailed("FluidAudio not initialized") + } + let result = try await fluidAsrManager.transcribe(url, source: .microphone) + return result.text + } + + func commitCompletedFluidChunks() { + while let outcome = fluidCompletedChunkOutcomes.removeValue(forKey: fluidNextChunkIndexToCommit) { + if case .text(let text) = outcome { + confirmedSegments.append(text) + currentTranscript = currentTranscript.isEmpty ? text : currentTranscript + " " + text + } + fluidNextChunkIndexToCommit += 1 + } + volatileText = "" + } + + func completeFluidStopIfPossible() { + guard !fluidCapturingAudio, fluidPendingChunkTranscriptions == 0 else { return } + isRecording = false + fluidCompletedChunkOutcomes.removeAll() + fluidChunkRecorder.reset() + } +} +#else +extension TranscriptionService { + func prepareFluidAsrManager() async throws {} +} +#endif + enum TranscriptionError: LocalizedError { case unsupportedFormat(String) case modelLoadFailed diff --git a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift index f3aef891..be234690 100644 --- a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift +++ b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift @@ -30,13 +30,16 @@ final class FloatingRecordingPillPanel: NSPanel { isMovableByWindowBackground = true hidesOnDeactivate = false contentView = hostingView + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor } - func showPill(startDate: Date, onStop: @escaping () -> Void) { + func showPill(startDate: Date, onStop: @escaping () -> Void, onTap: @escaping () -> Void = {}) { hostingView.rootView = RecordingPillView( isAnimating: true, recordingStart: startDate, - onStop: onStop + onStop: onStop, + onTap: onTap ) // Re-evaluate size and position each show (handles display changes) @@ -64,6 +67,7 @@ private struct RecordingPillView: View { var isAnimating: Bool = true var recordingStart: Date = .now var onStop: (() -> Void)? + var onTap: (() -> Void)? var body: some View { HStack(spacing: 6) { @@ -83,6 +87,7 @@ private struct RecordingPillView: View { .contentShape(Capsule()) .onTapGesture { NSApplication.shared.activate(ignoringOtherApps: true) + onTap?() } if isAnimating { @@ -152,7 +157,7 @@ private struct AudioBar: View { var seed: Int var isAnimating: Bool - private let green = Color(hex: "4ade80") + private let green = Color(hex: "B1D4F9") private let maxHeight: CGFloat = 14 private let minFraction: CGFloat = 0.25 @@ -185,6 +190,8 @@ final class FloatingRecordingPillController { /// Called when the user taps the stop button on the pill. var onStop: (() -> Void)? + /// Called when the user taps the pill body (navigate to meeting block). + var onTap: (() -> Void)? /// Whether recording is active. Set from outside; the controller handles show/hide. var isRecording: Bool = false { @@ -209,6 +216,8 @@ final class FloatingRecordingPillController { } panel?.showPill(startDate: recordingStart ?? .now, onStop: { [weak self] in Task { @MainActor in self?.onStop?() } + }, onTap: { [weak self] in + Task { @MainActor in self?.onTap?() } }) } else { panel?.hidePill() diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 13c6d308..468a51f2 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -108,6 +108,7 @@ struct ContentView: View { applyTheme(appState.settings.theme) editorZoomScale = clampedEditorZoomScale(editorZoomScale) editorUI.focusModeEnabled = appState.settings.focusModeOnType + warmUpTranscriptionModel() } .onChange(of: appState.settings) { _, newSettings in appSettingsStore.save(newSettings) @@ -256,6 +257,9 @@ struct ContentView: View { recordingPillController.onStop = { [weak doc] in doc?.onStopMeeting?(blockId) } + recordingPillController.onTap = { [weak doc] in + doc?.scrollToBlockId = blockId + } } recordingPillController.isRecording = recording } @@ -1370,6 +1374,14 @@ struct ContentView: View { var lastVolatile = "" var lastLevel: Float = -1 while ts.isRecording { + // Stop if the recording block was deleted + if let doc, doc.index(for: blockId) == nil { + _ = ts.stopRecording() + appState?.isRecording = false + appState?.recordingBlockId = nil + break + } + let level = ts.audioLevel if level != lastLevel { lastLevel = level @@ -1908,6 +1920,12 @@ struct ContentView: View { return !isDir.boolValue } + private func warmUpTranscriptionModel() { + Task(priority: .background) { + try? await transcriptionService.prepareFluidAsrManager() + } + } + private func loadAppSettings() { appState.settings = appSettingsStore.load() } diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index b2564026..7bebda00 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -12,18 +12,13 @@ struct MeetingBlockView: View { @State private var isTranscriptOpen = false @State private var transcriptSearch = "" @State private var isSearchingTranscript = false - @State private var isSummaryExpanded = false - @State private var showSummary = true - @State private var isHovered = false - private var hasVoiceActivity: Bool { document.meetingAudioLevel > 0.01 } - @State private var processingStatus = "" @State private var showTranscriptSheet = false + @State private var copiedTranscript = false + @State private var isGenerating = false + @FocusState private var searchFocused: Bool - // Ask anything Q&A state (transient) - @State private var askQuestion = "" - @State private var askPairs: [(question: String, answer: String)] = [] - @State private var isAskLoading = false + private let transcriptBottomAnchorID = "transcript-bottom" init(document: BlockDocument, block: Block) { self.document = document @@ -52,8 +47,8 @@ struct MeetingBlockView: View { RoundedRectangle(cornerRadius: Radius.lg) .strokeBorder(Color.fallbackBorderColor, lineWidth: 1) ) + .frame(maxWidth: .infinity) .contentShape(RoundedRectangle(cornerRadius: Radius.lg)) - .onHover { isHovered = $0 } .padding(.vertical, 4) .overlay { if showTranscriptSheet { @@ -117,6 +112,7 @@ struct MeetingBlockView: View { .background(Color.primary.opacity(Opacity.subtle)) .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) } @@ -125,10 +121,9 @@ struct MeetingBlockView: View { .padding(.bottom, 4) meetingNotesChildBlocks - .frame(minHeight: 80) - .padding(.horizontal, 10) - .padding(.top, 2) - .padding(.bottom, 8) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 12) } } @@ -149,8 +144,6 @@ struct MeetingBlockView: View { Spacer() - ladybugButton - Button(action: stopRecording) { HStack(spacing: 5) { RoundedRectangle(cornerRadius: 2) @@ -164,6 +157,7 @@ struct MeetingBlockView: View { .background(Color.primary.opacity(Opacity.subtle)) .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) } @@ -171,19 +165,17 @@ struct MeetingBlockView: View { .padding(.vertical, 12) meetingNotesChildBlocks - .frame(minHeight: 160) .padding(.horizontal, 10) .padding(.vertical, 8) Divider() bottomBar(showWaveform: true) + .zIndex(1) if isTranscriptOpen { transcriptDrawer } - - askAnythingSection } } @@ -215,26 +207,34 @@ struct MeetingBlockView: View { // MARK: - After State (Complete) private var afterStateView: some View { - VStack(spacing: 0) { + let sections = parseSections(block.language) + return VStack(spacing: 0) { HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(block.meetingTitle.isEmpty ? "Meeting" : block.meetingTitle) - .font(.system(size: Typography.title3, weight: .semibold)) - .foregroundStyle(Color.fallbackTextPrimary) - } - - Spacer() - - ladybugButton + TextField("Meeting", text: $title, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + .lineLimit(1...) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: title) { _, newVal in + document.updateMeetingTitle(blockId: block.id, title: newVal) + } - // Generate summary button (only when no summary exists) - if parseSections(block.language).isEmpty && block.meetingActionItems.isEmpty && block.meetingSummary.isEmpty && (!block.meetingTranscript.isEmpty || !block.meetingNotes.isEmpty || !block.children.isEmpty) { + // Generate summary button (only when no summary exists and not already generating) + let hasHeadingChild = block.children.contains(where: { $0.type == .heading }) + if sections.isEmpty && block.meetingActionItems.isEmpty && block.meetingSummary.isEmpty && !hasHeadingChild && (!block.meetingTranscript.isEmpty || !block.meetingNotes.isEmpty || !block.children.isEmpty) { Button { Task { await generateSummary() } } label: { HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.system(size: 10)) + if isGenerating { + ProgressView() + .controlSize(.mini) + .frame(width: 11, height: 11) + } else { + Image(systemName: "ladybug") + .font(.system(size: 11)) + } Text("Generate") .font(.system(size: Typography.caption, weight: .medium)) } @@ -244,39 +244,9 @@ struct MeetingBlockView: View { .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) } .buttonStyle(.borderless) + .disabled(isGenerating) } - // Expand button (hover only) - if isHovered { - Button(action: { withAnimation(.easeInOut(duration: 0.25)) { isSummaryExpanded.toggle() } }) { - Image(systemName: isSummaryExpanded ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") - .font(.system(size: 11)) - .foregroundStyle(Color.fallbackTextSecondary) - .frame(width: 24, height: 24) - .background(Color.primary.opacity(Opacity.subtle)) - .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) - } - .buttonStyle(.borderless) - .transition(.opacity) - } - - // Toggle AI summary visibility - Button(action: { withAnimation(.easeInOut(duration: 0.2)) { showSummary.toggle() } }) { - HStack(spacing: 4) { - Text("Summary") - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) - Image(systemName: showSummary ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(Color.fallbackTextSecondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.primary.opacity(Opacity.subtle)) - .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) - } - .buttonStyle(.borderless) - Button(action: resumeRecording) { HStack(spacing: 5) { Circle() @@ -290,147 +260,96 @@ struct MeetingBlockView: View { .background(Color.primary.opacity(Opacity.subtle)) .foregroundStyle(Color.fallbackTextPrimary) .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) } .padding(.horizontal, 14) .padding(.vertical, 12) - // Combined content area: summary (toggleable) + notes - if showSummary { - summaryView + if !sections.isEmpty || !block.meetingActionItems.isEmpty || !block.meetingSummary.isEmpty { + summaryView(sections) } notesView Divider() bottomBar(showWaveform: false) + .zIndex(1) if isTranscriptOpen { transcriptDrawer } - - askAnythingSection } } // MARK: - Summary View - private var summaryView: some View { - ZStack(alignment: .bottom) { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - // Parse structured summary from the language field (dev convention) - let sections = parseSections(block.language) - - if !sections.isEmpty { - ForEach(Array(sections.enumerated()), id: \.offset) { _, section in - VStack(alignment: .leading, spacing: 4) { - if !section.heading.isEmpty { - Text(section.heading) - .font(.system(size: Typography.bodySmall, weight: .semibold)) - .foregroundStyle(Color.fallbackTextPrimary) - } - ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in - if item.isActionItem { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "square") - .font(.system(size: 12)) - .foregroundStyle(Color.fallbackTextSecondary) - .padding(.top, 2) - Text(item.text) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextPrimary) - } - } else if item.isUserNote { - Text(item.text) - .font(.system(size: Typography.bodySmall).italic()) - .foregroundStyle(Color.accentColor) - .padding(.leading, 8) - } else if item.isSummaryText { - Text(item.text) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextSecondary) - } else { - HStack(alignment: .top, spacing: 6) { - Text("\u{2022}") - .foregroundStyle(Color.fallbackTextSecondary) - Text(item.text) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextPrimary) - } - } - } - } - } - } - - // Action items from dedicated field - if !block.meetingActionItems.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("Action Items") + private func summaryView(_ sections: [MeetingSection]) -> some View { + VStack(alignment: .leading, spacing: 12) { + if !sections.isEmpty { + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 4) { + if !section.heading.isEmpty { + Text(section.heading) .font(.system(size: Typography.bodySmall, weight: .semibold)) .foregroundStyle(Color.fallbackTextPrimary) - - ForEach(parseActionItems(block.meetingActionItems), id: \.self) { item in + } + ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in + if item.isActionItem { HStack(alignment: .top, spacing: 6) { Image(systemName: "square") .font(.system(size: 12)) .foregroundStyle(Color.fallbackTextSecondary) .padding(.top, 2) - Text(item) + Text(item.text) .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextPrimary) + .foregroundStyle(Color.fallbackTextSecondary) + } + } else if item.isUserNote { + Text(item.text) + .font(.system(size: Typography.bodySmall).italic()) + .foregroundStyle(Color.accentColor) + .padding(.leading, 8) + } else if item.isSummaryText { + Text(item.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextSecondary) + } else { + HStack(alignment: .top, spacing: 6) { + Text("\u{2022}") + .foregroundStyle(Color.fallbackTextSecondary) + Text(item.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextSecondary) } } } } + } + } - if sections.isEmpty && block.meetingActionItems.isEmpty && block.meetingSummary.isEmpty { - // Generate button when no summary exists yet - if !block.meetingTranscript.isEmpty || !block.meetingNotes.isEmpty { - Button { - Task { await generateSummary() } - } label: { - HStack(spacing: 6) { - Image(systemName: "ladybug.fill") - .font(.system(size: 12)) - Text("Generate Summary") - .font(.system(size: Typography.bodySmall, weight: .medium)) - } - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 6).fill(Color.accentColor)) - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 12) - } else { - Text("No summary generated yet.") + if !block.meetingActionItems.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Action Items") + .font(.system(size: Typography.bodySmall, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + + ForEach(parseActionItems(block.meetingActionItems), id: \.self) { item in + HStack(alignment: .top, spacing: 6) { + Image(systemName: "square") + .font(.system(size: 12)) + .foregroundStyle(Color.fallbackTextSecondary) + .padding(.top, 2) + Text(item) .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextMuted) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 20) + .foregroundStyle(Color.fallbackTextSecondary) } } } - .padding(14) - } - .frame(maxHeight: isSummaryExpanded ? nil : 200) - .clipped() - - if !isSummaryExpanded { - LinearGradient( - colors: [Color.fallbackCardBg.opacity(0), Color.fallbackCardBg], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 40) - .allowsHitTesting(false) } } - .animation(.easeInOut(duration: 0.25), value: isSummaryExpanded) + .padding(14) } // MARK: - Notes View @@ -447,8 +366,7 @@ struct MeetingBlockView: View { } meetingNotesChildBlocks - .frame(minHeight: 80) - .padding(.horizontal, 10) + .padding(.horizontal, 14) .padding(.vertical, 8) } } @@ -456,67 +374,79 @@ struct MeetingBlockView: View { // MARK: - Bottom Bar private func bottomBar(showWaveform: Bool) -> some View { - Button(action: { - withAnimation(.easeInOut(duration: 0.25)) { - isTranscriptOpen.toggle() - } - if isTranscriptOpen { - document.scrollToBlockId = block.id + HStack(spacing: 8) { + if showWaveform { + WaveformView(isActive: block.meetingState == .recording, audioLevel: document.meetingAudioLevel) + .frame(width: 40, height: 16) + } else { + Text("Transcript") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(Color.fallbackTextSecondary) } - }) { - HStack(spacing: 8) { - if showWaveform { - WaveformView(isActive: block.meetingState == .recording, audioLevel: document.meetingAudioLevel) - .frame(width: 40, height: 16) - } else { - Text("Transcript") - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) - } - Spacer() + Spacer() - if isTranscriptOpen { + if isTranscriptOpen { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isSearchingTranscript.toggle() + if isSearchingTranscript { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + searchFocused = true + } + } else { + transcriptSearch = "" + } + } + } label: { Image(systemName: "magnifyingglass") .font(.system(size: 11, weight: .medium)) .foregroundStyle(isSearchingTranscript ? Color.accentColor : Color.fallbackTextSecondary) .frame(width: 24, height: 24) .contentShape(Rectangle()) - .highPriorityGesture(TapGesture().onEnded { - withAnimation(.easeInOut(duration: 0.2)) { - isSearchingTranscript.toggle() - if !isSearchingTranscript { transcriptSearch = "" } - } - }) + } + .buttonStyle(.plain) - Image(systemName: "doc.on.doc") + Button { + let entries = !block.transcriptEntries.isEmpty + ? block.transcriptEntries + : block.meetingTranscript.components(separatedBy: "\n").filter { !$0.isEmpty } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(entries.joined(separator: "\n\n"), forType: .string) + copiedTranscript = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copiedTranscript = false + } + } label: { + Image(systemName: copiedTranscript ? "checkmark" : "doc.on.doc") .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) + .foregroundStyle(copiedTranscript ? Color.accentColor : Color.fallbackTextSecondary) .frame(width: 24, height: 24) .contentShape(Rectangle()) - .highPriorityGesture(TapGesture().onEnded { - let entries = !block.transcriptEntries.isEmpty - ? block.transcriptEntries - : block.meetingTranscript.components(separatedBy: "\n").filter { !$0.isEmpty } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(entries.joined(separator: "\n\n"), forType: .string) - }) - .help("Copy transcript") - } else if !showWaveform && !block.meetingTranscript.isEmpty { - Text("\(block.meetingTranscript.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count) words") - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackTextMuted) } + .buttonStyle(.plain) + .help("Copy transcript") + } else if !showWaveform && !block.meetingTranscript.isEmpty { + Text("\(block.meetingTranscript.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count) words") + .font(.system(size: Typography.caption)) + .foregroundStyle(Color.fallbackTextMuted) + } - Image(systemName: isTranscriptOpen ? "chevron.down" : "chevron.up") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Color.fallbackTextSecondary) + Image(systemName: isTranscriptOpen ? "chevron.down" : "chevron.up") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.fallbackTextSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.25)) { + isTranscriptOpen.toggle() + } + if isTranscriptOpen { + document.scrollToBlockId = block.id } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .contentShape(Rectangle()) } - .buttonStyle(.plain) .background( Color.primary.opacity(Opacity.subtle), in: UnevenRoundedRectangle( @@ -540,6 +470,7 @@ struct MeetingBlockView: View { TextField("Search transcript...", text: $transcriptSearch) .textFieldStyle(.plain) .font(.system(size: Typography.bodySmall)) + .focused($searchFocused) if !transcriptSearch.isEmpty { Image(systemName: "xmark.circle.fill") .font(.system(size: 11)) @@ -552,43 +483,55 @@ struct MeetingBlockView: View { .background(Color.primary.opacity(Opacity.subtle)) } - ScrollView { - LazyVStack(spacing: 6) { - let allEntries = !block.transcriptEntries.isEmpty - ? block.transcriptEntries - : block.meetingTranscript.components(separatedBy: "\n").filter { !$0.isEmpty } - let entries = transcriptSearch.isEmpty - ? allEntries - : allEntries.filter { $0.localizedCaseInsensitiveContains(transcriptSearch) } - - ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in - HStack { - Text(entry) - .font(.system(size: Typography.caption2)) - .foregroundStyle(Color.fallbackTextPrimary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - Spacer(minLength: 40) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 6) { + let rawEntries = !block.transcriptEntries.isEmpty + ? block.transcriptEntries + : block.meetingTranscript.components(separatedBy: "\n").filter { !$0.isEmpty } + let allEntries = rawEntries.flatMap { splitTranscriptEntry($0) } + let entries = transcriptSearch.isEmpty + ? allEntries + : allEntries.filter { $0.localizedCaseInsensitiveContains(transcriptSearch) } + let isLive = block.meetingState == .recording + let bubbleBg = isLive + ? Color(red: 0.694, green: 0.831, blue: 0.976) // #B1D4F9 + : Color.primary.opacity(0.07) + + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + HStack { + Spacer(minLength: 40) + Text(entry) + .font(.system(size: Typography.caption2)) + .foregroundStyle(Color.fallbackTextPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(bubbleBg) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } } - } - if block.meetingState == .recording { - HStack(spacing: 4) { - ProgressView() - .controlSize(.mini) - Text("Listening...") - .font(.system(size: Typography.caption2)) - .foregroundStyle(Color.fallbackTextMuted) + if isLive { + HStack(spacing: 4) { + ProgressView() + .controlSize(.mini) + Text("Listening...") + .font(.system(size: Typography.caption2)) + .foregroundStyle(Color.fallbackTextMuted) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) } - .padding(.horizontal, 10) - .padding(.vertical, 4) + + Color.clear.frame(height: 1).id(transcriptBottomAnchorID) } + .padding(10) + } + .frame(maxHeight: 400) + .onAppear { + proxy.scrollTo(transcriptBottomAnchorID, anchor: .bottom) } - .padding(10) } - .frame(maxHeight: 400) } .transition(.asymmetric( insertion: .push(from: .bottom).combined(with: .opacity), @@ -596,95 +539,6 @@ struct MeetingBlockView: View { )) } - // MARK: - Ask Anything - - private var askAnythingSection: some View { - VStack(spacing: 0) { - if !askPairs.isEmpty { - Divider() - ScrollView { - VStack(alignment: .leading, spacing: 10) { - ForEach(Array(askPairs.enumerated()), id: \.offset) { _, pair in - VStack(alignment: .leading, spacing: 4) { - Text(pair.question) - .font(.system(size: Typography.bodySmall, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - Text(pair.answer) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(Color.fallbackTextSecondary) - .textSelection(.enabled) - } - } - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - .frame(maxHeight: 200) - } - - Divider() - - HStack(spacing: 8) { - if isAskLoading { - ProgressView() - .controlSize(.small) - } - TextField("Ask anything...", text: $askQuestion) - .textFieldStyle(.plain) - .font(.system(size: Typography.bodySmall)) - .onSubmit { submitAskQuestion() } - .disabled(isAskLoading) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.primary.opacity(Opacity.subtle)) - } - } - - private func submitAskQuestion() { - let question = askQuestion.trimmingCharacters(in: .whitespacesAndNewlines) - guard !question.isEmpty else { return } - askQuestion = "" - isAskLoading = true - - let transcript = block.meetingTranscript - let notes = block.children.isEmpty - ? block.meetingNotes - : block.children.map { $0.text }.joined(separator: "\n") - let summary = block.meetingSummary - - Task { - var context = "" - if !transcript.isEmpty { context += "Transcript:\n\(transcript)\n\n" } - if !notes.isEmpty { context += "Notes:\n\(notes)\n\n" } - if !summary.isEmpty { context += "Summary:\n\(summary)\n\n" } - - let prompt = """ - You are answering questions about a meeting. Be concise and specific. - - \(context)Question: \(question) - """ - - let answer = await runClaude(prompt: prompt) ?? "Could not generate an answer." - askPairs.append((question: question, answer: answer)) - isAskLoading = false - } - } - - // MARK: - Ladybug AI Button - - private var ladybugButton: some View { - Button(action: openAiWithContext) { - Image("BugbookAI") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 20, height: 20) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } - .buttonStyle(.borderless) - .help("Ask AI about this meeting") - } - // MARK: - Meeting Notes (Child Blocks) private var meetingNotesChildBlocks: some View { @@ -729,11 +583,36 @@ struct MeetingBlockView: View { document.onStartMeeting?(block.id) } - private func openAiWithContext() { - NotificationCenter.default.post(name: .openAIPanel, object: nil) + // MARK: - Helpers + + /// Splits a single transcript entry into sentence-sized bubbles. + /// Splits on sentence-ending punctuation or every ~20 words if unpunctuated. + private func splitTranscriptEntry(_ text: String) -> [String] { + let words = text.components(separatedBy: " ").filter { !$0.isEmpty } + guard words.count > 6 else { return [text] } + var result: [String] = [] + var chunk: [String] = [] + for word in words { + chunk.append(word) + let ends = word.hasSuffix(".") || word.hasSuffix("?") || word.hasSuffix("!") + if ends || chunk.count >= 20 { + result.append(chunk.joined(separator: " ")) + chunk = [] + } + } + if !chunk.isEmpty { result.append(chunk.joined(separator: " ")) } + return result.isEmpty ? [text] : result } - // MARK: - Helpers + private func markdownToBlocks(_ sections: String, actionItems: String) -> [Block] { + var markdown = sections.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedItems = actionItems.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedItems.isEmpty { + if !markdown.isEmpty { markdown += "\n\n" } + markdown += "## Action Items\n" + trimmedItems + } + return MarkdownBlockParser.parse(markdown) + } private func parseActionItems(_ raw: String) -> [String] { raw.components(separatedBy: "\n") @@ -751,6 +630,8 @@ struct MeetingBlockView: View { // MARK: - AI Summary Generation (from dev) private func generateSummary() async { + isGenerating = true + defer { isGenerating = false } let transcript = block.meetingTranscript let userNotes = block.children.isEmpty ? block.meetingNotes @@ -776,10 +657,14 @@ struct MeetingBlockView: View { if !parsed.title.isEmpty { document.updateMeetingTitle(blockId: block.id, title: parsed.title) } - if !parsed.actionItems.isEmpty { - document.updateMeetingActionItems(blockId: block.id, actionItems: parsed.actionItems) + // Convert summary + action items into editable child blocks prepended before user notes + let summaryBlocks = markdownToBlocks(parsed.sections, actionItems: parsed.actionItems) + if !summaryBlocks.isEmpty { + let existingChildren = block.children + let combined = summaryBlocks + existingChildren + guard let idx = document.index(for: block.id) else { return } + document.blocks[idx].children = combined } - document.updateMeetingSummary(blockId: block.id, summary: parsed.sections) } } @@ -863,6 +748,8 @@ struct MeetingBlockView: View { ## Action Items - [ ] action item 1 - [ ] action item 2 + + IMPORTANT: Only include ## Action Items if there are real, concrete next steps. If there are no clear action items, omit the section entirely — do NOT write placeholder text like "No action items" or "---". """ if !notes.isEmpty { @@ -891,7 +778,7 @@ struct MeetingBlockView: View { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/zsh") let escaped = prompt.replacingOccurrences(of: "'", with: "'\"'\"'") - process.arguments = ["-l", "-c", "claude --model haiku --print '\(escaped)'"] + process.arguments = ["-c", "PATH=\"$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.local/bin:$HOME/.npm-global/bin\" claude --model haiku --print '\(escaped)'"] let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index 85c58126..84984d62 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -167,10 +167,10 @@ struct SidebarView: View { Button(action: { invokeAction { NotificationCenter.default.post(name: .openAIPanel, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { - Image(systemName: "sparkles") + Image(systemName: "bubble.left.and.bubble.right") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) - Text("Ask AI") + Text("Chat") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) Spacer() @@ -192,10 +192,10 @@ struct SidebarView: View { VStack(spacing: sectionSpacing) { Button(action: { invokeAction { NotificationCenter.default.post(name: .openGateway, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { - Image(systemName: "square.grid.2x2") + Image(systemName: "house") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) - Text("Gateway") + Text("Home") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) Spacer() @@ -209,44 +209,6 @@ struct SidebarView: View { .buttonStyle(.plain) .onHover { hovering in hoveredButton = hovering ? "gateway" : nil } - Button(action: { invokeAction { NotificationCenter.default.post(name: .openDailyNote, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "calendar") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Today") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "today" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "today" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openGraphView, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "point.3.connected.trianglepath.dotted") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Graph") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "graph" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "graph" : nil } - Button(action: { invokeAction { NotificationCenter.default.post(name: .openMail, object: nil) } }) { HStack(spacing: chromeButtonSpacing) { Image(systemName: "envelope") @@ -290,7 +252,7 @@ struct SidebarView: View { Image(systemName: "waveform") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) - Text("AI Meeting Notes") + Text("Meetings") .font(ShellZoomMetrics.font(Typography.body)) .foregroundStyle(.secondary) Spacer() @@ -336,6 +298,44 @@ struct SidebarView: View { ScrollView { VStack(spacing: ShellZoomMetrics.size(isCompact ? 3 : 4)) { if workspaceExpanded { + Button(action: { invokeAction { NotificationCenter.default.post(name: .openDailyNote, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "calendar") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Today") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "today" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "today" : nil } + + Button(action: { invokeAction { NotificationCenter.default.post(name: .openGraphView, object: nil) } }) { + HStack(spacing: chromeButtonSpacing) { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Text("Graph") + .font(ShellZoomMetrics.font(Typography.body)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, rowHorizontalPadding) + .padding(.vertical, rowVerticalPadding) + .background(hoveredButton == "graph" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredButton = hovering ? "graph" : nil } + if !appState.sidebarReferences.isEmpty { VStack(spacing: ShellZoomMetrics.size(1)) { ForEach(appState.sidebarReferences) { entry in diff --git a/macos/App/Info.plist b/macos/App/Info.plist index 9e59860e..97367212 100644 --- a/macos/App/Info.plist +++ b/macos/App/Info.plist @@ -26,8 +26,6 @@ NSMicrophoneUsageDescription Bugbook needs microphone access to record meeting audio for live transcription. - NSSpeechRecognitionUsageDescription - Bugbook uses speech recognition to transcribe meeting recordings in real-time. SUFeedURL SUPublicEDKey diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 31257f84..c0ace9f5 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ D809F8300B119795D5F00A8B /* AiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68748E44202E7C8D638C24B /* AiService.swift */; }; D9AC923C602E5B918B9171D6 /* GatewayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */; }; DA7A9136A777D4A1E7E649A8 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37262953A2E00C21609C6836 /* SearchSettingsView.swift */; }; + DB652669C66A472349133778 /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 7C4D37FADAAFA4137EF3A9F6 /* FluidAudio */; }; DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */; }; DC399F897D16862221AE55FF /* RowPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89D0872A52B79E2B456963C /* RowPageView.swift */; }; DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */; }; @@ -267,6 +268,7 @@ 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationEngine.swift; sourceTree = ""; }; 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModePickerButton.swift; sourceTree = ""; }; 4358B9082C37BE1854810696 /* PaneContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneContentView.swift; sourceTree = ""; }; + 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerRow.swift; sourceTree = ""; }; 476358F19D676EC12E2AC59A /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkspaceStore.swift; sourceTree = ""; }; 4A3E7869C11972E917693DA0 /* WorkspaceKnowledgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceKnowledgeService.swift; sourceTree = ""; }; @@ -407,6 +409,7 @@ AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */, DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */, 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */, + DB652669C66A472349133778 /* FluidAudio in Frameworks */, 8666FC986F5D189ADE7D49AE /* GhosttyKit.xcframework in Frameworks */, 8121BFA67D4FBF6DE190E808 /* Carbon.framework in Frameworks */, 1D7E279DFDE3F1EF429518E0 /* Metal.framework in Frameworks */, @@ -903,6 +906,7 @@ packageProductDependencies = ( 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */, 093BE9A2C3A2769A90DE0579 /* Sentry */, + 7C4D37FADAAFA4137EF3A9F6 /* FluidAudio */, ); productName = BugbookApp; productReference = C7935F81E8213A92201CFAA6 /* BugbookApp.app */; @@ -953,6 +957,7 @@ mainGroup = C62E41F6C7F9C9F59BB7D2C2; minimizedProjectReferenceProxies = 1; packageReferences = ( + 3D81A7EAFF439A6CDDDB7824 /* XCRemoteSwiftPackageReference "FluidAudio" */, CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */, ); @@ -1505,6 +1510,14 @@ minimumVersion = 2.6.0; }; }; + 3D81A7EAFF439A6CDDDB7824 /* XCRemoteSwiftPackageReference "FluidAudio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/FluidInference/FluidAudio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.7.9; + }; + }; CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/getsentry/sentry-cocoa"; @@ -1526,6 +1539,11 @@ package = 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 7C4D37FADAAFA4137EF3A9F6 /* FluidAudio */ = { + isa = XCSwiftPackageProductDependency; + package = 3D81A7EAFF439A6CDDDB7824 /* XCRemoteSwiftPackageReference "FluidAudio" */; + productName = FluidAudio; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 748DCF6AC60831FC7058F9CD /* Project object */; diff --git a/macos/project.yml b/macos/project.yml index d04de1c6..6b8c34a7 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -11,6 +11,9 @@ packages: SentryCocoa: url: https://github.com/getsentry/sentry-cocoa from: "8.40.0" + FluidAudio: + url: https://github.com/FluidInference/FluidAudio.git + from: "0.7.9" # GhosttyKit: GPU-accelerated terminal engine (local XCFramework) # Linked via Frameworks/GhosttyKit.xcframework targets: @@ -46,6 +49,7 @@ targets: - package: Sparkle - package: SentryCocoa product: Sentry + - package: FluidAudio - framework: ../Frameworks/GhosttyKit.xcframework embed: false - sdk: Carbon.framework @@ -72,7 +76,6 @@ targets: - public.data UTTypeTagSpecification: {} NSMicrophoneUsageDescription: Bugbook needs microphone access to record meeting audio for live transcription. - NSSpeechRecognitionUsageDescription: Bugbook uses speech recognition to transcribe meeting recordings in real-time. entitlements: path: App/Bugbook.entitlements properties: From 77f9c1fb49b2559fc9016d8fdc23a256fa8d1794 Mon Sep 17 00:00:00 2001 From: max4c Date: Wed, 1 Apr 2026 20:16:01 -0700 Subject: [PATCH 41/41] Fix SwiftLint line-length violation in DatabaseRowViewModel Co-Authored-By: Claude Sonnet 4.6 --- .../Bugbook/Views/Database/DatabaseRowViewModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift index 6a71d484..1188a0d8 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift @@ -332,7 +332,16 @@ final class DatabaseRowViewModel { } @ViewBuilder - func rowPageView(onBack: @escaping () -> Void = {}, autoFocusTitle: Bool = false, fullWidth: Bool = false, workspacePath: String? = nil, templates: [DatabaseTemplate] = [], onApplyTemplate: ((DatabaseTemplate) -> Void)? = nil, onNewTemplate: (() -> Void)? = nil, onSaveAsTemplate: (() -> Void)? = nil) -> some View { + func rowPageView( // swiftlint:disable:next function_parameter_count + onBack: @escaping () -> Void = {}, + autoFocusTitle: Bool = false, + fullWidth: Bool = false, + workspacePath: String? = nil, + templates: [DatabaseTemplate] = [], + onApplyTemplate: ((DatabaseTemplate) -> Void)? = nil, + onNewTemplate: (() -> Void)? = nil, + onSaveAsTemplate: (() -> Void)? = nil + ) -> some View { if let schema = schema, row != nil { RowPageView( schema: schema,