From 406c5898d103427193e589b4bbe615d0c739b2c5 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 02 Jun 2026 19:36:31 +0800
Subject: [PATCH] Prepare 7.8.9 tag navigation drop test package

---
 Apptag/OverlayWindowController.swift |   16 +
 Apptag/ApptagApp.swift               |   83 ++++++++
 Apptag/Diagnostics.swift             |   44 ++++
 Apptag/TagNavigationView.swift       |  126 ++++++++++++-
 Scripts/window_logic_qa.sh           |   85 +++++++++
 Apptag/ContentView.swift             |   75 ++++++++
 AGENTS.md                            |   13 +
 Apptag/Info.plist                    |    4 
 Apptag/AppDragCoordinator.swift      |   51 ++++-
 CHANGELOG.md                         |   18 ++
 Apptag/QuickSearch.swift             |    4 
 11 files changed, 478 insertions(+), 41 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1d17d55
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,13 @@
+# AGENTS.md - Taglauncher Agent 入口
+
+## 项目属性
+
+项目类型:研发项目
+主要产物:macOS App
+
+## 项目规则
+
+- 默认遵守 `/Users/ar/.codex/AGENTS.md`;项目本地文档、脚本和 release 规则优先。
+- 发布、分发或验证安装包前,必须读取并遵守 `~/.codex/rules/release-versioning.md`。
+- 可分发构建应先运行 `./build.sh` 生成 `.app`,再运行 `./make_dmg.sh` 生成 `.dmg`;只生成 `.app` 只能视为本地 QA 构建。
+- 版本、build 编号、`CHANGELOG.md`、`Release/` 资料和最终安装包文件必须保持一致。
diff --git a/Apptag/AppDragCoordinator.swift b/Apptag/AppDragCoordinator.swift
index e767f95..2dff55b 100644
--- a/Apptag/AppDragCoordinator.swift
+++ b/Apptag/AppDragCoordinator.swift
@@ -25,6 +25,7 @@
     private var dragWindow: NSWindow?
     private var dragImageSize: NSSize = .zero
     private var activePayload = ""
+    private weak var hoveredTarget: AppDropTargetReceivingView?
 
     private init() {}
 
@@ -129,15 +130,20 @@
             CATransaction.setDisableActions(true)
             dragLayer.position = contentPoint
             CATransaction.commit()
+            updateHoverTarget(at: screenPoint)
             return
         }
 
-        guard let dragWindow else { return }
+        guard let dragWindow else {
+            updateHoverTarget(at: screenPoint)
+            return
+        }
         let origin = NSPoint(
             x: screenPoint.x - dragImageSize.width / 2,
             y: screenPoint.y - dragImageSize.height / 2
         )
         dragWindow.setFrameOrigin(origin)
+        updateHoverTarget(at: screenPoint)
     }
 
     func finishDrag(at screenPoint: NSPoint, copy: Bool) {
@@ -147,18 +153,7 @@
         let source = parts.dropFirst().first ?? ""
         pruneDeadTargets()
 
-        let hitTarget = targets.values
-            .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
-                guard let view = target.view,
-                      let frame = view.screenFrame(),
-                      frame.contains(screenPoint)
-                else { return nil }
-                return (view, frame.width * frame.height)
-            }
-            .sorted { $0.1 < $1.1 }
-            .first?.0
-
-        if let hitTarget {
+        if let hitTarget = dropTarget(at: screenPoint) {
             hitTarget.performDrop(path: path, source: source, copy: copy)
             return
         }
@@ -191,6 +186,8 @@
     }
 
     private func endDragVisuals() {
+        hoveredTarget?.appDragHoverChanged(active: false)
+        hoveredTarget = nil
         dragLayer?.removeFromSuperlayer()
         dragLayer = nil
         dragLayerHostView = nil
@@ -207,6 +204,29 @@
     private func pruneDeadTargets() {
         targets = targets.filter { $0.value.view != nil }
         emptyTargets = emptyTargets.filter { $0.value.view != nil }
+    }
+
+    private func updateHoverTarget(at screenPoint: NSPoint) {
+        guard hasActiveDrag else { return }
+        pruneDeadTargets()
+        let nextTarget = dropTarget(at: screenPoint)
+        guard nextTarget !== hoveredTarget else { return }
+        hoveredTarget?.appDragHoverChanged(active: false)
+        nextTarget?.appDragHoverChanged(active: true)
+        hoveredTarget = nextTarget
+    }
+
+    private func dropTarget(at screenPoint: NSPoint) -> AppDropTargetReceivingView? {
+        targets.values
+            .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
+                guard let view = target.view,
+                      let frame = view.screenFrame(),
+                      frame.contains(screenPoint)
+                else { return nil }
+                return (view, frame.width * frame.height)
+            }
+            .sorted { $0.1 < $1.1 }
+            .first?.0
     }
 
     private static func cgImage(from image: NSImage) -> CGImage? {
@@ -261,6 +281,7 @@
 
 protocol AppDropTargetReceivingView: AnyObject {
     func screenFrame() -> NSRect?
+    func appDragHoverChanged(active: Bool)
     func performDrop(path: String, source: String, copy: Bool)
 }
 
@@ -277,6 +298,10 @@
     }
 }
 
+extension AppDropTargetReceivingView {
+    func appDragHoverChanged(active: Bool) {}
+}
+
 extension AppEmptyDropReceivingView where Self: NSView {
     func screenFrame() -> NSRect? {
         guard let window else { return nil }
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 7008544..c35007f 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -62,6 +62,7 @@
     private var lastShowDockIcon: Bool?
     private var statusMenuScreenForNextOverlay: NSScreen?
     private var suppressReopenUntil = Date.distantPast
+    private var overlayOpenedByQuickSearchOnly = false
 
     private lazy var overlayController = OverlayWindowController(
         dependencies: OverlayWindowController.Dependencies(
@@ -106,6 +107,11 @@
                 self?.removeOverlayKeyMonitor()
             },
             removeQuickSearchMouseMonitor: { [weak self] in
+                Diagnostics.log("app.quickSearch.removeMouseMonitor", [
+                    "quickSearchOpen": self?.isQuickSearchOpen,
+                    "quickSearchOnlyOverlaySession": self?.quickSearchOnlyOverlaySession,
+                    "quickSearchShouldHideOverlayOnClose": self?.quickSearchShouldHideOverlayOnClose
+                ])
                 self?.removeQuickSearchExternalMouseMonitor()
                 self?.quickSearchShouldHideOverlayOnClose = false
                 self?.quickSearchOnlyOverlaySession = false
@@ -122,7 +128,8 @@
             onWillHide: {
                 TagDatabase.flushPendingCategorySchemeBackupBatch()
             },
-            onDidHide: {
+            onDidHide: { [weak self] in
+                self?.overlayOpenedByQuickSearchOnly = false
                 NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil)
             },
             onDidShow: {
@@ -220,9 +227,22 @@
             return false
         }
         guard UserDefaults.standard.bool(forKey: Self.showDockIconKey) else { return false }
-        guard isPointerNearDockArea() else { return false }
+        guard isRecentUserClickNearDockArea() else { return false }
         showOrFocusOverlay()
         return false  // Suppress default "unhide all windows" behavior
+    }
+
+    private func isRecentUserClickNearDockArea() -> Bool {
+        let lastMouseDown = CGEventSource.secondsSinceLastEventType(
+            .combinedSessionState,
+            eventType: .leftMouseDown
+        )
+        let lastMouseUp = CGEventSource.secondsSinceLastEventType(
+            .combinedSessionState,
+            eventType: .leftMouseUp
+        )
+        let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2
+        return recentMouseClick && isPointerNearDockArea()
     }
 
     private func isPointerNearDockArea() -> Bool {
@@ -881,10 +901,12 @@
     }
 
     private func performToggleOverlay(preferredScreen: NSScreen?) {
+        overlayOpenedByQuickSearchOnly = false
         overlayController.toggle(preferredScreen: preferredScreen)
     }
 
     private func showOrFocusOverlay(preferredScreen: NSScreen? = nil) {
+        overlayOpenedByQuickSearchOnly = false
         overlayController.showOrFocus(preferredScreen: preferredScreen)
     }
 
@@ -893,10 +915,20 @@
         preferredScreen: NSScreen? = nil,
         stagedForAllSpaces: Bool = false
     ) {
+        Diagnostics.log("app.overlay.showRequest", [
+            "initialQuickSearchSource": initialQuickSearchSource,
+            "stagedForAllSpaces": stagedForAllSpaces,
+            "isQuickSearchOpen": isQuickSearchOpen,
+            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+        ])
         if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
             isQuickSearchOpen = true
             quickSearchShouldHideOverlayOnClose = true
             quickSearchOnlyOverlaySession = true
+            overlayOpenedByQuickSearchOnly = true
+        } else if initialQuickSearchSource == nil {
+            overlayOpenedByQuickSearchOnly = false
         }
         overlayController.show(
             initialQuickSearchSource: initialQuickSearchSource,
@@ -959,8 +991,9 @@
         if shouldHideOverlayAfterDismiss {
             quickSearchShouldHideOverlayOnClose = false
             quickSearchOnlyOverlaySession = false
+            overlayOpenedByQuickSearchOnly = true
             DispatchQueue.main.async { [weak self] in
-                self?.hideOverlay(force: true)
+                self?.hideOverlay(force: true, discardWindow: true)
             }
         }
     }
@@ -971,6 +1004,9 @@
             || source == QuickSearchOpenSource.globalHidden
         quickSearchOnlyOverlaySession = quickSearchOnlyOverlaySession
             || source == QuickSearchOpenSource.globalHidden
+        if source == QuickSearchOpenSource.globalHidden {
+            overlayOpenedByQuickSearchOnly = true
+        }
         promoteOverlayToForegroundInput()
         updateOverlayLevelForTextInput()
         installQuickSearchExternalMouseMonitor()
@@ -1014,6 +1050,14 @@
     }
 
     private func hideOverlay(force: Bool = false, discardWindow: Bool = false) {
+        Diagnostics.log("app.overlay.hideRequest", [
+            "force": force,
+            "discardWindow": discardWindow,
+            "isQuickSearchOpen": isQuickSearchOpen,
+            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+            "quickSearchShouldHideOverlayOnClose": quickSearchShouldHideOverlayOnClose,
+            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+        ])
         overlayController.hide(force: force, discardWindow: discardWindow)
     }
 
@@ -1106,6 +1150,13 @@
     }
 
     private func handleHotkeyEvent(id: UInt32) {
+        Diagnostics.log("app.hotkey", [
+            "id": id,
+            "isOverlayVisible": isOverlayVisible,
+            "isQuickSearchOpen": isQuickSearchOpen,
+            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
+            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
+        ])
         if id == LauncherHotkeyKind.quickSearch.eventID {
             showQuickSearchFromGlobalHotkey()
         } else {
@@ -1115,9 +1166,14 @@
 
     private func showQuickSearchFromGlobalHotkey() {
         let now = Date()
-        if overlayWindow?.isVisible == true, isQuickSearchOpen || quickSearchOnlyOverlaySession {
+        if overlayWindow?.isVisible == true,
+           isQuickSearchOpen || quickSearchOnlyOverlaySession || overlayOpenedByQuickSearchOnly {
             lastQuickSearchHotkeyAt = now
-            dismissQuickSearchFromKeyboard()
+            if isQuickSearchOpen || quickSearchOnlyOverlaySession {
+                dismissQuickSearchFromKeyboard()
+            } else {
+                hideOverlay(force: true)
+            }
             return
         }
 
@@ -1411,10 +1467,23 @@
             guard let self else { return }
             let active = (notification.userInfo?["active"] as? Bool) ?? false
             let hideOverlayOnClose = (notification.userInfo?["hideOverlayOnClose"] as? Bool) ?? false
+            let wasQuickSearchOnlyOverlay = self.quickSearchOnlyOverlaySession
+                || self.quickSearchShouldHideOverlayOnClose
+                || self.overlayOpenedByQuickSearchOnly
+            Diagnostics.log("app.quickSearch.visibilityChanged", [
+                "active": active,
+                "hideOverlayOnClose": hideOverlayOnClose,
+                "isQuickSearchOpenBefore": self.isQuickSearchOpen,
+                "quickSearchOnlyOverlaySessionBefore": self.quickSearchOnlyOverlaySession,
+                "quickSearchShouldHideOverlayOnCloseBefore": self.quickSearchShouldHideOverlayOnClose,
+                "overlayOpenedByQuickSearchOnlyBefore": self.overlayOpenedByQuickSearchOnly,
+                "wasQuickSearchOnlyOverlay": wasQuickSearchOnlyOverlay
+            ])
             self.isQuickSearchOpen = active
             if active, hideOverlayOnClose {
                 self.quickSearchShouldHideOverlayOnClose = true
                 self.quickSearchOnlyOverlaySession = true
+                self.overlayOpenedByQuickSearchOnly = true
             }
             if self.isQuickSearchOpen {
                 self.promoteOverlayToForegroundInput()
@@ -1424,10 +1493,10 @@
                 self.installQuickSearchExternalMouseMonitor()
             } else {
                 self.removeQuickSearchExternalMouseMonitor()
-                if hideOverlayOnClose || self.quickSearchShouldHideOverlayOnClose {
+                if hideOverlayOnClose || wasQuickSearchOnlyOverlay {
                     self.quickSearchShouldHideOverlayOnClose = false
                     self.quickSearchOnlyOverlaySession = false
-                    self.hideOverlay(force: true)
+                    self.hideOverlay(force: true, discardWindow: wasQuickSearchOnlyOverlay)
                 }
             }
         }
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 9b6ce7c..f29d986 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -318,6 +318,7 @@
     var appDragResetToken = 0
     var bubbleDraftNote = ""
     var tagNavigationHoveredGroupName: String? = nil
+    var tagNavigationAppDropTargetName: String? = nil
     var tagNavigationLastHoverScrollID: String? = nil
     var tagNavigationLastHoverScrollAt: Date? = nil
 }
@@ -948,6 +949,7 @@
 
     private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
         let isHorizontal = orientation == .horizontal
+        let appDropTargetID = appGridInteraction.tagNavigationAppDropTargetName
         return TagNavigationView(
             items: tagLabels,
             orientation: orientation,
@@ -956,6 +958,8 @@
                 : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
             dragModeActive: tagNavDragModeActive,
             draggingItemID: tagNavDragItem,
+            appDragModeActive: appGridInteraction.appDragModeActive,
+            appDropTargetID: appDropTargetID,
             onActivate: { tagID in
                 activateTagNavigation(tagID)
             },
@@ -965,6 +969,9 @@
             canReorder: { tagID in
                 canReorderTag(tagID)
             },
+            canAcceptAppDrop: { tagID in
+                canAssignDroppedAppToTag(tagID)
+            },
             onReorderBegan: { tagID in
                 beginTagNavReorder(tagID)
             },
@@ -973,6 +980,12 @@
             },
             onReorderEnded: {
                 endTagNavReorder()
+            },
+            onAppDropHoverChange: { tagID, active in
+                handleTagNavigationAppDropHover(tagID, active: active)
+            },
+            onAppDrop: { path, source, targetTag, copy in
+                dropAppOnTagNavigation(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
             }
         )
     }
@@ -1903,6 +1916,13 @@
     }
 
     private func handleTagNavigationHover(_ id: String, active: Bool) {
+        if appGridInteraction.appDragModeActive {
+            if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
+                appGridInteraction.tagNavigationHoveredGroupName = nil
+            }
+            return
+        }
+
         guard active else {
             if appGridInteraction.tagNavigationHoveredGroupName == id {
                 appGridInteraction.tagNavigationHoveredGroupName = nil
@@ -1913,6 +1933,24 @@
         appGridInteraction.tagNavigationHoveredGroupName = id
         fillColorlessContainer(id)
         scrollToTagFromHover(id)
+    }
+
+    private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
+        guard appGridInteraction.appDragModeActive else {
+            if appGridInteraction.tagNavigationAppDropTargetName == id {
+                appGridInteraction.tagNavigationAppDropTargetName = nil
+            }
+            return
+        }
+
+        guard active, canAssignDroppedAppToTag(id) else {
+            if appGridInteraction.tagNavigationAppDropTargetName == id {
+                appGridInteraction.tagNavigationAppDropTargetName = nil
+            }
+            return
+        }
+
+        appGridInteraction.tagNavigationAppDropTargetName = id
     }
 
     private func scrollToTagFromHover(_ id: String) {
@@ -2050,6 +2088,22 @@
         )
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
+    }
+
+    private func dropAppOnTagNavigation(path: String, sourceTag: String, targetTag: String, copy: Bool) {
+        resetTransientDragState()
+        guard canAssignDroppedAppToTag(targetTag) else { return }
+        guard let app = allApps.first(where: { $0.path.path == path }) else { return }
+        guard !appHasTag(app, tagName: targetTag) else { return }
+        TagEditor.appendTags([targetTag], to: [path])
+        showDropRefresh()
+        refreshApps(forceLayoutRefresh: true)
+    }
+
+    private func canAssignDroppedAppToTag(_ tagName: String) -> Bool {
+        tagColors[tagName] != nil
+            && !isAppleBuiltInDropTarget(tagName)
+            && !isUncategorizedDropTarget(tagName)
     }
 
     private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
