Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
listHydratedAiSessions,
loadHydratedAiSession,
} from '@studio/common/ai/sessions/manage';
import { findAiSessionOwnerSite } from '@studio/common/ai/sessions/owner-site';
import {
deleteAiSessionPlacement,
readAiSessionPlacement,
Expand Down Expand Up @@ -324,11 +325,8 @@ async function reconcileSessionEnvironmentBeforeRun( sessionId: string ): Promis
if ( summary.activeEnvironment !== 'live' ) {
return;
}
if ( ! summary.ownerSitePath ) {
return;
}

const ownerServer = SiteServer.getByPath( summary.ownerSitePath );
const ownerSite = findAiSessionOwnerSite( SiteServer.getAllDetails(), summary );
const ownerServer = ownerSite ? SiteServer.get( ownerSite.id ) : undefined;
if ( ! ownerServer ) {
return;
}
Expand All @@ -345,7 +343,7 @@ async function reconcileSessionEnvironmentBeforeRun( sessionId: string ): Promis
// CLI's replay sees Local on the next turn.
await appendStudioEntry( root, sessionId, 'studio.site_selected', {
siteName: ownerServer.details.name,
sitePath: summary.ownerSitePath,
sitePath: ownerServer.details.path,
} );
}

Expand Down Expand Up @@ -442,14 +440,17 @@ export async function setSessionEnvironment(
): Promise< SetSessionEnvironmentResult > {
const { summary } = await loadHydratedAiSession( getAiSessionsRootDirectory(), sessionId );

if ( ! summary.ownerSitePath || ! summary.ownerSiteName ) {
if ( ! summary.ownerSiteId && ! summary.ownerSitePath ) {
throw new Error( 'Cannot change environment: session has no owner site' );
}

const ownerServer = SiteServer.getByPath( summary.ownerSitePath );
const ownerSite = findAiSessionOwnerSite( SiteServer.getAllDetails(), summary );
const ownerServer = ownerSite ? SiteServer.get( ownerSite.id ) : undefined;
if ( ! ownerServer ) {
throw new Error(
`Cannot change environment: owner site is no longer available (${ summary.ownerSitePath })`
`Cannot change environment: owner site is no longer available (${
summary.ownerSiteId ?? summary.ownerSitePath
})`
);
}

Expand All @@ -465,9 +466,9 @@ export async function setSessionEnvironment(

await appendStudioEntry( getAiSessionsRootDirectory(), sessionId, 'studio.site_selected', {
siteName: liveSite.name,
// Keep the desktop placement path on remote picks too, so live/local
// Keep the local owner's path on remote picks too, so live/local
// environment flips still resolve against the same local site.
sitePath: summary.ownerSitePath,
sitePath: ownerServer.details.path,
remote: true,
url: liveSite.url,
wpcomSiteId: liveSite.id,
Expand Down
61 changes: 59 additions & 2 deletions apps/ui/src/components/site-list/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { useIsSessionRunning, useSessionHasPendingQuestion } from '@/data/queries/use-agent-run';
Expand All @@ -16,7 +16,7 @@ import {
} from '@/data/queries/use-sites';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { SiteList } from './index';
import type { SiteDetails } from '@/data/core';
import type { AiSessionSummary, SiteDetails } from '@/data/core';
import type { ReactNode } from 'react';

vi.mock( '@tanstack/react-router', () => ( {
Expand Down Expand Up @@ -145,8 +145,65 @@ describe( 'SiteList', () => {
expect( actionGlyph?.querySelector( 'rect' ) ).toHaveAttribute( 'width', '8' );
expect( actionGlyph?.querySelector( 'path' ) ).not.toBeInTheDocument();
} );

it( 'groups sessions by owner site id, falling back to path for legacy sessions', () => {
useSitesMock.mockReturnValue( {
data: [
createSite( { id: 'site-a', name: 'Site A', path: '/sites/site-a' } ),
createSite( { id: 'site-b', name: 'Site B', path: '/sites/site-b' } ),
],
isLoading: false,
} );
useSessionsMock.mockReturnValue( {
data: [
// A stale path must lose to the site id.
createSession( {
id: 'by-id',
firstPrompt: 'Matched by id',
ownerSiteId: 'site-b',
ownerSitePath: '/sites/site-a',
} ),
createSession( {
id: 'legacy',
firstPrompt: 'Matched by path',
ownerSitePath: '/sites/site-a',
} ),
// A deleted site's id must not fall back to a path that now
// belongs to another site.
createSession( {
id: 'orphan',
firstPrompt: 'Dead site id',
ownerSiteId: 'deleted-site',
ownerSitePath: '/sites/site-a',
} ),
],
isLoading: false,
} );

render( <SiteList /> );

const siteA = screen.getByText( 'Site A' ).closest( 'section' )!;
const siteB = screen.getByText( 'Site B' ).closest( 'section' )!;
const unassigned = screen.getByText( 'Unassigned' ).closest( 'section' )!;

expect( within( siteB ).getByText( 'Matched by id' ) ).toBeInTheDocument();
expect( within( siteA ).getByText( 'Matched by path' ) ).toBeInTheDocument();
expect( within( unassigned ).getByText( 'Dead site id' ) ).toBeInTheDocument();
} );
} );

function createSession( overrides: Partial< AiSessionSummary > = {} ): AiSessionSummary {
return {
id: 'session-1',
filePath: '/sessions/session-1.jsonl',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
activeEnvironment: 'local',
eventCount: 1,
...overrides,
};
}

function createSite( overrides: Partial< SiteDetails > = {} ): SiteDetails {
return {
id: 'site-1',
Expand Down
13 changes: 7 additions & 6 deletions apps/ui/src/components/site-list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { findAiSessionOwnerSite } from '@studio/common/ai/sessions/owner-site';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import { __, sprintf } from '@wordpress/i18n';
import {
Expand Down Expand Up @@ -50,8 +51,7 @@ function groupSessionsByOwner(
sites: SiteDetails[] | undefined,
sessions: AiSessionSummary[] | undefined
): SiteGroup[] {
const knownSitePaths = new Set( ( sites ?? [] ).map( ( site ) => site.path ) );
const sessionsByPath = new Map< string, AiSessionSummary[] >();
const sessionsBySiteId = new Map< string, AiSessionSummary[] >();
const unassigned: AiSessionSummary[] = [];

for ( const session of sessions ?? [] ) {
Expand All @@ -61,24 +61,25 @@ function groupSessionsByOwner(
if ( session.archived ) {
continue;
}
if ( ! session.ownerSitePath || ! knownSitePaths.has( session.ownerSitePath ) ) {
const ownerSite = findAiSessionOwnerSite( sites, session );
if ( ! ownerSite ) {
unassigned.push( session );
continue;
}

const existing = sessionsByPath.get( session.ownerSitePath );
const existing = sessionsBySiteId.get( ownerSite.id );
if ( existing ) {
existing.push( session );
} else {
sessionsByPath.set( session.ownerSitePath, [ session ] );
sessionsBySiteId.set( ownerSite.id, [ session ] );
}
}

const groups: SiteGroup[] = ( sites ?? [] ).map( ( site ) => ( {
key: site.id,
site,
label: site.name,
sessions: sessionsByPath.get( site.path ) ?? [],
sessions: sessionsBySiteId.get( site.id ) ?? [],
} ) );

// Sort site-groups by the newest session's updatedAt so the most recently
Expand Down
8 changes: 3 additions & 5 deletions apps/ui/src/ui-classic/components/session-view/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveSessionModel } from '@studio/common/ai/models';
import { findAiSessionOwnerSite } from '@studio/common/ai/sessions/owner-site';
import { useNavigate } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { clsx } from 'clsx';
Expand Down Expand Up @@ -31,7 +32,7 @@ function SessionHeader( { summary }: SessionHeaderProps ) {
const sidebarCollapsed = useSidebarCollapsed();
const reserveTrafficLightSpace = useTrafficLightSpace();
const { data: sites } = useSites();
const site = sites?.find( ( candidate ) => candidate.path === summary.ownerSitePath );
const site = findAiSessionOwnerSite( sites, summary );
const effectiveEnvironment = useSessionEffectiveEnvironment( summary, site?.id );
if ( ! siteName ) {
return null;
Expand Down Expand Up @@ -152,10 +153,7 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) {
const navigate = useNavigate();
const { data, isLoading, error } = useSession( sessionId );
const { data: sites } = useSites();
const ownerSitePath = data?.summary.ownerSitePath;
const ownerSite = ownerSitePath
? sites?.find( ( candidate ) => candidate.path === ownerSitePath )
: undefined;
const ownerSite = findAiSessionOwnerSite( sites, data?.summary );
const effectiveEnvironment = useSessionEffectiveEnvironment( data?.summary, ownerSite?.id );
const {
isRunning,
Expand Down
6 changes: 2 additions & 4 deletions apps/ui/src/ui-classic/router/layout-dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { findAiSessionOwnerSite } from '@studio/common/ai/sessions/owner-site';
import { createRoute, Outlet, useRouterState } from '@tanstack/react-router';
import { useCallback, useEffect, useState } from 'react';
import {
Expand Down Expand Up @@ -52,10 +53,7 @@ function DashboardLayoutContent() {
const { data: sessionData } = useSession( sessionId );
const preview = useSessionPreviewUI();
const onAnnotationsDone = useSessionPreviewAnnotationsHandler();
const sessionOwnerSitePath = sessionData?.summary.ownerSitePath;
const sessionSite = sessionOwnerSitePath
? sites?.find( ( site ) => site.path === sessionOwnerSitePath )
: undefined;
const sessionSite = findAiSessionOwnerSite( sites, sessionData?.summary );
const effectiveEnvironment = useSessionEffectiveEnvironment(
sessionData?.summary,
sessionSite?.id
Expand Down
3 changes: 2 additions & 1 deletion apps/ui/src/ui-classic/router/route-index/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { aiSessionBelongsToSite } from '@studio/common/ai/sessions/owner-site';
import { createRoute, redirect } from '@tanstack/react-router';
import { SESSIONS_QUERY_KEY } from '@/data/queries/use-sessions';
import { SITES_QUERY_KEY } from '@/data/queries/use-sites';
Expand All @@ -22,7 +23,7 @@ export const indexRoute = createRoute( {
} );
// Sessions arrive sorted newest-first, so the first session owned by
// the site is its most recently updated one.
const topSession = sessions.find( ( session ) => session.ownerSitePath === firstSite.path );
const topSession = sessions.find( ( session ) => aiSessionBelongsToSite( session, firstSite ) );
if ( topSession ) {
throw redirect( { to: '/sessions/$sessionId', params: { sessionId: topSession.id } } );
}
Expand Down
9 changes: 7 additions & 2 deletions packages/common/ai/sessions/manage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { aiSessionBelongsToSite } from '@studio/common/ai/sessions/owner-site';
import {
hydrateAiSessionSummaryWithPlacement,
readAiSessionPlacement,
Expand Down Expand Up @@ -82,7 +83,11 @@ export async function createOrReuseAiSession(
if ( ! site ) {
const reusable = existing
.filter(
( session ) => ! session.ownerSitePath && ! session.firstPrompt && ! session.archived
( session ) =>
! session.ownerSiteId &&
! session.ownerSitePath &&
! session.firstPrompt &&
! session.archived
)
.sort( newestFirst )[ 0 ];
if ( reusable ) {
Expand All @@ -94,7 +99,7 @@ export async function createOrReuseAiSession(
const reusable = existing
.filter(
( session ) =>
session.ownerSitePath === site.path && ! session.firstPrompt && ! session.archived
! session.firstPrompt && ! session.archived && aiSessionBelongsToSite( session, site )
)
.sort( newestFirst )[ 0 ];
if ( reusable ) {
Expand Down
25 changes: 25 additions & 0 deletions packages/common/ai/sessions/owner-site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { AiSessionSummary } from './types';

type OwnerRef = Pick< AiSessionSummary, 'ownerSiteId' | 'ownerSitePath' >;

// A session with an ownerSiteId never matches by path — a dead id must not
// rebind to a new site created at the same folder. Path matching only covers
// placements written before siteId existed.
export function aiSessionBelongsToSite(
session: OwnerRef,
site: { id: string; path: string }
): boolean {
return session.ownerSiteId
? session.ownerSiteId === site.id
: session.ownerSitePath === site.path;
}

export function findAiSessionOwnerSite< T extends { id: string; path: string } >(
sites: T[] | undefined,
session: OwnerRef | undefined
): T | undefined {
if ( ! session ) {
return undefined;
}
return sites?.find( ( site ) => aiSessionBelongsToSite( session, site ) );
}
14 changes: 12 additions & 2 deletions packages/common/ai/sessions/placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,19 @@ export function hydrateAiSessionSummaryWithPlacement(
placement?: AiSessionSitePlacement
): AiSessionSummary {
if ( ! placement ) {
return { ...summary, ownerSitePath: undefined, ownerSiteName: undefined };
return {
...summary,
ownerSiteId: undefined,
ownerSitePath: undefined,
ownerSiteName: undefined,
};
}
return { ...summary, ownerSitePath: placement.sitePath, ownerSiteName: placement.siteName };
return {
...summary,
ownerSiteId: placement.siteId,
ownerSitePath: placement.sitePath,
ownerSiteName: placement.siteName,
};
}

// Extracts a created-site placement from a `chat.artifact` event, if the agent
Expand Down
1 change: 1 addition & 0 deletions packages/common/ai/sessions/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export async function readAiSessionSummaryFromEntries(
updatedAt: updatedAt ?? createdAt ?? fallbackTimestamp,
firstPrompt,
assistantReplyPreview,
ownerSiteId: undefined,
ownerSitePath: undefined,
ownerSiteName: undefined,
selectedSiteName,
Expand Down
1 change: 1 addition & 0 deletions packages/common/ai/sessions/tests/create-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe( 'createAiSession', () => {
site: { name: 'My Site', path: '/tmp/my-site' },
} );

expect( summary.ownerSiteId ).toBeUndefined();
expect( summary.ownerSitePath ).toBeUndefined();
expect( summary.ownerSiteName ).toBeUndefined();
expect( summary.selectedSiteName ).toBe( 'My Site' );
Expand Down
Loading
Loading