Chat View and Sidebar chat list
This commit is contained in:
1 parent
5b59d3df75
commit
73fe8a95d4
7 files changed
+350
-39
No files matched your search
@@ -29,6 +29,16 @@ def start_server(app):
|
|||||||
async def index():
|
async def index():
|
||||||
return await send_from_directory(server.static_folder, 'index.html')
|
return await send_from_directory(server.static_folder, 'index.html')
|
||||||
|
|
||||||
|
# These paths must match the routes in the frontend SPA
|
||||||
|
# file: frontend/src/routes/index.js
|
||||||
|
@server.route('/chat')
|
||||||
|
@server.route('/agent')
|
||||||
|
@server.route('/chat/<path:subpath>')
|
||||||
|
@server.route('/agent/<path:subpath>')
|
||||||
|
async def serve_spa_routes(subpath=None):
|
||||||
|
# Ignore subpath and render index, Vue router will take care of everything
|
||||||
|
return await send_from_directory(server.static_folder, 'index.html')
|
||||||
|
|
||||||
@server.route('/<path:path>')
|
@server.route('/<path:path>')
|
||||||
async def serve_static(path):
|
async def serve_static(path):
|
||||||
return await send_from_directory(server.static_folder, path)
|
return await send_from_directory(server.static_folder, path)
|
||||||
|
|||||||
@@ -23,15 +23,15 @@
|
|||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
:root {
|
:root {
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-black);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
@@ -40,22 +40,23 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-green: #00bd7e;
|
--color-green: #00bd7e;
|
||||||
--color-green-dark: #003825;
|
--color-green-dark: #003825;
|
||||||
|
--color-blue: #3d8de1;
|
||||||
|
|
||||||
--border-radius: 6px;
|
--border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
--color-background: var(--vt-c-black);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
*[hidden] { display: none !important; }
|
*[hidden] { display: none !important; }
|
||||||
|
|
||||||
a,
|
a {
|
||||||
.green {
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--color-green);
|
color: var(--color-blue);
|
||||||
transition: 0.4s;
|
transition: 0.4s;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -25,8 +25,8 @@ let loginTokenDeferred = createDeferred();
|
|||||||
let authDeferred = createDeferred();
|
let authDeferred = createDeferred();
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const pendingActions = new Map(); // Maps nonce to { resolve, reject }
|
const pendingActions = {}; // Maps nonce to { resolve, reject }
|
||||||
const chatListeners = new Map(); // Maps chatId to an array of callback functions
|
const chatListeners = {}; // Maps chatId to an array of callback functions
|
||||||
const orphanHandlers = []; // Queue of callbacks for orphan server messages
|
const orphanHandlers = []; // Queue of callbacks for orphan server messages
|
||||||
|
|
||||||
function createDeferred() {
|
function createDeferred() {
|
||||||
@@ -120,9 +120,9 @@ export function sendAction(action, body = {}) {
|
|||||||
const nonce = nextNonce++;
|
const nonce = nextNonce++;
|
||||||
const deferred = createDeferred();
|
const deferred = createDeferred();
|
||||||
|
|
||||||
pendingActions.set(nonce, deferred);
|
pendingActions[nonce] = deferred;
|
||||||
deferred.promise.finally(() => {
|
deferred.promise.finally(() => {
|
||||||
pendingActions.delete(nonce); // Clean up
|
delete pendingActions[nonce]; // Clean up
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
@@ -156,7 +156,7 @@ function handleIncomingMessage(rawData) {
|
|||||||
|
|
||||||
if (packet.nonce != null) {
|
if (packet.nonce != null) {
|
||||||
const nonce = packet.nonce;
|
const nonce = packet.nonce;
|
||||||
const actionPromise = pendingActions.get(nonce);
|
const actionPromise = pendingActions[nonce];
|
||||||
if (actionPromise) {
|
if (actionPromise) {
|
||||||
if (packet.error) {
|
if (packet.error) {
|
||||||
actionPromise.reject(new Error(packet.error));
|
actionPromise.reject(new Error(packet.error));
|
||||||
@@ -168,7 +168,7 @@ function handleIncomingMessage(rawData) {
|
|||||||
}
|
}
|
||||||
} else if (packet.action === 'chat-message') {
|
} else if (packet.action === 'chat-message') {
|
||||||
const chatId = packet.chatId;
|
const chatId = packet.chatId;
|
||||||
const listeners = chatListeners.get(chatId);
|
const listeners = chatListeners[chatId];
|
||||||
if (listeners) {
|
if (listeners) {
|
||||||
listeners.forEach(callback => {
|
listeners.forEach(callback => {
|
||||||
try {
|
try {
|
||||||
@@ -213,15 +213,15 @@ async function postRequest(path, body) {
|
|||||||
// --- Data Management ---
|
// --- Data Management ---
|
||||||
|
|
||||||
// Returns a function to unsubscribe
|
// Returns a function to unsubscribe
|
||||||
export function chatMessages(chatId, callback) {
|
export function onChatMessages(chatId, callback) {
|
||||||
if (typeof callback !== 'function') {
|
if (typeof callback !== 'function') {
|
||||||
throw new TypeError("chatMessages: callback must be a function.");
|
throw new TypeError("chatMessages: callback must be a function.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chatListeners.has(chatId)) {
|
if (!chatListeners[chatId]) {
|
||||||
chatListeners.set(chatId, []);
|
chatListeners[chatId] = [];
|
||||||
}
|
}
|
||||||
const listeners = chatListeners.get(chatId);
|
const listeners = chatListeners[chatId];
|
||||||
listeners.push(callback);
|
listeners.push(callback);
|
||||||
|
|
||||||
// Return an unsubscribe function
|
// Return an unsubscribe function
|
||||||
@@ -231,7 +231,7 @@ export function chatMessages(chatId, callback) {
|
|||||||
listeners.splice(index, 1);
|
listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
if (listeners.length === 0) {
|
if (listeners.length === 0) {
|
||||||
chatListeners.delete(chatId);
|
delete chatListeners[chatId];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,8 +239,8 @@ export function chatMessages(chatId, callback) {
|
|||||||
export async function getChats() {
|
export async function getChats() {
|
||||||
if (cache.chats === null) {
|
if (cache.chats === null) {
|
||||||
const response = await sendAction("get-chats", {});
|
const response = await sendAction("get-chats", {});
|
||||||
if (response && response.cache.chats) {
|
if (response && response.chats) {
|
||||||
cache.chats = response.cache.chats.map(msg => new ChatInfo({
|
cache.chats = response.chats.map(msg => new ChatInfo({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
name: msg.name?.length > 0 ? msg.name : `[Chat ${msg.id}]`,
|
name: msg.name?.length > 0 ? msg.name : `[Chat ${msg.id}]`,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,24 +5,40 @@
|
|||||||
onMounted, onUnmounted,
|
onMounted, onUnmounted,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
|
|
||||||
//import * as horde from '../../horde';
|
import * as backend from '../../backend';
|
||||||
import * as ui from '../../ui';
|
import * as ui from '../../ui';
|
||||||
|
|
||||||
|
const collapsed = ref(false);
|
||||||
|
const mode = ref('chats');
|
||||||
|
const chats = ref([]);
|
||||||
|
const agents = ref([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
chats.value = (await backend.getChats()).map(entry => {
|
||||||
|
return {id: entry.id, name: entry.name};
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="nav-button expand-navbar" v-if="collapsed" @click="collapsed = false;">
|
<button class="nav-button expand-navbar" v-if="collapsed" @click="collapsed=false;">
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<aside class="sidebar" style="min-width:8em" v-else>
|
<aside class="sidebar" style="min-width:8em" v-else>
|
||||||
<div class="drag-bar"></div>
|
<div class="drag-bar"></div>
|
||||||
<ul class="tab-bar">
|
<ul class="tab-bar">
|
||||||
<li><i class="fa-solid fa-message"></i></li>
|
<li @click="mode='chats'" :class="{active: mode=='chats'}">
|
||||||
<li><i class="fa-solid fa-user-group"></i></li>
|
<i class="fa-solid fa-message"></i>
|
||||||
|
</li>
|
||||||
|
<li @click="mode='agents'" :class="{active: mode=='agents'}">
|
||||||
|
<i class="fa-solid fa-user-group"></i>
|
||||||
|
</li>
|
||||||
<li><i class="fa-solid fa-angles-left"></i></li>
|
<li><i class="fa-solid fa-angles-left"></i></li>
|
||||||
</ul>
|
</ul>
|
||||||
<nav>
|
<nav>
|
||||||
<RouterLink to="/" class="nav-item">Home</RouterLink>
|
<RouterLink v-if="mode=='chats'" v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink>
|
||||||
<RouterLink to="/about" class="nav-item">About</RouterLink>
|
|
||||||
|
<RouterLink v-if="mode=='agents'" v-for="it in agents" :to="'/agent/' + it.id" class="nav-item">{{it.name}}</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
@@ -47,20 +63,24 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
border-radius: var(--border-radius);
|
||||||
width: 3em;
|
width: 3em;
|
||||||
height: 3em;
|
height: 3em;
|
||||||
transition: background-color 0.5s;
|
transition: background-color 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-bar li:hover {
|
.tab-bar li:hover {
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar li.active {
|
||||||
background-color: var(--color-background-soft);
|
background-color: var(--color-background-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
color: var(--color-green);
|
color: inherit;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
|
|
||||||
|
function objectView (name, mod) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: `/${name}/:id`,
|
||||||
|
props: true,
|
||||||
|
component: mod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These routers must be handled by the server by rendering the index page
|
||||||
|
// file: backend/server.py
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -17,6 +28,7 @@ const router = createRouter({
|
|||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/AboutView.vue'),
|
component: () => import('../views/AboutView.vue'),
|
||||||
},
|
},
|
||||||
|
objectView('chat', () => import('../views/Chat.vue')),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref, computed, reactive,
|
||||||
|
onMounted, onUnmounted,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
import * as backend from '../backend';
|
||||||
|
import * as ui from '../ui';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chat State
|
||||||
|
const chat = ref({});
|
||||||
|
const messages = ref([]);
|
||||||
|
const currentThreadId = ref(null);
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int>
|
||||||
|
const editingMessageId = ref(null);
|
||||||
|
const noteMode = ref(false);
|
||||||
|
|
||||||
|
let unsubscribeChat = null;
|
||||||
|
|
||||||
|
|
||||||
|
// === Chat Functionality === //
|
||||||
|
|
||||||
|
const inputFieldElem = ref(null);
|
||||||
|
const chatContainerElem = ref(null);
|
||||||
|
|
||||||
|
async function getChatMessages() {
|
||||||
|
let response = await backend.sendAction("get-chat-messages", {'chatId': props.id});
|
||||||
|
let messageObjects = response['messages'];
|
||||||
|
return messageObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage (message) {
|
||||||
|
requestRescroll();
|
||||||
|
messages.value.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage () {
|
||||||
|
let elem = inputFieldElem.value;
|
||||||
|
|
||||||
|
let content = elem.innerText;
|
||||||
|
elem.innerText = "";
|
||||||
|
|
||||||
|
if (!content.trim()) return; // Don't send empty messages
|
||||||
|
|
||||||
|
let threadId = currentThreadId.value;
|
||||||
|
let messageId = editingMessageId.value;
|
||||||
|
let isNote = noteMode.value;
|
||||||
|
|
||||||
|
if (messageId == null) {
|
||||||
|
backend.sendAction("send-chat-message", {
|
||||||
|
'chatId': props.id,
|
||||||
|
'content': content,
|
||||||
|
'threadId': threadId,
|
||||||
|
'kind': isNote ? 'note' : null,
|
||||||
|
}).then((response) => {
|
||||||
|
if (response['error']) {
|
||||||
|
print("Error sending chat message: ${response['error']}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newMessage = {
|
||||||
|
type: isNote ? 'note' : 'message',
|
||||||
|
sender: 'user',
|
||||||
|
content,
|
||||||
|
threadId,
|
||||||
|
};
|
||||||
|
addMessage(newMessage);
|
||||||
|
} else {
|
||||||
|
const messageRef = messages.firstWhere((element) => element.id == messageId);
|
||||||
|
|
||||||
|
backend.sendAction("update-chat-message", {
|
||||||
|
'messageId': messageId,
|
||||||
|
'content': content,
|
||||||
|
'kind': isNote ? 'note' : null,
|
||||||
|
}).then((response) => {
|
||||||
|
if (response['error']) {
|
||||||
|
print("Error updating message: ${response['error']}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
messageRef.type = isNote ? 'note' : 'message';
|
||||||
|
messageRef.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNote) noteMode.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// === UI Helper Functions === //
|
||||||
|
|
||||||
|
function requestRescroll () {
|
||||||
|
const elem = chatContainerElem.value;
|
||||||
|
|
||||||
|
const scroll = elem.scrollTop;
|
||||||
|
const max = elem.scrollTopMax;
|
||||||
|
|
||||||
|
// Rescroll in 2 frames
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
// Chat was fully scrolled down
|
||||||
|
if (scroll == max) {
|
||||||
|
elem.scrollTop = elem.scrollTopMax;
|
||||||
|
} else {
|
||||||
|
// Do nothing, scroll remains in place
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Something different has to be done when previous messages are added
|
||||||
|
}
|
||||||
|
|
||||||
|
function msgClass (msg) {
|
||||||
|
if (msg.sender == 'user') {
|
||||||
|
return ['user']
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputKeyDown (event) {
|
||||||
|
if (event.key == "Enter" && event.ctrlKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Initialize === //
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
requestRescroll();
|
||||||
|
|
||||||
|
messages.value = (await getChatMessages()).map(it => {
|
||||||
|
return {
|
||||||
|
type: it.kind,
|
||||||
|
sender: it.sender,
|
||||||
|
content: it.content,
|
||||||
|
threadId: it.threadId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unsubscribeChat) unsubscribeChat();
|
||||||
|
unsubscribeChat = backend.onChatMessages(props.id, it => {
|
||||||
|
if (it.sender == 'user') return;
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
type: it.kind,
|
||||||
|
sender: it.sender,
|
||||||
|
content: it.content,
|
||||||
|
threadId: it.threadId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (unsubscribeChat) {
|
||||||
|
unsubscribeChat();
|
||||||
|
unsubscribeChat = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
|
||||||
|
|
||||||
|
<div class="messages-container" ref="chatContainerElem">
|
||||||
|
<div v-for="msg in messages" class="message" :class="msgClass(msg)">
|
||||||
|
<div v-if="msg.type == 'message'" class="sender">{{msg.sender}}</div>
|
||||||
|
|
||||||
|
<!--message v-if="!msg.collapse" :value="msg.content" :format="msg.format"></message-->
|
||||||
|
<div v-if="!msg.collapse" class="content">{{msg.content}}</div>
|
||||||
|
|
||||||
|
<div v-if="msg.collapse">
|
||||||
|
<a href="#" @click.prevent="msg.collapse = false">Show</a>
|
||||||
|
</div>
|
||||||
|
<div v-if="msg.collapse === false">
|
||||||
|
<a href="#" @click.prevent="msg.collapse = true">Hide</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-container">
|
||||||
|
<div class="input-field" ref="inputFieldElem" @keydown="inputKeyDown($event)" contenteditable></div>
|
||||||
|
|
||||||
|
<div class="input-row">
|
||||||
|
<button id="send-button" @click="sendMessage()">
|
||||||
|
Send
|
||||||
|
<span style="font-size: 66%;">(Ctrl+Enter)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .content {
|
||||||
|
white-space: preserve
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .sender {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.user .content {
|
||||||
|
right: 100%;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.input-container {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: var(--color-background-soft);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
white-space: preserve;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:empty::before {
|
||||||
|
opacity: 50%;
|
||||||
|
content: "Type your message...";
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#send-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
height: min-content;
|
||||||
|
|
||||||
|
/* align-self: end; /* for flex rows */
|
||||||
|
float: right; /* for flow rows */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user