@@ -2263,12 +2317,17 @@
         if active {
             endTagNavReorder()
             clearAppBubbleState()
+            appGridInteraction.tagNavigationAppDropTargetName = nil
         }
         appGridInteraction.appDragModeActive = active
+        if !active {
+            appGridInteraction.tagNavigationAppDropTargetName = nil
+        }
         if active {
             DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
                 if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
                     appGridInteraction.appDragModeActive = false
+                    appGridInteraction.tagNavigationAppDropTargetName = nil
                 }
             }
         }
@@ -2282,6 +2341,9 @@
         AppDragCoordinator.shared.cancelDrag()
         if appGridInteraction.appDragModeActive {
             appGridInteraction.appDragModeActive = false
+        }
+        if appGridInteraction.tagNavigationAppDropTargetName != nil {
+            appGridInteraction.tagNavigationAppDropTargetName = nil
         }
         if hadAppDragState {
             appGridInteraction.appDragResetToken &+= 1
@@ -2355,6 +2417,13 @@
     ) {
         appGridInteraction.appDragModeActive = false
         endTagNavReorder()
+        let closeQuickSearchAndOverlayBeforeOpening = closeQuickSearchOnSuccess
+            && closeOverlayOnSuccess
+            && (quickSearchCloseHidesOverlay || quickSearchOnlySession)
+        if closeQuickSearchAndOverlayBeforeOpening {
+            closeQuickSearch(hideOverlayIfNeeded: true)
+            hideOverlay()
+        }
         let configuration = NSWorkspace.OpenConfiguration()
         NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
             DispatchQueue.main.async {
@@ -2366,10 +2435,10 @@
                 DispatchQueue.global(qos: .utility).async {
                     TagEditor.recordLauncherOpen(for: app.path.path)
                 }
-                if closeQuickSearchOnSuccess {
-                    closeQuickSearch(hideOverlayIfNeeded: false)
+                if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+                    closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
                 }
-                if closeOverlayOnSuccess {
+                if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
                     hideOverlay()
                 }
             }
diff --git a/Apptag/Diagnostics.swift b/Apptag/Diagnostics.swift
new file mode 100644
index 0000000..78536b1
--- /dev/null
+++ b/Apptag/Diagnostics.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+enum Diagnostics {
+    private static let enabledKey = "diagnosticLoggingEnabled"
+    private static let queue = DispatchQueue(label: "com.taglauncher.app.diagnostics")
+
+    static var logURL: URL {
+        AppIdentity.applicationSupportDirectory
+            .appendingPathComponent("TagLauncher-diagnostics.log")
+    }
+
+    static func log(_ event: String, _ fields: [String: CustomStringConvertible?] = [:]) {
+        guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
+        let timestamp = ISO8601DateFormatter().string(from: Date())
+        let suffix = fields
+            .sorted { $0.key < $1.key }
+            .map { key, value in "\(key)=\(value?.description ?? "nil")" }
+            .joined(separator: " ")
+        let line = suffix.isEmpty
+            ? "\(timestamp) \(event)\n"
+            : "\(timestamp) \(event) \(suffix)\n"
+
+        queue.async {
+            do {
+                try FileManager.default.createDirectory(
+                    at: AppIdentity.applicationSupportDirectory,
+                    withIntermediateDirectories: true
+                )
+                if !FileManager.default.fileExists(atPath: logURL.path) {
+                    FileManager.default.createFile(atPath: logURL.path, contents: nil)
+                }
+                let handle = try FileHandle(forWritingTo: logURL)
+                defer { try? handle.close() }
+                try handle.seekToEnd()
+                if let data = line.data(using: .utf8) {
+                    try handle.write(contentsOf: data)
+                }
+            } catch {
+                fputs("[TagLauncher] Diagnostics log failed: \(error)\n", stderr)
+            }
+        }
+    }
+
+}
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index 1d5c6b7..e72c52a 100644
--- a/Apptag/Info.plist
+++ b/Apptag/Info.plist
@@ -19,9 +19,9 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>7.8.7</string>
+	<string>7.8.9</string>
 	<key>CFBundleVersion</key>
-	<string>20260531.0252</string>
+	<string>20260602.1929</string>
 	<key>LSApplicationCategoryType</key>
 	<string>public.app-category.utilities</string>
 	<key>LSMinimumSystemVersion</key>
diff --git a/Apptag/OverlayWindowController.swift b/Apptag/OverlayWindowController.swift
index 1472d2e..5ca9317 100644
--- a/Apptag/OverlayWindowController.swift
+++ b/Apptag/OverlayWindowController.swift
@@ -130,6 +130,7 @@
             initialQuickSearchSource: initialQuickSearchSource
         )
         generation &+= 1
