diff --git a/.go/progress.md b/.go/progress.md index 261adeeb..912390cc 100644 --- a/.go/progress.md +++ b/.go/progress.md @@ -1,77 +1,5 @@ -# Long Run — 2026-03-29 - -Started: 9:45 PM -Finished: ~5:30 AM -Duration: ~8 hours (across 2 sessions due to interruptions) - -## Summary -Completed: 7/10 executable Bugbook tickets -Skipped: 3 (extremely large features — each multi-day scope) -Blocked: 5 (external deps, different repo, too vague) - -## Completed - -- [x] Outline (TOC) block type [High] (row_u9mndd) -- [x] Database table view calculations footer [High] (row_kegyb1) -- [x] Add Agents sidebar section [Medium] (row_woy99m) -- [x] Add MCP Servers listing [Low] (row_qfrvll) -- [x] Callout block type [High] (row_fnmxx9) -- [x] Change select option color [Medium] (row_bu993v) -- [x] Meeting notes markdown shortcuts [High] (row_34on11) - -## Discoveries - -These findings should inform future work: -- AggregationEngine already existed in BugbookCore/Engine from prior session -- ~/.claude/skills/ contains both regular folders and symlinks to ~/.agents/skills/ -- MCP servers are stored in ~/.claude.json (not ~/.claude/settings.json as ticket spec said) -- Select option color infrastructure existed but context menus were only on inline pills — added to dropdown popovers and edit dialog -- Dev branch has table block type (WIP) from prior commits - -## Review Guide - -All work is on `dev`. To review: - -```bash -git checkout dev -open macos/Bugbook.xcodeproj # Cmd+R -``` - -### 1. Outline (TOC) block [Medium risk] -Type `/toc` or `/outline` → TOC block with indented heading list, clickable entries - -### 2. Database calculations footer [Medium risk] -Open any database table → hover footer row → click "Calculate" → pick a function (Sum, Avg, etc.) - -### 3. Agents sidebar section [Medium risk] -Look for "Agents" section between Favorites and Workspace → skills listed, click opens in editor with banner - -### 4. MCP Servers listing [Low risk] -Under Agents section → MCP servers from ~/.claude.json shown with plug icon - -### 5. Callout block [Medium risk] -Type `/callout` → info callout with blue border. Click icon to cycle variants (info/warning/success/error). Child blocks inside. - -### 6. Select option color [Low risk] -Open a database → click a select cell → right-click an option → Color submenu, or Edit with color picker grid - -### 7. Meeting notes markdown [High risk] -Create/open a meeting block → notes area supports bullets (- ), headings (# ), tasks ([] ), slash menu, Tab indent - -## Skipped (too large for overnight) - -- AND/OR filter groups (row_1i5rmc) — recursive filter model + nested UI, multi-day scope -- Inline mentions (row_xxvee0) — attributed text or parse-time mentions, picker UI, backlinks, multi-day scope -- Formula/rollup/lookup (row_cygwau) — expression parser, cross-DB resolution, multi-day scope - -## Blocked - -- Build native Gateway — too vague, needs spec -- Google OAuth — needs domain, Google Console (external) -- Restructure Gateway 8.0 — content/data migration -- Live knowledge retrieval — too vague -- 7 Canopy tickets — different repo (/Users/maxforsey/canopy-menu/) - -## Build Status -Dev branch: PASSING (swift build clean) -All 7 features compile and build together. +# Go Run — 2026-03-31 +Started: 05:18 PM +Time budget: 8h +Queue: 12 tickets from .go/queue.json +Worker mix: 9 Codex, 3 Claude agent diff --git a/.go/queue.json b/.go/queue.json new file mode 100644 index 00000000..866551f2 --- /dev/null +++ b/.go/queue.json @@ -0,0 +1,14 @@ +[ + {"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/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..a60d9bce 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 @@ -161,6 +166,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 } } @@ -616,7 +629,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 +639,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 } } @@ -1003,12 +1019,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) @@ -1030,12 +1047,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) @@ -1147,6 +1164,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/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index f5959efc..ca940e50 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -695,35 +695,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] = [] @@ -773,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), @@ -782,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/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/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..29ecdc01 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() + } + } } } @@ -110,31 +117,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 +147,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 + } + } +} 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/Components/FloatingRecordingPill.swift b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift index 1f7748a2..be234690 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 @@ -30,10 +30,17 @@ final class FloatingRecordingPillPanel: NSPanel { isMovableByWindowBackground = true hidesOnDeactivate = false contentView = hostingView + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor } - func showPill() { - hostingView.rootView = RecordingPillView(isAnimating: true) + func showPill(startDate: Date, onStop: @escaping () -> Void, onTap: @escaping () -> Void = {}) { + hostingView.rootView = RecordingPillView( + isAnimating: true, + recordingStart: startDate, + onStop: onStop, + onTap: onTap + ) // Re-evaluate size and position each show (handles display changes) let size = hostingView.fittingSize @@ -58,17 +65,42 @@ final class FloatingRecordingPillPanel: NSPanel { 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) { - // 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) + onTap?() + } + + 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 +109,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)) } } } @@ -112,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 @@ -127,10 +172,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 +182,46 @@ 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)? + /// 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 { 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?() } + }, onTap: { [weak self] in + Task { @MainActor in self?.onTap?() } + }) } else { panel?.hidePill() + recordingStart = nil } } } 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 6443dc32..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) @@ -251,6 +252,15 @@ struct ContentView: View { } private func handleRecordingChange(_ recording: Bool) { + if recording, let blockId = appState.recordingBlockId { + let doc = blockDocuments.values.first { $0.blocks.contains(where: { $0.id == blockId }) } + recordingPillController.onStop = { [weak doc] in + doc?.onStopMeeting?(blockId) + } + recordingPillController.onTap = { [weak doc] in + doc?.scrollToBlockId = blockId + } + } recordingPillController.isRecording = recording } @@ -1364,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 @@ -1902,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/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..1188a0d8 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,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) -> 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, @@ -338,7 +367,8 @@ final class DatabaseRowViewModel { dbPath: dbPath, templates: templates, onApplyTemplate: onApplyTemplate, - onNewTemplate: onNewTemplate + onNewTemplate: onNewTemplate, + onSaveAsTemplate: onSaveAsTemplate ) } } 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/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/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index 6732d8f2..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) @@ -239,7 +238,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 +318,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 +347,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 +1355,104 @@ private struct RelationFlowLayout: Layout { } } +// MARK: - Option Button Row (Notion-style: pill + grip dots + kebab on hover) + +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 { + 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) + } + + // 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: 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, 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 } + } +} + // MARK: - Relation Flow Layout 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 diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift index 73f1a060..aeea0af4 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)) @@ -978,43 +976,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) } } } @@ -1040,6 +1046,7 @@ private struct RowDragHandleDots: View { var body: some View { GripDotsView() .fixedSize() + .frame(width: 12, height: 20) } } @@ -1290,9 +1297,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 +1321,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 +1333,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 diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index 41432b7c..7f179284 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 )) } @@ -71,14 +73,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 { @@ -103,7 +115,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 @@ -292,12 +304,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 +320,7 @@ private struct PopoverSyncModifier: ViewModifier { showSlashMenu = isSlashTarget showBlockMenu = isBlockMenuTarget showPagePicker = isPagePickerTarget + showMentionPicker = isMentionPickerTarget showAiPrompt = isAiPromptTarget } .onChange(of: document.slashMenuBlockId) { _, newVal in @@ -339,6 +354,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 +408,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/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/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 + } } } diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 26e38eb2..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 activeTab: MeetingTab = .summary - @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 - enum MeetingTab { - case summary - case notes - } + private let transcriptBottomAnchorID = "transcript-bottom" init(document: BlockDocument, block: Block) { self.document = document @@ -52,14 +47,40 @@ 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) - .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) + } } } @@ -82,25 +103,27 @@ 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)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .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(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 12) } } @@ -121,21 +144,20 @@ struct MeetingBlockView: View { Spacer() - ladybugButton - 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)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) } @@ -143,13 +165,13 @@ 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 @@ -185,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)) } @@ -214,60 +244,38 @@ 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) - } - - // Summary/Notes tab picker - Picker("", selection: $activeTab) { - Text("Summary").tag(MeetingTab.summary) - Text("Notes").tag(MeetingTab.notes) - } - .pickerStyle(.segmented) - .frame(width: 140) - 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)) + .contentShape(RoundedRectangle(cornerRadius: Radius.sm)) } .buttonStyle(.borderless) } .padding(.horizontal, 14) .padding(.vertical, 12) - // Content area: Summary or Notes - switch activeTab { - case .summary: - summaryView - case .notes: - notesView + if !sections.isEmpty || !block.meetingActionItems.isEmpty || !block.meetingSummary.isEmpty { + summaryView(sections) } + notesView Divider() bottomBar(showWaveform: false) + .zIndex(1) if isTranscriptOpen { transcriptDrawer @@ -277,216 +285,168 @@ struct MeetingBlockView: View { // 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 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 + .padding(.horizontal, 14) + .padding(.vertical, 8) } } // 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, phase: 0) - .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( @@ -510,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)) @@ -522,40 +483,55 @@ struct MeetingBlockView: View { .background(Color.primary.opacity(Opacity.subtle)) } - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - 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 - 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)) - } + 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), @@ -563,20 +539,6 @@ struct MeetingBlockView: View { )) } - // 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 { @@ -621,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") @@ -643,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 @@ -650,22 +639,32 @@ 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) + } + // 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 + } } } @@ -673,6 +672,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) @@ -693,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 { @@ -721,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 @@ -818,40 +875,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)) } } @@ -860,6 +911,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 +921,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 +970,6 @@ struct TranscriptBubbleView: View { .padding(20) } } - .frame(minWidth: 500, minHeight: 400) - .background(Color(nsColor: .windowBackgroundColor)) } private struct Bubble { 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/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() 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 { 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 { 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/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/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) + } } 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 5c1a74d3..c0ace9f5 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 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 */; }; @@ -42,6 +43,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 */; }; @@ -58,6 +60,7 @@ 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 */; }; @@ -123,6 +126,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 */; }; B739759EBB24637E9AB8D608 /* MailIntelligenceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7834ED8C2F8F828CCE06EC3 /* MailIntelligenceModels.swift */; }; @@ -149,13 +153,16 @@ 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 /* 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 */; }; @@ -170,6 +177,7 @@ EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */; }; EB44EC9D598AA910AE80CE79 /* PaneFocusIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */; }; EC1FEB44F619D0C010F9548B /* KeychainSecretStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60C3B84C6B10437033B8D43 /* KeychainSecretStore.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 */; }; @@ -216,6 +224,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 = ""; }; @@ -229,6 +238,7 @@ 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 = ""; }; @@ -245,6 +255,7 @@ 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 = ""; }; @@ -257,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 = ""; }; @@ -264,6 +276,7 @@ 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesChatView.swift; sourceTree = ""; }; 4C7D8DF23527AE667D4F5A45 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.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 = ""; }; 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailIntelligenceStore.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 = ""; }; @@ -314,6 +327,7 @@ 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 = ""; }; @@ -355,6 +369,7 @@ 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 = ""; }; E071A97E9E6B0892FA767898 /* BacklinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BacklinkService.swift; sourceTree = ""; }; @@ -394,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 */, @@ -401,12 +417,21 @@ 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 = ( @@ -491,6 +516,7 @@ 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */, 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */, 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */, + 4E4ED842193C600650451387 /* MentionPickerView.swift */, FB1D2F73F8536CB22D1959D7 /* OutlineBlockView.swift */, 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */, F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */, @@ -508,6 +534,7 @@ children = ( DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */, 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */, + 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */, ); path = AI; sourceTree = ""; @@ -516,6 +543,7 @@ isa = PBXGroup; children = ( EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */, + 33892ABFB220B57504C88B44 /* SkillDetailView.swift */, ); path = Agent; sourceTree = ""; @@ -592,6 +620,7 @@ 3586EF5D600822B9681D1866 /* Components */, B16E9F553D2EDB723B3CDFF8 /* Database */, 2579331778CC5EC6DB286690 /* Editor */, + 05582B553CC59B536CDD007D /* Gateway */, C575E14DD4F6A30BEA81C1B4 /* Graph */, 9CE5FB4F5FE35247A228ACC7 /* Mail */, F3EE2A2FCDD080B2CD99613C /* Meetings */, @@ -619,6 +648,7 @@ 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */, 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */, 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */, + 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */, 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */, 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */, ); @@ -629,6 +659,7 @@ isa = PBXGroup; children = ( 60576E64129D7C210FF7F7D0 /* AggregationEngine.swift */, + DB9D402516F42FFD4940DB38 /* FormulaEngine.swift */, 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */, 1A49740409C913AF7DE543D6 /* QueryEngine.swift */, 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */, @@ -785,6 +816,7 @@ 977F61568113A500928E2313 /* IOSurface.framework */, C225B558AE138387C4A5A434 /* Metal.framework */, A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */, + 23E7105242180B0C6CA782A1 /* WebKit.framework */, ); name = Frameworks; sourceTree = ""; @@ -874,6 +906,7 @@ packageProductDependencies = ( 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */, 093BE9A2C3A2769A90DE0579 /* Sentry */, + 7C4D37FADAAFA4137EF3A9F6 /* FluidAudio */, ); productName = BugbookApp; productReference = C7935F81E8213A92201CFAA6 /* BugbookApp.app */; @@ -924,6 +957,7 @@ mainGroup = C62E41F6C7F9C9F59BB7D2C2; minimizedProjectReferenceProxies = 1; packageReferences = ( + 3D81A7EAFF439A6CDDDB7824 /* XCRemoteSwiftPackageReference "FluidAudio" */, CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */, ); @@ -970,6 +1004,7 @@ 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 */, @@ -1055,6 +1090,8 @@ 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 */, @@ -1081,6 +1118,7 @@ E704106BDA659E0F10A43A3A /* MeetingNotesEditor.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 */, @@ -1106,6 +1144,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 */, @@ -1115,6 +1154,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 */, @@ -1470,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"; @@ -1491,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 f955a0c4..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 @@ -54,6 +58,7 @@ targets: - sdk: CoreText.framework - sdk: QuartzCore.framework - sdk: IOSurface.framework + - sdk: WebKit.framework info: path: App/Info.plist properties: @@ -71,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: