frontend: sidebar rework, add in chat

This commit is contained in:
Shivering Ocelot committed 2026-09-26 16:07:31 -05:00
1 parent 1232872360
commit 5b75d95a8c
7 files changed
+317 -270

No files matched your search

+167 -3
View File
@@ -1,10 +1,12 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, watch } from 'vue';
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import Sidebar from './components/main/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import Alerts from './components/main/Alerts.vue' import Alerts from './components/main/Alerts.vue'
import ContextMenu from './components/main/ContextMenu.vue' import ContextMenu from './components/main/ContextMenu.vue'
import * as iconoir from '@iconoir/vue';
import * as ui from './ui'; import * as ui from './ui';
import * as backend from './backend'; import * as backend from './backend';
import * as runs from './runs'; import * as runs from './runs';
@@ -12,6 +14,26 @@ import * as runs from './runs';
// possible states: loading, login, signup, logged // possible states: loading, login, signup, logged
const loginState = ref('loading'); const loginState = ref('loading');
// sidebar state
const sidebarCollapsed = ref(false);
const mode = ref('chats');
const chats = ref([]);
const agents = ref([]);
// View titles use this class for their sidebar interactions
watch(sidebarCollapsed, newValue => {
if (newValue) {
document.body.classList.remove('with-sidebar-expanded');
} else {
document.body.classList.add('with-sidebar-expanded');
}
}, { immediate: true });
// They communicate upwards too
ui.listen('sidebar-expand', ev => {
sidebarCollapsed.value = false;
});
function handleAuthResult (success) { function handleAuthResult (success) {
loginState.value = success ? 'logged' : 'login'; loginState.value = success ? 'logged' : 'login';
} }
@@ -39,6 +61,18 @@ function signupSubmit (formElem) {
backend.signup(username, email, password).then(handleAuthResult); backend.signup(username, email, password).then(handleAuthResult);
} }
// Populate the sidebar nav once the user is logged in
watch(loginState, async state => {
if (state != 'logged') return;
chats.value = (await backend.getChats()).map(it => {
return Object.assign({}, it);
});
agents.value = (await backend.getAgents()).map(it => {
return Object.assign({}, it);
});
});
const runsListener = runs.onCreated(run => { const runsListener = runs.onCreated(run => {
console.log("new run", run); console.log("new run", run);
}); });
@@ -93,7 +127,63 @@ backend.authResult().then(handleAuthResult);
</div> </div>
<template v-if="loginState=='logged'"> <template v-if="loginState=='logged'">
<Sidebar /> <Sidebar align="left" minWidth=150 maxWidth=400 v-model:collapsed="sidebarCollapsed">
<div class="tab-bar">
<div class="tab-group">
<div @click="mode='chats'" class="tab" :class="{active: mode=='chats'}">
<iconoir.Message />
</div>
<div @click="mode='agents'" class="tab" :class="{active: mode=='agents'}">
<iconoir.Group />
</div>
<div @click="mode='settings'" class="tab" :class="{active: mode=='settings'}">
<iconoir.Settings />
</div>
</div>
<div class="tab-group">
<button class="hollow icon" style="margin: auto;" @click="sidebarCollapsed=true;">
<<
</button>
</div>
</div>
<nav v-if="mode=='runs'">
</nav>
<nav v-if="mode=='chats'">
<RouterLink v-for="it in chats"
:to="'/chat/' + it.id"
activeClass="active"
class="nav-item">
<iconoir.Message class="icon" />{{it.name}}
</RouterLink>
<RouterLink
to="/chat/create"
activeClass="active"
class="nav-item">
<iconoir.PlusCircle class="icon" />Create Chat
</RouterLink>
</nav>
<nav v-if="mode=='agents'">
<RouterLink v-for="it in agents"
:to="'/agent/' + it.agentname"
activeClass="active"
class="nav-item">
<iconoir.User class="icon" />{{it.name}}
</RouterLink>
<RouterLink
to="/agent/create"
activeClass="active"
class="nav-item">
<iconoir.PlusCircle class="icon" />Create Agent
</RouterLink>
</nav>
<nav v-if="mode=='settings'">
<RouterLink to="/user" activeClass="active" class="nav-item no-icon">User</RouterLink>
<RouterLink to="/models" activeClass="active" class="nav-item no-icon">Manage Models</RouterLink>
<RouterLink to="/remotes" activeClass="active" class="nav-item no-icon">Remotes</RouterLink>
<RouterLink to="/drives" activeClass="active" class="nav-item no-icon">Drives</RouterLink>
<RouterLink to="/runs" activeClass="active" class="nav-item no-icon">Agent Runs</RouterLink>
</nav>
</Sidebar>
<RouterView /> <RouterView />
</template> </template>
</template> </template>
@@ -118,4 +208,78 @@ form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* Sidebar tabs (slot content, compiled in this component's scope) */
.tab {
display: flex;
justify-content: space-around;
align-items: center;
list-style-type: none;
height: var(--header-height);
width: var(--header-height);
padding-top: 3px;
color: var(--color-text-mute);
/*flex-grow: 1;*/
}
.tab-bar, .tab-group {
display: flex;
flex-direction: row;
padding: 0;
margin: 0;
}
.tab-bar {
justify-content: space-between;
}
.tab.active, .tab:hover {
background: linear-gradient(to bottom, var(--color-surface-strong) 20%, var(--color-surface) 100%);
}
.tab:hover {
color: var(--color-heading);
padding-top: 0;
border-top: 3px solid var(--color-heading);
}
.tab.active {
color: var(--color-highlight);
padding-top: 0;
border-top: 3px solid var(--color-highlight);
}
/* Sidebar nav items */
.nav-item {
display: flex;
text-align: left;
align-items: center;
color: inherit;
background-color: transparent;
border: none;
border-radius: 0;
font-size: inherit;
padding: 0 0.6em;
width: 100%;
height: 3em;
border-left: 3px solid rgba(1,1,1,0);
}
.nav-item .icon {
margin-right: 0.6em;
}
.nav-item:hover {
color: var(--color-heading);
background: linear-gradient(to left, var(--color-surface) 0%, var(--color-surface-strong) 80%);
border-color: var(--color-heading);
}
.nav-item.active {
color: var(--color-highlight);
background: linear-gradient(to left, var(--color-surface) 0%, var(--color-surface-strong) 80%);
border-color: var(--color-highlight);
}
</style> </style>
+1 -1
View File
@@ -42,7 +42,7 @@
--color-surface: var(--color-background-soft); --color-surface: var(--color-background-soft);
--color-surface-strong: var(--color-background-mute); --color-surface-strong: var(--color-background-mute);
--color-surface-input: var(--color-background); --color-surface-input: #1e1e1e;
--color-border: var(--vt-c-divider-dark-2); --color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1); --color-border-hover: var(--vt-c-divider-dark-1);
+10 -10
View File
@@ -13,7 +13,10 @@ main {
overflow: auto; overflow: auto;
} }
main section { .row { display: flex; flex-direction: row; flex: 1; }
.column { display: flex; flex-direction: column; flex: 1; }
main section, .sidebar section {
margin: 1em; margin: 1em;
padding: 1em; padding: 1em;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -21,15 +24,6 @@ main section {
background-color: var(--color-surface); background-color: var(--color-surface);
} }
.main-sidebar {
height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
z-index: var(--z-index-sidebar);
background-color: var(--color-background);
}
.main-header { .main-header {
height: var(--header-height); height: var(--header-height);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@@ -62,6 +56,12 @@ button.hollow:hover {
border: none; border: none;
} }
button.icon {
width: 3em;
height: 3em;
padding: 0;
}
button:hover { button:hover {
color: var(--color-heading); color: var(--color-heading);
border-color: var(--color-border-hover); border-color: var(--color-border-hover);
+108
View File
@@ -0,0 +1,108 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
// The container owns the collapsed state via v-model:collapsed
const collapsed = defineModel('collapsed');
const props = defineProps({
// Which side of the layout the sidebar sits on. Required.
align: {
type: String,
required: true,
validator: (value) => ['left', 'right'].includes(value)
},
minWidth: {
type: Number,
default: 1,
validator: (value) => isFinite(parseNumber(value)),
},
maxWidth: {
type: Number,
default: Infinity,
validator: (value) => isFinite(parseNumber(value)),
},
});
const sidebarWidth = ref(200);
let dragState = null;
onMounted(() => {
document.body.addEventListener('mousemove', documentMouseMove);
document.body.addEventListener('mouseup', documentMouseUp);
});
onUnmounted(() => {
document.body.removeEventListener('mousemove', documentMouseMove);
document.body.removeEventListener('mouseup', documentMouseUp);
});
watch([props.minWidth, sidebarWidth.value], ([min, width]) => {
if (width < min) sidebarWidth.value = min;
}, {immediate: true});
function dragBarMouseDown (event) {
dragState = {x: event.clientX, y: event.clientY, width: sidebarWidth.value};
event.preventDefault();
event.stopPropagation();
}
function documentMouseMove (event) {
if (dragState) {
let widthDiff = event.clientX - dragState.x;
// A right-aligned sidebar grows when dragging towards the left.
if (props.align === 'right') widthDiff = -widthDiff;
let newWidth = dragState.width + widthDiff;
newWidth = Math.min(props.maxWidth, Math.max(props.minWidth, newWidth));
sidebarWidth.value = newWidth;
}
}
function documentMouseUp (event) {
if (dragState) {
dragState = null;
}
}
function sidebarStyle () {
return {
flex: `0 0 ${sidebarWidth.value}px`
}
}
</script>
<template>
<aside class="sidebar" :style="sidebarStyle()" v-if="!collapsed">
<div :class="['drag-bar', align]" @mousedown="dragBarMouseDown($event)"></div>
<slot></slot>
</aside>
</template>
<style scoped>
.sidebar {
background-color: var(--color-surface);
position: relative;
}
.drag-bar {
height: 100%;
border-right: 1px solid var(--color-border);
width: 0.5em;
position: absolute;
cursor: ew-resize;
}
.drag-bar.left { right: 0; }
.drag-bar.right { left: 0; }
/* Adapt sidebar to small screens */
@media (max-width: 480px) {
.sidebar {
position: absolute;
width: 100%;
height: 100%;
}
.drag-bar {
display: none;
}
}
</style>
+1 -8
View File
@@ -8,7 +8,7 @@
<template> <template>
<header class="main-header"> <header class="main-header">
<button class="header-button when-sidebar-collapsed" @click="sidebarExpand()"> <button class="hollow icon when-sidebar-collapsed" @click="sidebarExpand()">
>> >>
<!--i class="fa-solid fa-angles-right"></i--> <!--i class="fa-solid fa-angles-right"></i-->
</button> </button>
@@ -30,11 +30,4 @@
.main-header { .main-header {
background-color: var(--color-surface); background-color: var(--color-surface);
} }
.header-button {
height: var(--header-height);
width: var(--header-height);
background-color: none;
border: none;
}
</style> </style>
-228
View File
@@ -1,228 +0,0 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import {
ref, computed, reactive, watch,
onMounted, onUnmounted,
} from "vue";
import * as backend from '../../backend';
import * as ui from '../../ui';
import * as iconoir from '@iconoir/vue';
const chats = ref([]);
const agents = ref([]);
const mode = ref('chats');
const sidebarExpanded = ref(true);
const MIN_WIDTH = 150;
const MAX_WIDTH = 400;
const sidebarWidth = ref(200);
let dragState = null;
watch(sidebarExpanded, newValue => {
if (newValue) {
document.body.classList.add('with-sidebar-expanded');
} else {
document.body.classList.remove('with-sidebar-expanded');
}
}, {immediate: true});
ui.listen('sidebar-expand', ev => {
sidebarExpanded.value = true;
});
onMounted(async () => {
document.body.addEventListener('mousemove', documentMouseMove);
document.body.addEventListener('mouseup', documentMouseUp);
chats.value = (await backend.getChats()).map(it => {
return Object.assign({}, it);
});
agents.value = (await backend.getAgents()).map(it => {
return Object.assign({}, it);
});
});
onUnmounted(() => {
document.body.removeEventListener('mousemove', documentMouseMove);
document.body.removeEventListener('mouseup', documentMouseUp);
});
function dragBarMouseDown (event) {
dragState = {x: event.clientX, y: event.clientY, width: sidebarWidth.value};
event.preventDefault();
event.stopPropagation();
console.log("Set Drag State", dragState);
}
function documentMouseMove (event) {
if (dragState) {
let widthDiff = event.clientX - dragState.x;
let newWidth = dragState.width + widthDiff;
newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, newWidth));
sidebarWidth.value = newWidth;
}
}
function documentMouseUp (event) {
if (dragState) {
dragState = null;
}
}
function sidebarStyle () {
return {
flex: `0 0 ${sidebarWidth.value}px`
}
}
</script>
<template>
<aside class="main-sidebar" :style="sidebarStyle()" v-if="sidebarExpanded">
<div class="drag-bar" @mousedown="dragBarMouseDown($event)"></div>
<div class="tab-bar">
<div class="tab-group">
<div @click="mode='chats'" class="tab" :class="{active: mode=='chats'}">
<iconoir.Message />
</div>
<div @click="mode='agents'" class="tab" :class="{active: mode=='agents'}">
<iconoir.Group />
</div>
<div @click="mode='settings'" class="tab" :class="{active: mode=='settings'}">
<iconoir.Settings />
</div>
</div>
<div class="tab-group">
<button class="header-button" @click="sidebarExpanded=false;">
<!--i class="fa-solid fa-angles-left"></i-->
<<
</button>
</div>
</div>
<nav v-if="mode=='runs'">
</nav>
<nav v-if="mode=='chats'">
<RouterLink v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink>
<RouterLink to="/chat/create" class="nav-item">Create Chat</RouterLink>
</nav>
<nav v-if="mode=='agents'">
<RouterLink v-for="it in agents" :to="'/agent/' + it.agentname" class="nav-item">{{it.name}}</RouterLink>
<RouterLink to="/agent/create" class="nav-item">Create Agent</RouterLink>
</nav>
<nav v-if="mode=='settings'">
<RouterLink to="/user" class="nav-item">User</RouterLink>
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
<RouterLink to="/remotes" class="nav-item">Remotes</RouterLink>
<RouterLink to="/drives" class="nav-item">Drives</RouterLink>
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
</nav>
</aside>
</template>
<style scoped>
.main-sidebar {
background-color: var(--color-surface);
}
.tab-bar, .tab-group {
display: flex;
flex-direction: row;
padding: 0;
margin: 0;
}
.tab-bar {
justify-content: space-between;
}
.tab {
display: flex;
justify-content: space-around;
align-items: center;
list-style-type: none;
height: var(--header-height);
width: var(--header-height);
padding-top: 3px;
color: var(--color-text-mute);
/*flex-grow: 1;*/
}
.tab.active, .tab:hover {
background: linear-gradient(to bottom, var(--color-surface-strong) 20%, var(--color-surface) 100%);
}
.tab:hover {
color: var(--color-heading);
padding-top: 0;
border-top: 3px solid var(--color-heading);
}
.tab.active {
color: var(--color-highlight);
padding-top: 0;
border-top: 3px solid var(--color-highlight);
}
.nav-item {
display: block;
color: inherit;
background-color: transparent;
border: none;
border-radius: 0;
padding: 0.2em 0.4em;
width: 100%;
text-align: left;
vertical-align: middle;
font-size: inherit;
}
.nav-item:hover {
color: var(--color-heading);
background-color: var(--color-surface-strong);
}
.nav-item i {
font-size: 80%;
padding-left: 0.5em;
padding-right: 0.5em;
}
.drag-bar {
height: 100%;
border-right: 1px solid var(--color-border);
width: 0.5em;
position: absolute;
right: 0;
cursor: ew-resize;
}
/* Adapt sidebar to small screens */
@media (max-width: 480px) {
.main-sidebar {
position: absolute;
width: 100%;
height: 100%;
}
.drag-bar {
display: none;
}
}
.header-button {
height: var(--header-height);
width: var(--header-height);
/*border-radius: 1.5em;*/
background-color: none;
border: none;
}
.header-button:hover {
background-color: var(--color-background-hover);
}
</style>
+30 -20
View File
@@ -6,6 +6,7 @@
import TitleBar from '../components/TitleBar.vue'; import TitleBar from '../components/TitleBar.vue';
import Message from '../components/Message.vue'; import Message from '../components/Message.vue';
import Sidebar from '../components/Sidebar.vue'
import * as backend from '../backend'; import * as backend from '../backend';
import * as ui from '../ui'; import * as ui from '../ui';
@@ -35,6 +36,7 @@
const expandedLogs = ui.useToggleSet(); const expandedLogs = ui.useToggleSet();
const inputFieldElem = ref(null); const inputFieldElem = ref(null);
const chatContainerElem = ref(null); const chatContainerElem = ref(null);
const sidebarCollapsed = ref(false);
let _isMounted = false; let _isMounted = false;
@@ -194,12 +196,13 @@
<main> <main>
<TitleBar> <TitleBar>
<h1>{{ chat.name || 'Chat #' + chat.id }}</h1> <h1>{{ chat.name || 'Chat #' + chat.id }}</h1>
<div> <button class="hollow icon" style="margin-left: auto;" @click="sidebarCollapsed=!sidebarCollapsed">
<button @click="renameChat(chat)">Rename</button> <iconoir.Settings />
<button @click="deleteChat(chat)">Delete</button> </button>
</div>
</TitleBar> </TitleBar>
<div class="row" style="overflow: auto;">
<div class="column">
<div class="messages-container" ref="chatContainerElem"> <div class="messages-container" ref="chatContainerElem">
<div class="message-container" v-for="msg in chatEntries"> <div class="message-container" v-for="msg in chatEntries">
<Message <Message
@@ -229,33 +232,46 @@
<div class="input-row"> <div class="input-row">
<div> <div>
<button class="hollow" :class="{primary: creatingThread}" @click="newThreadClick()"> <button class="hollow icon" :class="{primary: creatingThread}" @click="newThreadClick()">
<iconoir.ChatPlusInSolid v-if='creatingThread' /> <iconoir.ChatPlusInSolid v-if='creatingThread' />
<iconoir.ChatPlusIn v-else /> <iconoir.ChatPlusIn v-else />
</button> </button>
<button class="hollow" :class="{primary: noteMode}" @click="noteMode=!noteMode"> <button class="hollow icon" :class="{primary: noteMode}" @click="noteMode=!noteMode">
<iconoir.PostSolid v-if='noteMode' /> <iconoir.PostSolid v-if='noteMode' />
<iconoir.Post v-else /> <iconoir.Post v-else />
</button> </button>
<button class="hollow" @click="sendPicture()"> <button class="hollow icon" @click="sendPicture()">
<iconoir.Camera /> <iconoir.Camera />
</button> </button>
<button class="hollow" @click="uploadFile()"> <button class="hollow icon" @click="uploadFile()">
<iconoir.Attachment /> <iconoir.Attachment />
</button> </button>
</div> </div>
<div> <div>
<button class="hollow" @click="addRemoteResource()"> <button class="hollow icon primary" id="send-button" @click="sendMessage()">
Add Remote
</button>
</div>
<div>
<button class="hollow primary" id="send-button" @click="sendMessage()">
<iconoir.SendDiagonalSolid width="3em" height="3em" /> <iconoir.SendDiagonalSolid width="3em" height="3em" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<Sidebar align="right" minWidth=300 maxWidth=800 v-model:collapsed="sidebarCollapsed">
<section>
<h3>Settings</h3>
<div>
<button @click="renameChat(chat)">Rename</button>
<button @click="deleteChat(chat)">Delete</button>
</div>
</section>
<section>
<h3>Remotes</h3>
<button @click="addRemoteResource()">
Add Remote to Current Thread
</button>
</section>
</Sidebar>
</div>
</main> </main>
</template> </template>
@@ -320,12 +336,6 @@
align-items: end; align-items: end;
} }
.input-row button {
width: 4em;
height: 4em;
padding: 0;
}
#send-button { #send-button {
width: 5em; width: 5em;
height: 4em; height: 4em;