+        let showGeneration = generation
         window = newWindow
         dependencies.installOverlayKeyMonitor()
         if shouldStageAsAccessory {
@@ -149,6 +150,7 @@
         let placementFrame = placement.frame
         let finishForegroundClaim: () -> Void = { [weak self, weak newWindow] in
             guard let self, let newWindow, self.window === newWindow else { return }
+            guard self.generation == showGeneration, newWindow.isVisible else { return }
             self.dependencies.refreshChromeState(!shouldAvoidSpaceSwitch, shouldAvoidSpaceSwitch)
             if newWindow.frame != placementFrame {
                 newWindow.setFrame(placementFrame, display: true)
@@ -157,6 +159,7 @@
             for delay in [0.2, 0.5] {
                 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak newWindow] in
                     guard let self, let newWindow, self.window === newWindow else { return }
+                    guard self.generation == showGeneration, newWindow.isVisible else { return }
                     guard newWindow.frame != placementFrame else { return }
                     newWindow.setFrame(placementFrame, display: true)
                     self.orderFront(newWindow)
@@ -182,15 +185,20 @@
         if let settingsWindow = dependencies.settingsWindow(), settingsWindow.parent == window {
             dependencies.detachSettingsWindow(settingsWindow)
         }
-        window?.orderOut(nil)
+        let windowToHide = window
+        windowToHide?.ignoresMouseEvents = true
+        windowToHide?.alphaValue = 0
+        windowToHide?.orderOut(nil)
         dependencies.removeOverlayKeyMonitor()
         dependencies.removeQuickSearchMouseMonitor()
+        windowToHide?.contentView = nil
+        windowToHide?.close()
+        if window === windowToHide {
+            window = nil
+        }
         avoidsSpaceSwitch = false
         dependencies.refreshChromeState(false, false)
         dependencies.onDidHide()
-        if discardWindow {
-            window = nil
-        }
     }
 
     func screenUnderMouse() -> NSScreen? {
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index e8eb530..5a09ef3 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -1385,7 +1385,9 @@
 
     override func mouseDown(with event: NSEvent) {
         if let result {
-            onLaunch(result)
+            DispatchQueue.main.async { [onLaunch] in
+                onLaunch(result)
+            }
         } else {
             super.mouseDown(with: event)
         }
diff --git a/Apptag/TagNavigationView.swift b/Apptag/TagNavigationView.swift
index 327ab7a..449895d 100644
--- a/Apptag/TagNavigationView.swift
+++ b/Apptag/TagNavigationView.swift
@@ -18,12 +18,17 @@
     let contentInsets: NSEdgeInsets
     let dragModeActive: Bool
     let draggingItemID: String?
+    let appDragModeActive: Bool
+    let appDropTargetID: String?
     let onActivate: (String) -> Void
     let onHoverChange: (String, Bool) -> Void
     let canReorder: (String) -> Bool
+    let canAcceptAppDrop: (String) -> Bool
     let onReorderBegan: (String) -> Void
     let onReorderMoved: (String, String) -> Void
     let onReorderEnded: () -> Void
+    let onAppDropHoverChange: (String, Bool) -> Void
+    let onAppDrop: (String, String, String, Bool) -> Void
 
     func makeNSView(context: Context) -> TagNavigationHostView {
         let view = TagNavigationHostView()
@@ -33,12 +38,17 @@
             contentInsets: contentInsets,
             dragModeActive: dragModeActive,
             draggingItemID: draggingItemID,
+            appDragModeActive: appDragModeActive,
+            appDropTargetID: appDropTargetID,
             onActivate: onActivate,
             onHoverChange: onHoverChange,
             canReorder: canReorder,
+            canAcceptAppDrop: canAcceptAppDrop,
             onReorderBegan: onReorderBegan,
             onReorderMoved: onReorderMoved,
-            onReorderEnded: onReorderEnded
+            onReorderEnded: onReorderEnded,
+            onAppDropHoverChange: onAppDropHoverChange,
+            onAppDrop: onAppDrop
         )
         return view
     }
@@ -50,12 +60,17 @@
             contentInsets: contentInsets,
             dragModeActive: dragModeActive,
             draggingItemID: draggingItemID,
+            appDragModeActive: appDragModeActive,
+            appDropTargetID: appDropTargetID,
             onActivate: onActivate,
             onHoverChange: onHoverChange,
             canReorder: canReorder,
+            canAcceptAppDrop: canAcceptAppDrop,
             onReorderBegan: onReorderBegan,
             onReorderMoved: onReorderMoved,
-            onReorderEnded: onReorderEnded
+            onReorderEnded: onReorderEnded,
+            onAppDropHoverChange: onAppDropHoverChange,
+            onAppDrop: onAppDrop
         )
     }
 }
