Skip to content

Commit eb967d6

Browse files
authored
apps/ui: give instant feedback when creating a new session (#3237)
1 parent b73d401 commit eb967d6

6 files changed

Lines changed: 122 additions & 42 deletions

File tree

apps/ui/src/components/session-view/composer/index.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,27 @@ import { EnvironmentPill } from './environment-pill';
99
import styles from './style.module.css';
1010
import type { AiModelId, SyncSite } from '@/data/core';
1111

12+
/**
13+
* Invisible structural placeholder that mirrors Composer's outer DOM (shell +
14+
* textarea + toolbar + meta row) so the loading state can reserve the exact
15+
* same vertical space without rendering a visible composer. Heights track the
16+
* real composer's CSS automatically — no magic numbers that drift when the
17+
* composer changes.
18+
*/
19+
export function ComposerSkeleton() {
20+
return (
21+
<div className={ styles.root } style={ { visibility: 'hidden' } } aria-hidden="true">
22+
<div className={ styles.shell }>
23+
<textarea className={ styles.input } rows={ 2 } disabled tabIndex={ -1 } />
24+
<div className={ styles.toolbar }>
25+
<span className={ styles.pill } />
26+
</div>
27+
</div>
28+
<div className={ styles.meta }>{ '\u00A0' }</div>
29+
</div>
30+
);
31+
}
32+
1233
interface ComposerProps {
1334
busy: boolean;
1435
isInterrupting?: boolean;

apps/ui/src/components/session-view/index.tsx

Lines changed: 84 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@ import { useQueryClient } from '@tanstack/react-query';
33
import { __ } from '@wordpress/i18n';
44
import { IconButton } from '@wordpress/ui';
55
import { clsx } from 'clsx';
6-
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
7-
import { Composer } from '@/components/session-view/composer';
6+
import {
7+
useCallback,
8+
useLayoutEffect,
9+
useMemo,
10+
useRef,
11+
useState,
12+
type ReactNode,
13+
type Ref,
14+
} from 'react';
15+
import { Composer, ComposerSkeleton } from '@/components/session-view/composer';
816
import { pickLiveSite } from '@/components/session-view/composer/environment-pill';
917
import { Conversation } from '@/components/session-view/conversation';
1018
import { EmptyBackground } from '@/components/session-view/empty-background';
@@ -87,6 +95,29 @@ function SessionHeader( {
8795
);
8896
}
8997

98+
interface SessionFrameProps {
99+
header?: ReactNode;
100+
composer?: ReactNode;
101+
preview?: ReactNode;
102+
scrollRef?: Ref< HTMLDivElement >;
103+
children?: ReactNode;
104+
}
105+
106+
function SessionFrame( { header, composer, preview, scrollRef, children }: SessionFrameProps ) {
107+
return (
108+
<div className={ styles.root }>
109+
<div className={ styles.chatColumn }>
110+
{ header }
111+
<div ref={ scrollRef } className={ styles.scroll }>
112+
{ children }
113+
</div>
114+
<div className={ styles.composerOuter }>{ composer }</div>
115+
</div>
116+
{ preview }
117+
</div>
118+
);
119+
}
120+
90121
export function SessionView( { sessionId }: { sessionId: string } ) {
91122
const { data, isLoading, error } = useSession( sessionId );
92123
const connector = useConnector();
@@ -165,7 +196,22 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
165196
}, [ sessionId, data, isRunning, queuedPrompts.length ] );
166197

167198
if ( isLoading ) {
168-
return <div className={ styles.state }>{ __( 'Loading session…' ) }</div>;
199+
// Use the same SessionFrame with an empty header and a structural
200+
// ComposerSkeleton so the scroll area has the exact same dimensions
201+
// as the loaded view — otherwise the EmptyBackground canvas jumps
202+
// mid-transition.
203+
return (
204+
<SessionFrame
205+
header={ <div className={ styles.header } /> }
206+
composer={
207+
<div className={ styles.column }>
208+
<ComposerSkeleton />
209+
</div>
210+
}
211+
>
212+
<EmptyBackground />
213+
</SessionFrame>
214+
);
169215
}
170216

171217
if ( error || ! data ) {
@@ -178,48 +224,48 @@ export function SessionView( { sessionId }: { sessionId: string } ) {
178224
}
179225

180226
return (
181-
<div className={ styles.root }>
182-
<div className={ styles.chatColumn }>
227+
<SessionFrame
228+
scrollRef={ scrollRef }
229+
header={
183230
<SessionHeader
184231
summary={ data.summary }
185232
previewOpen={ showPreview }
186233
onTogglePreview={ () => setPreviewOpen( ( open ) => ! open ) }
187234
canTogglePreview={ canTogglePreview }
188235
/>
189-
<div ref={ scrollRef } className={ styles.scroll }>
190-
{ isEmpty ? <EmptyBackground /> : null }
191-
<div className={ clsx( styles.column, styles.conversationSpacing ) }>
192-
<Conversation
193-
data={ data }
194-
isRunning={ isRunning }
195-
startedAt={ startedAt }
196-
pendingQuestions={ pendingQuestionTexts }
197-
pendingAnswers={ pendingAnswers }
198-
onAnswerQuestion={ answerQuestion }
199-
/>
200-
</div>
201-
</div>
202-
<div className={ styles.composerOuter }>
203-
<div className={ styles.column }>
204-
<QueuedPrompts prompts={ queuedPrompts } onRemove={ removeQueuedPrompt } />
205-
<Composer
206-
busy={ composerBusy }
207-
isInterrupting={ isInterrupting }
208-
error={ runError }
209-
model={ currentModel }
210-
onModelChange={ onModelChange }
211-
onSend={ sendMessage }
212-
onInterrupt={ interrupt }
213-
sessionId={ sessionId }
214-
effectiveEnvironment={ effectiveEnvironment }
215-
liveSite={ liveSite }
216-
/>
217-
</div>
236+
}
237+
composer={
238+
<div className={ styles.column }>
239+
<QueuedPrompts prompts={ queuedPrompts } onRemove={ removeQueuedPrompt } />
240+
<Composer
241+
busy={ composerBusy }
242+
isInterrupting={ isInterrupting }
243+
error={ runError }
244+
model={ currentModel }
245+
onModelChange={ onModelChange }
246+
onSend={ sendMessage }
247+
onInterrupt={ interrupt }
248+
sessionId={ sessionId }
249+
effectiveEnvironment={ effectiveEnvironment }
250+
liveSite={ liveSite }
251+
/>
218252
</div>
253+
}
254+
preview={
255+
showPreview && ownerSite ? <SitePreview site={ ownerSite } sessionId={ sessionId } /> : null
256+
}
257+
>
258+
{ isEmpty ? <EmptyBackground /> : null }
259+
<div className={ clsx( styles.column, styles.conversationSpacing ) }>
260+
<Conversation
261+
data={ data }
262+
isRunning={ isRunning }
263+
startedAt={ startedAt }
264+
pendingQuestions={ pendingQuestionTexts }
265+
pendingAnswers={ pendingAnswers }
266+
onAnswerQuestion={ answerQuestion }
267+
/>
219268
</div>
220-
{ showPreview && ownerSite ? (
221-
<SitePreview site={ ownerSite } sessionId={ sessionId } />
222-
) : null }
223-
</div>
269+
</SessionFrame>
224270
);
225271
}

apps/ui/src/components/session-view/style.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
color: var(--wpds-color-fg-content-neutral-weak);
1919
}
2020

