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>
4 files changed,
+42
−14
ios/LChatMac/ChatFeedWebView.swift+33 −2ios/LChatMac/LChatMacApp.swift+5 −10ios/LChatMac/RootView.swift+1 −1ios/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 { |