@@ -82,12 +97,17 @@
         contentInsets: NSEdgeInsets,
         dragModeActive: Bool,
         draggingItemID: String?,
+        appDragModeActive: Bool,
+        appDropTargetID: String?,
         onActivate: @escaping (String) -> Void,
         onHoverChange: @escaping (String, Bool) -> Void,
         canReorder: @escaping (String) -> Bool,
+        canAcceptAppDrop: @escaping (String) -> Bool,
         onReorderBegan: @escaping (String) -> Void,
         onReorderMoved: @escaping (String, String) -> Void,
-        onReorderEnded: @escaping () -> Void
+        onReorderEnded: @escaping () -> Void,
+        onAppDropHoverChange: @escaping (String, Bool) -> Void,
+        onAppDrop: @escaping (String, String, String, Bool) -> Void
     ) {
         documentView.update(
             items: items,
@@ -95,15 +115,20 @@
             contentInsets: contentInsets,
             dragModeActive: dragModeActive,
             draggingItemID: draggingItemID,
+            appDragModeActive: appDragModeActive,
+            appDropTargetID: appDropTargetID,
             onActivate: onActivate,
             onHoverChange: onHoverChange,
             canReorder: canReorder,
+            canAcceptAppDrop: canAcceptAppDrop,
             onReorderBegan: onReorderBegan,
             onReorderMoved: onReorderMoved,
-            onReorderEnded: onReorderEnded
+            onReorderEnded: onReorderEnded,
+            onAppDropHoverChange: onAppDropHoverChange,
+            onAppDrop: onAppDrop
         )
-        scrollView.hasHorizontalScroller = orientation == .horizontal
-        scrollView.hasVerticalScroller = orientation == .vertical
+        scrollView.hasHorizontalScroller = false
+        scrollView.hasVerticalScroller = false
         needsLayout = true
     }
 
