Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/cli/ai/sessions/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function replaySessionHistory( ui: AiChatUI, entries: SessionEntry[] ): v
if ( data ) {
ui.setActiveSite(
{
id: data.siteId,
name: data.siteName,
path: data.sitePath,
// Placeholder — turn dispatch resolves the live state before each prompt.
Expand Down
1 change: 1 addition & 0 deletions apps/cli/ai/tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,7 @@ describe( 'Studio AI MCP tools', () => {
await getTool( 'site_create' ).rawHandler( { name: 'My Site' } as never );

expect( onSiteSelected ).toHaveBeenCalledWith( {
id: 'site-123',
name: 'My Site',
path: '/sites/my-site',
running: true,
Expand Down
1 change: 1 addition & 0 deletions apps/cli/ai/tools/create-site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const createSiteTool = defineTool(
const site = await resolveSite( args.name );
const url = getSiteUrl( site );
await emitLocalSiteSelected( {
id: site.id,
name: site.name,
path: site.path,
running: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/ai/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type AskUserHandler = (
) => Promise< Record< string, string > >;

export interface SiteInfo {
// Local site id from the registry. Optional: sites replayed from events
// written before siteId existed only carry the path.
id?: string;
name: string;
path: string;
running: boolean;
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export class AiChatUI implements AiOutputAdapter {
this.sitePickerSiteData = sites;
const runningStatus = await getSitesRunningStatus( sites );
this.sitePickerItems = sites.map( ( site ) => ( {
id: site.id,
name: site.name,
path: site.path,
running: runningStatus.get( site.id ) ?? false,
Expand Down Expand Up @@ -857,6 +858,7 @@ export class AiChatUI implements AiOutputAdapter {
// Keep _activeSiteData in sync for /browser command
this._activeSiteData = site;
return {
id: site.id,
name: site.name,
path: site.path,
running: await isSiteRunning( site ),
Expand Down
27 changes: 22 additions & 5 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { getActiveSlashCommands, type SlashCommandContext } from 'cli/ai/slash-c
import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { findSiteByFolder, findSiteById } from 'cli/lib/cli-config/sites';
import { isSiteRunning } from 'cli/lib/site-utils';
import { maybeShowTosNotice } from 'cli/lib/tos-notice';
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
Expand Down Expand Up @@ -97,6 +97,7 @@ export async function runCommand( options: {
resumeSessionId?: string;
showLegacyCommandNotice?: boolean;
activeSite?: {
id?: string;
name: string;
path: string;
remote?: boolean;
Expand All @@ -114,6 +115,7 @@ export async function runCommand( options: {
ui.currentModel = currentModel;
if ( options.activeSite ) {
ui.activeSite = {
id: options.activeSite.id,
name: options.activeSite.name,
path: options.activeSite.path,
// Placeholder — turn dispatch resolves the live state before each prompt.
Expand Down Expand Up @@ -219,6 +221,7 @@ export async function runCommand( options: {
appendStudioEntry( sm, 'studio.site_selected', {
siteName: site.name,
sitePath: site.path,
siteId: site.id,
remote: site.remote,
url: site.url,
wpcomSiteId: site.wpcomSiteId,
Expand All @@ -234,6 +237,7 @@ export async function runCommand( options: {
appendStudioEntry( sm, 'studio.site_selected', {
siteName: site.name,
sitePath: site.path,
siteId: site.id,
} )
);
}
Expand Down Expand Up @@ -479,9 +483,21 @@ export async function runCommand( options: {
// The stored running flag can be absent or stale — the session event log
// carries no running state, replay hardcodes false, and the site can be
// started/stopped mid-session — so ask the daemon before every turn.
// Resolve by id when the session recorded one, so a folder reused by a
// newer site can't redirect the turn; the registry record also refreshes
// a moved site's current name/path. Old events only carry the path.
if ( site && ! site.remote ) {
const siteData = await findSiteByFolder( site.path );
site.running = siteData ? await isSiteRunning( siteData ) : false;
const siteData = site.id
? await findSiteById( site.id )
: await findSiteByFolder( site.path );
if ( siteData ) {
site.id = siteData.id;
site.name = siteData.name;
site.path = siteData.path;
site.running = await isSiteRunning( siteData );
} else {
site.running = false;
}
}
if ( site?.remote && site?.url ) {
enrichedPrompt = `[Active site: "${ site.name }" (ID: ${ site.wpcomSiteId }) at ${ site.url } (WordPress.com)]\n\n${ prompt }`;
Expand Down Expand Up @@ -653,6 +669,7 @@ export async function runCommand( options: {
appendStudioEntry( sm, 'studio.site_selected', {
siteName: site.name,
sitePath: site.path,
siteId: site.id,
remote: site.remote,
url: site.url,
wpcomSiteId: site.wpcomSiteId,
Expand Down Expand Up @@ -801,13 +818,13 @@ export const registerCommand = ( yargs: StudioArgv ) => {
}

const sitePath = typeof argv.path === 'string' ? argv.path : undefined;
let activeSite: { name: string; path: string } | undefined;
let activeSite: { id?: string; name: string; path: string } | undefined;
if ( sitePath && typedArgv.siteName ) {
activeSite = { name: typedArgv.siteName, path: sitePath };
} else if ( sitePath ) {
const matchedSite = await findSiteByFolder( sitePath );
if ( matchedSite ) {
activeSite = { name: matchedSite.name, path: matchedSite.path };
activeSite = { id: matchedSite.id, name: matchedSite.name, path: matchedSite.path };
}
}
await runCommand( {
Expand Down
12 changes: 10 additions & 2 deletions apps/cli/commands/ai/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ describe( 'AI runCommand — active site banner running state', () => {
} );

it( 'reports the site as running when the daemon says it is', async () => {
( findSiteByFolder as Mock ).mockResolvedValue( { id: 'site-1', path: '/sites/my-site' } );
( findSiteByFolder as Mock ).mockResolvedValue( {
id: 'site-1',
name: 'My Site',
path: '/sites/my-site',
} );
( isSiteRunning as Mock ).mockResolvedValue( true );

await runCommand( {
Expand All @@ -198,7 +202,11 @@ describe( 'AI runCommand — active site banner running state', () => {
} );

it( 'reports the site as stopped when the daemon says it is not running', async () => {
( findSiteByFolder as Mock ).mockResolvedValue( { id: 'site-1', path: '/sites/my-site' } );
( findSiteByFolder as Mock ).mockResolvedValue( {
id: 'site-1',
name: 'My Site',
path: '/sites/my-site',
} );
( isSiteRunning as Mock ).mockResolvedValue( false );

await runCommand( {
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/lib/cli-config/sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export async function findSiteByFolder( siteFolder: string ): Promise< SiteData
return config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) );
}

export async function findSiteById( siteId: string ): Promise< SiteData | undefined > {
const config = await readCliConfig();
return config.sites.find( ( site ) => site.id === siteId );
}

export function getSiteUrl( site: SiteData ): string {
if ( site.url ) {
return site.url;
Expand Down
26 changes: 15 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,8 @@ 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,
siteId: ownerServer.details.id,
} );
}

Expand Down Expand Up @@ -442,14 +441,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 +467,10 @@ 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 and id on remote picks too, so live/local
// environment flips still resolve against the same local site.
sitePath: summary.ownerSitePath,
sitePath: ownerServer.details.path,
siteId: ownerServer.details.id,
remote: true,
url: liveSite.url,
wpcomSiteId: liveSite.id,
Expand All @@ -486,6 +489,7 @@ export async function setSessionEnvironment(
await appendStudioEntry( getAiSessionsRootDirectory(), sessionId, 'studio.site_selected', {
siteName: details.name,
sitePath: details.path,
siteId: details.id,
url: 'url' in details ? details.url : undefined,
} );

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
Loading