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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user