fix(web): preserve chat focus when restoring task layout (#1923)

This commit is contained in:
Carlos Florêncio
2026-07-24 16:44:24 +01:00
committed by GitHub
parent 65c303d37f
commit e2c0d74d51
2 changed files with 109 additions and 7 deletions
@@ -30,6 +30,22 @@ type MoveToCall = {
options: MoveToOptions;
};
type ReorderingPanel = {
id: string;
group: ReorderingGroup;
api: {
close: ReturnType<typeof vi.fn<() => void>>;
component: string;
moveTo: ReturnType<typeof vi.fn<(options: MoveToOptions) => void>>;
setActive: ReturnType<typeof vi.fn<() => void>>;
};
};
type ReorderingGroup = {
id: string;
panels: ReorderingPanel[];
};
const KEEP = "keep";
const KEEP_PANEL = `session:${KEEP}`;
const LEAKED_PANEL = "session:leaked";
@@ -165,6 +181,72 @@ function expectedMove(panelId: string, index: number): MoveToCall {
};
}
/**
* Models Dockview 4.13.1's same-group move behavior: moving the active panel
* removes it first, which activates a remaining sibling, then reinserts the
* moved panel inactive when skipSetActive is true.
*/
function makeReorderingAutoSessionApi(): {
api: DockviewApi;
activePanelId: () => string | null;
} {
const panels: ReorderingPanel[] = [];
const group: ReorderingGroup = { id: CENTER_GROUP, panels: [] };
let activePanel: ReorderingPanel | null = null;
const removePanel = (panel: ReorderingPanel) => {
const groupIndex = group.panels.indexOf(panel);
if (groupIndex !== -1) group.panels.splice(groupIndex, 1);
const panelIndex = panels.indexOf(panel);
if (panelIndex !== -1) panels.splice(panelIndex, 1);
if (activePanel === panel) activePanel = group.panels[0] ?? null;
};
const createPanel = (id: string, component: string): ReorderingPanel => {
const panel: ReorderingPanel = {
id,
group,
api: {
close: vi.fn(() => removePanel(panel)),
component,
moveTo: vi.fn((options: MoveToOptions) => {
const sourceIndex = group.panels.indexOf(panel);
if (sourceIndex !== -1) group.panels.splice(sourceIndex, 1);
if (activePanel === panel) activePanel = group.panels[0] ?? null;
group.panels.splice(options.index, 0, panel);
if (!options.skipSetActive) activePanel = panel;
}),
setActive: vi.fn(() => {
activePanel = panel;
}),
},
};
panels.push(panel);
group.panels.push(panel);
return panel;
};
activePanel = createPanel("chat", "chat");
createPanel("plan", "plan");
const api = {
get activePanel() {
return activePanel;
},
panels,
groups: [group],
getPanel: (id: string) => panels.find((panel) => panel.id === id) ?? null,
addPanel: (options: { id: string; component: string; inactive?: boolean }): ReorderingPanel => {
const panel = createPanel(options.id, options.component);
if (!options.inactive) activePanel = panel;
return panel;
},
removePanel,
} as unknown as DockviewApi;
return { api, activePanelId: () => activePanel?.id ?? null };
}
describe("reconcileRemovedSessionPanels", () => {
it("closes a stale tracked panel that's still live in dockview", () => {
// createdSet has session-A; A's panel is live; A is no longer in the task's
@@ -554,6 +636,19 @@ describe("resolveSessionTabSyncTarget", () => {
});
describe("runAutoSessionTabEffect", () => {
it("keeps chat active when replacing its placeholder beside a Plan tab", () => {
const sessionId = "session-current";
const { api, activePanelId } = makeReorderingAutoSessionApi();
const appStore = makeAutoSessionAppStore(AUTO_TASK_ID, [sessionId]);
const refs = makeAutoSessionRefs();
withDockviewState({ api, preMaximizeLayout: null }, () => {
runAutoSessionTabEffect(sessionId, appStore as never, refs as never);
});
expect(activePanelId()).toBe(`session:${sessionId}`);
});
it("updates previous refs when panel ensure is skipped for an unhydrated session", () => {
const api = {
panels: [{ id: "chat" }],
@@ -533,31 +533,35 @@ type AutoSessionTabRefs = {
function activateSessionPanel(
api: DockviewApi,
effectiveSessionId: string,
sessionPanelExistedBefore: boolean,
activation: {
sessionPanelExistedBefore: boolean;
activePanelIdBeforeEnsure: string | null;
},
refs: AutoSessionTabRefs,
tid: string | null,
): ReturnType<DockviewApi["getPanel"]> {
const activePanel = api.getPanel(`session:${effectiveSessionId}`);
if (!activePanel) return activePanel;
const currentActivePanelId = api.activePanel?.id ?? null;
const activePanelIdAfterReorder = api.activePanel?.id ?? null;
const shouldActivate = shouldActivateSessionPanel({
sessionPanelExistedBefore,
sessionPanelExistedBefore: activation.sessionPanelExistedBefore,
prevTaskId: refs.prevTaskIdRef.current,
prevSessionId: refs.prevSessionIdRef.current,
currentTaskId: tid,
currentSessionId: effectiveSessionId,
currentActivePanelId,
currentActivePanelId: activation.activePanelIdBeforeEnsure,
});
if (isDebug()) {
debug("useAutoSessionTab: activation decision", {
effectiveSessionId,
shouldActivate,
sessionPanelExistedBefore,
sessionPanelExistedBefore: activation.sessionPanelExistedBefore,
prevTaskId: refs.prevTaskIdRef.current,
prevSessionId: refs.prevSessionIdRef.current,
currentTaskId: tid,
currentActivePanelId,
activePanelIdBeforeEnsure: activation.activePanelIdBeforeEnsure,
activePanelIdAfterReorder,
activeGroupId: activePanel.group.id,
});
}
@@ -763,6 +767,9 @@ export function runAutoSessionTabEffect(
const initialPosition = resolveInitialPosition(api);
const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`);
// Preserve the restored/user-selected panel before adding and reordering the
// session tab. Dockview's same-group move briefly activates a sibling even
// with skipSetActive, so reading api.activePanel afterward loses this intent.
const activePanelIdBeforeEnsure = api.activePanel?.id ?? null;
const preserveActivePanel = shouldPreserveActivePanel(
sessionPanelExistedBefore,
@@ -788,7 +795,7 @@ export function runAutoSessionTabEffect(
const activePanel = activateSessionPanel(
api,
effectiveSessionId,
sessionPanelExistedBefore,
{ sessionPanelExistedBefore, activePanelIdBeforeEnsure },
refs,
tid,
);