@@ -126,6 +151,8 @@
         scrollView.borderType = .noBorder
         scrollView.autohidesScrollers = true
         scrollView.scrollerStyle = .overlay
+        scrollView.horizontalScrollElasticity = .allowed
+        scrollView.verticalScrollElasticity = .allowed
         scrollView.hasHorizontalScroller = false
         scrollView.hasVerticalScroller = false
         scrollView.documentView = documentView
@@ -140,12 +167,17 @@
     private var buttons: [TagNavigationButton] = []
     private var dragModeActive = false
     private var draggingItemID: String?
+    private var appDragModeActive = false
+    private var appDropTargetID: String?
     private var onActivate: (String) -> Void = { _ in }
     private var onHoverChange: (String, Bool) -> Void = { _, _ in }
     private var canReorder: (String) -> Bool = { _ in false }
+    private var canAcceptAppDrop: (String) -> Bool = { _ in false }
     private var onReorderBegan: (String) -> Void = { _ in }
     private var onReorderMoved: (String, String) -> Void = { _, _ in }
     private var onReorderEnded: () -> Void = {}
+    private var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
+    private var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
 
     override var isFlipped: Bool { true }
 
@@ -155,12 +187,17 @@
         contentInsets: NSEdgeInsets,
         dragModeActive: Bool,
         draggingItemID: String?,
+        appDragModeActive: Bool,
+        appDropTargetID: String?,
         onActivate: @escaping (String) -> Void,
         onHoverChange: @escaping (String, Bool) -> Void,
         canReorder: @escaping (String) -> Bool,
+        canAcceptAppDrop: @escaping (String) -> Bool,
         onReorderBegan: @escaping (String) -> Void,
         onReorderMoved: @escaping (String, String) -> Void,
-        onReorderEnded: @escaping () -> Void
+        onReorderEnded: @escaping () -> Void,
+        onAppDropHoverChange: @escaping (String, Bool) -> Void,
+        onAppDrop: @escaping (String, String, String, Bool) -> Void
     ) {
         let oldNames = self.items.map(\.name)
         let newNames = items.map(\.name)
@@ -170,12 +207,17 @@
         self.contentInsets = contentInsets
         self.dragModeActive = dragModeActive
         self.draggingItemID = draggingItemID
+        self.appDragModeActive = appDragModeActive
+        self.appDropTargetID = appDropTargetID
         self.onActivate = onActivate
         self.onHoverChange = onHoverChange
         self.canReorder = canReorder
+        self.canAcceptAppDrop = canAcceptAppDrop
         self.onReorderBegan = onReorderBegan
         self.onReorderMoved = onReorderMoved
         self.onReorderEnded = onReorderEnded
+        self.onAppDropHoverChange = onAppDropHoverChange
+        self.onAppDrop = onAppDrop
 
         if needsRebuild {
             rebuildButtons()
@@ -226,6 +268,14 @@
                 self?.handleReorderMove(tagID: tagID, screenPoint: screenPoint)
             }
             button.onReorderEnded = { [weak self] in self?.onReorderEnded() }
+            button.canAcceptAppDrop = { [weak self] tagID in self?.canAcceptAppDrop(tagID) ?? false }
+            button.onAppDropHoverChange = { [weak self] tagID, active in
+                self?.setLocalAppDropHover(tagID, active: active)
+                self?.onAppDropHoverChange(tagID, active)
+            }
+            button.onAppDrop = { [weak self] path, source, targetTag, copy in
+                self?.onAppDrop(path, source, targetTag, copy)
+            }
             addSubview(button)
             return button
         }
