Skip to content

Commit 49d35aa

Browse files
zenoachtigclaude
andauthored
Let visitors type a follow-up while the Assistant is answering (RND-11789) (#4377)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98b2df4 commit 49d35aa

46 files changed

Lines changed: 158 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Assistant: you can now send follow-up questions while an answer is still being written. Each one appears as your own message with a "Queued" badge (hover for when it will send, × to cancel), and they're sent automatically one at a time as each answer completes.

packages/gitbook/src/components/AI/useAIChat.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export type AIChatState = {
123123
* this value (seeding its editable content) and then clears it back to an empty string.
124124
*/
125125
draft: string;
126+
127+
/**
128+
* Follow-ups the visitor submitted while a previous turn was still streaming. They are held
129+
* here and sent automatically, one at a time in submission order, as each answer finishes
130+
* (see the flush in `streamResponse`), so submitting mid-stream isn't lost. Empty when
131+
* nothing is queued.
132+
*/
133+
queuedMessages: string[];
126134
};
127135

128136
export type AIChatEvent =
@@ -158,6 +166,8 @@ export type AIChatController = {
158166
focus: () => void;
159167
/** Pre-fill the chat input with draft text, without sending it. */
160168
setDraft: (draft: string) => void;
169+
/** Remove a follow-up queued to send after the current answer finishes, by its queue index. */
170+
cancelQueuedMessage: (index: number) => void;
161171
/** Register an event listener */
162172
on: <T extends AIChatEvent['type']>(
163173
event: T,
@@ -182,6 +192,7 @@ const globalState = zustand.create<AIChatState>(() => {
182192
initialQuery: null,
183193
references: [],
184194
draft: '',
195+
queuedMessages: [],
185196
};
186197
});
187198

@@ -257,6 +268,9 @@ export function AIChatProvider(props: {
257268
notify(eventsRef.current.get('close'), {});
258269
}, [setSearchState]);
259270

271+
// Lets `streamResponse` flush a queued follow-up via `onPostMessage`, which is defined later.
272+
const postMessageRef = React.useRef<((input: { message: string }) => void) | null>(null);
273+
260274
// Stream a message with the AI backend
261275
const streamResponse = React.useCallback(
262276
async (input: {
@@ -512,6 +526,15 @@ export function AIChatProvider(props: {
512526
loading: false,
513527
error: false,
514528
}));
529+
530+
// Turn settled: send the next queued follow-up (oldest first). Held back while a
531+
// control is pending, since posting would throw; it flushes after that resolves.
532+
const { queuedMessages, control: activeControl } = globalState.getState();
533+
const [next, ...rest] = queuedMessages;
534+
if (next !== undefined && !activeControl) {
535+
globalState.setState((state) => ({ ...state, queuedMessages: rest }));
536+
postMessageRef.current?.({ message: next });
537+
}
515538
}
516539
} catch (error) {
517540
console.error('Error streaming AI response', error);
@@ -545,8 +568,12 @@ export function AIChatProvider(props: {
545568
throw new Error("We can't post a message when a control is active");
546569
}
547570

548-
// Ignore duplicates while a previous turn is still streaming
571+
// Still streaming: queue this follow-up instead of dropping it (flushed in order in `streamResponse`).
549572
if (responding) {
573+
globalState.setState((state) => ({
574+
...state,
575+
queuedMessages: [...state.queuedMessages, input.message],
576+
}));
550577
return;
551578
}
552579

@@ -607,6 +634,21 @@ export function AIChatProvider(props: {
607634
[setSearchState, siteSpaceId, trackEvent, streamResponse]
608635
);
609636

637+
// Keep the ref current so `streamResponse` can flush a queued follow-up via the latest callback.
638+
postMessageRef.current = onPostMessage;
639+
640+
// Remove a follow-up queued while the assistant is still answering (the × on the affordance).
641+
const onCancelQueuedMessage = React.useCallback((index: number) => {
642+
globalState.setState((state) =>
643+
index < 0 || index >= state.queuedMessages.length
644+
? state
645+
: {
646+
...state,
647+
queuedMessages: state.queuedMessages.filter((_, i) => i !== index),
648+
}
649+
);
650+
}, []);
651+
610652
// Clear the conversation and reset ask parameter
611653
const onClear = React.useCallback(() => {
612654
globalState.setState((state) => ({
@@ -621,6 +663,7 @@ export function AIChatProvider(props: {
621663
error: false,
622664
initialQuery: null,
623665
references: [],
666+
queuedMessages: [],
624667
}));
625668

626669
// Reset ask parameter to empty string (keeps chat open but clears content)
@@ -704,6 +747,7 @@ export function AIChatProvider(props: {
704747
clearReferences: onClearReferences,
705748
focus: onFocus,
706749
setDraft: onSetDraft,
750+
cancelQueuedMessage: onCancelQueuedMessage,
707751
on: onEvent,
708752
};
709753
}, [
@@ -716,6 +760,7 @@ export function AIChatProvider(props: {
716760
onClearReferences,
717761
onFocus,
718762
onSetDraft,
763+
onCancelQueuedMessage,
719764
onEvent,
720765
]);
721766

packages/gitbook/src/components/AIChat/AIChat.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AIChatExpandButton } from './AIChatExpandButton';
3535
import { AIChatIcon } from './AIChatIcon';
3636
import { AIChatInput } from './AIChatInput';
3737
import { AIChatMessages } from './AIChatMessages';
38+
import { AIChatQueuedMessage } from './AIChatQueuedMessage';
3839
import { AIChatResizeHandle } from './AIChatResizeHandle';
3940
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
4041

@@ -119,6 +120,7 @@ export function AIChat() {
119120
chat={chat}
120121
suggestions={config.suggestions}
121122
trademark={config.trademark}
123+
assistantName={config.assistantName}
122124
/>
123125
</EmbeddableFrameBody>
124126
</EmbeddableFrameMain>
@@ -207,14 +209,17 @@ export function AIChatBody(props: {
207209
welcomeMessage?: string;
208210
suggestions?: string[];
209211
trademark?: boolean;
212+
/** Custom assistant name override; falls back to the branded/unbranded default name. */
213+
assistantName?: string;
210214
greeting?: {
211215
title: string;
212216
subtitle: string;
213217
};
214218
}) {
215-
const { chatController, chat, suggestions, greeting, trademark } = props;
219+
const { chatController, chat, suggestions, greeting, trademark, assistantName } = props;
216220

217221
const language = useLanguage();
222+
const resolvedAssistantName = assistantName ?? getAIChatName(language, trademark ?? true);
218223
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
219224

220225
const isEmpty = !chat.messages.length;
@@ -280,13 +285,24 @@ export function AIChatBody(props: {
280285
</ScrollContainer>
281286

282287
<div className="flex max-h-3/4 min-h-0 flex-col gap-2 not-embed:px-4 pb-4">
288+
{!chat.error &&
289+
chat.queuedMessages.map((message, index) => (
290+
<AIChatQueuedMessage
291+
// Queue order is stable and items carry no local state, so the index is a
292+
// safe key here.
293+
key={index}
294+
message={message}
295+
assistantName={resolvedAssistantName}
296+
onRemove={() => chatController.cancelQueuedMessage(index)}
297+
/>
298+
))}
283299
{/* Display an error banner when something went wrong. */}
284300
{chat.error ? <AIChatError chatController={chatController} /> : null}
285301

286302
{chat.control ? <AIChatControl control={chat.control} /> : null}
287303
<AIChatInput
288304
responding={chat.responding}
289-
disabled={chat.responding || chat.error}
305+
disabled={chat.error}
290306
onSubmit={(value) => {
291307
chatController.postMessage({ message: value });
292308
}}

packages/gitbook/src/components/AIChat/AIChatInput.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { AIChatReferenceChips } from './AIChatReferenceChips';
1111
export function AIChatInput(props: {
1212
disabled?: boolean;
1313
/**
14-
* When true, the input is disabled
14+
* When true, the assistant is streaming an answer. The field stays editable and submitting a
15+
* follow-up queues it (sent automatically once the answer finishes) rather than being blocked.
1516
*/
1617
responding: boolean;
1718
onSubmit: (value: string) => void;
@@ -110,7 +111,7 @@ export function AIChatInput(props: {
110111
}
111112
: undefined
112113
}
113-
disabled={disabled || responding || chat.control !== null}
114+
disabled={disabled || chat.control !== null}
114115
aria-busy={responding}
115116
ref={inputRef}
116117
header={
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { t, useLanguage } from '@/intl/client';
2+
import { tcls } from '@/lib/tailwind';
3+
import { Icon } from '@gitbook/icons';
4+
import { Button, Tooltip } from '../primitives';
5+
6+
/**
7+
* Shows a follow-up the visitor submitted while the assistant was still answering. It mirrors a
8+
* sent user message — a right-aligned, tinted bubble — so it reads as "theirs", with a clock icon
9+
* marking it as pending and a tooltip (on the whole bubble) explaining it will be sent once the
10+
* current answer finishes. The × beside the bubble drops it from the queue.
11+
*/
12+
export function AIChatQueuedMessage(props: {
13+
message: string;
14+
assistantName: string;
15+
onRemove: () => void;
16+
}) {
17+
const { message, assistantName, onRemove } = props;
18+
const language = useLanguage();
19+
20+
return (
21+
<div className="flex max-w-[80%] origin-top-right animate-scale-in items-center gap-1 self-end">
22+
<Tooltip label={t(language, 'ai_chat_queued_message', assistantName)}>
23+
<div
24+
className={tcls(
25+
'flex min-w-0 items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md',
26+
'bg-primary-solid/1 px-4 py-2 text-tint'
27+
)}
28+
>
29+
<Icon icon="clock" className="size-3.5 shrink-0 text-tint" />
30+
<span className="min-w-0 break-words">{message}</span>
31+
</div>
32+
</Tooltip>
33+
<Button
34+
variant="blank"
35+
size="small"
36+
iconOnly
37+
icon="xmark"
38+
label={t(language, 'clear')}
39+
onClick={onRemove}
40+
className="shrink-0 text-tint"
41+
/>
42+
</div>
43+
);
44+
}

packages/gitbook/src/components/Embeddable/EmbeddableAIChat.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
102102
suggestions={siteConfig.suggestions}
103103
greeting={siteConfig.greeting}
104104
trademark={trademark}
105+
assistantName={siteConfig.assistantName}
105106
/>
106107
</LinkContext>
107108
</EmbeddableFrameBody>

packages/gitbook/src/components/primitives/Input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
195195
};
196196

197197
const inputClassName = tcls(
198-
'peer -m-2 max-h-64 grow shrink resize-none leading-normal text-left outline-none placeholder:text-tint/8 placeholder-shown:text-ellipsis aria-busy:cursor-progress',
198+
// `aria-busy` must not change the cursor: a busy field can still be editable.
199+
'peer -m-2 max-h-64 grow shrink resize-none leading-normal text-left outline-none placeholder:text-tint/8 placeholder-shown:text-ellipsis',
199200
sizes[sizing].input
200201
);
201202

@@ -228,7 +229,6 @@ export const Input = React.forwardRef<InputElement, InputProps>((props, passedRe
228229
'focus-within:border-primary-hover focus-within:depth-subtle:shadow-lg focus-within:shadow-primary-subtle focus-within:ring-2 hover:not-has-[button:hover]:cursor-text hover:not-has-[button:hover]:border-tint-hover hover:not-has-[button:hover]:not-focus-within:bg-tint-subtle depth-subtle:hover:not-has-[button:hover]:not-focus-within:shadow-md focus-within:hover:not-has-[button:hover]:border-primary-hover',
229230
],
230231
multiline ? 'flex-col' : 'flex-row',
231-
ariaBusy ? 'cursor-progress' : '',
232232
sizes[sizing].container,
233233
className
234234
)}

packages/gitbook/src/intl/translations/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export const ar: TranslationLanguage = {
133133
ai_chat_context_previous_messages: 'الرسائل في محادثتك',
134134
ai_chat_context_disclaimer: 'قد تحتوي إجابات الذكاء الاصطناعي على أخطاء.',
135135
ai_chat_input_placeholder: 'اسأل أو ابحث أو اشرح...',
136+
ai_chat_queued_message: 'سيتم إرسال هذه الرسالة بعد أن ينتهي ${1}',
136137
send: 'إرسال',
137138
actions: 'إجراءات',
138139
ai_chat_suggested_questions_title: 'أسئلة مقترحة',

packages/gitbook/src/intl/translations/bg.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const bg: TranslationLanguage = {
137137
ai_chat_context_previous_messages: 'Съобщения във вашия разговор',
138138
ai_chat_context_disclaimer: 'AI отговорите може да съдържат грешки.',
139139
ai_chat_input_placeholder: 'Попитайте, потърсете или обяснете...',
140+
ai_chat_queued_message: 'Това съобщение ще бъде изпратено, след като ${1} приключи',
140141
send: 'Изпращане',
141142
actions: 'Действия',
142143
ai_chat_suggested_questions_title: 'Предложени въпроси',

packages/gitbook/src/intl/translations/cs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export const cs: TranslationLanguage = {
135135
ai_chat_context_previous_messages: 'Zprávy ve vaší konverzaci',
136136
ai_chat_context_disclaimer: 'Odpovědi AI mohou obsahovat chyby.',
137137
ai_chat_input_placeholder: 'Zeptejte se, hledejte nebo vysvětlete...',
138+
ai_chat_queued_message: 'Tato zpráva se odešle, až ${1} dokončí odpověď',
138139
send: 'Odeslat',
139140
actions: 'Akce',
140141
ai_chat_suggested_questions_title: 'Navrhované otázky',

0 commit comments

Comments
 (0)