Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/assets/api/schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,9 @@
}
]
}
},
"isTurnBased": {
"type": "boolean"
}
}
},
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/chat/chat_input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,21 @@
pr-textarea {
flex-grow: 1;
}

&.disabled {
background: var(--md-sys-color-surface-container-low, #f5f5f5);
opacity: 0.7;
cursor: not-allowed;
border-color: var(--md-sys-color-outline-variant);

pr-textarea {
cursor: not-allowed;
pointer-events: none;
}

pr-icon-button {
cursor: not-allowed;
pointer-events: none;
}
}
}
19 changes: 10 additions & 9 deletions frontend/src/components/chat/chat_input.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {observable} from 'mobx';
import {MobxLitElement} from '@adobe/lit-mobx';

import {CSSResultGroup, html, nothing} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {CSSResultGroup, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';

import {core} from '../../core/core';
import {ParticipantAnswerService} from '../../services/participant.answer';
Expand Down Expand Up @@ -40,6 +38,7 @@ export class ChatInputComponent extends MobxLitElement {
};
@property() isDisabled = false;
@property() isLoading = false;
@state() hasFocusedOnce = false;

override render() {
const sendInput = async () => {
Expand Down Expand Up @@ -73,18 +72,20 @@ export class ChatInputComponent extends MobxLitElement {
}
};

const autoFocus = () => {
// Only auto-focus chat input if on desktop
return navigator.maxTouchPoints === 0;
const shouldFocus = () => {
if (this.hasFocusedOnce || this.isDisabled) return false;
if (navigator.maxTouchPoints > 0) return false; // mobile
this.hasFocusedOnce = true;
return true;
};

return html`
<div class="input">
<div class="input ${this.isDisabled ? 'disabled' : ''}">
<pr-textarea
size="small"
placeholder="Send message"
.value=${this.getUserInput()}
?focused=${autoFocus()}
?focused=${shouldFocus()}
?disabled=${this.isDisabled}
@keydown=${handleKeyDown}
@input=${handleInput}
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/components/chat/chat_interface.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,99 @@ chat-input {
padding-top: 0;
position: sticky;
}

.banner {
@include typescale.label-large;
border-radius: 8px;
margin: common.$spacing-medium common.$spacing-xl;
padding: common.$spacing-medium common.$spacing-large;
text-align: center;

&.success {
background: var(--md-sys-color-success);
color: #1b3224; // Dark premium green readable on light green
font-weight: 500;
}

&.warning {
background: #fef9e7;
border: 1px solid #f7dc6f;
color: #7d6608; // High-contrast dark yellow readable on pale yellow
font-weight: 500;
}
}

.typing-msg {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: common.$spacing-large;
width: 100%;
padding: common.$spacing-medium 0;

.content {
display: flex;
flex-direction: column;
gap: common.$spacing-small;
align-items: flex-start;
}

.label {
@include typescale.label-medium;
color: var(--md-sys-color-on-surface-variant);
}

.typing-bubble {
background: var(--md-sys-color-secondary-container);
border-radius: 20px 20px 20px 8px;
padding: 12px 18px;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
}
}

.typing-dots {
display: flex;
align-items: center;
gap: 4px;
height: 8px;

span {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--md-sys-color-on-secondary-container);
opacity: 0.4;
animation: typingBlink 1.4s infinite both;

&:nth-child(2) {
animation-delay: 0.2s;
}

&:nth-child(3) {
animation-delay: 0.4s;
}
}
}

@keyframes typingBlink {
0% {
opacity: 0.4;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-4px);
}
60% {
opacity: 0.4;
transform: translateY(0);
}
100% {
opacity: 0.4;
transform: translateY(0);
}
}
170 changes: 155 additions & 15 deletions frontend/src/components/chat/chat_interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,33 @@ import '../chat/chat_input';
import '../chat/chat_message';

import {MobxLitElement} from '@adobe/lit-mobx';
import {computed} from 'mobx';
import {CSSResultGroup, html, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';

import {StageConfig, StageKind} from '@deliberation-lab/utils';
import {
ChatStageConfig,
ChatStagePublicData,
StageConfig,
StageKind,
} from '@deliberation-lab/utils';
import {core} from '../../core/core';
import {AuthService} from '../../services/auth.service';
import {CohortService} from '../../services/cohort.service';
import {ParticipantService} from '../../services/participant.service';

import {styles} from './chat_interface.scss';
import {getHashBasedColor, getProfileBasedColor} from '../../shared/utils';

/** Chat interface component */
@customElement('chat-interface')
export class ChatInterface extends MobxLitElement {
static override styles: CSSResultGroup = [styles];

private readonly cohortService = core.getService(CohortService);
private readonly participantService = core.getService(ParticipantService);
private readonly authService = core.getService(AuthService);

@property({type: Object}) stage: StageConfig | undefined = undefined;
@property({type: Boolean}) showPanel = false;
@property({type: Boolean}) showInput = true;
Expand Down Expand Up @@ -46,38 +61,163 @@ export class ChatInterface extends MobxLitElement {
`;
}

@computed get stagePublicData() {
if (!this.stage || this.stage.kind !== StageKind.CHAT) return null;
return this.cohortService.stagePublicDataMap[this.stage.id] as
| ChatStagePublicData
| undefined;
}

@computed get isMyTurn() {
if (!this.stage || this.stage.kind !== StageKind.CHAT) return true;
const config = this.stage as ChatStageConfig;
if (!config.isTurnBased) return true;

const data = this.stagePublicData;
// If turn-based but public data is not loaded yet, lock the input to prevent race conditions
if (!data || !data.currentTurnParticipantId) return false;

// AI Agents cannot speak inside the browser UI
if (this.participantService.profile?.agentConfig) return false;

return (
data.currentTurnParticipantId ===
this.participantService.profile?.publicId
);
}

@computed get currentTurnName() {
const data = this.stagePublicData;
if (!data || !data.currentTurnParticipantId) return '';

const participantProfile =
this.cohortService.participantMap[data.currentTurnParticipantId];
if (participantProfile && participantProfile.name)
return participantProfile.name;

const mediatorProfile =
this.cohortService.mediatorMap[data.currentTurnParticipantId];
if (mediatorProfile && mediatorProfile.name) return mediatorProfile.name;

return data.currentTurnParticipantId;
}

@computed get currentTurnProfile() {
const data = this.stagePublicData;
if (!data || !data.currentTurnParticipantId) return null;

const participantProfile =
this.cohortService.participantMap[data.currentTurnParticipantId];
if (participantProfile && participantProfile.name) {
return {
name: participantProfile.name,
avatar: participantProfile.avatar,
isMediator: false,
id: data.currentTurnParticipantId,
};
}

const mediatorProfile =
this.cohortService.mediatorMap[data.currentTurnParticipantId];
if (mediatorProfile && mediatorProfile.name) {
return {
name: mediatorProfile.name,
avatar: mediatorProfile.avatar ?? '🤖',
isMediator: true,
id: data.currentTurnParticipantId,
};
}

return {
name: data.currentTurnParticipantId,
avatar: '👤',
isMediator: false,
id: data.currentTurnParticipantId,
};
}

private renderTypingIndicator() {
if (this.stage?.kind !== StageKind.CHAT) return nothing;
const stage = this.stage as ChatStageConfig;
if (!stage.isTurnBased) return nothing;

// Do not show typing indicator if it is the current user's turn (they type in the textarea instead!)
if (this.isMyTurn) return nothing;

const profile = this.currentTurnProfile;
if (!profile) return nothing;

const color = profile.isMediator
? getHashBasedColor(profile.id)
: getProfileBasedColor(profile.id, profile.avatar ?? '');

return html`
<div class="chat-message typing-msg">
<avatar-icon .emoji=${profile.avatar} .color=${color}> </avatar-icon>
<div class="content">
<div class="label">${profile.name}</div>
<div class="chat-bubble typing-bubble">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
`;
}

override render() {
if (!this.stage) return nothing;
return html`
<div class="interface-wrapper ${this.mobileView ? 'vertical' : ''}">
${this.showPanel ? this.renderPanel() : nothing}
<div class="main-content">
<div class="chat-content">
${this.renderTurnBanner()}
<div class="chat-scroll">
<div class="chat-history">
${
this.mobileView
? html`<slot name="mobile-description"></slot>`
: nothing
}
${this.mobileView
? html`<slot name="mobile-description"></slot>`
: nothing}
<slot></slot>
${this.renderTypingIndicator()}
</div>
</div>
</div>
<slot name="indicators"></slot>
${
!this.showInput
? nothing
: html`<chat-input
.stageId=${this.stage?.id ?? ''}
.isDisabled=${this.disableInput}
></chat-input>`
}
</chat-input>
${!this.showInput
? nothing
: html`<chat-input
.stageId=${this.stage?.id ?? ''}
.isDisabled=${this.disableInput || !this.isMyTurn}
></chat-input>`}
</div>
</div>
`;
}

private renderTurnBanner() {
if (this.stage?.kind !== StageKind.CHAT) return nothing;
const stage = this.stage as ChatStageConfig;
if (!stage.isTurnBased) return nothing;

const isMyTurn = this.isMyTurn;

if (isMyTurn) {
return html` <div class="banner success">It's your turn to speak!</div> `;
}

const currentSpeaker = this.currentTurnName;
if (!currentSpeaker) return nothing;

return html`
<div class="banner warning">
Waiting for <strong>${currentSpeaker}</strong> to speak...
</div>
`;
}
}

declare global {
Expand Down
Loading