@@ -236,17 +286,30 @@
         buttons = items.compactMap { item in
             guard let button = existing[item.name] else { return nil }
             button.configure(item: item, orientation: orientation)
+            button.refreshAppDropRegistration()
             return button
         }
     }
 
     private func updateButtonRuntimeState() {
         for button in buttons {
+            let isAppDropTarget = appDropTargetID == button.itemName
+            let appDropVisualActive = appDropTargetID != nil
             button.configureRuntime(
-                dragModeActive: dragModeActive && canReorder(button.itemName),
-                isDragging: draggingItemID == button.itemName
+                dragModeActive: (dragModeActive && canReorder(button.itemName)) || appDropVisualActive,
+                isDragging: draggingItemID == button.itemName || isAppDropTarget
             )
+            button.refreshAppDropRegistration()
         }
+    }
+
+    private func setLocalAppDropHover(_ tagID: String, active: Bool) {
+        if active {
+            appDropTargetID = tagID
+        } else if appDropTargetID == tagID {
+            appDropTargetID = nil
+        }
+        updateButtonRuntimeState()
     }
 
     private func handleReorderMove(tagID: String, screenPoint: NSPoint) {
@@ -283,13 +346,16 @@
     }
 }
 
-final class TagNavigationButton: NSButton {
+final class TagNavigationButton: NSButton, AppDropTargetReceivingView {
     var onActivate: (String) -> Void = { _ in }
     var onHoverChange: (String, Bool) -> Void = { _, _ in }
     var canReorder: (String) -> Bool = { _ in false }
     var onReorderBegan: (String) -> Void = { _ in }
     var onReorderMoved: (String, NSPoint) -> Void = { _, _ in }
     var onReorderEnded: () -> Void = {}
+    var canAcceptAppDrop: (String) -> Bool = { _ in false }
+    var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
+    var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
 
     private var item: TagNavigationItem
     private var orientation: TagNavigationView.Orientation
@@ -297,6 +363,8 @@
     private var isMouseInside = false
     private var dragModeActive = false
     private var isDragging = false
+    private let appDropTargetID = UUID()
+    private var appDropRegistered = false
 
     var itemName: String { item.name }
 
@@ -330,12 +398,16 @@
     }
 
     func configure(item: TagNavigationItem, orientation: TagNavigationView.Orientation) {
+        if self.item.name != item.name {
+            unregisterAppDropTarget()
+        }
         self.item = item
         self.orientation = orientation
         title = item.name
         alignment = orientation == .horizontal ? .center : .left
         setAccessibilityLabel(item.name)
         updateAppearance()
+        refreshAppDropRegistration()
         needsLayout = true
     }
 
@@ -343,6 +415,34 @@
         self.dragModeActive = dragModeActive
         self.isDragging = isDragging
         updateAppearance()
+    }
+
+    func refreshAppDropRegistration() {
+        guard window != nil, canAcceptAppDrop(item.id) else {
+            unregisterAppDropTarget()
+            return
+        }
+        AppDragCoordinator.shared.register(id: appDropTargetID, view: self, tag: item.name)
+        appDropRegistered = true
+    }
+
+    override func viewDidMoveToWindow() {
+        super.viewDidMoveToWindow()
+        refreshAppDropRegistration()
+    }
+
+    func appDragHoverChanged(active: Bool) {
+        guard canAcceptAppDrop(item.id) || !active else { return }
+        onAppDropHoverChange(item.name, active)
+    }
+
+    func performDrop(path: String, source: String, copy: Bool) {
+        guard canAcceptAppDrop(item.id) else { return }
+        onAppDrop(path, source, item.name, copy)
+    }
+
+    deinit {
+        unregisterAppDropTarget()
     }
 
     override func updateTrackingAreas() {
@@ -421,6 +521,12 @@
         onActivate(item.id)
     }
 
+    private func unregisterAppDropTarget() {
+        guard appDropRegistered else { return }
+        AppDragCoordinator.shared.unregister(id: appDropTargetID)
+        appDropRegistered = false
+    }
+
     private func updateAppearance() {
         let background = TagColor.nsColor(for: item.colorIndex)
         let dragScale: CGFloat = isDragging ? 1.04 : 1.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b3936c..d0827d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
 # TagLauncher Changelog
 
+## [7.8.9] — 2026-06-02
+
+- 新增 App Grid 拖拽到标签栏建立关联:在 5 种 App Grid 视图中,App 图标可拖到顶部、左侧或右侧标签名称上,放手后追加该标签关联
+- 拖到标签名称时复用标签排序的视觉反馈:目标标签浮起,其他标签变暗,帮助用户确认即将生效的标签
+- 标签栏 drop 只追加目标标签,不删除原有标签;已有关联时不重复写入
+- 复用现有 App 拖拽协调器并增加 drop-target hover 通知,避免新增平行拖拽系统
+- 版本号更新为 `7.8.9`,Build 更新为 `20260602.1929`
+
+## [7.8.8] — 2026-05-31
+
+- 修复全局 `Fn + Space` Quick Search-only 场景下,鼠标在候选区 hover 或点击结果后 App Grid / Overlay 可能残留或重新冒出的问题
+- Quick Search 结果点击改为在鼠标事件完成后的主队列启动,避免 AppKit `mouseDown` 与窗口关闭交叉导致背板露出
+- Overlay 关闭时不再复用已隐藏的旧 `NSPanel`,每次关闭都释放窗口实例,下一次显示重新绑定当前 Space,减少隐藏 Dock / 全屏 / Split View 场景下的幽灵窗口风险
+- 移除顶部 AppKit 标签栏下方多余的浅灰色滚动条,同时保留标签栏横向滚动能力
+- 加固 Dock 图标显式打开 App Grid 的判定:必须是最近真实鼠标点击且位于 Dock 区域,避免重复启动或程序化打开误触发 App Grid
+- 窗口 QA 增加隐藏 Dock 下 Quick Search hover、点击搜索结果不弹 App Grid 的专项回归,并继续覆盖全屏、Split View、Settings、文件面板、Dock 图标和滚动后键盘路由
+- 版本号更新为 `7.8.8`,Build 更新为 `20260531.1601`
+
 ## [7.8.7] — 2026-05-31
 
 - 修复关闭“Show in Dock”后用主快捷键打开 App Grid 时 TagLauncher Dock 图标仍会闪现的问题;隐藏 Dock 图标设置现在严格生效
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index 4a333b0..5935181 100755
--- a/Scripts/window_logic_qa.sh
+++ b/Scripts/window_logic_qa.sh
@@ -97,6 +97,22 @@
   osascript -e 'tell application "System Events" to keystroke space using {option down, shift down}'
 }
 
+send_quick_search_hotkey() {
+  swift - <<'SWIFT' >/dev/null 2>&1
+import CoreGraphics
+import Foundation
+
+let source = CGEventSource(stateID: .hidSystemState)
+let down = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: true)!
+down.flags = [.maskSecondaryFn]
+down.post(tap: .cghidEventTap)
+usleep(80_000)
+let up = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: false)!
+up.flags = [.maskSecondaryFn]
+up.post(tap: .cghidEventTap)
+SWIFT
+}
+
 send_cmd_comma() {
   osascript -e 'tell application "System Events" to keystroke "," using {command down}'
 }
@@ -767,6 +783,25 @@
     let overlayWidth = dimension(overlayBounds, "Width")
     let overlayHeight = dimension(overlayBounds, "Height")
     print("\(Int(round(overlayX + overlayWidth / 2))) \(Int(round(overlayY + overlayHeight / 2)))")
+case "quick-search-result":
+    guard let quickSearch = tag.first(where: { window in
+        let name = (window[kCGWindowName as String] as? String) ?? ""
+        let bounds = window[kCGWindowBounds as String] as? NSDictionary ?? [:]
+        let width = dimension(bounds, "Width")
+        let height = dimension(bounds, "Height")
+        return name.isEmpty
+            && width >= 500
+            && width <= 900
+            && height >= 120
+            && height <= 850
+    }), let bounds = quickSearch[kCGWindowBounds as String] as? NSDictionary else {
+        fputs("FAIL: could not find quick search bounds\n", stderr)
+        exit(1)
+    }
+    let x = dimension(bounds, "X")
+    let y = dimension(bounds, "Y")
+    let width = dimension(bounds, "Width")
+    print("\(Int(round(x + width * 0.35))) \(Int(round(y + 125)))")
 case "fullscreen-target-center":
     guard let target = raw.first(where: { ($0[kCGWindowName as String] as? String) == "TagLauncherFullscreenQATargetFullscreen" }),
           let bounds = target[kCGWindowBounds as String] as? NSDictionary else {
@@ -1026,6 +1061,23 @@
   click_xy "$x" "$y"
 }
 
+hover_quick_search_results() {
+  local coords x y
+  coords="$(swift "$coords_swift" quick-search-result)"
+  read -r x y <<<"$coords"
+  for offset in 0 42 84 126 84 42 0; do
+    move_xy "$x" "$((y + offset))"
+    sleep 0.08
+  done
+}
+
+click_quick_search_result() {
+  local coords x y
+  coords="$(swift "$coords_swift" quick-search-result)"
+  read -r x y <<<"$coords"
+  click_xy "$x" "$y"
+}
+
 page_scroll_appgrid() {
   local coords
   coords="$(swift "$coords_swift" overlay-center)"
@@ -1147,6 +1199,36 @@
 kill_all_taglauncher_instances
 sleep 0.4
 swift_assert no-overlay
+prepare_isolated_app_instance
+assert_no_dock_tile
+log "==> QA hidden Dock: Fn+Space Quick Search hover does not resurrect App Grid"
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert quick-search
+hover_quick_search_results
+sleep 0.8
+wait_swift_assert quick-search
+assert_no_dock_tile
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert no-overlay
+assert_no_dock_tile
+kill_all_taglauncher_instances
+sleep 0.4
+swift_assert no-overlay
+prepare_isolated_app_instance
+assert_no_dock_tile
+log "==> QA hidden Dock: clicking Quick Search result closes without showing App Grid"
+send_quick_search_hotkey
+sleep 0.8
+wait_swift_assert quick-search
+click_quick_search_result
+sleep 1.4
+wait_swift_assert no-overlay
+assert_no_dock_tile
+kill_all_taglauncher_instances
+sleep 0.4
+swift_assert no-overlay
 
 log "==> QA split-view fullscreen geometry detection"
 swift_assert split-geometry
@@ -1164,6 +1246,7 @@
   log "==> QA fullscreen Space: quick search from appgrid does not switch Space"
   send_keycode 49
   sleep 0.4
+  wait_swift_assert quick-search
   assert_fullscreen_overlay_stable
   send_keycode 53
   sleep 0.3
@@ -1209,7 +1292,7 @@
 swift_assert file-panel
 send_keycode 53
 sleep 0.3
-send_cmd_w
+close_settings_window
 sleep 0.9
 swift_assert overlay
 assert_frontmost_taglauncher

--
Gitblit v1.9.3