From 69a6ef18cf5ec3030b9f2f82483e689180481a99 Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Thu, 9 Jul 2026 23:09:08 -0400 Subject: [PATCH 1/2] Focus Loci around creative threads --- Sources/Loci/ChatWorkspaceView.swift | 66 ++++++- Sources/Loci/ContentView.swift | 199 ++++++++++++-------- Sources/Loci/CreativeThreadBriefSheet.swift | 55 ++++++ Sources/Loci/GraphExplorerView.swift | 24 +-- Sources/Loci/LociDesign.swift | 5 + Sources/Loci/Models.swift | 21 ++- Sources/Loci/PersistentStore.swift | 27 ++- Sources/Loci/ReferenceViews.swift | 26 +-- Sources/Loci/ReviewQueueView.swift | 2 +- Tests/LociTests/StartupLoadingTests.swift | 16 ++ 10 files changed, 317 insertions(+), 124 deletions(-) create mode 100644 Sources/Loci/CreativeThreadBriefSheet.swift diff --git a/Sources/Loci/ChatWorkspaceView.swift b/Sources/Loci/ChatWorkspaceView.swift index aa5c109..c8f77c7 100644 --- a/Sources/Loci/ChatWorkspaceView.swift +++ b/Sources/Loci/ChatWorkspaceView.swift @@ -22,8 +22,9 @@ enum ChatRole: String, Hashable { } enum ChatSourceScope: String, CaseIterable, Identifiable { - case allDocuments = "All" + case allDocuments = "Library" case selected = "Selected" + case currentThread = "Thread" var id: String { rawValue } } @@ -45,6 +46,7 @@ struct ChatWorkspaceView: View { @State private var sourceQuery = "" @State private var browserSelectedID: ReferenceItem.ID? @State private var showShareSheet = false + @AppStorage("LociOpenRouterModel") private var configuredModel = "openai/gpt-4o-mini" private let primaryText = LociColor.ink private let secondaryText = LociColor.inkTertiary @@ -61,7 +63,7 @@ struct ChatWorkspaceView: View { .frame(width: 1) chatPanel - .frame(width: 400) + .frame(minWidth: 320, idealWidth: 360, maxWidth: 440) } .background(LociColor.surface) .onAppear { @@ -305,6 +307,9 @@ struct ChatWorkspaceView: View { activeSourceSummary .padding(.horizontal, 16) + groundingBanner + .padding(.horizontal, 16) + messageList .padding(.horizontal, 12) @@ -318,13 +323,15 @@ struct ChatWorkspaceView: View { private var chatScopeLabel: String { switch scope { case .allDocuments: - "Chat across all documents" + "Across your library" case .selected: if let item = activeViewerItem, scopedItems.count == 1 { "Focused on \"\(item.title)\"" } else { "Chat across \(scopedItems.count) selected sources" } + case .currentThread: + "\(activeThread?.name ?? "Creative Thread") · \(scopedItems.count) sources" } } @@ -354,7 +361,7 @@ struct ChatWorkspaceView: View { } } .buttonStyle(.plain) - .disabled(option == .selected && store.selectedItemIDs.isEmpty && store.notebookActiveItemID == nil) + .disabled(isScopeUnavailable(option)) } } .padding(3) @@ -383,6 +390,21 @@ struct ChatWorkspaceView: View { } } + private var groundingBanner: some View { + HStack(alignment: .top, spacing: 7) { + Image(systemName: "lock.document") + .lociFont(size: 10, weight: .semibold, relativeTo: .caption2) + .foregroundStyle(LociColor.inkTertiary) + Text("Grounded in \(scopedItems.count) source\(scopedItems.count == 1 ? "" : "s") · \(configuredModel). Answers cite the files used.") + .font(LociFont.caption) + .foregroundStyle(LociColor.inkTertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .background(LociColor.surfaceRecessed, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + private var messageList: some View { ScrollViewReader { proxy in ScrollView { @@ -465,9 +487,10 @@ struct ChatWorkspaceView: View { private var suggestedPrompts: [String] { [ - "Summarize the main points across these sources.", - "What themes or topics appear in these documents?", - "List key facts I should remember from these files." + "Find the visual themes across these sources.", + "What is missing from this creative direction?", + "Compare the strongest references and explain why they work.", + "Make a concise creative brief from these sources." ] } @@ -519,6 +542,9 @@ struct ChatWorkspaceView: View { ids = [notebookID] } return store.items.filter { ids.contains($0.id) && !$0.isTrashed } + case .currentThread: + guard let activeThread else { return [] } + return store.items.filter { $0.collectionID == activeThread.id && !$0.isTrashed } } } @@ -540,6 +566,8 @@ struct ChatWorkspaceView: View { private func syncScopeFromSelection() { if store.notebookActiveItemID != nil || !store.selectedItemIDs.isEmpty { scope = .selected + } else if activeThread != nil { + scope = .currentThread } } @@ -574,10 +602,16 @@ struct ChatWorkspaceView: View { .map { (role: $0.role == .user ? "user" : "assistant", content: $0.text) } let items = scopedItems let rootURL = store.vaultRootURL + let groundedQuestion: String + if scope == .currentThread, let thread = activeThread, !thread.brief.isEmpty { + groundedQuestion = "Creative thread: \(thread.name)\nBrief: \(thread.brief)\n\nQuestion: \(question)" + } else { + groundedQuestion = question + } Task { let result = await LLMWikiCompiler.answerNotebook( - question: question, + question: groundedQuestion, items: items, rootURL: rootURL, history: history.dropLast().map { $0 } @@ -605,6 +639,22 @@ struct ChatWorkspaceView: View { } } } + + private var activeThread: ReferenceCollection? { + guard let id = store.activeThreadID else { return nil } + return store.collections.first(where: { $0.id == id }) + } + + private func isScopeUnavailable(_ option: ChatSourceScope) -> Bool { + switch option { + case .allDocuments: + false + case .selected: + store.selectedItemIDs.isEmpty && store.notebookActiveItemID == nil + case .currentThread: + activeThread == nil + } + } } private struct NotebookSourceChip: View { diff --git a/Sources/Loci/ContentView.swift b/Sources/Loci/ContentView.swift index 5d9bc1c..e2825bb 100644 --- a/Sources/Loci/ContentView.swift +++ b/Sources/Loci/ContentView.swift @@ -355,13 +355,6 @@ struct MainReferencePane: View { .zIndex(12) } - if store.selectedFilter == .timeline { - TimelineView(store: store) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(LociColor.surface) - .zIndex(12) - } - if store.selectedFilter == .review { ReviewQueueView(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -1197,7 +1190,7 @@ struct LociTitle: View { case .xBookmarks: "X BOOKMARKS" case .files: "FILES" case .trash: "TRASH" - case .chat: "NOTEBOOK" + case .chat: "ASK LOCI" case .api: "CREATIVE MEMORY" case .graph: "GRAPH" case .timeline: "TIMELINE" @@ -1212,17 +1205,43 @@ struct LociTitle: View { var body: some View { VStack(spacing: 4) { - Text(title) - .font(LociFont.label) - .foregroundStyle(LociColor.inkSecondary) - .tracking(0.3) - Text("\(store.visibleItems.count.formatted()) ITEMS") - .font(LociFont.label) - .foregroundStyle(LociColor.inkFaint) - .tracking(0.2) + if let thread { + Text("CREATIVE THREAD") + .font(LociFont.label) + .foregroundStyle(LociColor.inkSecondary) + .tracking(0.3) + Text(thread.name) + .font(LociFont.headline) + .foregroundStyle(LociColor.ink) + if !thread.brief.isEmpty { + Text(thread.brief) + .font(LociFont.caption) + .foregroundStyle(LociColor.inkTertiary) + .lineLimit(1) + .frame(maxWidth: 360) + } else { + Text("Add a brief from the Space menu") + .font(LociFont.caption) + .foregroundStyle(LociColor.inkFaint) + } + } else { + Text(title) + .font(LociFont.label) + .foregroundStyle(LociColor.inkSecondary) + .tracking(0.3) + Text("\(store.visibleItems.count.formatted()) ITEMS") + .font(LociFont.label) + .foregroundStyle(LociColor.inkFaint) + .tracking(0.2) + } } .allowsHitTesting(false) } + + private var thread: ReferenceCollection? { + guard case .collection(let id) = store.selectedFilter else { return nil } + return store.collections.first(where: { $0.id == id }) + } } struct LociSidebar: View { @@ -1230,7 +1249,9 @@ struct LociSidebar: View { @Environment(\.undoManager) private var undoManager @State private var collectionToRename: ReferenceCollection? @State private var renameDraft = "" - @AppStorage("LociStudioSidebarExpanded") private var isStudioExpanded = false + @State private var collectionToEditBrief: ReferenceCollection? + @State private var briefDraft = "" + @AppStorage("LociAdvancedSidebarExpanded") private var isStudioExpanded = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -1260,38 +1281,6 @@ struct LociSidebar: View { .onDrop(of: [.text], isTargeted: nil) { providers in moveDroppedReferences(from: providers, to: nil) } - LociSidebarRow( - title: "X Bookmarks", - symbol: "bookmark.square.fill", - count: store.count(for: .xBookmarks).formatted(), - isSelected: store.selectedFilter == .xBookmarks - ) { - selectFilter(.xBookmarks) - } - LociSidebarRow( - title: "Files", - symbol: "folder.fill", - count: store.count(for: .files).formatted(), - isSelected: store.selectedFilter == .files - ) { - selectFilter(.files) - } - LociSidebarRow( - title: "Trash", - symbol: "trash.fill", - count: store.count(for: .trash).formatted(), - isSelected: store.selectedFilter == .trash - ) { - selectFilter(.trash) - } - .contextMenu { - Button(role: .destructive) { - store.emptyTrash() - } label: { - Label("Empty Trash", systemImage: "trash.slash") - } - .disabled(store.count(for: .trash) == 0) - } } Text("Spaces") @@ -1318,6 +1307,12 @@ struct LociSidebar: View { Label("Rename", systemImage: "pencil") } + Button { + beginEditingBrief(collection) + } label: { + Label("Edit Creative Brief", systemImage: "text.quote") + } + Button { store.deleteCollection(id: collection.id, undoManager: undoManager) } label: { @@ -1343,14 +1338,37 @@ struct LociSidebar: View { } } + SidebarGroup { + LociSidebarRow( + title: "Ask Loci", + symbol: "sparkles", + count: "", + isSelected: store.selectedFilter == .chat + ) { + selectFilter(.chat) + } + + LociSidebarRow( + title: "Rediscover", + symbol: "clock.arrow.circlepath", + count: reviewDueCount, + isSelected: store.selectedFilter == .review, + countStyle: .attention + ) { + _ = ReviewScheduler.autoEnqueueForgottenReferences() + selectFilter(.review) + } + } + .padding(.top, 12) + Spacer() - Button { - store.addCollection(undoManager: undoManager) - } label: { - Label("New Space", systemImage: "plus") - .font(LociFont.caption) - } + Button { + store.addCollection(undoManager: undoManager) + } label: { + Label("New Creative Thread", systemImage: "plus") + .font(LociFont.caption) + } .buttonStyle(.plain) .foregroundStyle(LociColor.ink) .padding(.leading, 18) @@ -1382,32 +1400,21 @@ struct LociSidebar: View { isActive: isStudioFilterSelected ) { LociSidebarRow( - title: "Review", - symbol: "brain.head.profile", - count: reviewDueCount, - isSelected: store.selectedFilter == .review, - countStyle: .attention - ) { - _ = ReviewScheduler.autoEnqueueForgottenReferences() - selectFilter(.review) - } - - LociSidebarRow( - title: "Timeline", - symbol: "clock.fill", - count: "", - isSelected: store.selectedFilter == .timeline + title: "X Bookmarks", + symbol: "bookmark.square.fill", + count: store.count(for: .xBookmarks).formatted(), + isSelected: store.selectedFilter == .xBookmarks ) { - selectFilter(.timeline) + selectFilter(.xBookmarks) } LociSidebarRow( - title: "Notebook", - symbol: "text.bubble", - count: store.count(for: .chat).formatted(), - isSelected: store.selectedFilter == .chat + title: "Files", + symbol: "folder.fill", + count: store.count(for: .files).formatted(), + isSelected: store.selectedFilter == .files ) { - selectFilter(.chat) + selectFilter(.files) } LociSidebarRow( @@ -1454,6 +1461,23 @@ struct LociSidebar: View { ) { selectFilter(.api) } + + LociSidebarRow( + title: "Trash", + symbol: "trash.fill", + count: store.count(for: .trash).formatted(), + isSelected: store.selectedFilter == .trash + ) { + selectFilter(.trash) + } + .contextMenu { + Button(role: .destructive) { + store.emptyTrash() + } label: { + Label("Empty Trash", systemImage: "trash.slash") + } + .disabled(store.count(for: .trash) == 0) + } } .padding(.bottom, 18) } @@ -1479,13 +1503,20 @@ struct LociSidebar: View { } message: { Text("Choose a new name for this collection.") } + .sheet(item: $collectionToEditBrief) { collection in + CreativeThreadBriefSheet( + threadName: collection.name, + brief: $briefDraft, + onSave: { store.updateCollectionBrief(id: collection.id, to: briefDraft) } + ) + } } private var isStudioFilterSelected: Bool { switch store.selectedFilter { - case .chat, .api, .graph, .timeline, .review, .capabilities, .patterns, .rules: + case .api, .graph, .capabilities, .patterns, .rules, .xBookmarks, .files, .trash: true - case .all, .inbox, .xBookmarks, .files, .trash, .collection: + case .all, .inbox, .chat, .timeline, .review, .collection: false } } @@ -1507,7 +1538,15 @@ struct LociSidebar: View { renameDraft = collection.name } + private func beginEditingBrief(_ collection: ReferenceCollection) { + briefDraft = collection.brief + collectionToEditBrief = collection + } + private func selectFilter(_ filter: CollectionFilter) { + if case .collection(let id) = filter { + store.activeThreadID = id + } var transaction = Transaction() transaction.animation = nil transaction.disablesAnimations = true @@ -1574,10 +1613,10 @@ struct StudioSidebarSection: View { .frame(width: 12) VStack(alignment: .leading, spacing: 1) { - Text("Studio") + Text("Advanced") .font(LociFont.label) .foregroundStyle(LociColor.ink) - Text("Review, graph, rules") + Text("Sources, connections, automation") .font(LociFont.caption) .foregroundStyle(LociColor.inkFaint) } diff --git a/Sources/Loci/CreativeThreadBriefSheet.swift b/Sources/Loci/CreativeThreadBriefSheet.swift new file mode 100644 index 0000000..8e671be --- /dev/null +++ b/Sources/Loci/CreativeThreadBriefSheet.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// A Space becomes a creative thread when it has an explicit question or direction. +/// References, its Board, and Ask Loci already share the same collection scope. +struct CreativeThreadBriefSheet: View { + let threadName: String + @Binding var brief: String + var onSave: () -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: LociSpacing.component) { + VStack(alignment: .leading, spacing: LociSpacing.tight) { + Text("Creative Thread") + .font(LociFont.title) + .foregroundStyle(LociColor.ink) + Text(threadName) + .font(LociFont.caption) + .foregroundStyle(LociColor.inkTertiary) + } + + Text("Write the question, feeling, or decision this space is helping you make. Ask Loci can use this as the thread’s north star.") + .font(LociFont.body) + .foregroundStyle(LociColor.inkSecondary) + .fixedSize(horizontal: false, vertical: true) + + TextEditor(text: $brief) + .font(LociFont.body) + .scrollContentBackground(.hidden) + .padding(10) + .frame(minHeight: 150) + .background(LociColor.surfaceRecessed, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(LociColor.hairline, lineWidth: 1) + } + .accessibilityLabel("Creative brief") + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save brief") { + onSave() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(LociSpacing.panel) + .frame(width: 520) + .background(LociColor.surface) + } +} diff --git a/Sources/Loci/GraphExplorerView.swift b/Sources/Loci/GraphExplorerView.swift index 32dd067..e77c028 100644 --- a/Sources/Loci/GraphExplorerView.swift +++ b/Sources/Loci/GraphExplorerView.swift @@ -23,7 +23,7 @@ struct GraphExplorerView: View { GeometryReader { proxy in let size = proxy.size ZStack { - Color.white + LociColor.canvas Canvas { ctx, _ in for edge in graph.edges { @@ -290,7 +290,7 @@ struct GraphExplorerView: View { .frame(width: 5.5, height: 5.5) Text(shortLabel(for: group)) .lociFont(size: 8.5, weight: .semibold, relativeTo: .caption2) - .foregroundStyle(Color.black.opacity(0.62)) + .foregroundStyle(LociColor.inkSecondary) .lineLimit(1) } } @@ -299,8 +299,8 @@ struct GraphExplorerView: View { .padding(.horizontal, 9) .padding(.vertical, 8) .frame(width: 102, height: 48, alignment: .center) - .background(Color.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(.black.opacity(0.08), lineWidth: 0.5)) + .background(LociColor.surface.opacity(0.92), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(LociColor.hairline, lineWidth: 0.5)) .shadow(color: .black.opacity(0.045), radius: 7, y: 3) } @@ -311,13 +311,13 @@ struct GraphExplorerView: View { .frame(width: 6, height: 6) Text(edgeReasonText(for: edge.relation)) .lociFont(size: 9, weight: .semibold, relativeTo: .caption2) - .foregroundStyle(Color.black.opacity(0.66)) + .foregroundStyle(LociColor.inkSecondary) .lineLimit(1) } .padding(.horizontal, 9) .frame(height: 26) - .background(Color.white.opacity(0.92), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(.black.opacity(0.08), lineWidth: 0.5)) + .background(LociColor.surface.opacity(0.96), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(LociColor.hairline, lineWidth: 0.5)) .shadow(color: .black.opacity(0.10), radius: 12, y: 5) .help(edgeReasonText(for: edge.relation)) } @@ -331,7 +331,7 @@ struct GraphExplorerView: View { } Text("\(Int((zoom * 100).rounded()))%") .lociFont(size: 8.5, weight: .bold, design: .rounded, relativeTo: .caption2) - .foregroundStyle(Color.black.opacity(0.56)) + .foregroundStyle(LociColor.inkSecondary) .frame(width: 28, height: 15) controlButton(systemName: "plus", help: "Zoom in") { withAnimation(AppMotion.quick) { @@ -339,7 +339,7 @@ struct GraphExplorerView: View { } } Rectangle() - .fill(Color.black.opacity(0.08)) + .fill(LociColor.hairline) .frame(width: 18, height: 1) .padding(.vertical, 1) controlButton(systemName: "arrow.up.left.and.arrow.down.right", help: "Rearrange graph") { @@ -352,8 +352,8 @@ struct GraphExplorerView: View { } } .padding(4) - .background(Color.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(.black.opacity(0.08), lineWidth: 0.5)) + .background(LociColor.surface.opacity(0.92), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(LociColor.hairline, lineWidth: 0.5)) .shadow(color: .black.opacity(0.045), radius: 7, y: 3) } @@ -361,7 +361,7 @@ struct GraphExplorerView: View { Button(action: action) { Image(systemName: systemName) .lociFont(size: 11, weight: .bold, relativeTo: .caption) - .foregroundStyle(Color.black.opacity(0.62)) + .foregroundStyle(LociColor.inkSecondary) .frame(width: 23, height: 23) .contentShape(Rectangle()) } diff --git a/Sources/Loci/LociDesign.swift b/Sources/Loci/LociDesign.swift index 2951c6f..629e39e 100644 --- a/Sources/Loci/LociDesign.swift +++ b/Sources/Loci/LociDesign.swift @@ -23,6 +23,8 @@ enum LociColor { /// Window and canvas surface. static var surface: Color { Color(nsColor: .windowBackgroundColor) } + /// Large working planes such as the Board and Explore modes. + static var canvas: Color { Color(nsColor: .textBackgroundColor) } /// Slightly recessed panels, rows, and wells. static var surfaceRecessed: Color { Color(nsColor: .controlBackgroundColor) } /// Hover/selected fills for list rows and tiles. @@ -32,6 +34,9 @@ enum LociColor { static var hairline: Color { Color(nsColor: .separatorColor) } /// Stronger borders (focused fields, active cards). static var border: Color { Color(nsColor: .gridColor) } + + /// Platform accent for the one place color should carry interaction meaning. + static var accent: Color { Color(nsColor: .controlAccentColor) } } /// Semantic type scale. The app renders dense, chrome-like UI, so sizes sit diff --git a/Sources/Loci/Models.swift b/Sources/Loci/Models.swift index d2302f5..9189776 100644 --- a/Sources/Loci/Models.swift +++ b/Sources/Loci/Models.swift @@ -5,9 +5,9 @@ import QuickLookThumbnailing import SwiftUI enum ViewMode: String, CaseIterable, Identifiable { - case grid = "Grid" - case canvas = "Canvas" - case infinity = "Infinity" + case grid = "Library" + case canvas = "Board" + case infinity = "Explore" var id: String { rawValue } @@ -140,6 +140,8 @@ struct ReferenceCollection: Identifiable, Hashable { var name: String var symbol: String var tint: Color + /// The human intent that turns a collection of references into a creative thread. + var brief: String = "" } struct ReferenceItem: Identifiable, Hashable { @@ -259,6 +261,8 @@ final class LibraryStore { var importJobResults: [ImportCoordinator.ImportJobResult] = [] var isAPILibraryVisible = false var notebookActiveItemID: ReferenceItem.ID? + /// The last Space selected by the user; Ask Loci uses it as a Creative Thread scope. + var activeThreadID: ReferenceCollection.ID? var collections: [ReferenceCollection] var items: [ReferenceItem] @@ -1658,8 +1662,8 @@ final class LibraryStore { func addCollection(undoManager: UndoManager? = nil) { let collection = ReferenceCollection( id: UUID(), - name: "Untitled \(collections.count + 1)", - symbol: "folder.fill", + name: "Untitled Thread \(collections.count + 1)", + symbol: "sparkles", tint: .gray ) collections.append(collection) @@ -1692,6 +1696,13 @@ final class LibraryStore { undoManager?.setActionName("Rename Collection") } + func updateCollectionBrief(id: UUID, to brief: String) { + guard let index = collections.firstIndex(where: { $0.id == id }) else { return } + collections[index].brief = brief.trimmingCharacters(in: .whitespacesAndNewlines) + persistence?.upsert(collection: collections[index]) + rebuildVaultGraph() + } + func mergeCollection(id: UUID, direction: CollectionMergeDirection) { guard let index = collections.firstIndex(where: { $0.id == id }) else { return } let targetIndex: Int diff --git a/Sources/Loci/PersistentStore.swift b/Sources/Loci/PersistentStore.swift index e9af0a2..1e66abc 100644 --- a/Sources/Loci/PersistentStore.swift +++ b/Sources/Loci/PersistentStore.swift @@ -452,15 +452,16 @@ final class LociPersistentStore { do { try queue.write { db in try db.execute(sql: """ - INSERT INTO collections (id, name, symbol, tint_hex, created_at, updated_at) - VALUES (?, ?, ?, ?, COALESCE((SELECT created_at FROM collections WHERE id = ?), ?), ?) + INSERT INTO collections (id, name, symbol, tint_hex, brief, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM collections WHERE id = ?), ?), ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, symbol = excluded.symbol, tint_hex = excluded.tint_hex, + brief = excluded.brief, updated_at = excluded.updated_at, deleted_at = NULL - """, arguments: [collection.id.uuidString, collection.name, collection.symbol, "system-gray", collection.id.uuidString, now, now]) + """, arguments: [collection.id.uuidString, collection.name, collection.symbol, "system-gray", collection.brief, collection.id.uuidString, now, now]) } } catch { print("GRDB upsert(collection:) failed: \(error)") @@ -1065,19 +1066,35 @@ final class LociPersistentStore { execute(statement) } } + + if migrationVersion() < 4 { + let v4 = [ + "ALTER TABLE collections ADD COLUMN brief TEXT NOT NULL DEFAULT ''", + "INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (4, '\(timestamp())')" + ] + for statement in v4 { + execute(statement) + } + } } private func loadCollections() -> [ReferenceCollection] { guard let queue = grdbQueue else { return [] } do { return try queue.read { db in - try Row.fetchAll(db, sql: "SELECT id, name, symbol, tint_hex FROM collections WHERE deleted_at IS NULL ORDER BY created_at ASC").compactMap { row in + try Row.fetchAll(db, sql: "SELECT id, name, symbol, tint_hex, brief FROM collections WHERE deleted_at IS NULL ORDER BY created_at ASC").compactMap { row in guard let idStr = row["id"] as String?, let id = UUID(uuidString: idStr), let name = row["name"] as String?, let symbol = row["symbol"] as String? else { return nil } let tintHex = row["tint_hex"] as String? ?? "system-gray" - return ReferenceCollection(id: id, name: name, symbol: symbol, tint: colorFromHex(tintHex)) + return ReferenceCollection( + id: id, + name: name, + symbol: symbol, + tint: colorFromHex(tintHex), + brief: row["brief"] as String? ?? "" + ) } } } catch { diff --git a/Sources/Loci/ReferenceViews.swift b/Sources/Loci/ReferenceViews.swift index 4caa275..e4c5d53 100644 --- a/Sources/Loci/ReferenceViews.swift +++ b/Sources/Loci/ReferenceViews.swift @@ -38,7 +38,7 @@ struct ReferenceGridView: View { LazyVStack(spacing: 0) { ForEach(gridBands(for: placements, contentHeight: contentHeight)) { band in ZStack(alignment: .topLeading) { - Color.white.opacity(0.001) + LociColor.canvas.opacity(0.001) .contentShape(Rectangle()) .frame(width: contentWidth, height: band.height) .onTapGesture { @@ -55,7 +55,7 @@ struct ReferenceGridView: View { .frame(width: contentWidth, height: contentHeight, alignment: .top) } .background { - Color.white + LociColor.canvas .contentShape(Rectangle()) .onTapGesture { dismissFocusedPreviewOrClearSelection() @@ -81,7 +81,7 @@ struct ReferenceGridView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .background(Color.white) + .background(LociColor.canvas) .animation(AppMotion.smooth, value: store.selectedFilter) .animation(AppMotion.smooth, value: store.activeSearchQuery) .gesture( @@ -526,7 +526,7 @@ struct ReferenceCanvasView: View { var body: some View { GeometryReader { proxy in ZStack { - Color.white + LociColor.canvas .contentShape(Rectangle()) .gesture(canvasSelectionGesture(in: proxy.size)) .onTapGesture { @@ -855,7 +855,7 @@ struct ReferenceInfinityView: View { var body: some View { GeometryReader { proxy in ZStack { - Color.white + LociColor.canvas InfinitySpaceBackground(zoom: store.zoom, pan: store.infinityPan) .allowsHitTesting(false) @@ -970,7 +970,7 @@ struct ReferenceInfinityView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .background(Color.white) + .background(LociColor.canvas) .clipped() .onAppear { baseZoom = store.infinityZoom @@ -1664,19 +1664,19 @@ struct ReferenceGridTile: View { var isSelected: Bool var clickRippleStrength: CGFloat = 0 @State private var isHovering = false - private let selectionBlue = Color(red: 0.02, green: 0.45, blue: 0.98) + private let selectionBlue = LociColor.accent var body: some View { ReferenceThumbnail(item: item, xBookmarkPayload: xBookmarkPayload) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .background { RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.white.opacity(0.96)) + .fill(LociColor.surface.opacity(0.96)) } .overlay { RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder( - isSelected ? selectionBlue : Color.black.opacity(isHovering ? 0.08 : 0.030), + isSelected ? selectionBlue : LociColor.hairline.opacity(isHovering ? 0.85 : 0.45), lineWidth: isSelected ? 2.2 : 0.6 ) } @@ -1685,7 +1685,7 @@ struct ReferenceGridTile: View { Image(systemName: "checkmark.circle.fill") .lociFont(size: 16, weight: .semibold, relativeTo: .headline) .foregroundStyle(selectionBlue) - .background(Color.white, in: Circle()) + .background(LociColor.surface, in: Circle()) .padding(7) .transition(.scale(scale: 0.72).combined(with: .opacity)) } @@ -1713,7 +1713,7 @@ struct ReferenceTile: View { let namespace: Namespace.ID var clickRippleStrength: CGFloat = 0 @State private var isHovering = false - private let selectionBlue = Color(red: 0.02, green: 0.45, blue: 0.98) + private let selectionBlue = LociColor.accent var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -1723,7 +1723,7 @@ struct ReferenceTile: View { .overlay { RoundedRectangle(cornerRadius: 7, style: .continuous) .strokeBorder( - isSelected ? selectionBlue : Color.black.opacity(isHovering ? 0.13 : 0.045), + isSelected ? selectionBlue : LociColor.hairline.opacity(isHovering ? 0.9 : 0.55), lineWidth: isSelected ? 2.1 : 0.7 ) } @@ -1732,7 +1732,7 @@ struct ReferenceTile: View { Image(systemName: "checkmark.circle.fill") .lociFont(size: 13, weight: .semibold, relativeTo: .subheadline) .foregroundStyle(selectionBlue) - .background(Color.white, in: Circle()) + .background(LociColor.surface, in: Circle()) .padding(4) .transition(.scale(scale: 0.72).combined(with: .opacity)) } diff --git a/Sources/Loci/ReviewQueueView.swift b/Sources/Loci/ReviewQueueView.swift index 64eaedd..59aa479 100644 --- a/Sources/Loci/ReviewQueueView.swift +++ b/Sources/Loci/ReviewQueueView.swift @@ -19,7 +19,7 @@ struct ReviewQueueView: View { dueList } } - .background(Color.white) + .background(LociColor.surface) .task { refresh() } } diff --git a/Tests/LociTests/StartupLoadingTests.swift b/Tests/LociTests/StartupLoadingTests.swift index 8807413..93decb3 100644 --- a/Tests/LociTests/StartupLoadingTests.swift +++ b/Tests/LociTests/StartupLoadingTests.swift @@ -4,6 +4,22 @@ import Foundation @Suite("StartupLoading") struct StartupLoadingTests { + @MainActor + @Test("Creative Thread brief stays attached to its Space") + func creativeThreadBriefStaysWithSpace() { + let thread = ReferenceCollection( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000201")!, + name: "Editorial identity", + symbol: "sparkles", + tint: .gray + ) + let store = LibraryStore(collections: [thread], items: [], persistence: nil) + + store.updateCollectionBrief(id: thread.id, to: "Find a warm, tactile direction for the rebrand.") + + #expect(store.collections.first?.brief == "Find a warm, tactile direction for the rebrand.") + } + @MainActor @Test("Deferred vault bootstrap leaves references visible immediately") func testDeferredVaultBootstrapKeepsReferencesVisible() async { From 52febb11f4c836649db28a341298cee927e2b55e Mon Sep 17 00:00:00 2001 From: Arnaud Bellemare Date: Thu, 9 Jul 2026 23:15:05 -0400 Subject: [PATCH 2/2] Add native workspace controls --- Sources/Loci/ChatWorkspaceView.swift | 40 +++++-- Sources/Loci/CommandPaletteView.swift | 146 ++++++++++++++++++++++++++ Sources/Loci/ContentView.swift | 20 ++++ Sources/Loci/LociApp.swift | 87 ++++++++++++++- 4 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 Sources/Loci/CommandPaletteView.swift diff --git a/Sources/Loci/ChatWorkspaceView.swift b/Sources/Loci/ChatWorkspaceView.swift index c8f77c7..1381061 100644 --- a/Sources/Loci/ChatWorkspaceView.swift +++ b/Sources/Loci/ChatWorkspaceView.swift @@ -47,23 +47,26 @@ struct ChatWorkspaceView: View { @State private var browserSelectedID: ReferenceItem.ID? @State private var showShareSheet = false @AppStorage("LociOpenRouterModel") private var configuredModel = "openai/gpt-4o-mini" + @AppStorage("LociNotebookInspectorVisible") private var isInspectorVisible = true private let primaryText = LociColor.ink private let secondaryText = LociColor.inkTertiary private let panelBackground = LociColor.surfaceRecessed var body: some View { - HStack(spacing: 0) { - leftPanel - .frame(minWidth: 420, maxWidth: .infinity) - .layoutPriority(1) - - Rectangle() - .fill(LociColor.hairline) - .frame(width: 1) - - chatPanel - .frame(minWidth: 320, idealWidth: 360, maxWidth: 440) + Group { + if isInspectorVisible { + HSplitView { + leftPanel + .frame(minWidth: 360, maxWidth: .infinity) + .layoutPriority(1) + + chatPanel + .frame(minWidth: 300, idealWidth: 360, maxWidth: 520) + } + } else { + leftPanel + } } .background(LociColor.surface) .onAppear { @@ -78,6 +81,11 @@ struct ChatWorkspaceView: View { scope = .selected } } + .onReceive(NotificationCenter.default.publisher(for: .lociToggleNotebookInspector)) { _ in + withAnimation(AppMotion.quick) { + isInspectorVisible.toggle() + } + } } private var leftPanel: some View { @@ -304,6 +312,16 @@ struct ChatWorkspaceView: View { scopePicker .padding(.horizontal, 16) + Button { + isInspectorVisible = false + } label: { + Label("Hide Ask Loci", systemImage: "sidebar.right") + .font(LociFont.caption) + } + .buttonStyle(.borderless) + .foregroundStyle(secondaryText) + .padding(.horizontal, 16) + activeSourceSummary .padding(.horizontal, 16) diff --git a/Sources/Loci/CommandPaletteView.swift b/Sources/Loci/CommandPaletteView.swift new file mode 100644 index 0000000..63fda8e --- /dev/null +++ b/Sources/Loci/CommandPaletteView.swift @@ -0,0 +1,146 @@ +import SwiftUI + +private enum LociCommand: CaseIterable, Identifiable { + case library + case inbox + case askLoci + case rediscover + case newThread + case toggleInspector + case settings + + var id: Self { self } + + var title: String { + switch self { + case .library: "Open Library" + case .inbox: "Open Inbox" + case .askLoci: "Ask Loci" + case .rediscover: "Open Rediscover" + case .newThread: "New Creative Thread" + case .toggleInspector: "Show or Hide Ask Loci" + case .settings: "Open Settings" + } + } + + var subtitle: String { + switch self { + case .library: "Browse every saved reference" + case .inbox: "Process new captures" + case .askLoci: "Ask grounded questions about your sources" + case .rediscover: "Bring useful references back into rotation" + case .newThread: "Start a project with a brief, board, and sources" + case .toggleInspector: "Tailor the Ask Loci workspace" + case .settings: "Models, privacy, integrations, and storage" + } + } + + var symbol: String { + switch self { + case .library: "square.grid.2x2" + case .inbox: "tray" + case .askLoci: "sparkles" + case .rediscover: "clock.arrow.circlepath" + case .newThread: "plus.circle" + case .toggleInspector: "sidebar.right" + case .settings: "gearshape" + } + } +} + +struct CommandPaletteView: View { + @Bindable var store: LibraryStore + @Binding var isPresented: Bool + @State private var query = "" + @FocusState private var isSearchFocused: Bool + + private var commands: [LociCommand] { + let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return LociCommand.allCases } + return LociCommand.allCases.filter { + $0.title.localizedCaseInsensitiveContains(normalized) + || $0.subtitle.localizedCaseInsensitiveContains(normalized) + } + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "command") + .foregroundStyle(LociColor.inkSecondary) + TextField("Search commands", text: $query) + .textFieldStyle(.plain) + .font(LociFont.body) + .focused($isSearchFocused) + Text("⌘K") + .font(LociFont.badge) + .foregroundStyle(LociColor.inkTertiary) + } + .padding(LociSpacing.element) + + Divider() + + ScrollView { + LazyVStack(spacing: 2) { + ForEach(commands) { command in + Button { perform(command) } label: { + HStack(spacing: 10) { + Image(systemName: command.symbol) + .frame(width: 20) + .foregroundStyle(LociColor.inkSecondary) + VStack(alignment: .leading, spacing: 2) { + Text(command.title) + .font(LociFont.headline) + .foregroundStyle(LociColor.ink) + Text(command.subtitle) + .font(LociFont.caption) + .foregroundStyle(LociColor.inkTertiary) + } + Spacer() + } + .padding(.horizontal, LociSpacing.element) + .padding(.vertical, 9) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .background(LociColor.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + .padding(6) + } + .frame(maxHeight: 320) + } + .background(LociColor.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(LociColor.hairline, lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.16), radius: 28, y: 12) + .onAppear { isSearchFocused = true } + .onExitCommand { isPresented = false } + .accessibilityElement(children: .contain) + .accessibilityLabel("Command Palette") + } + + private func perform(_ command: LociCommand) { + switch command { + case .library: + store.selectedFilter = .all + case .inbox: + store.selectedFilter = .inbox + case .askLoci: + UserDefaults.standard.set(true, forKey: "LociNotebookInspectorVisible") + store.selectedFilter = .chat + case .rediscover: + _ = ReviewScheduler.autoEnqueueForgottenReferences() + store.selectedFilter = .review + case .newThread: + store.addCollection() + case .toggleInspector: + NotificationCenter.default.post(name: .lociToggleNotebookInspector, object: nil) + case .settings: + NSApp.sendAction(Selector(("openSettings")), to: nil, from: nil) + } + isPresented = false + } +} diff --git a/Sources/Loci/ContentView.swift b/Sources/Loci/ContentView.swift index e2825bb..a392575 100644 --- a/Sources/Loci/ContentView.swift +++ b/Sources/Loci/ContentView.swift @@ -94,6 +94,7 @@ struct LociShell: View { @State private var warmedModes: [ViewMode] = [] @State private var didStartDemoAutoplay = false @State private var demoAutoplayObserver: NSObjectProtocol? + @State private var isShowingCommandPalette = false var body: some View { ZStack { @@ -138,6 +139,17 @@ struct LociShell: View { .padding(.top, 22) .padding(.trailing, 18) } + + if isShowingCommandPalette { + Color.black.opacity(0.16) + .ignoresSafeArea() + .onTapGesture { isShowingCommandPalette = false } + + CommandPaletteView(store: store, isPresented: $isShowingCommandPalette) + .frame(width: 560) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + .zIndex(30) + } } .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .animation(AppMotion.instant, value: store.mode) @@ -169,6 +181,11 @@ struct LociShell: View { store.searchText = "" } } + .onReceive(NotificationCenter.default.publisher(for: .lociShowCommandPalette)) { _ in + withAnimation(AppMotion.quick) { + isShowingCommandPalette = true + } + } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { store.warmCommonReferenceFilters() @@ -1547,6 +1564,9 @@ struct LociSidebar: View { if case .collection(let id) = filter { store.activeThreadID = id } + if filter == .chat { + UserDefaults.standard.set(true, forKey: "LociNotebookInspectorVisible") + } var transaction = Transaction() transaction.animation = nil transaction.disablesAnimations = true diff --git a/Sources/Loci/LociApp.swift b/Sources/Loci/LociApp.swift index 8ecffee..38fcf69 100644 --- a/Sources/Loci/LociApp.swift +++ b/Sources/Loci/LociApp.swift @@ -14,8 +14,13 @@ enum LociMain { } } +extension Notification.Name { + static let lociShowCommandPalette = Notification.Name("LociShowCommandPalette") + static let lociToggleNotebookInspector = Notification.Name("LociToggleNotebookInspector") +} + @MainActor -final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { +final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSToolbarDelegate { private var window: NSWindow? private var settingsWindow: NSWindow? private var localAPI: LocalReferenceAPIServer? @@ -56,6 +61,7 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { window.contentMinSize = NSSize(width: 980, height: 620) window.contentViewController = hostingController window.delegate = self + configureToolbar(for: window) window.center() window.makeKeyAndOrderFront(nil) @@ -126,6 +132,19 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { editMenuItem.submenu = editMenu mainMenu.addItem(editMenuItem) + let viewMenuItem = NSMenuItem() + let viewMenu = NSMenu(title: "View") + let commandPaletteItem = NSMenuItem(title: "Command Palette", action: #selector(showCommandPalette), keyEquivalent: "k") + commandPaletteItem.keyEquivalentModifierMask = .command + commandPaletteItem.target = self + viewMenu.addItem(commandPaletteItem) + let inspectorItem = NSMenuItem(title: "Show or Hide Ask Loci", action: #selector(toggleNotebookInspector), keyEquivalent: "i") + inspectorItem.keyEquivalentModifierMask = [.command, .option] + inspectorItem.target = self + viewMenu.addItem(inspectorItem) + viewMenuItem.submenu = viewMenu + mainMenu.addItem(viewMenuItem) + let windowMenuItem = NSMenuItem() let windowMenu = NSMenu(title: "Window") windowMenu.addItem(NSMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) @@ -161,6 +180,66 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } + @objc private func showCommandPalette(_ sender: Any? = nil) { + NotificationCenter.default.post(name: .lociShowCommandPalette, object: nil) + } + + @objc private func toggleNotebookInspector(_ sender: Any? = nil) { + NotificationCenter.default.post(name: .lociToggleNotebookInspector, object: nil) + } + + private func configureToolbar(for window: NSWindow) { + let toolbar = NSToolbar(identifier: "LociMainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true + window.toolbar = toolbar + window.toolbarStyle = .unifiedCompact + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.flexibleSpace, .space, + .lociCommandPalette, .lociNotebookInspector, .lociSettings] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.lociCommandPalette, .flexibleSpace, .lociNotebookInspector, .lociSettings] + } + + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.target = self + + switch itemIdentifier { + case .lociCommandPalette: + item.label = "Command Palette" + item.paletteLabel = "Command Palette" + item.toolTip = "Command Palette (⌘K)" + item.image = NSImage(systemSymbolName: "command", accessibilityDescription: "Command Palette") + item.action = #selector(showCommandPalette) + case .lociNotebookInspector: + item.label = "Ask Loci" + item.paletteLabel = "Show or Hide Ask Loci" + item.toolTip = "Show or Hide Ask Loci (⌥⌘I)" + item.image = NSImage(systemSymbolName: "sidebar.right", accessibilityDescription: "Show or Hide Ask Loci") + item.action = #selector(toggleNotebookInspector) + case .lociSettings: + item.label = "Settings" + item.paletteLabel = "Settings" + item.toolTip = "Settings (⌘,)" + item.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") + item.action = #selector(openSettings) + default: + return nil + } + return item + } + @objc private func openSettings() { if let existing = settingsWindow { existing.makeKeyAndOrderFront(nil) @@ -194,3 +273,9 @@ final class LociAppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } } } + +private extension NSToolbarItem.Identifier { + static let lociCommandPalette = NSToolbarItem.Identifier("LociCommandPalette") + static let lociNotebookInspector = NSToolbarItem.Identifier("LociNotebookInspector") + static let lociSettings = NSToolbarItem.Identifier("LociSettings") +}