Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
96129ce
Add StudioCodeProcess to manage AI headless child processes
sejas Mar 22, 2026
8ff912f
Add Studio Code IPC handlers and wire up preload, constants, and storage
sejas Mar 22, 2026
ab9f11e
Add Studio Code chat UI components (chat container, message, tool cal…
sejas Mar 22, 2026
f28f02d
Integrate Studio Code into Assistant tab with engine toggle
sejas Mar 22, 2026
a1c7cdb
Add unit tests for StudioCodeProcess module and StudioCodePermission …
sejas Mar 22, 2026
fea21d8
Fix review issues: double start, useEffect deps, console.warn
sejas Mar 22, 2026
ed3989e
Fix cancel listener race condition and add command validation
sejas Mar 22, 2026
2d979ea
Add CLI headless types and --headless flag to ai command
sejas Mar 22, 2026
580952f
Merge remote-tracking branch 'origin/trunk' into studio-code-assistant
sejas Mar 26, 2026
ae30943
Fix merge conflicts after merging trunk into studio-code-assistant
sejas Mar 26, 2026
a7a34b8
Add enableStudioCode feature flag to gate new assistant implementation
sejas Mar 26, 2026
e5d342e
Remove turn cost display from Studio Code chat
sejas Mar 26, 2026
a434428
Merge remote-tracking branch 'origin/trunk' into studio-code-assistant
sejas Apr 14, 2026
cded980
Merge branch 'trunk' of github.com:Automattic/studio into studio-code…
sejas Apr 14, 2026
a20a338
Remove headless
sejas Apr 14, 2026
efb4f08
Adapt --json mode to studio assistant UI
sejas Apr 14, 2026
e666d7d
Bring back update param from trunk
sejas Apr 14, 2026
927181b
Rename feature flag to ENABLE_STUDIO_CODE_UI
sejas Apr 14, 2026
c3bf1c4
Hide build with Telex for Studio Code UI
sejas Apr 14, 2026
190b602
Merge branch 'trunk' into studio-code-assistant
sejas Apr 14, 2026
4cbbf44
Fix JSON mode to auto-switch unavailable providers before running age…
sejas Apr 14, 2026
bbf6a8b
Merge branch 'studio-code-assistant' of github.com:Automattic/studio …
sejas Apr 14, 2026
bab6653
Fix permission flow: support multi-question, render actual options fr…
sejas Apr 14, 2026
80bfb2c
Pass selected site context to CLI so agent auto-selects it
sejas Apr 14, 2026
cd87f0b
Persist chat messages across tab switches using module-level cache
sejas Apr 14, 2026
74f1cbe
Resolve sites by folder name when agent passes only the directory bas…
sejas Apr 14, 2026
a969711
Persist Studio Code chat messages to localStorage across app restarts
sejas Apr 14, 2026
a127b60
Append to existing session file when resuming with --resume-session i…
sejas Apr 14, 2026
c1c14eb
Remove unused ai-settings-modal component
sejas Apr 14, 2026
1f0653a
Show login notice and disable input for unauthenticated users in Stud…
sejas Apr 15, 2026
0257618
Remove unused sprintf and LIMIT_OF_PROMPTS_PER_USER imports
sejas Apr 15, 2026
b413467
Merge branch 'trunk' of github.com:Automattic/studio into studio-code…
sejas Apr 15, 2026
6090ba9
Merge remote-tracking branch 'origin/trunk' into studio-code-assistant
sejas Apr 17, 2026
c5fec7f
Rename studio-code-types.ts files to clarify UI vs event types
sejas Apr 17, 2026
2843ca6
Address CLI review feedback on studio code AI command
sejas Apr 17, 2026
aeb6750
Merge remote-tracking branch 'origin/trunk' into studio-code-assistant
sejas Apr 20, 2026
ca485bb
Fix import order after trunk merge
sejas Apr 20, 2026
b3da4f4
Merge remote-tracking branch 'origin/trunk' into studio-code-assistant
sejas Apr 20, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/cli/ai/output-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class JsonAdapter implements AiOutputAdapter {
onSiteSelected: ( ( site: SiteInfo ) => void ) | null = null;
onInterrupt: ( () => void ) | null = null;
onBeforeExit: ( () => Promise< void > ) | null = null;
permissionResponse: Record< string, string > | null = null;

private sessionId: string | undefined;

Expand Down Expand Up @@ -94,7 +95,7 @@ export class JsonAdapter implements AiOutputAdapter {
}

setLoaderMessage( message: string, _update?: boolean ): void {
emitEvent( { type: 'progress', timestamp: new Date().toISOString(), message } );
this.showProgress( message );
}

beginAgentTurn(): void {
Expand Down Expand Up @@ -150,6 +151,14 @@ export class JsonAdapter implements AiOutputAdapter {
}

async askUser( questions: AskUserQuestion[] ): Promise< Record< string, string > > {
// If a permission response was pre-supplied (e.g. from desktop app),
// return it immediately instead of pausing.
if ( this.permissionResponse ) {
const response = this.permissionResponse;
this.permissionResponse = null;
return response;
}

emitEvent( {
type: 'question.asked',
timestamp: new Date().toISOString(),
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/ai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ async function resolveSite( nameOrPath: string ): Promise< SiteData > {
if ( siteByName ) {
return siteByName;
}

// Also try matching by the last folder segment of the site path,
// since the agent may pass just the folder name instead of the full path.
if ( ! path.isAbsolute( nameOrPath ) ) {
const config = await readCliConfig();
const siteByFolder = config.sites.find( ( site ) => path.basename( site.path ) === nameOrPath );
if ( siteByFolder ) {
return siteByFolder;
}
}

return getSiteByFolder( nameOrPath );
}

Expand Down
72 changes: 67 additions & 5 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { listAiSessions } from '@studio/common/ai/sessions/store';
import { type LoadedAiSession, type TurnStatus } from '@studio/common/ai/sessions/types';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __, _n, sprintf } from '@wordpress/i18n';
Expand All @@ -15,6 +16,7 @@ import { closeSharedBrowser } from 'cli/ai/browser-utils';
import { type AiOutputAdapter, JsonAdapter } from 'cli/ai/output-adapter';
import { AI_PROVIDERS, type AiProviderId } from 'cli/ai/providers';
import { resolveResumeSessionContext } from 'cli/ai/sessions/context';
import { getAiSessionsRootDirectory } from 'cli/ai/sessions/paths';
import { AiSessionRecorder } from 'cli/ai/sessions/recorder';
import { replaySessionHistory } from 'cli/ai/sessions/replay';
import { AI_CHAT_SLASH_COMMANDS, type SlashCommandContext } from 'cli/ai/slash-commands';
Expand Down Expand Up @@ -45,9 +47,11 @@ export async function runCommand( options: {
adapter: AiOutputAdapter;
initialMessage?: string;
resumeSession?: LoadedAiSession;
resumeSessionId?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the fact we can resume sessions now.

noSessionPersistence?: boolean;
autoApprove?: boolean;
showLegacyCommandNotice?: boolean;
activeSite?: { name: string; path: string; running?: boolean };
} ): Promise< void > {
const ui = options.adapter;
const isJsonMode = ui instanceof JsonAdapter;
Expand All @@ -57,6 +61,13 @@ export async function runCommand( options: {
let currentModel: AiModelId = resumeContext.model ?? DEFAULT_MODEL;
ui.currentProvider = currentProvider;
ui.currentModel = currentModel;
if ( options.activeSite ) {
ui.activeSite = {
name: options.activeSite.name,
path: options.activeSite.path,
running: options.activeSite.running ?? false,
};
}
ui.start();
ui.showWelcome();

Expand All @@ -66,7 +77,7 @@ export async function runCommand( options: {

let sessionRecorder: AiSessionRecorder | undefined;
let didDisableSessionPersistence = options.noSessionPersistence === true;
let sessionId: string | undefined = resumeContext.sessionId;
let sessionId: string | undefined = options.resumeSessionId ?? resumeContext.sessionId;
let persistQueue: Promise< void > = Promise.resolve();

if ( options.noSessionPersistence ) {
Expand All @@ -88,6 +99,27 @@ export async function runCommand( options: {
filePath: options.resumeSession.summary.filePath,
linkedAgentSessionIds: options.resumeSession.summary.linkedAgentSessionIds,
} );
} else if ( options.resumeSessionId ) {
// Find existing session file by SDK agent session ID so
// follow-up turns append to the same file instead of creating new ones.
const sessions = await listAiSessions( getAiSessionsRootDirectory() );
const existing = sessions.find( ( s ) =>
s.linkedAgentSessionIds.includes( options.resumeSessionId! )
);
if ( ! existing ) {
throw new Error(
sprintf(
/* translators: %s: agent session ID */
__( 'No AI session found for resume ID: %s' ),
options.resumeSessionId
)
);
}
sessionRecorder = await AiSessionRecorder.open( {
sessionId: existing.id,
filePath: existing.filePath,
linkedAgentSessionIds: existing.linkedAgentSessionIds,
} );
} else {
sessionRecorder = await AiSessionRecorder.create();
}
Expand Down Expand Up @@ -148,9 +180,9 @@ export async function runCommand( options: {
);
}

setProgressCallback( ( message, update ) => {
setProgressCallback( ( message ) => {
const timestamp = new Date().toISOString();
ui.setLoaderMessage( message, update );
ui.setLoaderMessage( message );
void persist( ( recorder ) => recorder.recordToolProgress( message, timestamp ) );
} );

Expand Down Expand Up @@ -361,6 +393,7 @@ export async function runCommand( options: {
async function runAgentTurn(
prompt: string
): Promise< { status: TurnStatus; usage?: { numTurns: number; costUsd?: number } } > {
await maybeAutoSwitchProvider();
const env = await resolveAiEnvironment( currentProvider );
ui.beginAgentTurn();

Expand Down Expand Up @@ -551,7 +584,6 @@ export async function runCommand( options: {
}
} else {
// Skill command — no handler, route to agent
await maybeAutoSwitchProvider();
ui.addUserMessage( prompt );
try {
await runAgentTurn( `Run the /${ cmd.name } skill using the Skill tool.` );
Expand All @@ -562,7 +594,6 @@ export async function runCommand( options: {
continue;
}

await maybeAutoSwitchProvider();
ui.addUserMessage( prompt );
try {
await runAgentTurn( prompt );
Expand Down Expand Up @@ -590,6 +621,11 @@ export const registerCommand = ( yargs: StudioArgv ) => {
.option( 'path', {
hidden: true,
} )
.option( 'site-name', {
type: 'string',
hidden: true,
description: __( 'Name of the active WordPress site' ),
} )
.option( 'json', {
type: 'boolean',
default: false,
Expand All @@ -599,6 +635,16 @@ export const registerCommand = ( yargs: StudioArgv ) => {
type: 'boolean',
description: __( 'Auto-approve all tool calls (defaults to true in --json mode)' ),
} )
.option( 'resume-session', {
type: 'string',
hidden: true,
description: __( 'SDK session ID to resume (for JSON mode multi-turn)' ),
} )
.option( 'permission-response', {
type: 'string',
hidden: true,
description: __( 'JSON-encoded permission response for a paused session' ),
} )
.option( 'session-persistence', {
type: 'boolean',
default: true,
Expand All @@ -618,16 +664,32 @@ export const registerCommand = ( yargs: StudioArgv ) => {
json?: boolean;
sessionPersistence?: boolean;
autoApprove?: boolean;
resumeSession?: string;
permissionResponse?: string;
siteName?: string;
};
const noSessionPersistence = typedArgv.sessionPersistence === false;
const adapter: AiOutputAdapter = typedArgv.json ? new JsonAdapter() : new AiChatUI();

if ( adapter instanceof JsonAdapter && typedArgv.permissionResponse ) {
adapter.permissionResponse = JSON.parse( typedArgv.permissionResponse ) as Record<
string,
string
>;
}

const sitePath = typeof argv.path === 'string' ? argv.path : undefined;
await runCommand( {
adapter,
initialMessage: typedArgv.message,
resumeSessionId: typedArgv.resumeSession,
noSessionPersistence,
autoApprove: typedArgv.autoApprove,
showLegacyCommandNotice: argv._[ 0 ] === 'ai',
activeSite:
sitePath && typedArgv.siteName
? { name: typedArgv.siteName, path: sitePath }
: undefined,
} );
} catch ( error ) {
if ( error instanceof LoggerError ) {
Expand Down
33 changes: 20 additions & 13 deletions apps/studio/src/components/ai-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface AIInputProps {
handleKeyDown: ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => void;
clearConversation: () => void;
isAssistantThinking: boolean;
showTelexLink?: boolean;
}

const MAX_ROWS = 10;
Expand Down Expand Up @@ -54,6 +55,7 @@ const UnforwardedAIInput = (
handleKeyDown,
clearConversation,
isAssistantThinking,
showTelexLink = true,
}: AIInputProps,
inputRef: React.RefObject< HTMLTextAreaElement > | React.RefCallback< HTMLTextAreaElement > | null
) => {
Expand Down Expand Up @@ -255,19 +257,24 @@ const UnforwardedAIInput = (
{ ( { onClose }: { onClose: () => void } ) => (
<>
<MenuGroup>
<MenuItem
data-testid="telex-link-button"
onClick={ () => {
const telexUrl = addUrlParams( `https://${ TELEX_HOSTNAME }/`, TELEX_UTM_PARAMS );
getIpcApi().openURL( telexUrl );
onClose();
} }
className="flex flex-row"
>
<SparklesIcon />
<span className="ltr:pl-2 rtl:pl-2">{ __( 'Build with Telex' ) }</span>
<ArrowIcon />
</MenuItem>
{ showTelexLink && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I don't think we should have this link to Telex.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I'll create a follow-up issue to remove it and we can discuss it there. This PR just keeps intact the experience of Studio Assistant.

<MenuItem
data-testid="telex-link-button"
onClick={ () => {
const telexUrl = addUrlParams(
`https://${ TELEX_HOSTNAME }/`,
TELEX_UTM_PARAMS
);
getIpcApi().openURL( telexUrl );
onClose();
} }
className="flex flex-row"
>
<SparklesIcon />
<span className="ltr:pl-2 rtl:pl-2">{ __( 'Build with Telex' ) }</span>
<ArrowIcon />
</MenuItem>
) }
<MenuItem
isDestructive
data-testid="clear-conversation-button"
Expand Down
12 changes: 12 additions & 0 deletions apps/studio/src/components/content-tab-assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import { ChatMessage, MarkDownWithCode } from 'src/components/chat-message';
import { ChatRating } from 'src/components/chat-rating';
import { LearnMoreLink } from 'src/components/learn-more';
import offlineIcon from 'src/components/offline-icon';
import { StudioCodeChat } from 'src/components/studio-code-chat';
import WelcomeComponent from 'src/components/welcome-message-prompt';
import { LIMIT_OF_PROMPTS_PER_USER, TELEX_HOSTNAME, TELEX_UTM_PARAMS } from 'src/constants';
import { useAuth } from 'src/hooks/use-auth';
import { useFeatureFlags } from 'src/hooks/use-feature-flags';
import { useOffline } from 'src/hooks/use-offline';
import { useThemeDetails } from 'src/hooks/use-theme-details';
import { cx } from 'src/lib/cx';
Expand Down Expand Up @@ -357,6 +359,16 @@ const UnauthenticatedView = ( { onAuthenticate }: { onAuthenticate: () => void }
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to access the UI even when I was logged out and it threw an error in a response message. I think we might want to improve this experience by either hiding it behind the login screen or find another alternative:

Image

export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps ) {
const { enableStudioCodeUi } = useFeatureFlags();

if ( enableStudioCodeUi ) {
return <StudioCodeChat selectedSite={ selectedSite } />;
}

return <WpcomAssistant selectedSite={ selectedSite } />;
}

function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) {
const inputRef = useRef< HTMLTextAreaElement >( null );
const wrapperRef = useRef< HTMLDivElement >( null );
const dispatch = useAppDispatch();
Expand Down
Loading
Loading