← reports

#3014 · Closing an active secondary tab does not restore its source tab

Bug Medium Effort: Low ui open on GitHub 2026-09-03 · base f6868ad

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The secondary panel does not keep the tab that was active before a new tab opened. Closing the new tab selects a positional neighbor. A live side-chat run selected the remaining Side chat instead of the prior Info tab. Two clean test runs produced the same state error.

2. Claims vs findings

ClaimStatusEvidence
Closing a new active tab can select an unrelated tab.VerifiedThe live run selected the older Side chat after closing the new Side chat.
The close path selects tabs by position.VerifiedThe state function checks the same file-tab index, then the prior index.
The state has no prior active-tab record.VerifiedThe state stores tabs, the active ID, and panel visibility only.
The side-chat path shows the defect.VerifiedThe browser run used two real Side chat tabs in an isolated thread.
The wrong tab always sits to the left.Condition-dependentThe unit case selected the left tab. The live replacement case selected the right tab.

3. Environment

4. Minimal reproduction

Live interface

  1. Start the app at the trusted commit with scripts/bb-dev-app current.
  2. Create an isolated local project and a ready thread.
  3. Open one Side chat tab in the secondary panel.
  4. Select the Info tab. This tab is now the source tab.
  5. Open a new tab, and start a second Side chat.
  6. Close the second Side chat.

Expected: the Info tab becomes active. Actual: the first Side chat becomes active.

The Info tab is active before a second Side chat opens.
The Info tab is active before the second Side chat opens.
The remaining Side chat tab is active after the new Side chat closes.
The remaining Side chat is active after the second Side chat closes.

Focused state test

Save this test file as secondaryPanelTabHistory.repro.test.ts beside the existing state tests.

import { describe, expect, it } from "vitest";
import {
  createEmptyFixedPanelTabsState,
  createHostFilePreviewFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
import {
  closeSecondaryPanelTabInState,
  openSecondaryPanelTabInState,
} from "@bb/client-core";

describe("secondary panel tab history", () => {
  it("returns to the source tab after closing a newly opened tab", () => {
    const makeTab = (path: string) =>
      createHostFilePreviewFixedPanelTab({
        environmentId: "env-1",
        tab: { lineRange: null, path },
        threadId: "thr-1",
      });
    const sourceTab = makeTab("/tmp/source.txt");
    const unrelatedTab = makeTab("/tmp/unrelated.txt");
    const openedTab = makeTab("/tmp/opened.txt");
    let state = createEmptyFixedPanelTabsState({
      secondary: {
        activeTabId: sourceTab.id,
        isOpen: true,
        tabs: [sourceTab, unrelatedTab],
      },
    });

    state = openSecondaryPanelTabInState({ state, tab: openedTab });
    state = closeSecondaryPanelTabInState(state, openedTab.id);

    expect(state.secondary.activeTabId).toBe(sourceTab.id);
  });
});

Run the test from apps/app.

pnpm exec vitest run src/components/secondary-panel/secondaryPanelTabHistory.repro.test.ts --config vitest.config.ts
Expected: host-file-preview:%2Ftmp%2Fsource.txt:thread%3Athr-1%3Aenvironment%3Aenv-1
Received: host-file-preview:%2Ftmp%2Funrelated.txt:thread%3Athr-1%3Aenvironment%3Aenv-1

Test Files  1 failed (1)
Tests       1 failed (1)

Raw results: first run and second run.

5. Root cause

The panel state setter replaces the active ID without saving the prior active ID.

Lines 173–195 write the state.

secondary: {
  tabs,
  activeTabId,
  isOpen,
}

When an active tab closes, the close helper selects the same file-tab index or the prior index.

Lines 318–341 contain this selection.

const nextActiveTab =
  fileTabsAfterClose[closedFileTabIndex] ??
  fileTabsAfterClose[closedFileTabIndex - 1] ??
  null;

The plugin-panel path creates a panel tab and activates it through the same state functions.

Lines 893–917 connect plugin tabs to this path.

The close helper cannot restore the source because no source ID remains after activation.

6. Proposed fix

Store the prior active tab ID when activation changes the active tab. Prefer that ID when the active tab closes and the prior tab still exists. Keep the positional rule as a fallback. Remove stale history when its tab disappears. Do not persist this short-lived history.

7. PR review

PR #3015

The open pull request adds a short-lived prior active-tab ID. It prefers that ID before the positional fallback. It also adds source-return and fallback tests. The static diff addresses the verified cause. I did not run the pull-request branch because issue content is untrusted.

Static verdict: likely addresses the root cause. No blocking finding appeared in the static diff.

8. Related issues

The repository search found no close match. PR #3015 links directly to this issue.

9. Verification

The first test ran in the trusted main checkout. The second test ran in a new detached checkout at the same commit. Both runs failed only the new source-tab assertion. The browser run confirmed the same wrong destination with real Side chat tabs. The second run required no report correction.

10. Appendix

The investigation used these commands:

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
scripts/bb-dev-app current
pnpm exec vitest run src/components/secondary-panel/secondaryPanelTabHistory.repro.test.ts --config vitest.config.ts
git clone --shared --no-checkout <trusted-checkout> <temporary-checkout>
git checkout --detach f6868ad0c9f60191cff22765fbbcab99e4dceb4f

The issue data was untrusted. The investigation used it only as a claim source. No issue command, patch, branch, or external link ran.