diff --git a/.go/progress.md b/.go/progress.md index 912390cc..cd510459 100644 --- a/.go/progress.md +++ b/.go/progress.md @@ -1,5 +1,47 @@ -# 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 +# Go Run — 2026-04-03 + +Started: 12:39 AM +Finished: 1:18 AM +Duration: 39m +Time utilization: 39m/8h (8%) + +## Completed (15 tickets, all verified via build) +- [x] Terminal paste fix (row_8h9iqr) — direct — PASS +- [x] Terminal history wipe fix (row_23xqr5) — direct — PASS +- [x] Ghostty config import — direct — PASS +- [x] Meetings rename (row_wcsgow) — direct — PASS (already correct) +- [x] Meeting block padding (row_k1pfpn) — direct — PASS +- [x] Callout block fix (row_fnmxx9) — direct — PASS +- [x] Outline/TOC fix (row_u9mndd) — direct — PASS +- [x] Heading toggles (row_b7h2vl) — Claude agent — PASS (2/3 behaviors) +- [x] TableBlockView fix (row_srmgse) — direct — PASS +- [x] Kebab menu fix (row_0lsztg) — direct — PASS +- [x] Chat redesign (row_qm7iyh) — direct — PASS +- [x] Mention picker styling (row_dimm5g) — direct — PASS +- [x] Cmd+K navigation fix (row_uqw8vz) — direct — PASS +- [x] Mail auto-refresh (row_iibyiq) — direct — PASS +- [x] Table grouped collapse (row_6pk1v8) — direct — PASS + +## Review Queue (15 tickets) +All moved to Review status in Bugbook. + +## Partial +- Heading toggles: Cmd+Shift+Enter and auto-nesting work. Enter-exits-toggle not yet implemented. + +## Skipped (valid reasons) +- Google OAuth verification (row_rv254w) — external blocker, needs domain + Google Console +- Canopy tickets (4) — different repo +- Mobile capture UX (row_vk26pw) — research note, no implementation +- Live knowledge retrieval (row_25nsk1) — research/future +- Lookup/Rollup/Formula fields — medium priority, larger scope + +## Discoveries +- ghostty_init(0, nil) prevents parent terminal TTY manipulation +- ghostty_config_load_default_files loads ~/.config/ghostty/config +- Cmd+K nav failed 3 times due to SwiftUI transaction swallowing @State changes; DispatchQueue.main.async fixes it +- TableBlockView had duplicate grip dots from both BlockCellView and its own gripDotsColumn +- Multiple Bugbook processes (release, Xcode debug, swift build) can interfere + +## How to Review +git checkout dev +swift build && .build/arm64-apple-macosx/debug/Bugbook diff --git a/.go/start_time b/.go/start_time new file mode 100644 index 00000000..f40b950d --- /dev/null +++ b/.go/start_time @@ -0,0 +1 @@ +1775201074 diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 4f24a0f9..cf588a9c 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -47,6 +47,7 @@ struct MCPServerInfo: Identifiable { var isRecording: Bool = false var recordingBlockId: UUID? var flashcardReviewOpen: Bool = false + var showShortcutOverlay: Bool = false @ObservationIgnored lazy var aiThreadStore = AiThreadStore() var activeTab: OpenFile? { diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index 11f35e60..442a950f 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -90,7 +90,7 @@ struct BugbookApp: App { } .keyboardShortcut("p") - Button("Ask AI") { + Button("Chat") { NotificationCenter.default.post(name: .openAIPanel, object: nil) } .keyboardShortcut("i") @@ -218,6 +218,13 @@ struct BugbookApp: App { } .keyboardShortcut(",") } + + CommandGroup(replacing: .help) { + Button("Keyboard Shortcuts") { + NotificationCenter.default.post(name: .toggleShortcutOverlay, object: nil) + } + .keyboardShortcut("/") + } } } } @@ -363,6 +370,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard !window.titlebarAppearsTransparent else { continue } window.titlebarAppearsTransparent = true window.titleVisibility = .hidden + window.title = "Bugbook" window.styleMask.insert(.fullSizeContentView) window.isMovableByWindowBackground = true } @@ -381,6 +389,7 @@ extension Notification.Name { static let quickOpenNewTab = Notification.Name("quickOpenNewTab") static let openSettings = Notification.Name("openSettings") static let openAIPanel = Notification.Name("openAIPanel") + static let openFullChat = Notification.Name("openFullChat") static let askAI = Notification.Name("askAI") static let toggleTheme = Notification.Name("toggleTheme") static let newDatabase = Notification.Name("newDatabase") @@ -397,6 +406,8 @@ extension Notification.Name { static let openCalendar = Notification.Name("openCalendar") static let openMeetings = Notification.Name("openMeetings") static let openGateway = Notification.Name("openGateway") + static let openTerminal = Notification.Name("openTerminal") + static let toggleShortcutOverlay = Notification.Name("toggleShortcutOverlay") static let fileDeleted = Notification.Name("fileDeleted") static let fileMoved = Notification.Name("fileMoved") static let movePage = Notification.Name("movePage") diff --git a/Sources/Bugbook/Lib/AttributedStringConverter.swift b/Sources/Bugbook/Lib/AttributedStringConverter.swift index d080d589..dc4a0e0d 100644 --- a/Sources/Bugbook/Lib/AttributedStringConverter.swift +++ b/Sources/Bugbook/Lib/AttributedStringConverter.swift @@ -104,6 +104,17 @@ enum AttributedStringConverter { continue } + // Mention: @[[Page Name]] → styled inline link + if let (name, end) = parseMention(markdown, from: i) { + var attrs = baseAttributes + attrs[.foregroundColor] = NSColor.controlAccentColor + attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue + attrs[Self.markdownSourceKey] = "@[[\(name)]]" + result.append(NSAttributedString(string: name, attributes: attrs)) + i = end + continue + } + // Double-equals separator: " == " → arrow indicator if let end = parseDoubleEqualsSeparator(markdown, from: i) { var attrs = baseAttributes @@ -234,6 +245,20 @@ enum AttributedStringConverter { return nil } + /// Parse mention: @[[Page Name]] → (name, endIndex) + private static func parseMention( + _ str: String, + from start: String.Index + ) -> (String, String.Index)? { + let prefix = "@[[" + guard str[start...].hasPrefix(prefix) else { return nil } + let nameStart = str.index(start, offsetBy: prefix.count) + guard let closingRange = str[nameStart...].range(of: "]]") else { return nil } + let name = String(str[nameStart.. 0, sibling.headingLevel <= level { + break + } + absorbed.append(blocks.remove(at: idx + 1)) + } + if !absorbed.isEmpty { + blocks[idx].children.append(contentsOf: absorbed) + blocks[idx].isExpanded = true + } + } + // MARK: - Table Mutations func updateTableCell(id: UUID, row: Int, col: Int, text: String) { @@ -911,7 +944,7 @@ class BlockDocument { static let slashCommands: [SlashCommand] = [ // Suggested - SlashCommand(name: "Ask AI", icon: "ladybug", action: .askAI, section: "Suggested", keywords: ["ai", "chat", "generate", "write"]), + SlashCommand(name: "Ask AI", icon: "ladybug", action: .askAI, section: "Suggested", keywords: ["ai", "chat", "generate", "write", "ask"]), SlashCommand(name: "Image", icon: "photo", action: .imagePicker, section: "Suggested", keywords: ["photo", "picture", "media", "upload"]), SlashCommand(name: "Template", icon: "doc.on.doc", action: .template, section: "Suggested", keywords: ["snippet", "preset"]), SlashCommand(name: "Meeting", icon: "mic.fill", action: .meeting, section: "Suggested", keywords: ["record", "transcribe", "audio", "notes"]), @@ -1087,6 +1120,9 @@ class BlockDocument { if type == .heading || type == .headingToggle { setHeadingLevel(id: blockId, level: headingLevel) } + if type == .headingToggle { + autoNestIntoHeadingToggle(blockId: blockId, level: headingLevel) + } } dismissSlashMenu() diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index 7564e12e..9116b609 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -9,6 +9,7 @@ enum TabKind: Equatable, Hashable, Codable { case graphView case skill case gateway + case chat case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } @@ -18,6 +19,7 @@ enum TabKind: Equatable, Hashable, Codable { var isGraphView: Bool { self == .graphView } var isSkill: Bool { self == .skill } var isGateway: Bool { self == .gateway } + var isChat: Bool { self == .chat } var isDatabaseRow: Bool { if case .databaseRow = self { return true }; return false } var databasePath: String? { if case .databaseRow(let p, _) = self { return p }; return nil } var databaseRowId: String? { if case .databaseRow(_, let r) = self { return r }; return nil } diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index fdbc59a9..129262c5 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -21,6 +21,7 @@ struct OpenFile: Identifiable, Equatable, Codable { var isGraphView: Bool { kind.isGraphView } var isSkill: Bool { kind.isSkill } var isGateway: Bool { kind.isGateway } + var isChat: Bool { kind.isChat } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/PaneContent.swift b/Sources/Bugbook/Models/PaneContent.swift index af920c69..8414429a 100644 --- a/Sources/Bugbook/Models/PaneContent.swift +++ b/Sources/Bugbook/Models/PaneContent.swift @@ -48,7 +48,16 @@ enum PaneContent: Codable, Equatable { let id = UUID() return .document(openFile: OpenFile( id: id, path: "bugbook://gateway", content: "", isDirty: false, isEmptyTab: false, - kind: .gateway, displayName: "Gateway", icon: "square.grid.2x2" + kind: .gateway, displayName: "Home", icon: "square.grid.2x2" + )) + } + + /// A full-page chat pane. + static func chatDocument() -> PaneContent { + let id = UUID() + return .document(openFile: OpenFile( + id: id, path: "bugbook://chat", content: "", isDirty: false, isEmptyTab: false, + kind: .chat, displayName: "Chat", icon: "bubble.left.and.bubble.right" )) } diff --git a/Sources/Bugbook/Models/WorkspaceManager.swift b/Sources/Bugbook/Models/WorkspaceManager.swift index 0433ebe9..8bfc0c4d 100644 --- a/Sources/Bugbook/Models/WorkspaceManager.swift +++ b/Sources/Bugbook/Models/WorkspaceManager.swift @@ -9,6 +9,8 @@ private let log = Logger(subsystem: "com.bugbook.app", category: "WorkspaceManag class WorkspaceManager { var workspaces: [Workspace] = [] var activeWorkspaceIndex: Int = 0 + /// Set after each successful layout save; UI can observe this for a brief indicator. + var lastSavedAt: Date? @ObservationIgnored private var persistTask: Task? @@ -261,6 +263,19 @@ class WorkspaceManager { activeWorkspace = ws } + /// Swap the content of two panes by their IDs. + func swapPaneContents(paneA: UUID, paneB: UUID) { + guard var ws = activeWorkspace else { return } + guard let leafA = ws.root.findLeaf(id: paneA), + let leafB = ws.root.findLeaf(id: paneB) else { return } + let contentA = leafA.content + let contentB = leafB.content + ws.root = ws.root.updatingLeafContent(leafId: paneA, content: contentB) + ws.root = ws.root.updatingLeafContent(leafId: paneB, content: contentA) + activeWorkspace = ws + schedulePersist() + } + // MARK: - Queries /// All document-type leaves across all workspaces. @@ -322,6 +337,7 @@ class WorkspaceManager { do { let data = try JSONEncoder().encode(layout) try data.write(to: Self.layoutFileURL, options: .atomic) + lastSavedAt = Date() } catch { log.error("Failed to persist workspace layout: \(error)") } diff --git a/Sources/Bugbook/Services/TerminalManager.swift b/Sources/Bugbook/Services/TerminalManager.swift index e1b3a5e8..aaf8ef41 100644 --- a/Sources/Bugbook/Services/TerminalManager.swift +++ b/Sources/Bugbook/Services/TerminalManager.swift @@ -20,18 +20,19 @@ final class TerminalManager { func ensureInitialized() { guard ghosttyApp == nil else { return } - // ghostty_init must be called before any other API call - let initResult = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) + // ghostty_init: pass empty args so libghostty doesn't touch the parent TTY + let initResult = ghostty_init(0, nil) guard initResult == GHOSTTY_SUCCESS else { log.error("ghostty_init failed with code \(initResult)") return } - // Create config + // Create config and load the user's Ghostty config (~/.config/ghostty/config) guard let cfg = ghostty_config_new() else { log.error("ghostty_config_new failed") return } + ghostty_config_load_default_files(cfg) ghostty_config_finalize(cfg) self.ghosttyConfig = cfg @@ -50,13 +51,24 @@ final class TerminalManager { // (new tab, new window, etc.). We don't support those yet. return false }, - read_clipboard_cb: { _, _, _ in - // Read clipboard: ghostty wants to read the clipboard. - // Return false to indicate we don't support this yet. - return false + read_clipboard_cb: { userdata, clipboard, state in + // Must access NSPasteboard on the main thread + let work = { + guard let surface = _activeSurface else { return } + guard let text = NSPasteboard.general.string(forType: .string) else { return } + text.withCString { cStr in + ghostty_surface_complete_clipboard_request(surface, cStr, state, true) + } + } + if Thread.isMainThread { work() } else { DispatchQueue.main.async { work() } } + return true }, - confirm_read_clipboard_cb: { _, _, _, _ in - // Confirm clipboard read: no-op for now. + confirm_read_clipboard_cb: { userdata, content, state, requestType in + let work = { + guard let surface = _activeSurface, let content else { return } + ghostty_surface_complete_clipboard_request(surface, content, state, true) + } + if Thread.isMainThread { work() } else { DispatchQueue.main.async { work() } } }, write_clipboard_cb: { userdata, loc, content, len, confirm in // Write to system clipboard diff --git a/Sources/Bugbook/Services/TerminalSession.swift b/Sources/Bugbook/Services/TerminalSession.swift index e02940e2..36e21e43 100644 --- a/Sources/Bugbook/Services/TerminalSession.swift +++ b/Sources/Bugbook/Services/TerminalSession.swift @@ -5,6 +5,10 @@ import os private let log = Logger(subsystem: "com.bugbook.app", category: "Terminal") +/// Tracks the most recently focused Ghostty surface for clipboard callbacks. +/// Set on the main thread in becomeFirstResponder; read in C callbacks that also run on main. +nonisolated(unsafe) var _activeSurface: ghostty_surface_t? = nil + /// Manages a single terminal instance backed by a libghostty surface. @MainActor @Observable @@ -87,16 +91,51 @@ class GhosttySurfaceHostView: NSView { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() - if let surface { ghostty_surface_set_focus(surface, true) } + if let surface { + ghostty_surface_set_focus(surface, true) + _activeSurface = surface + } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - if let surface { ghostty_surface_set_focus(surface, false) } + if let surface { + ghostty_surface_set_focus(surface, false) + if _activeSurface == surface { _activeSurface = nil } + } return result } + // MARK: - Key Equivalents (Cmd+key) + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard let surface else { return super.performKeyEquivalent(with: event) } + + // Forward key equivalents to ghostty before AppKit routes them + // through the menu system. This ensures Cmd+V (paste), Cmd+C + // (copy), and other ghostty bindings are handled by the terminal. + let text = event.ghosttyCharacters + var keyEv = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) + if let text, !text.isEmpty, let codepoint = text.utf8.first, codepoint >= 0x20 { + let handled = text.withCString { ptr -> Bool in + keyEv.text = ptr + return ghostty_surface_key(surface, keyEv) + } + if handled { return true } + } else { + if ghostty_surface_key(surface, keyEv) { return true } + } + return super.performKeyEquivalent(with: event) + } + + // MARK: - Paste (fallback for Edit menu) + + @objc func paste(_ sender: Any?) { + guard let surface else { return } + _ = ghostty_surface_binding_action(surface, "paste_from_clipboard", 0) + } + // MARK: - Keyboard Input override func keyDown(with event: NSEvent) { diff --git a/Sources/Bugbook/Views/AI/AiSidePanelView.swift b/Sources/Bugbook/Views/AI/AiSidePanelView.swift index dc712866..685d6901 100644 --- a/Sources/Bugbook/Views/AI/AiSidePanelView.swift +++ b/Sources/Bugbook/Views/AI/AiSidePanelView.swift @@ -159,7 +159,7 @@ struct AiSidePanelView: View { showThreadPicker.toggle() } label: { HStack(spacing: 4) { - Text(threadStore.activeThread?.title ?? "Ask AI") + Text(threadStore.activeThread?.title ?? "Chat") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.fallbackTextPrimary) .lineLimit(1) diff --git a/Sources/Bugbook/Views/AI/NotesChatView.swift b/Sources/Bugbook/Views/AI/NotesChatView.swift index 29ecdc01..0bef034f 100644 --- a/Sources/Bugbook/Views/AI/NotesChatView.swift +++ b/Sources/Bugbook/Views/AI/NotesChatView.swift @@ -87,16 +87,6 @@ struct NotesChatView: View { .help("Clear chat") .disabled(messages.isEmpty) - Button(action: closeChat) { - Label("Close", systemImage: "xmark") - .labelStyle(.iconOnly) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.borderless) - .help("Close chat") } .padding(.horizontal, 28) .padding(.vertical, 14) @@ -164,24 +154,21 @@ struct NotesChatView: View { @ViewBuilder private var messageArea: some View { if messages.isEmpty && !aiService.isRunning { - Spacer() - VStack(spacing: 16) { - Image("BugbookLogo") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .opacity(0.85) - VStack(spacing: 6) { - Text("Chat with your notes") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(Color.fallbackTextPrimary) - Text("Ask anything about your workspace") - .font(.system(size: 14)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 12) { + Text("Ask anything about your workspace") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + + WrappingHStack(spacing: 6) { + suggestionChip("Summarize today's meetings") + suggestionChip("What changed this week?") + suggestionChip("Draft a follow-up email") + suggestionChip("Open tickets overview") } } - Spacer() + .padding(.horizontal, 24) + .padding(.top, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ScrollViewReader { proxy in ScrollView { @@ -536,10 +523,6 @@ struct NotesChatView: View { threadStore.createThread() } - private func closeChat() { - appState.closeNotesChat() - } - private var allReferenceFiles: [FileEntry] { var files: [FileEntry] = [] flattenFiles(appState.fileTree, into: &files) @@ -640,6 +623,28 @@ struct NotesChatView: View { """ } + // MARK: - Suggestion Chips + + private func suggestionChip(_ text: String) -> some View { + Button { + inputText = text + sendMessage() + } label: { + Text(text) + .font(.system(size: Typography.caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.primary.opacity(Opacity.light), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + // MARK: - Helpers private static let relativeFormatter: RelativeDateTimeFormatter = { @@ -653,6 +658,48 @@ struct NotesChatView: View { } } +private struct WrappingHStack: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + + return CGSize(width: maxWidth, height: y + rowHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX && x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + private struct ChatReferencedFile: Identifiable, Equatable { let path: String let name: String diff --git a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift index c8a29ddf..10dd28e7 100644 --- a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift +++ b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift @@ -6,6 +6,7 @@ struct CalendarDayView: View { let events: [CalendarEvent] let databaseItems: [CalendarDatabaseItem] let calendarVM: CalendarViewModel + let calendarSources: [CalendarSource] var onEventTapped: (CalendarEvent) -> Void var onDatabaseItemTapped: (CalendarDatabaseItem) -> Void @@ -14,138 +15,303 @@ struct CalendarDayView: View { private let hourHeight: CGFloat = 48 private let timeGutterWidth: CGFloat = 44 + private var dayEvents: [CalendarEvent] { + calendarVM.events(for: date, from: events) + } + + private var timedEvents: [CalendarEvent] { + dayEvents.filter { !$0.isAllDay } + } + + private var allDayEvents: [CalendarEvent] { + dayEvents.filter { $0.isAllDay } + } + + private var dayDbItems: [CalendarDatabaseItem] { + calendarVM.databaseItems(for: date, from: databaseItems) + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + var body: some View { - ScrollView(.vertical) { - ZStack(alignment: .topLeading) { - // Time grid - VStack(spacing: 0) { - ForEach(calendarVM.visibleHours, id: \.self) { hour in - HStack(spacing: 0) { - HStack { - Spacer() - Text(calendarVM.hourLabel(hour)) - .font(.system(size: 10)) - .foregroundStyle(.quaternary) - .padding(.trailing, 4) - } - .frame(width: timeGutterWidth, alignment: .topTrailing) - .offset(y: -6) + VStack(spacing: 0) { + dateHeader + Divider() - VStack(spacing: 0) { - Divider().opacity(0.3) - Spacer() + if !allDayEvents.isEmpty { + allDaySection + Divider() + } + + ScrollView(.vertical) { + ScrollViewReader { proxy in + ZStack(alignment: .topLeading) { + timeGrid + eventOverlays + nowIndicator + + // Empty state — shown over the grid when no events + if dayEvents.isEmpty && dayDbItems.isEmpty { + VStack(spacing: 6) { + Text("Nothing scheduled") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.secondary) + Text("Your day is clear") + .font(.system(size: Typography.caption)) + .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity) + .padding(.top, hourHeight * 2) + } + } + .frame(height: CGFloat(calendarVM.visibleHours.count) * hourHeight) + .onAppear { + let targetHour = max(calendarVM.dayStartHour, Calendar.current.component(.hour, from: Date()) - 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo(targetHour, anchor: .top) } - .frame(height: hourHeight) } } + } + } + } - // Events - HStack(spacing: 0) { - Color.clear.frame(width: timeGutterWidth) + // MARK: - Date Header - ZStack(alignment: .topLeading) { - let timedEvents = calendarVM.events(for: date, from: events) - .filter { !$0.isAllDay } - - ForEach(timedEvents, id: \.id) { event in - let y = calendarVM.yPosition(for: event.startDate, hourHeight: hourHeight) - let h = calendarVM.eventHeight(start: event.startDate, end: event.endDate, hourHeight: hourHeight) - let isHovered = hoveredEventId == event.id - let eventColor = Color.accentColor - - Button(action: { onEventTapped(event) }) { - VStack(alignment: .leading, spacing: 2) { - Text(event.title) - .font(.system(size: Typography.body, weight: .medium)) - .lineLimit(3) - Text("\(calendarVM.timeString(for: event.startDate))–\(calendarVM.timeString(for: event.endDate))") - .font(.system(size: Typography.caption)) - .opacity(0.8) - if let location = event.location, !location.isEmpty { - Text(location) - .font(.system(size: Typography.caption)) - .opacity(0.6) - } - } - .padding(.leading, 8) - .padding(.trailing, 6) - .padding(.vertical, 4) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: h, alignment: .top) - .foregroundStyle(eventColor) - .background(eventColor.opacity(isHovered ? 0.14 : 0.08)) - .overlay(alignment: .leading) { - Rectangle().fill(eventColor).frame(width: 2) - } - .clipShape(.rect(cornerRadius: Radius.sm)) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredEventId = hovering ? event.id : nil } - .padding(.horizontal, 4) - .offset(y: y) - } + private var dateHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(Calendar.current.component(.day, from: date))") + .font(.system(size: Typography.title, design: .monospaced).weight(.bold)) + .foregroundStyle(Calendar.current.isDateInToday(date) ? Color.accentColor : .primary) - // Database items - let dbItems = calendarVM.databaseItems(for: date, from: databaseItems) - .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - ForEach(dbItems, id: \.id) { item in - let y = calendarVM.yPosition(for: item.date, hourHeight: hourHeight) - let color = TagColor.color(for: item.color) - - Button(action: { onDatabaseItemTapped(item) }) { - HStack(spacing: 6) { - Circle().fill(color).frame(width: 5, height: 5) - Text(item.title) - .font(.system(size: Typography.bodySmall)) - .lineLimit(1) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(color.opacity(0.06)) - .clipShape(.rect(cornerRadius: Radius.xs)) - .foregroundStyle(color) - } - .buttonStyle(.plain) - .padding(.horizontal, 4) - .offset(y: y) - } + VStack(alignment: .leading, spacing: 0) { + Text(dayNameString) + .font(.system(size: Typography.body, weight: .medium)) + .foregroundStyle(.primary) + Text(monthYearString) + .font(.system(size: Typography.caption)) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + // MARK: - All-Day Section + + private var allDaySection: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(allDayEvents, id: \.id) { event in + let color = eventColor(for: event) + Button(action: { onEventTapped(event) }) { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 6, height: 6) + Text(event.title) + .font(.system(size: Typography.caption, weight: .medium)) + .lineLimit(1) + Spacer() + Text("All day") + .font(.system(size: Typography.caption2)) + .foregroundStyle(.tertiary) } - .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(color.opacity(Opacity.light)) + .foregroundStyle(color) + .clipShape(.rect(cornerRadius: Radius.sm)) } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + } + + // MARK: - Time Grid - // Current time - if Calendar.current.isDateInToday(date) { - let now = Date() - let y = calendarVM.yPosition(for: now, hourHeight: hourHeight) - let nowColor = StatusColor.error - - // Time label in gutter - HStack(spacing: 0) { - HStack { - Spacer() - Text(calendarVM.timeString(for: now)) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(nowColor) + private var timeGrid: some View { + VStack(spacing: 0) { + ForEach(calendarVM.visibleHours, id: \.self) { hour in + HStack(spacing: 0) { + HStack { + Spacer() + if !shouldHideHourLabel(hour) { + Text(calendarVM.hourLabel(hour)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) .padding(.trailing, 4) } - .frame(width: timeGutterWidth) + } + .frame(width: timeGutterWidth, alignment: .topTrailing) + .offset(y: -6) + + VStack(spacing: 0) { + Divider().opacity(0.3) Spacer() } - .offset(y: y - 6) - .allowsHitTesting(false) + .frame(maxWidth: .infinity) + } + .frame(height: hourHeight) + .id(hour) + } + } + } + + // MARK: - Event Overlays + + private var eventOverlays: some View { + HStack(spacing: 0) { + Color.clear.frame(width: timeGutterWidth) - HStack(spacing: 0) { - Color.clear.frame(width: timeGutterWidth - 4) - Circle().fill(nowColor).frame(width: 8, height: 8) - Rectangle().fill(nowColor).frame(height: 1.5) + ZStack(alignment: .topLeading) { + ForEach(timedEvents, id: \.id) { event in + timedEventCard(event) + } + + ForEach(dayDbItems, id: \.id) { item in + databaseItemCard(item) + } + } + .frame(maxWidth: .infinity) + } + } + + private func timedEventCard(_ event: CalendarEvent) -> some View { + let y = calendarVM.yPosition(for: event.startDate, hourHeight: hourHeight) + let h = calendarVM.eventHeight(start: event.startDate, end: event.endDate, hourHeight: hourHeight) + let isHovered = hoveredEventId == event.id + let color = eventColor(for: event) + + return Button(action: { onEventTapped(event) }) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(event.title) + .font(.system(size: Typography.body, weight: .medium)) + .lineLimit(3) + if event.linkedPagePath != nil { + Image(systemName: "waveform") + .font(.system(size: 9)) + .opacity(0.7) } - .offset(y: y - 4) - .allowsHitTesting(false) } + Text("\(calendarVM.timeString(for: event.startDate)) – \(calendarVM.timeString(for: event.endDate))") + .font(.system(size: Typography.caption)) + .opacity(0.8) + if let location = event.location, !location.isEmpty { + Text(location) + .font(.system(size: Typography.caption)) + .opacity(0.6) + } + } + .padding(.leading, 10) + .padding(.trailing, 6) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: h, alignment: .top) + .foregroundStyle(color) + .background(color.opacity(isHovered ? 0.14 : Opacity.light)) + .overlay(alignment: .leading) { + Rectangle().fill(color).frame(width: 3) + } + .clipShape(.rect(cornerRadius: Radius.sm)) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredEventId = hovering ? event.id : nil } + .padding(.horizontal, 4) + .offset(y: y) + } + + private func databaseItemCard(_ item: CalendarDatabaseItem) -> some View { + let y = calendarVM.yPosition(for: item.date, hourHeight: hourHeight) + let color = TagColor.color(for: item.color) + + return Button(action: { onDatabaseItemTapped(item) }) { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 5, height: 5) + Text(item.title) + .font(.system(size: Typography.bodySmall)) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.06)) + .clipShape(.rect(cornerRadius: Radius.xs)) + .foregroundStyle(color) + } + .buttonStyle(.plain) + .padding(.horizontal, 4) + .offset(y: y) + } + + // MARK: - Now Indicator + + @ViewBuilder + private var nowIndicator: some View { + if Calendar.current.isDateInToday(date) { + let now = Date() + let y = calendarVM.yPosition(for: now, hourHeight: hourHeight) + let nowColor = StatusColor.error + + // Time label in gutter + HStack(spacing: 0) { + HStack { + Spacer() + Text(calendarVM.timeString(for: now)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(nowColor) + .lineLimit(1) + .fixedSize() + .padding(.trailing, 4) + } + .frame(width: timeGutterWidth) + Spacer() } - .frame(height: CGFloat(calendarVM.visibleHours.count) * hourHeight) + .offset(y: y - 6) + .allowsHitTesting(false) + + // Dot + line + HStack(spacing: 0) { + Color.clear.frame(width: timeGutterWidth - 4) + Circle().fill(nowColor).frame(width: 8, height: 8) + Rectangle().fill(nowColor).frame(height: 1.5) + } + .offset(y: y - 4) + .allowsHitTesting(false) } } + + // MARK: - Helpers + + private func shouldHideHourLabel(_ hour: Int) -> Bool { + guard Calendar.current.isDateInToday(date) else { return false } + let nowHour = Calendar.current.component(.hour, from: Date()) + let nowMinute = Calendar.current.component(.minute, from: Date()) + if hour == nowHour && nowMinute > 20 { return true } + if hour == nowHour + 1 && nowMinute >= 40 { return true } + return false + } + + private func eventColor(for event: CalendarEvent) -> Color { + if let source = calendarSources.first(where: { $0.id == event.calendarId }) { + let hex = source.color + if hex.hasPrefix("#") { + return Color(hex: String(hex.dropFirst())) + } + return TagColor.color(for: hex) + } + return Color.accentColor + } + + private var dayNameString: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE" + return formatter.string(from: date) + } + + private var monthYearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: date) + } } diff --git a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift index 19664e0f..94754ae4 100644 --- a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift +++ b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift @@ -233,9 +233,16 @@ struct CalendarWeekView: View { .opacity(0.8) } } else { - Text(event.title) - .font(.system(size: 11, weight: .medium)) - .lineLimit(h > hourHeight ? 3 : 1) + HStack(spacing: 3) { + Text(event.title) + .font(.system(size: 11, weight: .medium)) + .lineLimit(h > hourHeight ? 3 : 1) + if event.linkedPagePath != nil { + Image(systemName: "waveform") + .font(.system(size: 8)) + .opacity(0.7) + } + } Text("\(calendarVM.timeString(for: event.startDate))–\(calendarVM.timeString(for: event.endDate))") .font(.system(size: 10)) .opacity(0.8) diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift index f2684b93..769ecb7d 100644 --- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift +++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift @@ -10,6 +10,10 @@ struct WorkspaceCalendarView: View { var aiService: AiService var onNavigateToFile: (String) -> Void + /// Tracks whether the user explicitly picked a view mode (overriding width-adaptive auto). + @State private var userOverrodeViewMode = false + @State private var lastAutoMode: CalendarViewMode? + @State private var transcriptionService = TranscriptionService() @State private var showImportRecording = false @State private var showCreateEventSheet = false @@ -21,18 +25,32 @@ struct WorkspaceCalendarView: View { @State private var createEventError: String? var body: some View { - VStack(spacing: 0) { - calendarHeader - if let error = calendarService.error, !error.isEmpty { - calendarErrorBanner(error) + GeometryReader { geo in + VStack(spacing: 0) { + calendarHeader + if let error = calendarService.error, !error.isEmpty { + calendarErrorBanner(error) + } + calendarContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: geo.size.width) { _, newWidth in + applyWidthAdaptiveMode(width: newWidth) + } + .onAppear { + applyWidthAdaptiveMode(width: geo.size.width) } - calendarContent - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .ignoresSafeArea(.container, edges: .top) .background(Color.fallbackEditorBg) .animation(.easeInOut(duration: 0.15), value: calendarVM.viewMode) + .onChange(of: calendarVM.viewMode) { oldValue, newValue in + // If the mode changed and it wasn't from our auto-switch, mark as user override + if newValue != lastAutoMode { + userOverrodeViewMode = true + } + } .sheet(isPresented: $showCreateEventSheet) { CalendarEventComposerSheet( draft: $createEventDraft, @@ -72,6 +90,7 @@ struct WorkspaceCalendarView: View { events: visibleEvents, databaseItems: calendarService.databaseItems, calendarVM: calendarVM, + calendarSources: calendarService.sources, onEventTapped: handleEventTapped, onDatabaseItemTapped: handleDatabaseItemTapped ) @@ -244,6 +263,20 @@ struct WorkspaceCalendarView: View { } } + // MARK: - Width-Adaptive Mode + + /// Auto-switch between day and week based on available width. + /// Month is never auto-selected. User override is respected until the pane + /// crosses the threshold in the other direction. + private func applyWidthAdaptiveMode(width: CGFloat) { + guard !userOverrodeViewMode else { return } + let preferred: CalendarViewMode = width > 700 ? .week : .day + if calendarVM.viewMode != preferred, calendarVM.viewMode != .month { + lastAutoMode = preferred + calendarVM.viewMode = preferred + } + } + // MARK: - Actions private func handleEventTapped(_ event: CalendarEvent) { diff --git a/Sources/Bugbook/Views/Components/CommandPaletteView.swift b/Sources/Bugbook/Views/Components/CommandPaletteView.swift index fd9e8862..50a162ca 100644 --- a/Sources/Bugbook/Views/Components/CommandPaletteView.swift +++ b/Sources/Bugbook/Views/Components/CommandPaletteView.swift @@ -349,7 +349,7 @@ struct CommandPaletteView: View { Image(systemName: "sparkles") .font(.system(size: 13)) .foregroundStyle(.secondary) - Text("Ask AI: ") + Text("Chat: ") .font(.system(size: 15)) .foregroundStyle(.secondary) + Text(query) diff --git a/Sources/Bugbook/Views/Components/KeyboardShortcutOverlay.swift b/Sources/Bugbook/Views/Components/KeyboardShortcutOverlay.swift new file mode 100644 index 00000000..c4fdda8a --- /dev/null +++ b/Sources/Bugbook/Views/Components/KeyboardShortcutOverlay.swift @@ -0,0 +1,164 @@ +import SwiftUI + +/// Full-screen overlay showing keyboard shortcuts, triggered by Cmd+/. +/// Tap anywhere or press Escape/Cmd+/ to dismiss. +struct KeyboardShortcutOverlay: View { + var onDismiss: () -> Void + + private let primarySections: [(title: String, shortcuts: [(keys: String, label: String)])] = [ + ("Panes", [ + ("\u{2318}\u{2325}\u{2190}/\u{2192}/\u{2191}/\u{2193}", "Move focus between panes"), + ("\u{2318}D", "Split pane right"), + ("\u{2318}\u{21E7}E", "Split pane down"), + ("\u{2318}\u{21E7}W", "Close workspace"), + ]), + ("Navigation", [ + ("\u{2318}K", "Quick open"), + ("\u{2318}1\u{2013}9", "Switch workspace"), + ("\u{2318}T", "New tab"), + ("\u{2318}W", "Close tab"), + ("\u{2318}B", "Toggle sidebar"), + ("\u{2318}[ \u{2318}]", "Back / Forward"), + ]), + ] + + private let secondarySections: [(title: String, shortcuts: [(keys: String, label: String)])] = [ + ("Views", [ + ("\u{2318}\u{21E7}0", "Home"), + ("\u{2318}\u{21E7}M", "Mail"), + ("\u{2318}\u{21E7}Y", "Calendar"), + ("\u{2318}I", "Chat"), + ("\u{2318}\u{21E7}D", "Today's note"), + ]), + ("Editor", [ + ("\u{2318}N", "New note"), + ("\u{2318}\u{21E7}L", "Toggle theme"), + ("\u{2318}+/\u{2318}-", "Zoom in/out"), + ]), + ] + + private let workflows: [(keys: String, label: String)] = [ + ("\u{2318}D \u{2192} \u{2318}\u{21E7}Y", "Open Calendar beside current pane"), + ("\u{2318}D \u{2192} \u{2318}\u{21E7}M", "Open Mail beside current pane"), + ("\u{2318}\u{2325}\u{2192} \u{2192} \u{2318}D", "Focus right pane, then split it"), + ("\u{2318}T \u{2192} \u{2318}\u{21E7}0", "New workspace tab with Home"), + ] + + var body: some View { + ZStack { + // Backdrop + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { onDismiss() } + + VStack(spacing: 0) { + // Header + HStack { + Text("Keyboard Shortcuts") + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Text("\u{2318}/") + .font(.system(size: Typography.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(.rect(cornerRadius: Radius.xs)) + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Primary sections — full visual weight + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 24), GridItem(.flexible(), spacing: 24)], + alignment: .leading, + spacing: 16 + ) { + ForEach(primarySections, id: \.title) { section in + shortcutSection(section.title, shortcuts: section.shortcuts, isPrimary: true) + } + } + + Divider() + .padding(.vertical, 2) + + // Secondary sections — dimmer + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 24), GridItem(.flexible(), spacing: 24)], + alignment: .leading, + spacing: 16 + ) { + ForEach(secondarySections, id: \.title) { section in + shortcutSection(section.title, shortcuts: section.shortcuts, isPrimary: false) + } + } + + Divider() + .padding(.vertical, 2) + + // Workflows — composed shortcuts + VStack(alignment: .leading, spacing: 8) { + Text("Workflows") + .font(.system(size: Typography.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + ForEach(workflows, id: \.label) { wf in + HStack(spacing: 10) { + Text(wf.keys) + .font(.system(size: Typography.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 160, alignment: .trailing) + + Text(wf.label) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.primary) + } + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } + } + .frame(width: 540) + .frame(maxHeight: 520) + .background(Color.fallbackEditorBg) + .clipShape(.rect(cornerRadius: Radius.lg)) + .overlay( + RoundedRectangle(cornerRadius: Radius.lg) + .strokeBorder(Color.fallbackBorderColor, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.25), radius: 20, y: 8) + } + .onExitCommand { onDismiss() } + } + + private func shortcutSection(_ title: String, shortcuts: [(keys: String, label: String)], isPrimary: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: Typography.caption2, weight: .semibold)) + .foregroundStyle(isPrimary ? .secondary : .tertiary) + .textCase(.uppercase) + + ForEach(shortcuts, id: \.label) { shortcut in + HStack(spacing: 10) { + Text(shortcut.keys) + .font(.system(size: Typography.caption, design: .monospaced)) + .foregroundStyle(isPrimary ? .secondary : .quaternary) + .frame(width: 100, alignment: .trailing) + + Text(shortcut.label) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(isPrimary ? .primary : .secondary) + } + } + } + } +} diff --git a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift index 052b3777..6183d85e 100644 --- a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift +++ b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift @@ -7,10 +7,13 @@ import AppKit struct WorkspaceTabBar: View { var workspaceManager: WorkspaceManager var sidebarOpen: Bool + var currentView: ViewMode = .editor @State private var dragOverIndex: Int? @State private var draggingId: UUID? @State private var showNewMenu = false + @State private var showSavedIndicator = false + @State private var savedIndicatorTask: Task? var body: some View { HStack(alignment: .bottom, spacing: 0) { @@ -80,6 +83,7 @@ struct WorkspaceTabBar: View { .scrollIndicators(.hidden) .padding(.leading, sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(112)) Spacer() + layoutSavedIndicator } .padding(.top, ShellZoomMetrics.size(6)) .frame(height: ShellZoomMetrics.size(36)) @@ -91,9 +95,35 @@ struct WorkspaceTabBar: View { .frame(height: 1) } ) + .onChange(of: workspaceManager.lastSavedAt) { _, _ in + savedIndicatorTask?.cancel() + withAnimation(.easeIn(duration: 0.15)) { showSavedIndicator = true } + savedIndicatorTask = Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.3)) { showSavedIndicator = false } + } + } + } + + @ViewBuilder + private var layoutSavedIndicator: some View { + if showSavedIndicator { + Text("Saved") + .font(.system(size: Typography.caption2)) + .foregroundStyle(.tertiary) + .padding(.trailing, ShellZoomMetrics.size(12)) + .transition(.opacity) + } } private func tabTitle(for ws: Workspace) -> String { + // Override for full-page views + if ws.id == workspaceManager.activeWorkspace?.id { + if currentView == .chat { return "Chat" } + if currentView == .graphView { return "Graph" } + if currentView == .calendar { return "Calendar" } + } // Derive name from the focused pane's content if let leaf = ws.focusedLeaf { switch leaf.content { @@ -103,6 +133,7 @@ struct WorkspaceTabBar: View { if file.isCalendar { return "Calendar" } if file.isMeetings { return "Meetings" } if file.isGraphView { return "Graph" } + if file.isMail { return "Mail" } let fileName = (file.path as NSString).lastPathComponent return fileName.hasSuffix(".md") ? String(fileName.dropLast(3)) : fileName case .terminal: @@ -113,11 +144,18 @@ struct WorkspaceTabBar: View { } private func tabIcon(for ws: Workspace) -> String? { + if ws.id == workspaceManager.activeWorkspace?.id { + if currentView == .chat { return "sf:bubble.left.and.bubble.right" } + if currentView == .graphView { return "sf:point.3.connected.trianglepath.dotted" } + if currentView == .calendar { return "sf:calendar" } + } guard let leaf = ws.focusedLeaf else { return nil } switch leaf.content { case .document(let file): + if file.isGateway { return "sf:house" } + if file.isMail { return "sf:envelope" } if file.isCalendar { return "sf:calendar" } - if file.isMeetings { return "sf:person.2" } + if file.isMeetings { return "sf:waveform" } if file.isGraphView { return "sf:point.3.connected.trianglepath.dotted" } return file.icon case .terminal: diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index fdc5c18f..90ed977c 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -87,6 +87,7 @@ struct ContentView: View { movePageOverlay themeToastOverlay editorZoomOverlay + shortcutOverlay } } @@ -395,6 +396,16 @@ struct ContentView: View { appState.showSettings = false openContentInFocusedPane(.gatewayDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .openTerminal)) { _ in + appState.currentView = .editor + appState.showSettings = false + openContentInFocusedPane(.terminal) + } + .onReceive(NotificationCenter.default.publisher(for: .toggleShortcutOverlay)) { _ in + withAnimation(.easeInOut(duration: 0.15)) { + appState.showShortcutOverlay.toggle() + } + } .onReceive(NotificationCenter.default.publisher(for: .newDatabase)) { _ in createNewDatabase() } @@ -422,6 +433,10 @@ struct ContentView: View { private func applyDatabaseNotifications(to view: V) -> some View { view .onReceive(NotificationCenter.default.publisher(for: .openAIPanel)) { _ in + ensureAiInitializedIfNeeded() + appState.toggleAiPanel() + } + .onReceive(NotificationCenter.default.publisher(for: .openFullChat)) { _ in ensureAiInitializedIfNeeded() appState.openNotesChat() } @@ -578,17 +593,40 @@ struct ContentView: View { appState: appState, isPresented: $appState.commandPaletteOpen, onSelectFile: { entry in - pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: false, searchQuery: nil, id: UUID()) + DispatchQueue.main.async { + navigateToEntryInPane(entry) + } }, onSelectFileNewTab: { entry in - pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: true, searchQuery: nil, id: UUID()) + DispatchQueue.main.async { + openEntryInNewWorkspaceTab(entry) + } }, onCreateFile: { name in createNewFileWithName(name) }, onSelectContentMatch: { entry, query in let newTab = appState.commandPaletteMode == .newTab - pendingCmdKNavigation = CmdKNavRequest(entry: entry, inNewTab: newTab, searchQuery: query, id: UUID()) + DispatchQueue.main.async { + if newTab { + openEntryInNewWorkspaceTab(entry) + } else { + navigateToEntryInPane(entry) + } + if let query = query as String? { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + guard let ws = workspaceManager.activeWorkspace, + let doc = blockDocuments[ws.focusedPaneId] else { return } + let lowerQuery = query.lowercased() + if let block = doc.blocks.first(where: { + $0.text.lowercased().contains(lowerQuery) + }) { + doc.focusedBlockId = block.id + doc.cursorPosition = 0 + } + } + } + } } ) Spacer() @@ -810,6 +848,12 @@ struct ContentView: View { if appState.showSettings { SettingsView(appState: appState) } else if appState.currentView == .chat { + WorkspaceTabBar( + workspaceManager: workspaceManager, + sidebarOpen: appState.sidebarOpen, + currentView: appState.currentView + ) + .opacity(editorUI.focusModeActive ? 0.0 : 1.0) NotesChatView(appState: appState, aiService: aiService) } else if appState.currentView == .graphView { if let workspace = appState.workspacePath { @@ -872,7 +916,8 @@ struct ContentView: View { private var editorModeContent: some View { WorkspaceTabBar( workspaceManager: workspaceManager, - sidebarOpen: appState.sidebarOpen + sidebarOpen: appState.sidebarOpen, + currentView: appState.currentView ) .opacity(editorUI.focusModeActive ? 0.0 : 1.0) @@ -1086,6 +1131,9 @@ struct ContentView: View { MeetingsView( appState: appState, viewModel: meetingsVM, + transcriptionService: transcriptionService, + meetingNoteService: meetingNoteService, + aiService: aiService, onNavigateToFile: { path in navigateToFilePath(path) } @@ -2333,6 +2381,20 @@ struct ContentView: View { } } + // MARK: - Shortcut Overlay + + @ViewBuilder + private var shortcutOverlay: some View { + if appState.showShortcutOverlay { + KeyboardShortcutOverlay { + withAnimation(.easeInOut(duration: 0.15)) { + appState.showShortcutOverlay = false + } + } + .transition(.opacity) + } + } + private var editorZoomRange: ClosedRange { Double(EditorTypography.minZoomScale)...Double(EditorTypography.maxZoomScale) } diff --git a/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift b/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift index d16419bc..07c61760 100644 --- a/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift @@ -767,47 +767,41 @@ struct DatabaseFullPageView: View { switch state.activeView?.type ?? .table { case .table: - GeometryReader { geo in - ScrollView([.horizontal, .vertical]) { - TableView( - schema: schema, - rows: boundRows, - viewConfig: state.activeView ?? state.defaultViewConfig(), - onOpenRow: { row in openRow(row) }, - onSave: { row in state.saveRow(row) }, - onDelete: { row in state.deleteRow(row) }, - onToggleColumn: { propId in state.toggleColumnVisibility(propId) }, - onAddProperty: { type in state.addPropertyFromTable(type: type) }, - onRenameProperty: { propId, newName in - state.renameProperty(propId, to: newName) - }, - onDeleteProperty: { propId in state.deleteProperty(propId) }, - onChangePropertyType: { propId, newType in state.changePropertyType(propId, to: newType) }, - onAddSelectOption: { propId, option in state.addSelectOption(propId, option: option) }, - onUpdateSelectOption: { propId, optId, name, color in state.updateSelectOption(propId, optionId: optId, name: name, color: color) }, - onDeleteSelectOption: { propId, optId in state.deleteSelectOption(propId, optionId: optId) }, - onLoadRelationRows: { prop in state.loadRelationRows(for: prop) }, - onListDatabases: { state.listDatabaseCandidates(workspacePath: workspacePath) }, - onSetRelationTarget: { propId, target in state.setRelationTarget(propId, target: target) }, - onResizeColumn: { propId, width in state.resizeColumn(propId, to: width) }, - onReorderRows: { draggedId, targetId in - state.reorderRows(draggedId: draggedId, before: targetId, visibleRowIds: filteredIds) - }, - onClearSorts: { state.clearSorts() }, - onNewRow: { createNewRow() }, - onSetCalculation: { propId, fn in state.setCalculation(propertyId: propId, function: fn) }, - calculationResults: state.calculationResults(for: filtered), - showVerticalLines: showVerticalLines, - usesInnerScroll: false, - containerWidth: geo.size.width - ) - .frame( - minWidth: geo.size.width, - minHeight: geo.size.height, - alignment: .topLeading - ) - .fixedSize(horizontal: true, vertical: true) - } + ScrollView([.horizontal, .vertical]) { + TableView( + schema: schema, + rows: boundRows, + viewConfig: state.activeView ?? state.defaultViewConfig(), + onOpenRow: { row in openRow(row) }, + onSave: { row in state.saveRow(row) }, + onDelete: { row in state.deleteRow(row) }, + onToggleColumn: { propId in state.toggleColumnVisibility(propId) }, + onAddProperty: { type in state.addPropertyFromTable(type: type) }, + onRenameProperty: { propId, newName in + state.renameProperty(propId, to: newName) + }, + onDeleteProperty: { propId in state.deleteProperty(propId) }, + onChangePropertyType: { propId, newType in state.changePropertyType(propId, to: newType) }, + onAddSelectOption: { propId, option in state.addSelectOption(propId, option: option) }, + onUpdateSelectOption: { propId, optId, name, color in state.updateSelectOption(propId, optionId: optId, name: name, color: color) }, + onDeleteSelectOption: { propId, optId in state.deleteSelectOption(propId, optionId: optId) }, + onLoadRelationRows: { prop in state.loadRelationRows(for: prop) }, + onListDatabases: { state.listDatabaseCandidates(workspacePath: workspacePath) }, + onSetRelationTarget: { propId, target in state.setRelationTarget(propId, target: target) }, + onResizeColumn: { propId, width in state.resizeColumn(propId, to: width) }, + onReorderRows: { draggedId, targetId in + state.reorderRows(draggedId: draggedId, before: targetId, visibleRowIds: filteredIds) + }, + onClearSorts: { state.clearSorts() }, + onNewRow: { createNewRow() }, + onSetCalculation: { propId, fn in state.setCalculation(propertyId: propId, function: fn) }, + calculationResults: state.calculationResults(for: filtered), + showVerticalLines: showVerticalLines, + usesInnerScroll: false, + containerWidth: 0 + ) + .fixedSize(horizontal: true, vertical: true) + .padding(.bottom, 48) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) case .kanban: diff --git a/Sources/Bugbook/Views/Database/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index 7748e5ad..e9cca0b2 100644 --- a/Sources/Bugbook/Views/Database/PropertyEditorView.swift +++ b/Sources/Bugbook/Views/Database/PropertyEditorView.swift @@ -41,7 +41,7 @@ struct PropertyEditorView: View { if usesOptionEditing { mainEditor .databasePointerCursor() - .floatingPopover(item: $editingOptionId) { optId in + .floatingPopover(item: $editingOptionId, arrowEdge: .trailing) { optId in editOptionPopover(optionId: optId) } .alert("Delete Option", isPresented: $showDeleteAlert) { @@ -1398,13 +1398,15 @@ private struct OptionButtonRow: View { // 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) + Button(action: onKebab) { + Text("···") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isHovered ? 1 : 0) } if isActive { diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index 7f179284..b9af68d4 100644 --- a/Sources/Bugbook/Views/Editor/BlockCellView.swift +++ b/Sources/Bugbook/Views/Editor/BlockCellView.swift @@ -57,13 +57,13 @@ struct BlockCellView: View { .padding(.top, listEdgePadding(neighbor: previousBlockType)) .padding(.bottom, listEdgePadding(neighbor: nextBlockType)) .background( - block.backgroundColor != .default + block.backgroundColor != .default && !blockHasOwnContainer ? block.backgroundColor.backgroundColor : Color.clear ) .opacity(isBeingDragged ? 0.22 : 1) .background(BlockFrameReporter(document: document, blockId: block.id)) - .clipShape(.rect(cornerRadius: block.backgroundColor != .default ? 4 : 0)) + .clipShape(.rect(cornerRadius: block.backgroundColor != .default && !blockHasOwnContainer ? 4 : 0)) .onHover { inside in isRowHovering = inside } @@ -93,6 +93,18 @@ struct BlockCellView: View { return 2 } + /// Blocks that render their own styled container (background + border). + /// Row-level background color should not apply to these — it would color the entire row + /// instead of just the container. + private var blockHasOwnContainer: Bool { + switch block.type { + case .outline, .callout, .codeBlock: + true + default: + false + } + } + private var blockUsesOwnInteractions: Bool { switch block.type { case .databaseEmbed, .image, .pageLink, .meeting, .table, .outline: @@ -285,7 +297,7 @@ struct BlockCellView: View { TableBlockView(document: document, block: block) case .outline: - OutlineBlockView(document: document) + OutlineBlockView(document: document, block: block) case .callout: CalloutBlockView(document: document, block: block, onTyping: onTyping) diff --git a/Sources/Bugbook/Views/Editor/BlockTextView.swift b/Sources/Bugbook/Views/Editor/BlockTextView.swift index d84390d1..47ff9696 100644 --- a/Sources/Bugbook/Views/Editor/BlockTextView.swift +++ b/Sources/Bugbook/Views/Editor/BlockTextView.swift @@ -163,6 +163,10 @@ struct BlockTextView: NSViewRepresentable { doc.insertPageLinkBlock(at: idx + 1, name: pageName) } } + textView.toggleBlockExpandAction = { [weak coordinator] in + guard let coordinator = coordinator else { return } + coordinator.parent.document.toggleBlockExpanded(id: coordinator.parent.blockId) + } textView.copySelectionAction = { [weak coordinator] in coordinator?.handleCopySelection() ?? false } @@ -1303,6 +1307,7 @@ class BlockNSTextView: NSTextView { var onPageLinkDrop: ((String) -> Void)? var copySelectionAction: (() -> Bool)? var cutSelectionAction: (() -> Bool)? + var toggleBlockExpandAction: (() -> Void)? var onFrameWidthChanged: (() -> Void)? var isInBlockSelection = false private var lastKnownWidth: CGFloat = 0 @@ -1500,6 +1505,11 @@ class BlockNSTextView: NSTextView { } if flags.contains([.command, .shift]) && !flags.contains(.option) && !flags.contains(.control) { + // Cmd+Shift+Return — toggle expand/collapse for toggle/headingToggle blocks + if event.keyCode == 36 { + toggleBlockExpandAction?() + return + } if let chars = event.charactersIgnoringModifiers?.lowercased() { if chars == "x" { formatStrikethroughAction?() diff --git a/Sources/Bugbook/Views/Editor/CalloutBlockView.swift b/Sources/Bugbook/Views/Editor/CalloutBlockView.swift index 42c178ce..f9a7d887 100644 --- a/Sources/Bugbook/Views/Editor/CalloutBlockView.swift +++ b/Sources/Bugbook/Views/Editor/CalloutBlockView.swift @@ -31,68 +31,64 @@ struct CalloutBlockView: View { } var body: some View { - HStack(alignment: .top, spacing: 0) { - // Left accent border - RoundedRectangle(cornerRadius: 1.5) - .fill(accentColor.opacity(0.5)) - .frame(width: 3) - .padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 4) { - // Header: icon + editable title - HStack(alignment: .top, spacing: 6) { - Button { - showPicker.toggle() - } label: { - Image(systemName: block.calloutIcon) - .font(.system(size: 14, weight: .medium)) - .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( + VStack(alignment: .leading, spacing: 4) { + // Header: icon + editable title + HStack(alignment: .top, spacing: 6) { + Button { + showPicker.toggle() + } label: { + Image(systemName: block.calloutIcon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(accentColor) + .frame(width: 20, height: 24) + } + .buttonStyle(.plain) + .appCursor(.pointingHand) + .popover(isPresented: $showPicker, arrowEdge: .bottom) { + CalloutPickerView( document: document, blockId: block.id, - selectionVersion: document.selectionVersion, - font: .systemFont(ofSize: EditorTypography.bodyFontSize, weight: .semibold), - textColor: .labelColor, - placeholder: "Callout", - onTextChange: onTyping, - textHeight: $textHeight + currentIcon: block.calloutIcon, + currentColor: block.calloutColor ) - .frame(height: textHeight) } - // Children - if !block.children.isEmpty { - VStack(alignment: .leading, spacing: 0) { - 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) - } + BlockTextView( + document: document, + blockId: block.id, + selectionVersion: document.selectionVersion, + font: .systemFont(ofSize: EditorTypography.bodyFontSize, weight: .regular), + textColor: .labelColor, + placeholder: "Callout", + onTextChange: onTyping, + textHeight: $textHeight + ) + .frame(height: textHeight) + } + + // Children + if !block.children.isEmpty { + VStack(alignment: .leading, spacing: 0) { + 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) } - .padding(.leading, 26) } + .padding(.leading, 26) } - .padding(.horizontal, 10) - .padding(.vertical, 8) } + .padding(.horizontal, 10) + .padding(.vertical, 8) .background( - RoundedRectangle(cornerRadius: Radius.sm) + RoundedRectangle(cornerRadius: Radius.md) .fill(fillColor) ) + .overlay( + RoundedRectangle(cornerRadius: Radius.md) + .strokeBorder(Color.primary.opacity(Opacity.light), lineWidth: 1) + ) } } diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift index 7bebda00..ec437400 100644 --- a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -118,12 +118,12 @@ struct MeetingBlockView: View { } .padding(.horizontal, 14) .padding(.top, 12) - .padding(.bottom, 4) + .padding(.bottom, 0) meetingNotesChildBlocks .padding(.horizontal, 14) - .padding(.top, 12) - .padding(.bottom, 12) + .padding(.top, 4) + .padding(.bottom, 20) } } diff --git a/Sources/Bugbook/Views/Editor/OutlineBlockView.swift b/Sources/Bugbook/Views/Editor/OutlineBlockView.swift index c8be4680..c595457e 100644 --- a/Sources/Bugbook/Views/Editor/OutlineBlockView.swift +++ b/Sources/Bugbook/Views/Editor/OutlineBlockView.swift @@ -5,9 +5,17 @@ import SwiftUI /// Updates live as headings change. struct OutlineBlockView: View { var document: BlockDocument + var block: Block? = nil - private var headings: [(id: UUID, text: String, level: Int)] { - collectHeadings(from: document.blocks) + private var containerFill: Color { + if let block, block.backgroundColor != .default { + return block.backgroundColor.backgroundColor + } + return Color.primary.opacity(Opacity.subtle) + } + + private var headings: [(id: UUID, text: String, depth: Int)] { + collectHeadings(from: document.blocks, depth: 0) } var body: some View { @@ -25,7 +33,7 @@ struct OutlineBlockView: View { .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: Radius.md) - .fill(Color.primary.opacity(Opacity.subtle)) + .fill(containerFill) ) .overlay( RoundedRectangle(cornerRadius: Radius.md) @@ -36,30 +44,13 @@ struct OutlineBlockView: View { // MARK: - Rows - private func headingRow(_ entry: (id: UUID, text: String, level: Int)) -> some View { - let indent = CGFloat(max(0, entry.level - 1)) * 16 + private func headingRow(_ entry: (id: UUID, text: String, depth: Int)) -> some View { + let indent = CGFloat(entry.depth) * 16 - return Button { + return TOCLink(text: entry.text, indent: indent) { document.focusedBlockId = entry.id document.scrollToBlockId = entry.id - } label: { - HStack(spacing: 6) { - Circle() - .fill(Color.fallbackTextSecondary.opacity(0.5)) - .frame(width: 4, height: 4) - - Text(entry.text.isEmpty ? "Untitled" : entry.text) - .font(.system(size: EditorTypography.bodyFontSize - 1)) - .foregroundStyle(entry.text.isEmpty ? Color.fallbackTextSecondary : .primary) - .lineLimit(1) - } - .padding(.leading, indent) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .appCursor(.pointingHand) } private var emptyState: some View { @@ -71,16 +62,45 @@ struct OutlineBlockView: View { // MARK: - Heading Collection - private func collectHeadings(from blocks: [Block]) -> [(id: UUID, text: String, level: Int)] { - var result: [(id: UUID, text: String, level: Int)] = [] + /// Collect headings with their nesting depth in the block tree (not heading level number). + /// depth 0 = top-level heading, depth 1 = heading inside a toggle/callout/etc. + private func collectHeadings(from blocks: [Block], depth: Int) -> [(id: UUID, text: String, depth: Int)] { + var result: [(id: UUID, text: String, depth: Int)] = [] for block in blocks { - if block.type == .heading || block.type == .headingToggle { - result.append((id: block.id, text: block.text, level: block.headingLevel)) + if (block.type == .heading || block.type == .headingToggle), block.headingLevel > 1 { + let plainText = AttributedStringConverter.plainText(from: block.text) + result.append((id: block.id, text: plainText, depth: depth)) } if !block.children.isEmpty { - result.append(contentsOf: collectHeadings(from: block.children)) + result.append(contentsOf: collectHeadings(from: block.children, depth: depth + 1)) } } return result } } + +/// A single TOC link with hover highlight. +private struct TOCLink: View { + let text: String + let indent: CGFloat + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Text(text.isEmpty ? "Untitled" : text) + .font(.system(size: EditorTypography.bodyFontSize - 1)) + .foregroundStyle(isHovered ? Color.primary : Color.fallbackTextSecondary) + .underline() + .lineLimit(1) + .padding(.leading, indent) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .appCursor(.pointingHand) + .onHover { isHovered = $0 } + } +} diff --git a/Sources/Bugbook/Views/Editor/TableBlockView.swift b/Sources/Bugbook/Views/Editor/TableBlockView.swift index 17d84a6e..8a277478 100644 --- a/Sources/Bugbook/Views/Editor/TableBlockView.swift +++ b/Sources/Bugbook/Views/Editor/TableBlockView.swift @@ -40,10 +40,6 @@ struct TableBlockView: View { var body: some View { HStack(alignment: .top, spacing: 0) { - // 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 @@ -59,36 +55,16 @@ struct TableBlockView: View { .opacity(isHovering ? 1 : 0) } - // Add column button — outside the table, to the right (issue #4) + // Add column button — to the right of the table addColumnButton .opacity(isHovering ? 1 : 0) } .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 private var tableGrid: some View { diff --git a/Sources/Bugbook/Views/Home/HomeBottomZone.swift b/Sources/Bugbook/Views/Home/HomeBottomZone.swift index a52391a4..b982cd7e 100644 --- a/Sources/Bugbook/Views/Home/HomeBottomZone.swift +++ b/Sources/Bugbook/Views/Home/HomeBottomZone.swift @@ -7,110 +7,46 @@ struct HomeBottomZone: View { @State private var showingPinPicker = false var body: some View { - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 12) { - calendarColumn - inboxColumn - } - VStack(spacing: 12) { - calendarColumn - inboxColumn - } + VStack(spacing: 12) { + pinnedSection } } - private var calendarColumn: some View { + private var pinnedSection: some View { surface { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("TODAY") - - if vm.todayTimeline.isEmpty { - Text("No events on the calendar today.") - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackTextSecondary) - } else { - ForEach(vm.todayTimeline) { item in - switch item.kind { - case .freeGap(let label): - HStack(spacing: 8) { - Rectangle() - .fill(Color.fallbackBorderColor) - .frame(height: 1) - Text(label) - .font(.system(size: 10)) - .italic() - .foregroundStyle(Color.fallbackTextMuted) - .fixedSize() - Rectangle() - .fill(Color.fallbackBorderColor) - .frame(height: 1) - } - .padding(.vertical, 3) - .padding(.horizontal, 8) - case .event(let event): - calendarRow(event) - .opacity(event.isPast ? 0.35 : 1) + VStack(alignment: .leading, spacing: 12) { + HStack { + sectionHeader("PINNED") + Spacer() + if !unpinnedDatabases.isEmpty { + Button { + showingPinPicker = true + } label: { + Text("+") + .font(.system(size: Typography.caption, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.fallbackTextMuted) + .frame(minWidth: 24, minHeight: 24) + .contentShape(Rectangle()) } - } - } - } - } - .frame(maxWidth: .infinity, alignment: .topLeading) - } - - private var inboxColumn: some View { - VStack(spacing: 12) { - surface { - VStack(alignment: .leading, spacing: 6) { - sectionHeader("INBOX") - - if vm.inboxThreads.isEmpty { - Text("No recent threads.") - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackTextSecondary) - } else { - ForEach(Array(vm.inboxThreads.enumerated()), id: \.element.id) { index, item in - inboxRow(index: index + 1, item: item) + .buttonStyle(.plain) + .floatingPopover(isPresented: $showingPinPicker) { + pinPickerPopover } } } - } - surface { - VStack(alignment: .leading, spacing: 12) { - HStack { - sectionHeader("PINNED") - Spacer() - if !unpinnedDatabases.isEmpty { - Button { - showingPinPicker = true - } label: { - Text("+") - .font(.system(size: Typography.caption, weight: .medium, design: .monospaced)) - .foregroundStyle(Color.fallbackTextMuted) - .frame(minWidth: 24, minHeight: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .floatingPopover(isPresented: $showingPinPicker) { - pinPickerPopover - } + if vm.pinnedDatabases.isEmpty { + Text("Pin a database to track it here.") + .font(.system(size: Typography.caption)) + .foregroundStyle(Color.fallbackTextSecondary) + } else { + ForEach(vm.pinnedDatabases) { database in + PinnedDatabaseCard(database: database) { + onNavigateToFile(database.path) } - } - - if vm.pinnedDatabases.isEmpty { - Text("Pin a database to track it here.") - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackTextSecondary) - } else { - ForEach(vm.pinnedDatabases) { database in - PinnedDatabaseCard(database: database) { - onNavigateToFile(database.path) - } - .contextMenu { - Button("Unpin") { - vm.togglePin(database.path) - } + .contextMenu { + Button("Unpin") { + vm.togglePin(database.path) } } } @@ -148,90 +84,6 @@ struct HomeBottomZone: View { .popoverSurface() } - private func calendarRow(_ event: HomeViewModel.CalendarItem) -> some View { - HStack(alignment: .top, spacing: 10) { - Text(event.isAllDay ? "all day" : event.startDate.formatted(.dateTime.hour(.defaultDigits(amPM: .narrow)).minute())) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(Color.fallbackTextMuted) - .frame(width: 42, alignment: .trailing) - - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 5) { - if event.isPast { - Text("✓") - .font(.system(size: 10)) - .foregroundStyle(Color.fallbackTextMuted) - } - if let hex = event.calendarColor { - Circle() - .fill(Color(hex: hex)) - .frame(width: 4, height: 4) - } - Text(event.title) - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - .lineLimit(1) - } - - if let context = event.context { - Text(context) - .font(.system(size: 10)) - .foregroundStyle(Color.fallbackTextSecondary) - } - - if let contextLine = event.contextLine { - Text(contextLine) - .font(.system(size: 10)) - .foregroundStyle(Color.fallbackTextMuted) - .lineLimit(1) - } - } - } - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.fallbackSurfaceSubtle) - .clipShape(.rect(cornerRadius: Radius.sm)) - } - - private func inboxRow(index: Int, item: HomeViewModel.InboxItem) -> some View { - HStack(alignment: .top, spacing: 8) { - Text("\(index)") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(Color.fallbackTextMuted) - .frame(width: 12, alignment: .trailing) - .padding(.top, 2) - - VStack(alignment: .leading, spacing: 1) { - Text(item.subject) - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - .lineLimit(1) - Text(inboxSecondaryText(for: item)) - .font(.system(size: Typography.caption2)) - .foregroundStyle(Color.fallbackTextMuted) - .lineLimit(1) - } - - Spacer(minLength: 0) - - if item.showsReplyBadge { - Text("reply") - .font(.system(size: 9, weight: .semibold)) - .tracking(0.3) - .foregroundStyle(TagColor.color(for: "blue")) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(TagColor.color(for: "blue").opacity(0.12)) - .clipShape(.rect(cornerRadius: 3)) - } - } - .padding(.vertical, 3) - } - - private func inboxSecondaryText(for item: HomeViewModel.InboxItem) -> String { - item.sender - } - private func sectionHeader(_ title: String) -> some View { Text(title) .font(.system(size: Typography.caption2, weight: .semibold)) diff --git a/Sources/Bugbook/Views/Mail/MailPaneView.swift b/Sources/Bugbook/Views/Mail/MailPaneView.swift index c0955845..8b0c824e 100644 --- a/Sources/Bugbook/Views/Mail/MailPaneView.swift +++ b/Sources/Bugbook/Views/Mail/MailPaneView.swift @@ -6,6 +6,7 @@ struct MailPaneView: View { @Bindable var mailService: MailService @State private var searchText = "" + @State private var activeFilter: MailFilter = .all var body: some View { VStack(spacing: 0) { @@ -23,14 +24,9 @@ struct MailPaneView: View { message: "Sign in once and Bugbook will use that Google account for both Mail and Calendar." ) } else { - HStack(spacing: 0) { - mailboxRail - .frame(width: 180) - Divider() + VStack(spacing: 0) { + mailFilterTabs threadList - .frame(width: 320) - Divider() - detailPane } } } @@ -50,6 +46,16 @@ struct MailPaneView: View { mailService.loadCachedData(accountEmail: newEmail) refreshSelectedMailbox(force: false) } + .task { + // Auto-refresh inbox every 60 seconds while the mail pane is visible. + // SwiftUI cancels this task automatically when the view disappears. + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(60)) + guard !Task.isCancelled else { break } + guard appState.settings.googleConnected else { continue } + refreshSelectedMailbox(force: true) + } + } } private var header: some View { @@ -82,7 +88,7 @@ struct MailPaneView: View { .padding(.vertical, 7) .background(Color.primary.opacity(0.05)) .clipShape(.rect(cornerRadius: 8)) - .frame(width: 320) + .frame(maxWidth: 320) if mailService.isSearching || mailService.isLoadingMailbox || mailService.isLoadingThread || mailService.isSending { ProgressView() @@ -107,6 +113,38 @@ struct MailPaneView: View { .padding(.vertical, 8) } + private var mailFilterTabs: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + ForEach(MailFilter.allCases) { filter in + Button { + activeFilter = filter + mailService.selectMailbox(filter.mailbox) + refreshSelectedMailbox(force: false) + } label: { + Text(filter.label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(activeFilter == filter ? .primary : .tertiary) + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background( + activeFilter == filter + ? Color.primary.opacity(0.08) + : Color.clear + ) + .clipShape(.rect(cornerRadius: Radius.xs)) + } + .buttonStyle(.plain) + } + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + + Divider() + } + } + private var mailboxRail: some View { VStack(alignment: .leading, spacing: 6) { Button(action: { mailService.presentNewComposer() }) { @@ -510,6 +548,29 @@ struct MailPaneView: View { } } +private enum MailFilter: String, CaseIterable, Identifiable { + case all, unread, starred, sent + + var id: String { rawValue } + + var label: String { + switch self { + case .all: return "All" + case .unread: return "Unread" + case .starred: return "Starred" + case .sent: return "Sent" + } + } + + var mailbox: MailMailbox { + switch self { + case .all, .unread: return .inbox + case .starred: return .starred + case .sent: return .sent + } + } +} + private struct MailHTMLView: NSViewRepresentable { let html: String @@ -524,7 +585,16 @@ private struct MailHTMLView: NSViewRepresentable { } func updateNSView(_ nsView: WKWebView, context: Context) { - nsView.loadHTMLString(wrappedHTML(html), baseURL: nil) + let wrapped = wrappedHTML(html) + guard context.coordinator.lastLoadedHTML != wrapped else { return } + context.coordinator.lastLoadedHTML = wrapped + nsView.loadHTMLString(wrapped, baseURL: nil) + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + class Coordinator { + var lastLoadedHTML: String = "" } private func wrappedHTML(_ body: String) -> String { diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 376f4274..b12232f1 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -1,14 +1,36 @@ import SwiftUI +import BugbookCore struct MeetingsView: View { var appState: AppState @Bindable var viewModel: MeetingsViewModel + var transcriptionService: TranscriptionService + var meetingNoteService: MeetingNoteService + var aiService: AiService var onNavigateToFile: (String) -> Void + @State private var meetingTitle = "" + @State private var isRecording = false + @State private var liveTranscript: [String] = [] + @State private var volatileText = "" + @State private var audioLevel: Float = 0 + @State private var pollingTask: Task? + @State private var isSaving = false + @State private var showTranscript = false + @State private var notesText = "" + var body: some View { VStack(spacing: 0) { header - meetingsList + + if isRecording { + recordingView + } else if isSaving { + savingView + } else { + recorderPrompt + recentRecordings + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .ignoresSafeArea(.container, edges: .top) @@ -24,129 +46,359 @@ struct MeetingsView: View { } // MARK: - Header - // NOTE: Audio import (NSOpenPanel → TranscriptionService) was removed 2026-03-31. - // The plumbing exists in MeetingNoteService.importRecording if we revisit later. private var header: some View { HStack(spacing: 8) { - Text("AI Meeting Notes") + Text("Meetings") .font(.system(size: 16, weight: .semibold)) .lineLimit(1) Spacer() - if viewModel.isScanning { - ProgressView() - .controlSize(.small) + if isRecording { + PulsingRecordDot() + Text("Recording") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(StatusColor.error) } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + // MARK: - Recorder Prompt (idle state) + + private var recorderPrompt: some View { + VStack(spacing: 16) { + Spacer() + + // Title field + TextField("Meeting title (optional)", text: $meetingTitle) + .textFieldStyle(.plain) + .font(.system(size: Typography.body)) + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(.rect(cornerRadius: Radius.sm)) + .padding(.horizontal, 24) - Button(action: { appState.openNotesChat() }) { - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.system(size: 11)) - Text("Chat with all my notes") - .font(.system(size: 12)) + // Big record button + Button(action: startRecording) { + HStack(spacing: 10) { + Circle() + .fill(StatusColor.error) + .frame(width: 14, height: 14) + Text("Start recording") + .font(.system(size: Typography.body, weight: .medium)) } - .foregroundStyle(.secondary) + .foregroundStyle(.primary) + .padding(.horizontal, 24) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(.rect(cornerRadius: Radius.md)) + .overlay( + RoundedRectangle(cornerRadius: Radius.md) + .strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1) + ) } .buttonStyle(.plain) + .padding(.horizontal, 24) - Button(action: rescan) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 12)) - .foregroundStyle(.secondary) + Spacer() + } + } + + // MARK: - Recording View (active state) + + private var recordingView: some View { + VStack(spacing: 0) { + // Title (editable during recording) + TextField("Meeting title", text: $meetingTitle) + .textFieldStyle(.plain) + .font(.system(size: Typography.body, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + // Notes / Transcript toggle + HStack(spacing: 0) { + toggleTab("Notes", isActive: !showTranscript) { showTranscript = false } + toggleTab("Transcript", isActive: showTranscript) { showTranscript = true } + Spacer() } - .buttonStyle(.plain) - .disabled(viewModel.isScanning) + .padding(.horizontal, 10) + .padding(.bottom, 4) + + Divider() + + // Content area — either notes or transcript + if showTranscript { + transcriptView + } else { + notesView + } + + Divider() + + // Waveform + stop button + HStack(spacing: 12) { + HStack(spacing: 2) { + ForEach(0..<5, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(StatusColor.error.opacity(0.7)) + .frame(width: 3, height: barHeight(index: i)) + } + } + .frame(height: 20) + + Spacer() + + Button(action: stopRecording) { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(.primary) + .frame(width: 10, height: 10) + Text("Stop") + .font(.system(size: Typography.bodySmall, weight: .medium)) + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(.rect(cornerRadius: Radius.sm)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) } - .padding(.horizontal, 16) - .padding(.vertical, 6) } - // MARK: - List + // MARK: - Notes / Transcript Views + + private func toggleTab(_ label: String, isActive: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(isActive ? .primary : .tertiary) + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background(isActive ? Color.primary.opacity(Opacity.light) : Color.clear) + .clipShape(.rect(cornerRadius: Radius.xs)) + } + .buttonStyle(.plain) + } + + private var notesView: some View { + TextEditor(text: $notesText) + .font(.system(size: Typography.body)) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var transcriptView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(Array(liveTranscript.enumerated()), id: \.offset) { idx, segment in + Text(segment) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.primary) + .id(idx) + } + + if !volatileText.isEmpty { + Text(volatileText) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.tertiary) + .id("volatile") + } + + if liveTranscript.isEmpty && volatileText.isEmpty { + Text("Listening...") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.quaternary) + } + } + .padding(12) + } + .onChange(of: liveTranscript.count) { _, _ in + if let last = liveTranscript.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } + } + } + } + + // MARK: - Saving View + + private var savingView: some View { + VStack(spacing: 12) { + Spacer() + ProgressView() + .controlSize(.regular) + Text("Saving meeting note...") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Recent Recordings (compact list below prompt) @ViewBuilder - private var meetingsList: some View { + private var recentRecordings: some View { let groups = viewModel.groupedMeetings - if groups.isEmpty && !viewModel.isScanning { - emptyState - } else { + if !groups.isEmpty { + Divider() + ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(groups, id: \.bucket) { group in - sectionHeader(group.bucket.rawValue) - ForEach(group.meetings) { meeting in - meetingRow(meeting) - } + sectionDivider("Recent") + ForEach(groups.flatMap(\.meetings).prefix(8)) { meeting in + meetingRow(meeting) } } - .padding(.vertical, 8) + .padding(.vertical, 4) } } } - private var emptyState: some View { - VStack(spacing: 12) { - Image(systemName: "person.2.circle") - .font(.system(size: 36)) - .foregroundStyle(.quaternary) - Text("No meetings found") - .font(.system(size: Typography.body)) - .foregroundStyle(.secondary) - Text("Meetings are discovered from date-prefixed pages\nor pages containing blocks.") - .font(.system(size: Typography.caption)) - .foregroundStyle(.tertiary) - .multilineTextAlignment(.center) + // MARK: - Actions + + private func startRecording() { + isRecording = true + appState.isRecording = true + liveTranscript = [] + volatileText = "" + + pollingTask = Task { + await transcriptionService.startRecording() + + var lastSegmentCount = 0 + var lastVolatile = "" + + while transcriptionService.isRecording { + let segments = transcriptionService.confirmedSegments + let vol = transcriptionService.volatileText + let level = transcriptionService.audioLevel + + if segments.count != lastSegmentCount { + lastSegmentCount = segments.count + liveTranscript = segments + } + if vol != lastVolatile { + lastVolatile = vol + volatileText = vol + } + audioLevel = level + + try? await Task.sleep(for: .milliseconds(100)) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func stopRecording() { + let transcript = transcriptionService.stopRecording() + pollingTask?.cancel() + pollingTask = nil + isRecording = false + appState.isRecording = false + audioLevel = 0 + + guard !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let workspace = appState.workspacePath else { + return + } + + isSaving = true + let title = meetingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveTitle = title.isEmpty ? "Meeting \(Self.dateFormatter.string(from: Date()))" : title + + Task { + // Create a lightweight event placeholder to carry the title + let placeholderEvent = CalendarEvent( + id: UUID().uuidString, + title: effectiveTitle, + startDate: Date(), + endDate: Date().addingTimeInterval(3600), + isAllDay: false, + calendarId: "" + ) + let path = await meetingNoteService.createMeetingNoteWithTranscript( + transcription: TranscriptionResult(fullText: transcript, timestampedText: transcript), + event: placeholderEvent, + workspace: workspace, + aiService: aiService, + apiKey: appState.settings.anthropicApiKey + ) + isSaving = false + meetingTitle = "" + liveTranscript = [] + + if let path { + onNavigateToFile(path) + } + + // Refresh the list + viewModel.scan(workspace: workspace) + } + } + + // MARK: - Waveform + + private func barHeight(index: Int) -> CGFloat { + let base: CGFloat = 4 + let scale = CGFloat(audioLevel) * 16 + let offset = sin(Double(index) * 1.3 + Date().timeIntervalSinceReferenceDate * 3) * 0.5 + 0.5 + return base + scale * CGFloat(offset) } // MARK: - Components - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(size: Typography.caption, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 6) + private func sectionDivider(_ title: String) -> some View { + HStack(spacing: 8) { + Text(title) + .font(.system(size: Typography.caption2, weight: .medium)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + .fixedSize() + + Rectangle() + .fill(Color.primary.opacity(Opacity.subtle)) + .frame(height: 1) + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) } private func meetingRow(_ meeting: DiscoveredMeeting) -> some View { Button(action: { onNavigateToFile(meeting.filePath) }) { - HStack(spacing: 10) { - Image(systemName: "doc.text") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(meeting.title) - .font(.system(size: Typography.body)) - .foregroundStyle(.primary) - .lineLimit(1) + HStack(spacing: 8) { + Text(formattedTime(meeting.timestamp)) + .font(.system(size: Typography.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(width: 48, alignment: .trailing) - HStack(spacing: 6) { - Text(meeting.parentPageName) - .font(.system(size: Typography.caption)) - .foregroundStyle(.tertiary) - .lineLimit(1) - - Text("\u{00B7}") - .font(.system(size: Typography.caption)) - .foregroundStyle(.quaternary) + Text(meeting.title) + .font(.system(size: Typography.body)) + .foregroundStyle(.primary) + .lineLimit(1) - Text(formattedDate(meeting.timestamp)) - .font(.system(size: Typography.caption)) - .foregroundStyle(.tertiary) - } - } + Spacer(minLength: 4) - Spacer() + Text(meeting.parentPageName) + .font(.system(size: Typography.caption)) + .foregroundStyle(.quaternary) + .lineLimit(1) } - .padding(.horizontal, 20) - .padding(.vertical, 8) + .padding(.horizontal, 12) + .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -155,17 +407,12 @@ struct MeetingsView: View { // MARK: - Helpers - private func rescan() { - guard let workspace = appState.workspacePath else { return } - viewModel.scan(workspace: workspace) - } - - private func formattedDate(_ date: Date) -> String { + private func formattedTime(_ date: Date) -> String { let cal = Calendar.current if cal.isDateInToday(date) { return Self.timeFormatter.string(from: date) } else if cal.isDateInYesterday(date) { - return "Yesterday" + return "Yest" } else { return Self.shortDateFormatter.string(from: date) } @@ -179,11 +426,36 @@ struct MeetingsView: View { private static let shortDateFormatter: DateFormatter = { let f = DateFormatter() - f.dateFormat = "MMM d, yyyy" + f.dateFormat = "MMM d" + return f + }() + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm" return f }() } +// MARK: - Pulsing Record Dot + +private struct PulsingRecordDot: View { + @State private var pulse = false + + var body: some View { + Circle() + .fill(StatusColor.error) + .frame(width: 8, height: 8) + .scaleEffect(pulse ? 1.3 : 1.0) + .opacity(pulse ? 0.6 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} + // MARK: - Hover Highlight private struct HoverHighlight: View { diff --git a/Sources/Bugbook/Views/Panes/PaneContentView.swift b/Sources/Bugbook/Views/Panes/PaneContentView.swift index 1e245545..76a3d620 100644 --- a/Sources/Bugbook/Views/Panes/PaneContentView.swift +++ b/Sources/Bugbook/Views/Panes/PaneContentView.swift @@ -13,6 +13,7 @@ struct PaneContentView: View { let terminalContentBuilder: (PaneNode.Leaf, Bool) -> AnyView @State private var isHovered = false + @State private var isDropTarget = false var body: some View { ZStack(alignment: .topTrailing) { @@ -31,9 +32,28 @@ struct PaneContentView: View { .padding([.trailing, .bottom], 6) .transition(.opacity) } + + // Drop target highlight for pane swap + if isDropTarget { + RoundedRectangle(cornerRadius: 0) + .strokeBorder(Color.fallbackAccent.opacity(Opacity.strong), lineWidth: 2) + .allowsHitTesting(false) + } } .clipShape(Rectangle()) .onHover { isHovered = $0 } + .onDrop(of: [.text], isTargeted: $isDropTarget) { providers in + guard let provider = providers.first else { return false } + _ = provider.loadObject(ofClass: NSString.self) { item, _ in + guard let idString = item as? String, + let sourceId = UUID(uuidString: idString), + sourceId != leaf.id else { return } + DispatchQueue.main.async { + workspaceManager.swapPaneContents(paneA: sourceId, paneB: leaf.id) + } + } + return true + } .contextMenu { Menu("Split Right") { paneTypeMenu { content in @@ -99,6 +119,19 @@ private struct PaneActionBar: View { var body: some View { HStack(spacing: 0) { + // Drag handle for pane swap + Image(systemName: "line.3.horizontal") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + .onDrag { + NSItemProvider(object: leaf.id.uuidString as NSString) + } + .help("Drag to swap panes") + + divider + // Split right splitMenu(direction: .right, help: "Split right") { content in workspaceManager.setFocusedPane(id: leaf.id) diff --git a/Sources/Bugbook/Views/Panes/SplitDividerView.swift b/Sources/Bugbook/Views/Panes/SplitDividerView.swift index a895d1ea..c8f2c922 100644 --- a/Sources/Bugbook/Views/Panes/SplitDividerView.swift +++ b/Sources/Bugbook/Views/Panes/SplitDividerView.swift @@ -4,7 +4,7 @@ import AppKit #endif /// Draggable divider between two panes in a split. -/// Visual: 1px line with 8px hit area. Hover/drag shows thicker accent line. +/// Visual: 2px line with 8px hit area. Hover/drag shows thicker accent line with grip dots. struct SplitDividerView: View { let axis: PaneNode.Split.Axis @Binding var ratio: Double @@ -26,6 +26,11 @@ struct SplitDividerView: View { height: isVerticalLine ? nil : lineThickness ) + // Grip dots — centered on divider, visible on hover/drag + if isHovered || isDragging { + gripDots + } + // Transparent hit area Color.clear .frame( @@ -75,6 +80,35 @@ struct SplitDividerView: View { } } + // MARK: - Grip Dots + + /// Small dots centered on the divider to signal draggability. + private var gripDots: some View { + let dotCount = 3 + let dotSize: CGFloat = 3 + let dotSpacing: CGFloat = 3 + + return Group { + if isVerticalLine { + VStack(spacing: dotSpacing) { + ForEach(0.. + + + + +Bugbook — Interaction Design Sheet + + + + + +
+ +
Interaction Design Sheet
+
How panes move, compose, and respond to keyboard input.
+ + + +
+
Keyboard Shortcuts — ⌘/ to show overlay
+ +
+
+

Pane Management

+ + + + + + + +
⌘⌥ ← → ↑ ↓Move focus between panes
⌘DSplit focused pane right
⌘⇧ESplit focused pane down
⌘⇧WClose workspace
Drag ☰Drag pane onto another to swap
Double-click dividerReset split to 50/50
+
+ +
+

Navigation

+ + + + + + + +
⌘KQuick open / command palette
⌘1 – 9Switch workspace by number
⌘TNew workspace tab
⌘WClose tab
⌘BToggle sidebar
⌘[   ⌘]Back / Forward
+
+ +
+

Open Views

+ + + + + + +
⌘⇧0Home
⌘⇧MMail
⌘⇧YCalendar
⌘IAsk AI / Chat
⌘⇧DToday's daily note
+
+ +
+

Editor & Display

+ + + + + + +
⌘NNew page
⌘⇧LToggle light/dark theme
⌘+   ⌘−Zoom in / out
⌘0Reset zoom
⌘/Show shortcut overlay
+
+
+
+ + + +
+
Composed Workflows
+ +
+
+
Read a mail thread
+
+ Click thread + + ⌘⇧E +
+
Click a message in Mail — the list splits down and the thread opens below it. List stays visible on top, thread content below. Press ⌘⇧E from Mail to pre-split, or click and the thread replaces an existing bottom pane. Back button or Escape returns to list-only.
+
+ +
+
Calendar beside Mail
+
+ ⌘D + + ⌘⇧Y +
+
Split right from Mail, then open Calendar in the new pane. Instant comms layout.
+
+ +
+
New dev workspace
+
+ ⌘T + + ⌘D + + ⌘I +
+
New tab, split right, open Chat. Terminal is already the default split content.
+
+ +
+
Review a meeting transcript
+
+ Click ≋ event +
+
Click a calendar event with the ≋ transcript badge. The Calendar pane slides from day view into the meeting detail: summary, action items, and transcript with notes/transcript toggle. Back arrow returns to day view.
+
+ +
+
Focus on one pane
+
+ ⌘⌥→ + + Pop out ↗ +
+
Move focus to the pane, pop it out to its own workspace tab. Full screen, no splits.
+
+ +
+
Quick meeting capture
+
+ ⌘D + + Meetings + + Record +
+
Split right, choose Meetings from the pane menu, hit record. Notes open alongside Calendar.
+
+
+
+ + + +
+
Pane Operations
+ +
+ +
+
+
+
✉ Mail
+
Message list
+
+
+
+
+ New pane
+
Choose: Terminal, Calendar, Chat…
+
+
+
+
+
Split Right
+
Divides the focused pane vertically. Pick what opens in the new half.
+
⌘D or hover menu → ⊞→
+
+
+ + +
+
+
+
▦ Calendar
+
Day timeline
+
+
+
+
+ New pane
+
+
+
+
+
Split Down
+
Divides the focused pane horizontally. Same picker for the new half.
+
⌘⇧E or hover menu → ⊞↓
+
+
+ + +
+
+
+
Comms
+
✉ Mail
+
+
+
+
✉ Mail
+
+
+
+
+
▦ Calendar
+
Now the only pane
+
+
+
+
+
+
Pop Out to Tab
+
Extracts the pane into its own workspace tab. Remaining panes fill the gap.
+
Hover menu → ↗
+
+
+ + +
+
+
+
✉ Mail
+
Was on the right →
now on the left
+
+
+
+
▦ Calendar
+
Was on the left →
now on the right
+
+
☰ ✉ Mail
+
+
+
Drag Swap
+
Drag the ☰ handle from one pane onto another. Their contents swap positions.
+
Drag ☰ handle → drop on target pane
+
+
+ + +
+
+
+
◈ Chat
+
+
+
+
+
+
▸ Terminal
+
Expands to fill
+
+
+
+
Close Pane
+
Removes the pane. Its sibling expands to fill the space. Last pane closes the workspace.
+
Hover menu → ✕
+
+
+ + +
+
+
+
✉ Mail
+
▦ Calendar
+
Same pane, different content
+
+
+
+
Replace With
+
Swaps the pane's content without changing the layout. Right-click → Replace With.
+
Right-click → Replace With → pick type
+
+
+
+
+ + + +
+
In-Pane Navigation
+ +
+ +
+
+
+
✉ Mail — list
+
+
Sudhanshu · Re: 80K Hours
+
Nathan · 8 Shapes of Stories
+
+
+
+
+
← ✉ Thread: Re: 80K Hours
+
+ Hey Max — wanted to circle back on the call we had last week… +
+
+
click ↓
+
+
+
Read a mail thread
+
Click a message in the list. The Mail pane splits down — list stays on top (minimum 4 visible rows), thread content opens below. The split ratio is resizable via the same drag divider as pane splits. List selection highlights the active thread — click another row to switch without going back. ← back button collapses to list-only.
+
Click thread row, or ⌘⇧E to pre-split
+
+
+ + +
+
+
+
▦ Day view
+
+
10 AM — SDK Review ≋
+
2 PM — Harbor Prototype
+
+
+
+
← ▦ SDK Review — transcript
+
+
Summary: discussed state-adaptive UI…
+
Action items: bring Pixel 9 build…
+
▾ Full transcript
+
+
+
click ≋
+
+
+
View meeting transcript in Calendar
+
Click a calendar event with the ≋ badge. The Calendar pane transitions from day view to meeting detail: AI summary, action items, and collapsible full transcript. ← back arrow returns to the day timeline. Events without transcripts show "No transcript — attach notes" with an action to link a page.
+
Click ≋ event, ← to return
+
+
+
+
+ + + +
+
Resize & Constraints
+ +
+
+
+
+
✉ Mail
+
65%
+
+ +
+
+
+
+
+
+
+
+
+
▦ Calendar
+
35%
+
+
+
+
Drag to Resize
+
Divider widens to 3px with grip dots on hover. Ratio clamped 15%–85%. Double-click resets to 50/50.
+
+
+ +
+
+
+
✉ Mail
+
Sidebar hidden
2 panes max
+
+
+
+
◈ Chat
+
~200px minimum
+
+
+
+
13" Laptop & Responsive Collapse
+
Sidebar auto-collapses. Panes never go below ~200px, so 2 side-by-side is the practical max. A 2×2 "Comms" workspace built on a monitor collapses to a prioritized 1×2 when undocked — the two most recently focused panes survive, others become tab-accessible. Re-dock and the full layout restores.
+
+
+
+
+ + + +
+
Pane Roster — 7 types, each does one thing
+ +
+
+
+
Mail
+
Gmail message list with filter tabs
+
read + triage
+
+ +
+
+
Calendar
+
Day timeline with meeting history. Events with ≋ badge open transcript detail.
+
schedule + history
+
+ +
+
+
Meetings
+
Quick-draw recorder. Notes-first during capture, transcript on toggle.
+
record + review
+
+ +
+
+
Chat
+
AI assistant that queries across all panes
+
cross-pane glue
+
+ +
+
+
Terminal
+
Ghostty shell, per-pane sessions
+
build + run
+
+ +
+
+
Home
+
Launcher and overview. Tileable — useful alongside active panes for quick actions.
+
orient + jump
+
+ +
+
📄
+
Pages
+
Notes, docs, databases, block editor
+
write + organize
+
+
+
+ + + +
+
Workspace Persistence
+ +
+
+
+
Auto-save
+
Layout saves 500ms after any change. Green dot + "Saved" appears briefly in the tab bar.
+
+
+
+
+
Named workspaces
+
Each tab is a workspace. Double-click tab to rename. Drag tabs to reorder. ⌘1–9 to switch.
+
+
+
+
+
Full restore
+
Relaunch Bugbook → same tabs, same pane layout, same split ratios. Terminal sessions are ephemeral — new shell on restore.
+
+
+
+
+
Chat thread persistence
+
Chat threads are global, not workspace-scoped. Switching workspaces keeps the same active thread. Thread history is saved to disk — pick up where you left off across relaunches. Switch threads via the thread picker in the Chat pane header.
+
+
+
+
+ + + +
+
Design Decisions & Open Questions
+ +
+ +
+
+
Action items → Tickets flow-through
+
Calendar transcript detail shows action items from AI summary. Currently static text. Future: action items become checkable and sync to a Tickets database as rows. "Create ticket from action item" button on each line. This turns passive meeting notes into active task tracking without manual re-entry. Not built yet — worth building once the transcript detail view ships.
+
+
+ +
+
+
Chat thread pinning per workspace
+
Currently threads are global — switching workspaces keeps the same thread. After daily driving: each workspace should optionally pin a default thread that auto-loads on workspace switch. Comms always opens to the Comms chat, Dev to the Dev chat. Manual override still works. Implementation: add optional pinnedThreadId to the Workspace model. Draft state (mid-typed message) must be preserved per thread — losing a half-written message on workspace switch would be frustrating enough to stop people switching.
+
+
+ +
+
+
Responsive collapse
+
When a 2×2 layout can't fit (undock to laptop), deprioritized panes slide out with 200ms ease, surviving panes expand. No toast on collapse — the laptop layout should feel intentional, not broken. Only toast on re-dock: "Layout restored" when the full 2×2 comes back. If you stay on laptop, the collapsed layout is just your layout now.
+
+
+ +
+
+
Pane attention highlights
+
Exactly two levels. Subtle: dim accent border on pane edge when something changed while unfocused (new mail, build finished, agent completed). Urgent: pulsing badge or brighter border for things needing action now (meeting starting, build failed, @mention). Both dismiss automatically when the pane receives focus — you looked at it, it stops asking. No third level. Two is enough.
+
+
+ +
+
+
Notification toasts
+
Bugbook-native toasts, top-right, 4s auto-dismiss, max 2 stacked. Toasts that relate to a specific pane are clickable to focus that pane — "Build failed" clicks to Terminal, "New mail from Sudhanshu" clicks to Mail. If the toast is ignored, the source pane gets the subtle attention border instead. macOS system notifications only when Bugbook is in the background.
+
+
+ +
+
+
Meeting notes editor quality
+
The notes scratchpad during recording is currently plain TextEditor. If it's a lesser editor than workspace pages, people will open a page alongside the meeting pane instead, making the notes area dead weight. Milestone: upgrade to basic block editing — markdown formatting, checklists, headings. Doesn't need to be the full block editor on day one, but needs to not feel like a downgrade.
+
+
+ +
+
+
Replace With deserves a shortcut
+
Currently right-click only. This is the fastest layout reconfiguration — swap content without changing geometry. Candidate shortcut: ⌘⇧R → opens the same type picker as Replace With. Preserves split ratios, focus state, and pane position. Especially useful on laptop where you can't afford extra splits.
+
+
+ +
+
+
Drag preview fidelity
+
During pane drag-swap, the drag preview should be a translucent thumbnail of the pane content (not just a label). Captures a snapshot of the pane at drag start, renders at ~60% opacity and slightly reduced scale. Drop target pane shows an accent border. This gives confidence about what you're moving. If performance is an issue, fall back to icon + pane type label.
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/design/pane-tiles.html b/design/pane-tiles.html new file mode 100644 index 00000000..74a4999b --- /dev/null +++ b/design/pane-tiles.html @@ -0,0 +1,452 @@ + + + + + +Bugbook — Tiling Panes + + + + + +
+ + +
+
+
Comms ✉ ▦ ◈
+
Dev
+
Harbor
+
+
+
Saved
+
+ +
+ + + + +
+ +
+
+
+ All + Unread + Starred + Sent +
+
+
+
Sudhanshu Kasewa
2h
+
Re: 80,000 Hours Follow Up
+
+
+
Nathan Baugh
5h
+
The 8 Shapes of Stories
+
+
+
GitHub
8h
+
PR merged: feat/pane-tiling
+
+
+
Contrary Weekly
1d
+
Portfolio Update: March Recap
+
+
+
South Park Commons
1d
+
SPC Applications Open
+
+
+
Zhanel
2d
+
dinner tonight?
+
+
+
RunPod Alerts
3d
+
GPU cluster scaling event
+
+
+
+ +
+ +
+
+ 02 +
Thursday
APR 2026
+
+
+
+
9 AM
+
Standup
9:00 – 9:15
+
+
10 AM
+
SDK Review
10:00 – 11:00
+
+
12 PM
+
Lunch w/ Zhanel
12:30 – 1:30
+
+
2 PM
+
Harbor Prototype
2:00 – 3:30
+
+
4 PM
+
Contrary Hours
4:00 – 5:00
+
+
+
+
+ +
+ + +
+
+
+
What do I need to prep for the Harbor session?
+
Based on the SDK Review transcript: bring the Pixel 9 prototype, prepare the context-switching demo, review @Harbor Notes.
+
Show me unread from Contrary
+
1 unread — "Portfolio Update: March Recap" — three new investments this month.
+
+
Message…
+
+ +
+ +
+
+
~/Code/bugbook git status
+
On branch dev
+
nothing to commit, working tree clean
+
~/Code/bugbook swift build 2>&1 | tail -1
+
Build complete! (0.19s)
+
~/Code/bugbook
+
+
zshsession-1
+
+
+
+
+
+ +
≋ = has transcript. Drag dividers to resize. Double-click resets 50/50. ⌘/ for all shortcuts.
+ +
+ + +
+ +
+
+
+
+ +
Start recording
+
+
+
Recent
+
+ 10:00a + SDK Review — RunPod +
+
+ Yest + JustBuild Sync +
+
+
+
+
+ + +
+
+
+
+ Contrary Office Hours + REC +
+
+ Notes + Transcript +
+
+
- Harbor prototype: bring Pixel 9 build
+
- Context-switching demo needs new state model
+
- Follow up with SPC on timeline
+
Type notes while recording…
+
+
+
+ +
Stop
+
+
+
+
+ + +
+
+
+
+
THU 02 APR · 10:14 AM
+
Morning, Max
+
+ +
+
New page
⌘N
+
Compose
⌘⇧M
+
Ask AI
⌘I
+
Terminal
⌘`
+
+
+
Recent
+
📄 Pane Tiling Architecture 2m
+
🗃 Agent Tickets 18m
+
📄 Harbor Notes 1h
+
+
+
+
+
+ +
+ See interactions.html for keyboard shortcuts, pane operations, drag swap, and the full pane roster. +
+ +
+ + + \ No newline at end of file diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 0dcdd140..e71798b8 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ 28B792819ED4E4EEEE5F64E2 /* GoogleSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10F504D61026F1BD4B47F81 /* GoogleSettingsView.swift */; }; 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D74F02ED2A5522822B783A /* GripDotsView.swift */; }; 290D6457E202795F776249B1 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E66496064A29930C3F3B17 /* AppSettings.swift */; }; - 2A2F026CECBF4AE9013847D0 /* MessagesStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CB89A5B1CCFBB0DC02D157 /* MessagesStubs.swift */; }; 2AD1B1200C4938F37EFF82CA /* ViewModePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */; }; 2AE1228061FA351710FDD3A7 /* HomeTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799B738BA3EC09A6406E599 /* HomeTimeView.swift */; }; 2BDEEDC610523F8472D8FF1A /* DatabaseRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F257D6BA8D9A6DCAA4EB896 /* DatabaseRowViewModel.swift */; }; @@ -184,6 +183,7 @@ 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 */; }; + F138E57CF6D943B0AF4B485C /* KeyboardShortcutOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64761AE2591D8E6B1F8E1B5 /* KeyboardShortcutOverlay.swift */; }; F1D183B0EC41EE0853B0913F /* DatabaseTemplateEditorModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C636CE45E2D5E20C53C703 /* DatabaseTemplateEditorModal.swift */; }; F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AC347581598E6C53507F712 /* CoreGraphics.framework */; }; F6196917751CF468444068C8 /* RowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC779B217122291C509E482 /* RowStore.swift */; }; @@ -305,7 +305,6 @@ 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesEditor.swift; sourceTree = ""; }; 6F22A7B4A57E1178ABD90C2F /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFullPageView.swift; sourceTree = ""; }; - 70CB89A5B1CCFBB0DC02D157 /* MessagesStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesStubs.swift; sourceTree = ""; }; 70D0E04210ECEB0AD796BE41 /* SplitDividerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitDividerView.swift; sourceTree = ""; }; 70DE6960E273C11B104C1DD9 /* IndexManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexManager.swift; sourceTree = ""; }; 71023CB4011C219BC7514109 /* InlineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineStyle.swift; sourceTree = ""; }; @@ -346,6 +345,7 @@ A52CFB20317770DA9DB4D732 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseService.swift; sourceTree = ""; }; A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + A64761AE2591D8E6B1F8E1B5 /* KeyboardShortcutOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutOverlay.swift; sourceTree = ""; }; A7522F2BBEE8963CD915BD5F /* HomeBottomZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeBottomZone.swift; sourceTree = ""; }; A7712E15B025309AF9EAF67D /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePointerCursor.swift; sourceTree = ""; }; @@ -449,7 +449,6 @@ D0C085F6BB0BCE9846714A8A /* Debug */, 755E726FCF5D458D5AC786CD /* Extensions */, 241CF1EFDA2CCA146EE8F121 /* Lib */, - B7E54670D70E679023BA8ED1 /* Messages */, 1B4C22DE16CEBDAEF95DE127 /* Models */, 916BED7AF73337FAFDA118D6 /* Services */, 809E72A7F64540EC6AD611BF /* ViewModels */, @@ -579,6 +578,7 @@ C8A5DE98CE38F83FE2B092CF /* CoverPickerView.swift */, 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */, C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */, + A64761AE2591D8E6B1F8E1B5 /* KeyboardShortcutOverlay.swift */, E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */, 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */, 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */, @@ -772,14 +772,6 @@ path = Database; sourceTree = ""; }; - B7E54670D70E679023BA8ED1 /* Messages */ = { - isa = PBXGroup; - children = ( - 70CB89A5B1CCFBB0DC02D157 /* MessagesStubs.swift */, - ); - path = Messages; - sourceTree = ""; - }; B9ACAF37F4AD4DE19AB6BDF4 /* BugbookCore */ = { isa = PBXGroup; children = ( @@ -1136,6 +1128,7 @@ B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */, 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */, 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */, + F138E57CF6D943B0AF4B485C /* KeyboardShortcutOverlay.swift in Sources */, EC1FEB44F619D0C010F9548B /* KeychainSecretStore.swift in Sources */, 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */, 31319D254BD21B3105E098D8 /* Logger.swift in Sources */, @@ -1154,7 +1147,6 @@ 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */, 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */, 215DC599267BF8F4A44B11D2 /* MentionPickerView.swift in Sources */, - 2A2F026CECBF4AE9013847D0 /* MessagesStubs.swift in Sources */, D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */, 3AAA59CAFE542617EFFD989E /* NotesChatView.swift in Sources */, CBDA6D4C212A797378E7C062 /* OnboardingService.swift in Sources */,