fix: resolve stacked panes focus and event duplication issues
This commit addresses several critical issues with stacked panes:
**1. Fix duplicate workspace events**
- Remove redundant emitWorkspaceEvent calls in JavaScript workspace controller
- Keep only bridge() calls which are processed by the backend
- Eliminates repetitive "Forwarding workspace event" log messages
**2. Fix focus synchronization between Go and JavaScript**
- Update notifyJavaScript to inform both old and new panes of focus changes
- Send {active: true} to new pane and {active: false} to old pane
- Ensures omnibox and shortcuts target the correct active pane
**3. Fix app.activePane synchronization**
- Add app.activePane sync in FocusManager.SetActivePane
- Ensures window shortcuts use the same active pane as focus manager
- Resolves omnibox opening in wrong pane after navigation
**4. Fix focus order during pane transitions**
- Call SetActivePane before updating currentlyFocused in both:
- finalizeStackCreation (stacked pane creation)
- NavigateStack (Alt+Up/Down navigation)
- Prevents "already active" early returns that skip synchronization
**5. Improve stacked pane visibility logic**
- Process all panes in single loop to prevent flickering
- Add explicit logging for active pane title bar hiding
- Enforce absolute rule: active pane title bar is never visible
**Result**:
- Omnibox now opens in the correct active pane after navigation
- Focus changes are properly synchronized between Go and JavaScript
- Title bar visibility is consistent (only inactive panes show title bars)
- Single workspace event per action (no more duplicates)- commit
- d148d83c217afa19a642337e7baaab6a04f3c1d7
- author
- bamen · · 2025-09-26 05:55 UTC
- parent
- fcf18d5
- signature
- unsigned
3 files · +43 -36
M gui/src/injected/modules/workspace.ts +0 -4
| @@ -255,7 +255,6 @@ class WorkspaceController implements WorkspaceRuntime { | |||
| 255 | 255 | clearTimeout(this.paneModeTimer); | |
| 256 | 256 | this.paneModeTimer = null; | |
| 257 | 257 | } | |
| 258 | - | this.emitWorkspaceEvent("pane-mode-exited", { action: reason }); | |
| 259 | 258 | this.bridge({ event: "pane-mode-exited", reason }); | |
| 260 | 259 | } | |
| 261 | 260 | ||
| @@ -341,9 +340,6 @@ class WorkspaceController implements WorkspaceRuntime { | |||
| 341 | 340 | this.exitPaneMode("split-down"); | |
| 342 | 341 | break; | |
| 343 | 342 | case "stack-pane": | |
| 344 | - | this.emitWorkspaceEvent("pane-stack", { | |
| 345 | - | config: this.config, | |
| 346 | - | }); | |
| 347 | 343 | this.bridge({ event: "pane-stack", action: "stack-pane" }); | |
| 348 | 344 | this.showToast("Stacked pane"); | |
| 349 | 345 | this.exitPaneMode("stack-pane"); | |
| @@ -255,7 +255,6 @@ class WorkspaceController implements WorkspaceRuntime { | |||||
| 255 | clearTimeout(this.paneModeTimer); | 255 | clearTimeout(this.paneModeTimer); | ||
| 256 | this.paneModeTimer = null; | 256 | this.paneModeTimer = null; | ||
| 257 | } | 257 | } | ||
| 258 | - | this.emitWorkspaceEvent("pane-mode-exited", { action: reason }); | |||
| 259 | this.bridge({ event: "pane-mode-exited", reason }); | 258 | this.bridge({ event: "pane-mode-exited", reason }); | ||
| 260 | } | 259 | } | ||
| 261 | 260 | ||||
| @@ -341,9 +340,6 @@ class WorkspaceController implements WorkspaceRuntime { | |||||
| 341 | this.exitPaneMode("split-down"); | 340 | this.exitPaneMode("split-down"); | ||
| 342 | break; | 341 | break; | ||
| 343 | case "stack-pane": | 342 | case "stack-pane": | ||
| 344 | - | this.emitWorkspaceEvent("pane-stack", { | |||
| 345 | - | config: this.config, | |||
| 346 | - | }); | |||
| 347 | this.bridge({ event: "pane-stack", action: "stack-pane" }); | 343 | this.bridge({ event: "pane-stack", action: "stack-pane" }); | ||
| 348 | this.showToast("Stacked pane"); | 344 | this.showToast("Stacked pane"); | ||
| 349 | this.exitPaneMode("stack-pane"); | 345 | this.exitPaneMode("stack-pane"); | ||
M internal/app/browser/focus_manager.go +27 -12
| @@ -67,9 +67,15 @@ func (fm *FocusManager) SetActivePane(node *paneNode) { | |||
| 67 | 67 | fm.updateVisualState(oldPane, node) | |
| 68 | 68 | ||
| 69 | 69 | // Step 3: Notify JavaScript bridge | |
| 70 | - | fm.notifyJavaScript(node) | |
| 70 | + | fm.notifyJavaScript(oldPane, node) | |
| 71 | 71 | ||
| 72 | - | // Step 4: Execute callbacks | |
| 72 | + | // Step 4: Sync app.activePane for window shortcuts | |
| 73 | + | if fm.wm != nil && fm.wm.app != nil && node.pane != nil { | |
| 74 | + | fm.wm.app.activePane = node.pane | |
| 75 | + | log.Printf("[focus-manager] Synced app.activePane to %p", node.pane.webView) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Step 5: Execute callbacks | |
| 73 | 79 | fm.executeCallbacks(oldPane, node) | |
| 74 | 80 | } | |
| 75 | 81 | ||
| @@ -171,18 +177,27 @@ func (fm *FocusManager) updateStackVisibility(stackNode *paneNode) { | |||
| 171 | 177 | } | |
| 172 | 178 | ||
| 173 | 179 | // notifyJavaScript dispatches focus events to the JavaScript bridge | |
| 174 | - | func (fm *FocusManager) notifyJavaScript(node *paneNode) { | |
| 175 | - | if node.pane == nil || node.pane.webView == nil { | |
| 176 | - | return | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | detail := map[string]any{ | |
| 180 | - | "hasFocus": true, | |
| 181 | - | "paneId": fm.getPaneID(node), | |
| 180 | + | func (fm *FocusManager) notifyJavaScript(oldPane, newPane *paneNode) { | |
| 181 | + | // Notify old pane it lost focus | |
| 182 | + | if oldPane != nil && oldPane.pane != nil && oldPane.pane.webView != nil { | |
| 183 | + | oldDetail := map[string]any{ | |
| 184 | + | "active": false, | |
| 185 | + | "paneId": fm.getPaneID(oldPane), | |
| 186 | + | } | |
| 187 | + | if err := oldPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", oldDetail); err != nil { | |
| 188 | + | log.Printf("[focus-manager] Failed to dispatch blur event: %v", err) | |
| 189 | + | } | |
| 182 | 190 | } | |
| 183 | 191 | ||
| 184 | - | if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil { | |
| 185 | - | log.Printf("[focus-manager] Failed to dispatch focus event: %v", err) | |
| 192 | + | // Notify new pane it gained focus | |
| 193 | + | if newPane != nil && newPane.pane != nil && newPane.pane.webView != nil { | |
| 194 | + | newDetail := map[string]any{ | |
| 195 | + | "active": true, | |
| 196 | + | "paneId": fm.getPaneID(newPane), | |
| 197 | + | } | |
| 198 | + | if err := newPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", newDetail); err != nil { | |
| 199 | + | log.Printf("[focus-manager] Failed to dispatch focus event: %v", err) | |
| 200 | + | } | |
| 186 | 201 | } | |
| 187 | 202 | } | |
| 188 | 203 | ||
| @@ -67,9 +67,15 @@ func (fm *FocusManager) SetActivePane(node *paneNode) { | |||||
| 67 | fm.updateVisualState(oldPane, node) | 67 | fm.updateVisualState(oldPane, node) | ||
| 68 | 68 | ||||
| 69 | // Step 3: Notify JavaScript bridge | 69 | // Step 3: Notify JavaScript bridge | ||
| 70 | - | fm.notifyJavaScript(node) | 70 | + | fm.notifyJavaScript(oldPane, node) |
| 71 | 71 | ||||
| 72 | - | // Step 4: Execute callbacks | 72 | + | // Step 4: Sync app.activePane for window shortcuts |
| 73 | + | if fm.wm != nil && fm.wm.app != nil && node.pane != nil { | |||
| 74 | + | fm.wm.app.activePane = node.pane | |||
| 75 | + | log.Printf("[focus-manager] Synced app.activePane to %p", node.pane.webView) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Step 5: Execute callbacks | |||
| 73 | fm.executeCallbacks(oldPane, node) | 79 | fm.executeCallbacks(oldPane, node) | ||
| 74 | } | 80 | } | ||
| 75 | 81 | ||||
| @@ -171,18 +177,27 @@ func (fm *FocusManager) updateStackVisibility(stackNode *paneNode) { | |||||
| 171 | } | 177 | } | ||
| 172 | 178 | ||||
| 173 | // notifyJavaScript dispatches focus events to the JavaScript bridge | 179 | // notifyJavaScript dispatches focus events to the JavaScript bridge | ||
| 174 | - | func (fm *FocusManager) notifyJavaScript(node *paneNode) { | 180 | + | func (fm *FocusManager) notifyJavaScript(oldPane, newPane *paneNode) { |
| 175 | - | if node.pane == nil || node.pane.webView == nil { | 181 | + | // Notify old pane it lost focus |
| 176 | - | return | 182 | + | if oldPane != nil && oldPane.pane != nil && oldPane.pane.webView != nil { |
| 177 | - | } | 183 | + | oldDetail := map[string]any{ |
| 178 | - | 184 | + | "active": false, | |
| 179 | - | detail := map[string]any{ | 185 | + | "paneId": fm.getPaneID(oldPane), |
| 180 | - | "hasFocus": true, | 186 | + | } |
| 181 | - | "paneId": fm.getPaneID(node), | 187 | + | if err := oldPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", oldDetail); err != nil { |
| 188 | + | log.Printf("[focus-manager] Failed to dispatch blur event: %v", err) | |||
| 189 | + | } | |||
| 182 | } | 190 | } | ||
| 183 | 191 | ||||
| 184 | - | if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil { | 192 | + | // Notify new pane it gained focus |
| 185 | - | log.Printf("[focus-manager] Failed to dispatch focus event: %v", err) | 193 | + | if newPane != nil && newPane.pane != nil && newPane.pane.webView != nil { |
| 194 | + | newDetail := map[string]any{ | |||
| 195 | + | "active": true, | |||
| 196 | + | "paneId": fm.getPaneID(newPane), | |||
| 197 | + | } | |||
| 198 | + | if err := newPane.pane.webView.DispatchCustomEvent("dumber:workspace-focus", newDetail); err != nil { | |||
| 199 | + | log.Printf("[focus-manager] Failed to dispatch focus event: %v", err) | |||
| 200 | + | } | |||
| 186 | } | 201 | } | ||
| 187 | } | 202 | } | ||
| 188 | 203 | ||||
M internal/app/browser/stacked_panes.go +16 -20
| @@ -285,18 +285,13 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod | |||
| 285 | 285 | // Mark stack operation timestamp to prevent focus conflicts | |
| 286 | 286 | spm.wm.lastStackOperation = time.Now() | |
| 287 | 287 | ||
| 288 | - | // Update stack visibility to show current state (existing pane still active) | |
| 288 | + | // Immediately switch to the new pane (don't use deprecated IdleAdd) | |
| 289 | + | stackNode.activeStackIndex = insertIndex | |
| 289 | 290 | spm.UpdateStackVisibility(stackNode) | |
| 290 | 291 | ||
| 291 | - | // Transition to the new pane after a brief delay to avoid rendering conflicts | |
| 292 | - | webkit.IdleAdd(func() bool { | |
| 293 | - | // Now switch to the new pane | |
| 294 | - | stackNode.activeStackIndex = insertIndex | |
| 295 | - | spm.UpdateStackVisibility(stackNode) | |
| 296 | - | spm.wm.currentlyFocused = newLeaf | |
| 297 | - | spm.wm.focusManager.SetActivePane(newLeaf) | |
| 298 | - | return false // Remove idle callback | |
| 299 | - | }) | |
| 292 | + | // Set focus on the new pane synchronously | |
| 293 | + | spm.wm.focusManager.SetActivePane(newLeaf) | |
| 294 | + | spm.wm.currentlyFocused = newLeaf | |
| 300 | 295 | ||
| 301 | 296 | log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d", | |
| 302 | 297 | stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex) | |
| @@ -331,16 +326,17 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 331 | 326 | ||
| 332 | 327 | log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes)) | |
| 333 | 328 | ||
| 334 | - | // First, ensure the active pane is visible (prevents rendering gaps) | |
| 335 | - | activePane := stackNode.stackedPanes[activeIndex] | |
| 336 | - | webkit.WidgetSetVisible(activePane.container, true) | |
| 337 | - | webkit.WidgetSetVisible(activePane.titleBar, false) | |
| 338 | - | webkit.WidgetAddCSSClass(activePane.container, "stacked-pane-active") | |
| 339 | - | webkit.WidgetRemoveCSSClass(activePane.container, "stacked-pane-collapsed") | |
| 340 | - | ||
| 341 | - | // Then hide other panes and show their title bars | |
| 329 | + | // CRITICAL: Process ALL panes in a single pass to prevent flickering | |
| 342 | 330 | for i, pane := range stackNode.stackedPanes { | |
| 343 | - | if i != activeIndex { | |
| 331 | + | if i == activeIndex { | |
| 332 | + | // Active pane: show container, NEVER show title bar | |
| 333 | + | webkit.WidgetSetVisible(pane.container, true) | |
| 334 | + | webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane | |
| 335 | + | webkit.WidgetAddCSSClass(pane.container, "stacked-pane-active") | |
| 336 | + | webkit.WidgetRemoveCSSClass(pane.container, "stacked-pane-collapsed") | |
| 337 | + | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | |
| 338 | + | } else { | |
| 339 | + | // Inactive panes: hide container, show title bar | |
| 344 | 340 | webkit.WidgetSetVisible(pane.container, false) | |
| 345 | 341 | webkit.WidgetSetVisible(pane.titleBar, true) | |
| 346 | 342 | webkit.WidgetAddCSSClass(pane.container, "stacked-pane-collapsed") | |
| @@ -409,8 +405,8 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 409 | 405 | ||
| 410 | 406 | // Focus the new active pane | |
| 411 | 407 | newActivePane := stackNode.stackedPanes[newIndex] | |
| 412 | - | spm.wm.currentlyFocused = newActivePane | |
| 413 | 408 | spm.wm.focusManager.SetActivePane(newActivePane) | |
| 409 | + | spm.wm.currentlyFocused = newActivePane | |
| 414 | 410 | ||
| 415 | 411 | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | |
| 416 | 412 | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | |
| @@ -285,18 +285,13 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod | |||||
| 285 | // Mark stack operation timestamp to prevent focus conflicts | 285 | // Mark stack operation timestamp to prevent focus conflicts | ||
| 286 | spm.wm.lastStackOperation = time.Now() | 286 | spm.wm.lastStackOperation = time.Now() | ||
| 287 | 287 | ||||
| 288 | - | // Update stack visibility to show current state (existing pane still active) | 288 | + | // Immediately switch to the new pane (don't use deprecated IdleAdd) |
| 289 | + | stackNode.activeStackIndex = insertIndex | |||
| 289 | spm.UpdateStackVisibility(stackNode) | 290 | spm.UpdateStackVisibility(stackNode) | ||
| 290 | 291 | ||||
| 291 | - | // Transition to the new pane after a brief delay to avoid rendering conflicts | 292 | + | // Set focus on the new pane synchronously |
| 292 | - | webkit.IdleAdd(func() bool { | 293 | + | spm.wm.focusManager.SetActivePane(newLeaf) |
| 293 | - | // Now switch to the new pane | 294 | + | spm.wm.currentlyFocused = newLeaf |
| 294 | - | stackNode.activeStackIndex = insertIndex | |||
| 295 | - | spm.UpdateStackVisibility(stackNode) | |||
| 296 | - | spm.wm.currentlyFocused = newLeaf | |||
| 297 | - | spm.wm.focusManager.SetActivePane(newLeaf) | |||
| 298 | - | return false // Remove idle callback | |||
| 299 | - | }) | |||
| 300 | 295 | ||||
| 301 | log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d", | 296 | log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d", | ||
| 302 | stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex) | 297 | stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex) | ||
| @@ -331,16 +326,17 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||||
| 331 | 326 | ||||
| 332 | log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes)) | 327 | log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes)) | ||
| 333 | 328 | ||||
| 334 | - | // First, ensure the active pane is visible (prevents rendering gaps) | 329 | + | // CRITICAL: Process ALL panes in a single pass to prevent flickering |
| 335 | - | activePane := stackNode.stackedPanes[activeIndex] | |||
| 336 | - | webkit.WidgetSetVisible(activePane.container, true) | |||
| 337 | - | webkit.WidgetSetVisible(activePane.titleBar, false) | |||
| 338 | - | webkit.WidgetAddCSSClass(activePane.container, "stacked-pane-active") | |||
| 339 | - | webkit.WidgetRemoveCSSClass(activePane.container, "stacked-pane-collapsed") | |||
| 340 | - | ||||
| 341 | - | // Then hide other panes and show their title bars | |||
| 342 | for i, pane := range stackNode.stackedPanes { | 330 | for i, pane := range stackNode.stackedPanes { | ||
| 343 | - | if i != activeIndex { | 331 | + | if i == activeIndex { |
| 332 | + | // Active pane: show container, NEVER show title bar | |||
| 333 | + | webkit.WidgetSetVisible(pane.container, true) | |||
| 334 | + | webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane | |||
| 335 | + | webkit.WidgetAddCSSClass(pane.container, "stacked-pane-active") | |||
| 336 | + | webkit.WidgetRemoveCSSClass(pane.container, "stacked-pane-collapsed") | |||
| 337 | + | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | |||
| 338 | + | } else { | |||
| 339 | + | // Inactive panes: hide container, show title bar | |||
| 344 | webkit.WidgetSetVisible(pane.container, false) | 340 | webkit.WidgetSetVisible(pane.container, false) | ||
| 345 | webkit.WidgetSetVisible(pane.titleBar, true) | 341 | webkit.WidgetSetVisible(pane.titleBar, true) | ||
| 346 | webkit.WidgetAddCSSClass(pane.container, "stacked-pane-collapsed") | 342 | webkit.WidgetAddCSSClass(pane.container, "stacked-pane-collapsed") | ||
| @@ -409,8 +405,8 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||||
| 409 | 405 | ||||
| 410 | // Focus the new active pane | 406 | // Focus the new active pane | ||
| 411 | newActivePane := stackNode.stackedPanes[newIndex] | 407 | newActivePane := stackNode.stackedPanes[newIndex] | ||
| 412 | - | spm.wm.currentlyFocused = newActivePane | |||
| 413 | spm.wm.focusManager.SetActivePane(newActivePane) | 408 | spm.wm.focusManager.SetActivePane(newActivePane) | ||
| 409 | + | spm.wm.currentlyFocused = newActivePane | |||
| 414 | 410 | ||||
| 415 | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | 411 | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | ||
| 416 | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | 412 | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | ||