Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 86 additions & 18 deletions Sources/Loci/ChatWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand All @@ -45,23 +46,27 @@ 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"
@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(width: 400)
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 {
Expand All @@ -76,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 {
Expand Down Expand Up @@ -302,9 +312,22 @@ 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)

groundingBanner
.padding(.horizontal, 16)

messageList
.padding(.horizontal, 12)

Expand All @@ -318,13 +341,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"
}
}

Expand Down Expand Up @@ -354,7 +379,7 @@ struct ChatWorkspaceView: View {
}
}
.buttonStyle(.plain)
.disabled(option == .selected && store.selectedItemIDs.isEmpty && store.notebookActiveItemID == nil)
.disabled(isScopeUnavailable(option))
}
}
.padding(3)
Expand Down Expand Up @@ -383,6 +408,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 {
Expand Down Expand Up @@ -465,9 +505,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."
]
}

Expand Down Expand Up @@ -519,6 +560,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 }
}
}

Expand All @@ -540,6 +584,8 @@ struct ChatWorkspaceView: View {
private func syncScopeFromSelection() {
if store.notebookActiveItemID != nil || !store.selectedItemIDs.isEmpty {
scope = .selected
} else if activeThread != nil {
scope = .currentThread
}
}

Expand Down Expand Up @@ -574,10 +620,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 }
Expand Down Expand Up @@ -605,6 +657,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 {
Expand Down
146 changes: 146 additions & 0 deletions Sources/Loci/CommandPaletteView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading