Skip to content

Commit ab28213

Browse files
committed
feat(core): support synchronization of ai playground input value and send button (#12607)
Close [AI-86](https://linear.app/affine-design/issue/AI-86) [123.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/01ca98ef-60a3-4a42-9bef-62993f6a657b.mov" />](https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/01ca98ef-60a3-4a42-9bef-62993f6a657b.mov)
1 parent 39cb1af commit ab28213

File tree

2 files changed

+172
-10
lines changed

2 files changed

+172
-10
lines changed

packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
55
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
66
import { openFilesWith } from '@blocksuite/affine/shared/utils';
77
import type { EditorHost } from '@blocksuite/affine/std';
8+
import { ShadowlessElement } from '@blocksuite/affine/std';
89
import {
910
CloseIcon,
1011
ImageIcon,
1112
PublishIcon,
1213
ThinkingIcon,
1314
} from '@blocksuite/icons/lit';
1415
import { type Signal, signal } from '@preact/signals-core';
15-
import { css, html, LitElement, nothing } from 'lit';
16+
import { css, html, nothing } from 'lit';
1617
import { property, query, state } from 'lit/decorators.js';
1718
import { repeat } from 'lit/directives/repeat.js';
1819
import { styleMap } from 'lit/directives/style-map.js';
@@ -37,7 +38,9 @@ function getFirstTwoLines(text: string) {
3738
return lines.slice(0, 2);
3839
}
3940

40-
export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
41+
export class AIChatInput extends SignalWatcher(
42+
WithDisposable(ShadowlessElement)
43+
) {
4144
static override styles = css`
4245
:host {
4346
width: 100%;

packages/frontend/core/src/blocksuite/ai/components/playground/content.ts

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,16 @@ export class PlaygroundContent extends SignalWatcher(
8686
@state()
8787
accessor sessions: CopilotSessionType[] = [];
8888

89+
@state()
90+
accessor sharedInputValue: string = '';
91+
8992
private rootSessionId: string | undefined = undefined;
9093

91-
private readonly _getSessions = async () => {
94+
private isUpdatingTextareas = false;
95+
96+
private isSending = false;
97+
98+
private readonly getSessions = async () => {
9299
const sessions =
93100
(await AIProvider.session?.getSessions(
94101
this.doc.workspace.id,
@@ -107,7 +114,7 @@ export class PlaygroundContent extends SignalWatcher(
107114
});
108115
if (rootSessionId) {
109116
this.rootSessionId = rootSessionId;
110-
const forkSession = await this._forkSession(rootSessionId);
117+
const forkSession = await this.forkSession(rootSessionId);
111118
if (forkSession) {
112119
this.sessions = [forkSession];
113120
}
@@ -120,15 +127,15 @@ export class PlaygroundContent extends SignalWatcher(
120127
if (childSessions.length > 0) {
121128
this.sessions = childSessions;
122129
} else {
123-
const forkSession = await this._forkSession(rootSession.id);
130+
const forkSession = await this.forkSession(rootSession.id);
124131
if (forkSession) {
125132
this.sessions = [forkSession];
126133
}
127134
}
128135
}
129136
};
130137

131-
private readonly _forkSession = async (parentSessionId: string) => {
138+
private readonly forkSession = async (parentSessionId: string) => {
132139
const forkSessionId = await AIProvider.forkChat?.({
133140
workspaceId: this.doc.workspace.id,
134141
docId: this.doc.id,
@@ -144,19 +151,171 @@ export class PlaygroundContent extends SignalWatcher(
144151
);
145152
};
146153

147-
private readonly _addChat = async () => {
154+
private readonly addChat = async () => {
148155
if (!this.rootSessionId) {
149156
return;
150157
}
151-
const forkSession = await this._forkSession(this.rootSessionId);
158+
const forkSession = await this.forkSession(this.rootSessionId);
152159
if (forkSession) {
153160
this.sessions = [...this.sessions, forkSession];
154161
}
155162
};
156163

164+
private setupTextareaSync() {
165+
const observer = new MutationObserver(() => {
166+
this.syncAllTextareas();
167+
this.syncAllSendButtons();
168+
});
169+
observer.observe(this, {
170+
childList: true,
171+
subtree: true,
172+
});
173+
this._disposables.add(() => observer.disconnect());
174+
this.syncAllTextareas();
175+
this.syncAllSendButtons();
176+
}
177+
178+
private syncAllTextareas() {
179+
const textareas = this.getAllTextareas();
180+
textareas.forEach(textarea => {
181+
this.setupTextareaListeners(textarea);
182+
});
183+
}
184+
185+
private getAllTextareas(): HTMLTextAreaElement[] {
186+
return Array.from(
187+
this.querySelectorAll(
188+
'ai-chat-input textarea[data-testid="chat-panel-input"]'
189+
)
190+
) as HTMLTextAreaElement[];
191+
}
192+
193+
private setupTextareaListeners(textarea: HTMLTextAreaElement) {
194+
if (textarea.dataset.synced) return;
195+
196+
textarea.dataset.synced = 'true';
197+
198+
const handleInput = (event: Event) => {
199+
if (this.isUpdatingTextareas) return;
200+
201+
const target = event.target as HTMLTextAreaElement;
202+
const newValue = target.value;
203+
204+
if (newValue !== this.sharedInputValue) {
205+
this.sharedInputValue = newValue;
206+
this.updateOtherTextareas(target, newValue);
207+
}
208+
};
209+
210+
// paste need delay to ensure the content is fully processed
211+
const handlePaste = (event: ClipboardEvent) => {
212+
if (this.isUpdatingTextareas) return;
213+
214+
const target = event.target as HTMLTextAreaElement;
215+
setTimeout(() => {
216+
const newValue = target.value;
217+
if (newValue !== this.sharedInputValue) {
218+
this.sharedInputValue = newValue;
219+
this.updateOtherTextareas(target, newValue);
220+
}
221+
}, 0);
222+
};
223+
224+
textarea.addEventListener('input', handleInput);
225+
textarea.addEventListener('paste', handlePaste);
226+
227+
this._disposables.add(() => {
228+
textarea.removeEventListener('input', handleInput);
229+
textarea.removeEventListener('paste', handlePaste);
230+
});
231+
}
232+
233+
private updateOtherTextareas(
234+
sourceTextarea: HTMLTextAreaElement,
235+
newValue: string
236+
) {
237+
this.isUpdatingTextareas = true;
238+
239+
const textareas = this.getAllTextareas();
240+
textareas.forEach(textarea => {
241+
if (textarea !== sourceTextarea && textarea.value !== newValue) {
242+
textarea.value = newValue;
243+
this.triggerInputEvent(textarea);
244+
}
245+
});
246+
247+
this.isUpdatingTextareas = false;
248+
}
249+
250+
private triggerInputEvent(textarea: HTMLTextAreaElement) {
251+
const inputEvent = new Event('input', { bubbles: true });
252+
textarea.dispatchEvent(inputEvent);
253+
}
254+
255+
private syncAllSendButtons() {
256+
const sendButtons = this.getAllSendButtons();
257+
sendButtons.forEach(button => {
258+
this.setupSendButtonListener(button);
259+
});
260+
}
261+
262+
private getAllSendButtons(): HTMLElement[] {
263+
return Array.from(
264+
this.querySelectorAll('[data-testid="chat-panel-send"]')
265+
) as HTMLElement[];
266+
}
267+
268+
private setupSendButtonListener(button: HTMLElement) {
269+
if (button.dataset.syncSetup) return;
270+
271+
button.dataset.syncSetup = 'true';
272+
273+
const handleSendClick = async (_event: MouseEvent) => {
274+
if (this.isSending) {
275+
return;
276+
}
277+
this.isSending = true;
278+
try {
279+
await this.triggerOtherSendButtons(button);
280+
} finally {
281+
this.isSending = false;
282+
}
283+
};
284+
285+
button.addEventListener('click', handleSendClick);
286+
287+
this._disposables.add(() => {
288+
button.removeEventListener('click', handleSendClick);
289+
});
290+
}
291+
292+
private async triggerOtherSendButtons(sourceButton: HTMLElement) {
293+
const allSendButtons = this.getAllSendButtons();
294+
const otherButtons = allSendButtons.filter(
295+
button => button !== sourceButton
296+
);
297+
298+
const clickPromises = otherButtons.map(async button => {
299+
try {
300+
const clickEvent = new MouseEvent('click', {
301+
bubbles: true,
302+
cancelable: true,
303+
view: window,
304+
});
305+
306+
button.dispatchEvent(clickEvent);
307+
} catch (error) {
308+
console.error(error);
309+
}
310+
});
311+
312+
await Promise.allSettled(clickPromises);
313+
}
314+
157315
override connectedCallback() {
158316
super.connectedCallback();
159-
this._getSessions().catch(console.error);
317+
this.getSessions().catch(console.error);
318+
this.setupTextareaSync();
160319
}
161320

162321
override render() {
@@ -179,7 +338,7 @@ export class PlaygroundContent extends SignalWatcher(
179338
.extensions=${this.extensions}
180339
.affineFeatureFlagService=${this.affineFeatureFlagService}
181340
.session=${session}
182-
.addChat=${this._addChat}
341+
.addChat=${this.addChat}
183342
></playground-chat>
184343
</div>
185344
`

0 commit comments

Comments
 (0)