21+
2122
.header {
2223
display: flex;
2324
align-items: center;

apps/ui/src/components/site-list/index.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ function SessionItem( { session }: { session: AiSessionSummary } ) {
114114

115115
function NewSessionButton( { site }: { site: SiteDetails } ) {
116116
const navigate = useNavigate();
117+
const [ isPending, setIsPending ] = useState( false );
118+
const handleClick = async () => {
119+
setIsPending( true );
120+
try {
121+
await navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } );
122+
} finally {
123+
setIsPending( false );
124+
}
125+
};
117126
return (
118127
<IconButton
119128
variant="minimal"
@@ -122,7 +131,9 @@ function NewSessionButton( { site }: { site: SiteDetails } ) {
122131
icon={ plus }
123132
label={ __( 'New session' ) }
124133
className={ styles.siteAction }
125-
onClick={ () => void navigate( { to: '/sites/$siteId/new', params: { siteId: site.id } } ) }
134+
loading={ isPending }
135+
loadingAnnouncement={ __( 'Creating session' ) }
136+
onClick={ handleClick }
126137
/>
127138
);
128139
}

apps/ui/src/router/route-new-session/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export const newSessionRoute = createRoute( {
1616
const summary = await context.connector.createSession( params.siteId );
1717
// Bypasses `useCreateSession`, so we need to invalidate the sessions
1818
// list ourselves — otherwise the sidebar wouldn't reflect the new
19-
// session on first render after the redirect.
20-
await context.queryClient.invalidateQueries( { queryKey: SESSIONS_QUERY_KEY } );
19+
// session on first render after the redirect. Fire-and-forget: the
20+
// refetch can happen in the background while we redirect immediately.
21+
void context.queryClient.invalidateQueries( { queryKey: SESSIONS_QUERY_KEY } );
2122
throw redirect( { to: '/sessions/$sessionId', params: { sessionId: summary.id } } );
2223
},
2324
} );

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)