eleven / ocho Public

mac: fix chat file-drop, rail reorder, and rail initials (#77)

* mac: private rail drag type so Finder file-drop into the chat works again

The rail reorder used the public .text drag type; SwiftUI then claimed public text/file drags window-wide, shadowing the chat feed WKWebView (DropWebView) so Finder file-drops stopped uploading. Give the rail its own UTI (chat.luan.server, .ownProcess) so it only matches rail drags. Reorder still works; file-drop restored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: only the selected instance accepts Finder file-drops

Every opened instance keeps its WKWebView mounted (hidden) and each was a registered file-drop target, so a Finder drop could land on a hidden instances webview instead of the visible feed once more than one server had been opened. Register drop types only on the active (selected) webview; unregister the rest. Fixes drag-to-upload breaking after switching servers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: revert rail to .text drag type (private UTI broke reorder drop matching)

The private UTI experiment for the rail broke drag-to-reorder (the drop no longer matched). It was unnecessary: the rail drop is sidebar-scoped so it never shadowed the chat feed; the real file-drop fix is gating drop registration to the selected webview. Back to the NSString/.text drag that reorder worked with.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: word-based rail initials (per-instance), stable during reorder

Initials were collision-gated (two letters only when first letters clashed), so two-word names with unique first letters showed a single letter, and initials recomputed as group names loaded / the set changed. Now: single-word → one letter, two-word+ → first letters of the first two words (Campbell Family → CF), computed per-instance independent of order. Also key the name-fetch task by the set of chat ids so reordering does not re-run it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* mac: focus the message input when Eleven regains focus

On NSApplication.didBecomeActive, the selected instance makes its webview first responder and focuses the composer (#text) so you can start typing right away. Only the active instance reacts, and only when a composer is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 7ef91b4cd86cdef6de640156ad6b22dff29ac782 parent 9850240
4 files changed, +42 −14
  • ios/LChatMac/ChatFeedWebView.swift +33 −2
  • ios/LChatMac/LChatMacApp.swift +5 −10
  • ios/LChatMac/RootView.swift +1 −1
  • ios/LChatMac/Sidebar.swift +3 −1
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
index ed8f381..cb4c6fd 100644
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -27,6 +27,7 @@ import UserNotifications
struct ChatFeedWebView: NSViewRepresentable {
let chat: SavedChat
let app: MacAppState
+ let isActive: Bool
func makeCoordinator() -> Coordinator { Coordinator(chat: chat, app: app) }
@@ -68,7 +69,15 @@ struct ChatFeedWebView: NSViewRepresentable {
return web
}
- func updateNSView(_ web: WKWebView, context: Context) {}
+ func updateNSView(_ web: WKWebView, context: Context) {
+ // Only the visible/selected instance accepts Finder file-drops. Every opened
+ // instance keeps its webview mounted (hidden), and each is a registered drop
+ // target — so a drop could otherwise land on a hidden instance's webview
+ // instead of the feed you're looking at. That's what intermittently broke
+ // drag-to-upload once more than one server had been opened.
+ if isActive { web.registerForDraggedTypes([.fileURL]) }
+ else { web.unregisterDraggedTypes() }
+ }
// Runs on every page load. (1) Window drag: start a native window-drag when the
// user presses in the header on a non-interactive element and stream the pointer
@@ -139,7 +148,29 @@ struct ChatFeedWebView: NSViewRepresentable {
private var didAttemptJoin = false
weak var webView: WKWebView?
- init(chat: SavedChat, app: MacAppState) { self.chat = chat; self.app = app }
+ init(chat: SavedChat, app: MacAppState) {
+ self.chat = chat
+ self.app = app
+ super.init()
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(appDidBecomeActive),
+ name: NSApplication.didBecomeActiveNotification, object: nil)
+ }
+
+ deinit { NotificationCenter.default.removeObserver(self) }
+
+ // When Eleven regains focus, drop the caret into the selected instance's
+ // message box (chat.js's #text) so you can start typing immediately. Only the
+ // active instance reacts, and only when a composer is present.
+ @objc private func appDidBecomeActive() {
+ guard app.selection == chat.id, let web = webView else { return }
+ DispatchQueue.main.async {
+ web.window?.makeFirstResponder(web)
+ web.evaluateJavaScript(
+ "(function(){var t=document.getElementById('text'); if (t) t.focus();})()",
+ completionHandler: nil)
+ }
+ }
// Read dropped files (non-sandboxed, so plain reads work) and hand them to
// chat.js's file input. Base64 crosses the JS bridge safely.
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 764a09e..15e5165 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -124,25 +124,20 @@ final class MacAppState: ObservableObject {
return Double(h % 360) / 360.0
}
- // Icon initials for the rail + palette: one letter per instance, bumped to the
- // first letters of the first two WORDS when instances collide on a first letter
- // (lchat.run.garden → LR, lchat testing → LT); single-word names stay one letter.
+ // Icon initials for the rail + palette. Single-word names get one letter; names
+ // with two or more words get the first letters of the first two words (Campbell
+ // Family → CF, lchat.run.garden → LR). Computed per-instance, independent of the
+ // other chats and their order — so reordering the rail never changes an initial.
func initials() -> [String: String] {
func words(_ chat: SavedChat) -> [Substring] {
serverName(chat).split { !($0.isLetter || $0.isNumber) }
}
func head(_ w: Substring?) -> String? { w?.first.map { String($0).uppercased() } }
- var firstCount: [String: Int] = [:]
- for chat in chats { if let h = head(words(chat).first) { firstCount[h, default: 0] += 1 } }
var out: [String: String] = [:]
for chat in chats {
let ws = words(chat)
guard let first = head(ws.first) else { out[chat.id] = "?"; continue }
- if (firstCount[first] ?? 0) > 1, ws.count >= 2, let second = head(ws[1]) {
- out[chat.id] = first + second
- } else {
- out[chat.id] = first
- }
+ out[chat.id] = ws.count >= 2 ? first + (head(ws[1]) ?? "") : first
}
return out
}
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index cfcb305..a000afe 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -46,7 +46,7 @@ struct RootView: View {
ZStack {
ForEach(app.chats) { chat in
if activated.contains(chat.id) {
- ChatFeedWebView(chat: chat, app: app)
+ ChatFeedWebView(chat: chat, app: app, isActive: chat.id == app.selection)
.opacity(chat.id == app.selection ? 1 : 0)
.allowsHitTesting(chat.id == app.selection)
}
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift
index 4a347f2..431097f 100644
--- a/ios/LChatMac/Sidebar.swift
+++ b/ios/LChatMac/Sidebar.swift
@@ -45,7 +45,9 @@ struct Sidebar: View {
return true
}
}
- .task(id: app.chats) { await loadNames() }
+ // Keyed by the SET of chat ids (not the array) so reordering doesn't re-run
+ // the name fetch — only adding/removing an instance does.
+ .task(id: Set(app.chats.map(\.id))) { await loadNames() }
}
private var addButton: some View {