diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html
index cd8e542..b229755 100644
--- a/client/src/app/chat-page.component.html
+++ b/client/src/app/chat-page.component.html
@@ -190,7 +190,14 @@
}
@default {
-
{{ entry.text }}
+ @if (entry.showSpinner) {
+
+ } @else {
+ {{ entry.text }}
+ }
}
}
diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss
index ed8f0dd..70800ec 100644
--- a/client/src/app/chat-page.component.scss
+++ b/client/src/app/chat-page.component.scss
@@ -16,6 +16,11 @@
box-shadow: 0 20px 60px var(--shadow-color);
}
+.chat-page {
+ width: min(100%, 95vw);
+ margin-inline: auto;
+}
+
.back-link {
color: var(--link-color);
text-decoration: none;
@@ -297,6 +302,23 @@
display: block;
}
+.bubble-system-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+.bubble-spinner {
+ width: 1rem;
+ height: 1rem;
+ flex: 0 0 auto;
+ border: 0.15rem solid currentColor;
+ border-right-color: transparent;
+ border-radius: 999px;
+ opacity: 0.8;
+ animation: bubble-spin 700ms linear infinite;
+}
+
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -467,6 +489,12 @@
}
}
+@keyframes bubble-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
@media (max-width: 767.98px) {
.chat-layout {
grid-template-columns: 1fr;
diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts
index 4051c42..8a9ccbb 100644
--- a/client/src/app/chat-page.component.ts
+++ b/client/src/app/chat-page.component.ts
@@ -116,7 +116,17 @@ export class ChatPageComponent {
return;
}
- await this.session.requestGeneratedImage(peerId, this.messageText);
+ const requested = await this.session.requestGeneratedImage(peerId, this.messageText);
+
+ if (!requested) {
+ return;
+ }
+
+ this.messageText = '';
+ this.handleMessageTextChange('');
+ this.emojiPickerOpen.set(false);
+ this.composerSelectionStart = 0;
+ this.composerSelectionEnd = 0;
}
handleComposerEnter(event: Event): void {
diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts
index 65cd7e5..72acdca 100644
--- a/client/src/app/chat-session.service.ts
+++ b/client/src/app/chat-session.service.ts
@@ -157,7 +157,10 @@ export class ChatSessionService {
private readonly outgoingTypingIdleTimeouts = new Map();
private readonly outgoingTypingStates = new Map();
private readonly messageStoreOperations = new Map>();
- private readonly pendingImageGenerationRequests = new Map();
+ private readonly pendingImageGenerationRequests = new Map<
+ string,
+ { peerId: string; prompt: string; waitMessageId: string }
+ >();
private readonly remoteVideoStreams = signal>([]);
private readonly activeCameraPeerId = signal(null);
private sessionKeepaliveIntervalId: number | null = null;
@@ -772,33 +775,39 @@ export class ChatSessionService {
}
}
- async requestGeneratedImage(peerId: string, prompt: string): Promise {
+ async requestGeneratedImage(peerId: string, prompt: string): Promise {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
this.error.set('Enter a text prompt before requesting an image.');
- return;
+ return false;
}
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.error.set('You must be connected to signaling before requesting an image.');
- return;
+ return false;
}
const requestId = crypto.randomUUID();
+ const waitMessageId = this.addSystemMessage(peerId, 'Generating image from prompt.', {
+ persistent: true,
+ showSpinner: true,
+ });
this.pendingImageGenerationRequests.set(requestId, {
peerId,
prompt: trimmedPrompt,
+ waitMessageId,
});
this.error.set(null);
- this.addSystemMessage(peerId, 'Generating image from prompt.');
this.websocket.send(JSON.stringify({
type: 'image-generation',
requestId,
peerId,
prompt: trimmedPrompt,
}));
+
+ return true;
}
private async loadAccessKeys(): Promise {
@@ -972,6 +981,10 @@ export class ChatSessionService {
fileMimeType: event.mimeType,
downloadUrl: URL.createObjectURL(imageBlob),
}, imageBlob);
+
+ if (pendingRequest) {
+ this.removeMessageById(pendingRequest.waitMessageId);
+ }
}
private handleGeneratedImageError(event: Extract): void {
@@ -979,6 +992,7 @@ export class ChatSessionService {
if (pendingRequest) {
this.pendingImageGenerationRequests.delete(event.requestId);
+ this.removeMessageById(pendingRequest.waitMessageId);
this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.');
}
@@ -1628,7 +1642,11 @@ export class ChatSessionService {
}
}
- private addSystemMessage(peerId: string, text: string): void {
+ private addSystemMessage(
+ peerId: string,
+ text: string,
+ options?: { persistent?: boolean; showSpinner?: boolean },
+ ): string {
const id = crypto.randomUUID();
this.pushMessage({
@@ -1638,13 +1656,18 @@ export class ChatSessionService {
kind: 'system',
createdAt: Date.now(),
authorLabel: 'System',
+ showSpinner: options?.showSpinner,
text,
});
- const timeoutId = window.setTimeout(() => {
- this.removeMessageById(id);
- }, ChatSessionService.systemMessageLifetimeMs);
- this.systemMessageTimeouts.set(id, timeoutId);
+ if (!options?.persistent) {
+ const timeoutId = window.setTimeout(() => {
+ this.removeMessageById(id);
+ }, ChatSessionService.systemMessageLifetimeMs);
+ this.systemMessageTimeouts.set(id, timeoutId);
+ }
+
+ return id;
}
private isPolitePeer(peerId: string): boolean {
diff --git a/client/src/app/models.ts b/client/src/app/models.ts
index b549d87..b9be44d 100644
--- a/client/src/app/models.ts
+++ b/client/src/app/models.ts
@@ -97,6 +97,7 @@ export interface ChatEntry {
kind: 'text' | 'json' | 'file' | 'system';
createdAt: number;
authorLabel: string;
+ showSpinner?: boolean;
text?: string;
payload?: unknown;
fileName?: string;