Keep forked chats focused in new tabs (#2038)

* fix(app): focus explicitly opened workspace tabs

Explicit tab navigation could be overwritten by attention-aware workspace
navigation when the visible agent had just finished. Make workspace navigation
own both normal attention selection and explicit targets so callers cannot
sequence them inconsistently.

* test(app): strengthen forked tab focus assertion
This commit is contained in:
Mohamed Boudra
2026-07-13 15:38:09 +02:00
committed by GitHub
parent fe9a486ac1
commit f350c716b9
18 changed files with 156 additions and 139 deletions
@@ -11,6 +11,7 @@ import {
navigateToLastWorkspace as navigateToLastWorkspacePure,
navigateToWorkspace as navigateToWorkspacePure,
parseActiveWorkspaceSelection,
type NavigateToWorkspaceInput,
type NavigateToWorkspaceDeps,
} from "./navigation";
import { useSessionStore } from "@/stores/session-store";
@@ -19,6 +20,7 @@ import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/ut
import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navigation";
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export type { NavigateToWorkspaceInput } from "./navigation";
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
@@ -34,9 +36,10 @@ function navigateDeps(): NavigateToWorkspaceDeps {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
openWorkspaceAgentTab: (workspaceKey, agentId) => {
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId });
},
openTabFocused: (workspaceKey, target) =>
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, target),
pinAgent: (workspaceKey, agentId) =>
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => {
navigateToHostWorkspaceRoute(route);
@@ -57,8 +60,8 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated();
}
export function navigateToWorkspace(serverId: string, workspaceId: string) {
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
export function navigateToWorkspace(input: NavigateToWorkspaceInput): string {
return navigateToWorkspacePure(input, navigateDeps());
}
export function navigateToLastWorkspace(): boolean {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import {
navigateToLastWorkspace,
navigateToWorkspace,
@@ -9,25 +10,28 @@ import {
} from "./navigation";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
interface RecordedAgentTab {
interface RecordedTab {
workspaceKey: string;
agentId: string;
target: WorkspaceTabTarget;
}
function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const navigations: string[] = [];
const remembered: ActiveWorkspaceSelection[] = [];
const openedAgentTabs: RecordedAgentTab[] = [];
const openedTabs: RecordedTab[] = [];
const deps: NavigateToWorkspaceDeps = {
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
openWorkspaceAgentTab: (workspaceKey, agentId) =>
openedAgentTabs.push({ workspaceKey, agentId }),
openTabFocused: (workspaceKey, target) => {
openedTabs.push({ workspaceKey, target });
return target.kind === "agent" ? target.agentId : null;
},
pinAgent: () => undefined,
rememberLastWorkspace: (selection) => remembered.push(selection),
navigateToRoute: (route) => navigations.push(route),
...overrides,
};
return { deps, navigations, remembered, openedAgentTabs };
return { deps, navigations, remembered, openedTabs };
}
function createLastSelectionDeps(
@@ -63,7 +67,7 @@ describe("workspace navigation", () => {
it("navigates to a workspace route and remembers the selection", () => {
const { deps, navigations, remembered } = createFakeDeps();
navigateToWorkspace("server-1", "workspace-a", deps);
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]);
@@ -81,14 +85,53 @@ describe("workspace navigation", () => {
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedAgentTabs } = createFakeDeps({
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace("server-1", "workspace-a", deps);
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(openedAgentTabs).toEqual([{ workspaceKey: "server-1:workspace-a", agentId: "agent-1" }]);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "agent", agentId: "agent-1" },
},
]);
});
it("keeps an explicit tab authoritative over an attention agent", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace(
{
serverId: "server-1",
workspaceId: "workspace-a",
target: { kind: "draft", draftId: "draft-1" },
},
deps,
);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "draft", draftId: "draft-1" },
},
]);
});
it("reads the active workspace from the current route", () => {
@@ -10,6 +10,8 @@ import {
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
export interface RouteSelectionInput {
pathname: string;
@@ -19,10 +21,16 @@ export interface RouteSelectionInput {
};
}
export interface NavigateToWorkspaceDeps {
export interface NavigateToWorkspaceInput {
serverId: string;
workspaceId: string;
target?: WorkspaceTabTarget;
pin?: boolean;
}
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
openWorkspaceAgentTab: (workspaceKey: string, agentId: string) => void;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
}
@@ -71,27 +79,35 @@ export function parseActiveWorkspaceSelection(
}
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
input: NavigateToWorkspaceInput,
deps: NavigateToWorkspaceDeps,
): void {
const workspaces = deps.getSessionWorkspaces(serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openWorkspaceAgentTab(`${serverId}:${resolvedWorkspaceId}`, attentionAgentId);
): string {
if (input.target) {
prepareWorkspaceTab({ ...input, target: input.target }, deps);
} else {
const workspaces = deps.getSessionWorkspaces(input.serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: input.workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openTabFocused(`${input.serverId}:${resolvedWorkspaceId}`, {
kind: "agent",
agentId: attentionAgentId,
});
}
}
deps.rememberLastWorkspace({ serverId, workspaceId });
deps.navigateToRoute(buildHostWorkspaceRoute(serverId, workspaceId));
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
deps.navigateToRoute(route);
return route;
}
export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): boolean {
@@ -99,6 +115,6 @@ export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): bool
if (!selection) {
return false;
}
navigateToWorkspace(selection.serverId, selection.workspaceId, deps);
navigateToWorkspace(selection, deps);
return true;
}