Port the mai-ios block parser into LumaCore; render headings, lists, tables, task lists, blockquotes, code blocks and footnotes across the SwiftUI and GTK frontends. Parse inline spans and rewrite LaTeX to Unicode in LumaCore so both frontends share one parser. Highlight code through StyledText. Drop the inline-only AttributedString path. Co-authored-by: trufae <[email protected]>
590 lines
21 KiB
Swift
590 lines
21 KiB
Swift
import LumaCore
|
|
import SwiftUI
|
|
|
|
#if canImport(AppKit)
|
|
private let textEditorBackground = Color(nsColor: .textBackgroundColor)
|
|
#else
|
|
private let textEditorBackground = Color(uiColor: .secondarySystemBackground)
|
|
#endif
|
|
|
|
struct AddressNotePopover: View {
|
|
let engine: Engine
|
|
let sessionID: UUID
|
|
let address: UInt64
|
|
@Binding var isPresented: Bool
|
|
|
|
@State private var notes: [AddressNote] = []
|
|
@State private var activeNoteID: UUID?
|
|
@State private var messages: [AddressNoteMessage] = []
|
|
@State private var draft: String = ""
|
|
@State private var pending: PendingState = .idle
|
|
@State private var unusedTransientNoteIDs: Set<UUID> = []
|
|
@State private var pendingActions: [MissionAction] = []
|
|
@State private var actionsObservation: LumaCore.StoreObservation?
|
|
@State private var rejectingAction: MissionAction?
|
|
@State private var rejectReason: String = ""
|
|
@State private var editingMessageID: UUID?
|
|
@State private var editingDraft: String = ""
|
|
@State private var pendingDeleteThread: AddressNote?
|
|
@State private var pendingDeleteMessage: AddressNoteMessage?
|
|
@State private var renamingNoteID: UUID?
|
|
@State private var renameDraft: String = ""
|
|
|
|
@FocusState private var inputFocused: Bool
|
|
@FocusState private var editFocused: Bool
|
|
|
|
private enum PendingState {
|
|
case idle
|
|
case sending
|
|
case error(String)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
if let note = activeNote {
|
|
threadView(note: note)
|
|
} else {
|
|
emptyState
|
|
}
|
|
}
|
|
.frame(width: 460, height: 460)
|
|
.onAppear {
|
|
refresh()
|
|
observePendingActions()
|
|
DispatchQueue.main.async { inputFocused = true }
|
|
}
|
|
.onChange(of: activeNoteID) { _, _ in reloadActiveNote() }
|
|
.onChange(of: hasActiveReply) { _, active in
|
|
pending = active ? .sending : .idle
|
|
if !active { reloadMessages() }
|
|
}
|
|
.onDisappear {
|
|
actionsObservation = nil
|
|
discardUnusedTransientNotes()
|
|
}
|
|
.confirmationDialog(
|
|
"Delete thread?",
|
|
isPresented: Binding(
|
|
get: { pendingDeleteThread != nil },
|
|
set: { if !$0 { pendingDeleteThread = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingDeleteThread
|
|
) { note in
|
|
Button("Delete", role: .destructive) { commitDelete(note: note) }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.confirmationDialog(
|
|
"Delete message?",
|
|
isPresented: Binding(
|
|
get: { pendingDeleteMessage != nil },
|
|
set: { if !$0 { pendingDeleteMessage = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingDeleteMessage
|
|
) { message in
|
|
Button("Delete", role: .destructive) { commitDelete(message: message) }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.alert(
|
|
"Rename thread",
|
|
isPresented: Binding(
|
|
get: { renamingNoteID != nil },
|
|
set: { if !$0 { renamingNoteID = nil } }
|
|
)
|
|
) {
|
|
TextField("Title", text: $renameDraft)
|
|
Button("Save") { commitRename() }
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
.alert("Reject action?", isPresented: Binding(
|
|
get: { rejectingAction != nil },
|
|
set: { if !$0 { rejectingAction = nil } }
|
|
)) {
|
|
TextField("Reason (optional)", text: $rejectReason)
|
|
Button("Reject", role: .destructive) {
|
|
if let a = rejectingAction {
|
|
let reason = rejectReason.isEmpty ? nil : rejectReason
|
|
Task { await engine.rejectMissionAction(actionID: a.id, reason: reason) }
|
|
}
|
|
rejectingAction = nil
|
|
}
|
|
Button("Cancel", role: .cancel) { rejectingAction = nil }
|
|
} message: {
|
|
if let a = rejectingAction {
|
|
Text("Tell the agent why you rejected \(a.toolName). This signal can help it adjust.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var activeNote: AddressNote? {
|
|
guard let id = activeNoteID else { return nil }
|
|
return notes.first(where: { $0.id == id })
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "bubble.left.and.text.bubble.right")
|
|
.foregroundStyle(.secondary)
|
|
Text(addressLabel)
|
|
.font(.system(.callout, design: .monospaced))
|
|
if let editors = activeNote?.editors, !editors.isEmpty {
|
|
AuthorAvatarStack(authors: editors, avatarSize: 18)
|
|
}
|
|
Spacer()
|
|
if notes.count > 1 {
|
|
Menu {
|
|
ForEach(notes) { note in
|
|
Button {
|
|
activeNoteID = note.id
|
|
} label: {
|
|
if note.id == activeNoteID {
|
|
Label(noteTitle(note), systemImage: "checkmark")
|
|
} else {
|
|
Text(noteTitle(note))
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Label("Switch", systemImage: "chevron.up.chevron.down")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.frame(width: 24)
|
|
}
|
|
Button {
|
|
createNote()
|
|
} label: {
|
|
Image(systemName: "plus.bubble")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("New thread")
|
|
if let note = activeNote {
|
|
Button {
|
|
renameDraft = noteTitle(note)
|
|
renamingNoteID = note.id
|
|
} label: {
|
|
Image(systemName: "pencil")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Rename thread")
|
|
Button(role: .destructive) {
|
|
pendingDeleteThread = note
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Delete thread")
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var addressLabel: String {
|
|
String(format: "0x%llx", address)
|
|
}
|
|
|
|
private func threadView(note: AddressNote) -> some View {
|
|
VStack(spacing: 0) {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(messages) { message in
|
|
messageRow(message)
|
|
.id(message.id)
|
|
}
|
|
if let placeholder = livePlaceholder {
|
|
MessageRow(message: placeholder, isEditing: false, editingDraft: .constant(""))
|
|
.id(placeholder.id)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 12)
|
|
|
|
Color.clear
|
|
.frame(height: 12)
|
|
.id(Self.bottomAnchorID)
|
|
}
|
|
}
|
|
.defaultScrollAnchor(.bottom)
|
|
.onAppear {
|
|
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
|
|
}
|
|
.onChange(of: messages.count) { _, _ in
|
|
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
|
|
}
|
|
.onChange(of: livePlaceholder?.bodyMarkdown) { _, _ in
|
|
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
|
|
}
|
|
.onChange(of: editingMessageID) { _, newID in
|
|
guard let newID else { return }
|
|
withAnimation { proxy.scrollTo(newID, anchor: .bottom) }
|
|
}
|
|
}
|
|
if !pendingActions.isEmpty {
|
|
Divider()
|
|
pendingApprovals
|
|
}
|
|
Divider()
|
|
inputBar(note: note)
|
|
}
|
|
}
|
|
|
|
private var pendingApprovals: some View {
|
|
VStack(spacing: 8) {
|
|
ForEach(pendingActions) { action in
|
|
if action.toolName == MissionTools.requestUserInputToolName {
|
|
RequestUserInputCard(action: action) { answer in
|
|
engine.submitUserInputResponse(actionID: action.id, answer: answer)
|
|
}
|
|
} else {
|
|
ActionCard(
|
|
engine: engine,
|
|
action: action,
|
|
onApprove: { Task { await engine.approveMissionAction(actionID: action.id) } },
|
|
onReject: {
|
|
rejectingAction = action
|
|
rejectReason = ""
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func messageRow(_ message: AddressNoteMessage) -> some View {
|
|
let isEditing = editingMessageID == message.id
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
MessageRow(message: message, isEditing: isEditing, editingDraft: $editingDraft)
|
|
.focused($editFocused, equals: isEditing)
|
|
if isEditing {
|
|
HStack(spacing: 6) {
|
|
Button("Cancel") { editingMessageID = nil }
|
|
.buttonStyle(.bordered)
|
|
Button("Save") { commitEdit(message: message) }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(editingDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
}
|
|
.contextMenu {
|
|
if message.role == .user {
|
|
Button {
|
|
editingDraft = message.bodyMarkdown
|
|
editingMessageID = message.id
|
|
DispatchQueue.main.async { editFocused = true }
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
Button(role: .destructive) {
|
|
pendingDeleteMessage = message
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static let bottomAnchorID = "address-note-bottom"
|
|
|
|
private func inputBar(note: AddressNote) -> some View {
|
|
VStack(spacing: 6) {
|
|
if case .error(let reason) = pending {
|
|
Text(reason)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
HStack(alignment: .center, spacing: 6) {
|
|
TextEditor(text: $draft)
|
|
.font(.body)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(6)
|
|
.background(RoundedRectangle(cornerRadius: 6, style: .continuous).fill(textEditorBackground))
|
|
.overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.secondary.opacity(0.3)))
|
|
.frame(height: 64)
|
|
.focused($inputFocused)
|
|
VStack(spacing: 0) {
|
|
Button {
|
|
saveNote(note: note)
|
|
} label: {
|
|
Image(systemName: "square.and.pencil")
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.help("Save as user note")
|
|
.disabled(isSending || draft.isEmpty)
|
|
Spacer(minLength: 0)
|
|
Button {
|
|
if isSending {
|
|
cancelReply()
|
|
} else {
|
|
askAI(note: note)
|
|
}
|
|
} label: {
|
|
Group {
|
|
if isSending {
|
|
Image(systemName: "stop.fill")
|
|
} else {
|
|
Image(systemName: "paperplane.fill")
|
|
}
|
|
}
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.help(isSending ? "Cancel reply" : "Ask AI")
|
|
.disabled(!isSending && draft.isEmpty)
|
|
}
|
|
.frame(height: 64)
|
|
}
|
|
}
|
|
.padding(10)
|
|
}
|
|
|
|
private var isSending: Bool {
|
|
if case .sending = pending { return true }
|
|
return false
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 12) {
|
|
Text("No threads on this address yet.")
|
|
.foregroundStyle(.secondary)
|
|
Button("Start a thread") {
|
|
createNote()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var hasActiveReply: Bool {
|
|
engine.noteReplyProgress[sessionID]?.noteID == activeNoteID
|
|
}
|
|
|
|
private var livePlaceholder: AddressNoteMessage? {
|
|
guard isSending, let noteID = activeNoteID else { return nil }
|
|
let progress = engine.noteReplyProgress[sessionID]
|
|
let text = progress?.noteID == noteID ? progress?.text ?? "" : ""
|
|
return AddressNoteMessage(
|
|
noteID: noteID,
|
|
index: -1,
|
|
role: .assistant,
|
|
bodyMarkdown: text,
|
|
modelID: LumaAppState.shared.missionDefaults.modelID
|
|
)
|
|
}
|
|
|
|
private func refresh() {
|
|
notes = engine.addressNotes(sessionID: sessionID)
|
|
.filter { engine.resolveSync(sessionID: sessionID, anchor: $0.anchor) == address }
|
|
if activeNoteID == nil {
|
|
activeNoteID = notes.last?.id
|
|
}
|
|
reloadActiveNote()
|
|
}
|
|
|
|
private func reloadActiveNote() {
|
|
reloadMessages()
|
|
if hasActiveReply { pending = .sending }
|
|
}
|
|
|
|
private func observePendingActions() {
|
|
applyPendingActions((try? engine.store.fetchAllPendingMissionActions()) ?? [])
|
|
actionsObservation = engine.store.observeAllPendingMissionActions { rows in
|
|
Task { @MainActor in applyPendingActions(rows) }
|
|
}
|
|
}
|
|
|
|
private func applyPendingActions(_ rows: [MissionAction]) {
|
|
pendingActions = rows.filter {
|
|
$0.sessionID == sessionID && engine.isAmbientMission($0.missionID)
|
|
}
|
|
}
|
|
|
|
private func reloadMessages() {
|
|
guard let id = activeNoteID else { messages = []; return }
|
|
messages = engine.addressNoteMessages(noteID: id)
|
|
}
|
|
|
|
private func createNote() {
|
|
guard let note = engine.createAddressNote(sessionID: sessionID, address: address) else { return }
|
|
notes.append(note)
|
|
activeNoteID = note.id
|
|
messages = []
|
|
unusedTransientNoteIDs.insert(note.id)
|
|
DispatchQueue.main.async { inputFocused = true }
|
|
}
|
|
|
|
private func commitDelete(note: AddressNote) {
|
|
engine.deleteAddressNote(note)
|
|
notes.removeAll { $0.id == note.id }
|
|
unusedTransientNoteIDs.remove(note.id)
|
|
activeNoteID = notes.last?.id
|
|
reloadMessages()
|
|
if notes.isEmpty {
|
|
isPresented = false
|
|
}
|
|
}
|
|
|
|
private func commitDelete(message: AddressNoteMessage) {
|
|
engine.deleteAddressNoteMessage(message)
|
|
messages.removeAll { $0.id == message.id }
|
|
}
|
|
|
|
private func commitRename() {
|
|
guard let id = renamingNoteID,
|
|
var note = notes.first(where: { $0.id == id })
|
|
else { return }
|
|
let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let newTitle = trimmed.isEmpty ? nil : trimmed
|
|
guard newTitle != note.title else { return }
|
|
note.title = newTitle
|
|
engine.updateAddressNote(note)
|
|
notes = notes.map { $0.id == note.id ? note : $0 }
|
|
}
|
|
|
|
private func commitEdit(message: AddressNoteMessage) {
|
|
let body = editingDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !body.isEmpty, body != message.bodyMarkdown,
|
|
let updated = engine.editUserMessage(noteID: message.noteID, messageID: message.id, body: body)
|
|
else {
|
|
editingMessageID = nil
|
|
return
|
|
}
|
|
messages = messages.map { $0.id == updated.id ? updated : $0 }
|
|
editingMessageID = nil
|
|
}
|
|
|
|
private func saveNote(note: AddressNote) {
|
|
let body = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !body.isEmpty,
|
|
let message = engine.appendUserMessage(noteID: note.id, body: body)
|
|
else { return }
|
|
messages.append(message)
|
|
unusedTransientNoteIDs.remove(note.id)
|
|
draft = ""
|
|
}
|
|
|
|
private func cancelReply() {
|
|
Task { @MainActor in
|
|
await engine.cancelAddressNoteReply(sessionID: sessionID)
|
|
}
|
|
}
|
|
|
|
private func askAI(note: AddressNote) {
|
|
let body = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !body.isEmpty else { return }
|
|
guard let userMessage = engine.appendUserMessage(noteID: note.id, body: body) else { return }
|
|
messages.append(userMessage)
|
|
unusedTransientNoteIDs.remove(note.id)
|
|
draft = ""
|
|
pending = .sending
|
|
let defaults = LumaAppState.shared.missionDefaults
|
|
Task { @MainActor in
|
|
let reply = await engine.requestAIReply(
|
|
noteID: note.id,
|
|
providerID: defaults.providerID,
|
|
modelID: defaults.modelID
|
|
)
|
|
reloadMessages()
|
|
pending = reply == nil ? .error("Reply failed. Check provider settings.") : .idle
|
|
}
|
|
}
|
|
|
|
private func discardUnusedTransientNotes() {
|
|
for note in notes where engine.addressNoteMessages(noteID: note.id).isEmpty {
|
|
engine.deleteAddressNote(note)
|
|
}
|
|
unusedTransientNoteIDs.removeAll()
|
|
}
|
|
|
|
private func noteTitle(_ note: AddressNote) -> String {
|
|
note.title ?? "Thread from \(note.createdAt.formatted(date: .abbreviated, time: .shortened))"
|
|
}
|
|
}
|
|
|
|
private struct MessageRow: View {
|
|
let message: AddressNoteMessage
|
|
let isEditing: Bool
|
|
@Binding var editingDraft: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
if let author = message.author {
|
|
AuthorAvatar(author: author, size: 16)
|
|
} else {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(tint)
|
|
}
|
|
Text(roleLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(message.createdAt.formatted(date: .omitted, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
if isEditing {
|
|
TextEditor(text: $editingDraft)
|
|
.font(.body)
|
|
.scrollContentBackground(.hidden)
|
|
.padding(6)
|
|
.background(RoundedRectangle(cornerRadius: 6, style: .continuous).fill(textEditorBackground))
|
|
.overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.secondary.opacity(0.3)))
|
|
.frame(height: 120)
|
|
} else {
|
|
MarkdownView(message.bodyMarkdown)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(8)
|
|
.background(background, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var icon: String {
|
|
switch message.role {
|
|
case .user: return "person.crop.circle"
|
|
case .assistant: return "sparkles"
|
|
case .system: return "gearshape"
|
|
}
|
|
}
|
|
|
|
private var tint: Color {
|
|
switch message.role {
|
|
case .user: return .accentColor
|
|
case .assistant: return .purple
|
|
case .system: return .secondary
|
|
}
|
|
}
|
|
|
|
private var roleLabel: String {
|
|
switch message.role {
|
|
case .user: return message.author?.name ?? "You"
|
|
case .assistant:
|
|
if let modelID = message.modelID, !modelID.isEmpty, modelID != "default" {
|
|
return modelID
|
|
}
|
|
return "Assistant"
|
|
case .system: return "System"
|
|
}
|
|
}
|
|
|
|
private var background: AnyShapeStyle {
|
|
switch message.role {
|
|
case .user: return AnyShapeStyle(.quaternary)
|
|
case .assistant: return AnyShapeStyle(.tertiary)
|
|
case .system: return AnyShapeStyle(.quinary)
|
|
}
|
|
}
|
|
}
|