Add the agent scaffold, mobile web UI, voice auto-leave, and text notifications

This commit is contained in:
Fisto
2026-09-14 16:38:14 +02:00
parent c7aad7547f
commit eab94132bb
27 changed files with 1521 additions and 73 deletions
+108 -3
View File
@@ -45,6 +45,7 @@ const state = {
takeover: localStorage.getItem("veskslop-takeover") === "1",
consoleOpen: false,
consoleTab: "events",
mobileTab: localStorage.getItem("veskslop-mobile-tab") ?? "chat",
voiceReloadAt: 0
};
@@ -419,9 +420,10 @@ async function loadDms() {
} catch {
state.dms = [];
}
renderDirect();
renderDmRail();
renderRightDms();
renderRail();
updateMobileBadge();
}
async function loadVoice() {
@@ -529,6 +531,7 @@ async function selectGuild(guildId) {
async function selectChannel(channelId, guildId) {
if (guildId && guildId !== state.guildId) state.guildId = guildId;
const switched = Boolean(state.channelId) && state.channelId !== channelId;
state.channelId = channelId;
state.channel = findGuildChannel(channelId);
@@ -544,6 +547,10 @@ async function selectChannel(channelId, guildId) {
renderComposer();
await loadMessages({ refresh: true });
await loadVoice();
if (switched && isMobileLayout()) {
setMobileTab(VOICE_TYPES.has(state.channel?.type) ? "call" : "chat");
}
}
async function sendMessage() {
@@ -1068,8 +1075,15 @@ function renderMessages() {
{ class: "vk-message-head" },
el("span", { class: "vk-message-author", text: author }),
isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) })
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) }),
message.editedAt ? el("span", { class: "vk-mono vk-dim", text: "· edited" }) : null
),
message.replyTo
? el("span", {
class: "vk-message-reply",
text: `↳ replying to ${message.replyTo.authorName ?? message.replyTo.authorId ?? "a message"}`
})
: null,
el("span", { class: "vk-message-body", text: message.content })
);
@@ -1203,6 +1217,46 @@ function renderRightVoice() {
if (!root) return;
root.replaceChildren();
// The phone Call tab shows the live call, not just favourites.
const callMembers = isMobileLayout() && state.mobileTab === "call" ? (state.voiceSelf?.members ?? []) : [];
if (callMembers.length) {
$("#voice-count").textContent = `${callMembers.length} in call`;
const card = el(
"div",
{ class: "vk-right-group" },
el(
"div",
{ class: "vk-right-group-head" },
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-name", text: state.voiceSelf?.channelName ?? "call" })
),
...callMembers.map(member =>
el(
"div",
{ class: "vk-right-row" },
avatar(member, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(),
text: member.username ?? member.id
}),
el("span", {
class: "vk-sub",
text: member.id === state.user?.id ? "this client" : member.status ?? ""
})
),
el("span", { class: "vk-right-trail" }, voiceMarks(member))
)
)
);
root.append(card);
return;
}
const inCall = state.favouriteUsers.filter(user => user.channelId);
$("#voice-count").textContent = inCall.length ? `${inCall.length} in call` : "";
@@ -1403,6 +1457,48 @@ function setConsoleTab(tab) {
if (tab === "transcript") void refreshTranscript();
}
/* Mobile layout (1c): one pane at a time behind a four-tab bar. */
const MOBILE_QUERY = window.matchMedia("(max-width: 768px)");
const MOBILE_TABS = new Set(["users", "chat", "call", "activity"]);
function isMobileLayout() {
return MOBILE_QUERY.matches;
}
function setMobileTab(tab) {
if (!MOBILE_TABS.has(tab)) return;
state.mobileTab = tab;
localStorage.setItem("veskslop-mobile-tab", tab);
for (const button of $$(".vk-tab")) button.classList.toggle("active", button.dataset.mobileTab === tab);
if (isMobileLayout()) $(".vk-app").dataset.mobileTab = tab;
renderRightVoice();
updateMobileBadge();
}
function syncMobileLayout() {
const app = $(".vk-app");
if (!app) return;
if (isMobileLayout()) app.dataset.mobileTab = state.mobileTab;
else delete app.dataset.mobileTab;
for (const button of $$(".vk-tab")) {
button.classList.toggle("active", button.dataset.mobileTab === state.mobileTab);
}
}
function updateMobileBadge() {
const badgeEl = $("#mobile-chat-badge");
if (!badgeEl) return;
const total = state.dms.reduce((sum, dm) => sum + ((dm.unread ?? 0) || (dm.mentions ?? 0)), 0);
badgeEl.hidden = total === 0;
badgeEl.textContent = total > 99 ? "99+" : String(total);
}
/* Settings */
function openSettings() {
@@ -2175,7 +2271,16 @@ function init() {
tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
}
$("#raw-events").addEventListener("click", () => setConsoleTab("events"));
for (const tab of $$(".vk-tab")) {
tab.addEventListener("click", () => setMobileTab(tab.dataset.mobileTab));
}
MOBILE_QUERY.addEventListener("change", syncMobileLayout);
syncMobileLayout();
$("#raw-events").addEventListener("click", () => {
setConsoleTab("events");
if (isMobileLayout()) setMobileTab("activity");
});
$("#transcript-refresh").addEventListener("click", () => void refreshTranscript());
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
+3 -1
View File
@@ -23,7 +23,9 @@ const ICON_PATHS = {
warning: '<path d="M8 2 1.5 13.5h13z"/><path d="M8 6.5v3.5M8 12v.5"/>',
refresh: '<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5V6H10"/>',
play: '<path d="M5 3.5 12.5 8 5 12.5z"/>',
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>'
microphone: '<rect x="5.5" y="1.5" width="5" height="7.5" rx="2.5"/><path d="M3 7.5a5 5 0 0 0 10 0M8 12.5v2"/>',
users: '<circle cx="6" cy="5.5" r="2.5"/><path d="M1.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"/><path d="M11 3.5a2.5 2.5 0 0 1 0 5"/>',
activity: '<path d="M1.5 8h3l2-4.5 2.5 9L11 8h3.5"/>'
};
const FILLED = new Set(["star"]);
+23 -2
View File
@@ -47,7 +47,8 @@
type="button"
title="Play the call in this browser"
>
Client audio
<span class="vk-icon" data-icon="speaker"></span>
<span class="vk-btn-text">Client audio</span>
</button>
<label class="vk-takeover" title="Read-only unless takeover is on">
<input type="checkbox" id="takeover" />
@@ -56,7 +57,7 @@
</label>
<button id="settings-open" class="btn btn-secondary vk-settings" type="button">
<span class="vk-icon" data-icon="gear"></span>
Settings
<span class="vk-btn-text">Settings</span>
</button>
</div>
</header>
@@ -222,6 +223,26 @@
</div>
</div>
</section>
<nav id="mobile-tabs" class="vk-tabbar" aria-label="Sections">
<button class="vk-tab" data-mobile-tab="users" type="button">
<span class="vk-icon" data-icon="users"></span>
<span>Users</span>
</button>
<button class="vk-tab" data-mobile-tab="chat" type="button">
<span class="vk-icon" data-icon="hash"></span>
<span>Chat</span>
<span id="mobile-chat-badge" class="vk-rail-badge unread" hidden></span>
</button>
<button class="vk-tab" data-mobile-tab="call" type="button">
<span class="vk-icon" data-icon="volume"></span>
<span>Call</span>
</button>
<button class="vk-tab" data-mobile-tab="activity" type="button">
<span class="vk-icon" data-icon="activity"></span>
<span>Activity</span>
</button>
</nav>
</div>
<div id="settings-overlay" class="vk-overlay" hidden>
+177 -3
View File
@@ -835,6 +835,11 @@ button {
word-break: break-word;
}
.vk-message-reply {
font-size: 11px;
color: var(--color-neutral-500);
}
.vk-message-image {
display: block;
margin-top: 6px;
@@ -1723,15 +1728,184 @@ a.vk-attachment:hover {
}
@media (max-width: 768px) {
.vk-body {
grid-template-columns: minmax(0, 1fr);
:root {
--vk-header-h: 46px;
}
.vk-rail {
.vk-app {
height: 100dvh;
}
.vk-header {
gap: 8px;
padding: 0 10px;
}
.vk-brand {
display: none;
}
.vk-health {
display: flex;
gap: 6px;
flex: none;
}
#health-uptime,
#health-bridge {
display: none;
}
.vk-btn-text {
display: none;
}
.vk-takeover-label {
display: none;
}
.vk-scope-btn {
padding: 4px 8px;
font-size: 11.5px;
}
.vk-guild-picker {
flex: 1;
min-width: 0;
}
.vk-guild-button {
width: 100%;
justify-content: space-between;
}
#guild-picker-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vk-body {
display: flex;
flex-direction: column;
grid-template-columns: none;
}
.vk-rail,
.vk-center,
.vk-right,
.vk-console {
display: none;
}
.vk-app[data-mobile-tab="users"] .vk-rail {
display: flex;
flex: 1;
}
.vk-app[data-mobile-tab="chat"] .vk-center {
display: flex;
flex: 1;
}
.vk-app[data-mobile-tab="call"] .vk-right {
display: flex;
flex: 1;
box-shadow: none;
}
.vk-app[data-mobile-tab="activity"] .vk-body {
display: none;
}
.vk-app[data-mobile-tab="activity"] .vk-console {
display: flex;
flex: 1;
min-height: 0;
}
.vk-app[data-mobile-tab="activity"] .vk-console-bar {
box-shadow: none;
}
.vk-app[data-mobile-tab="activity"] #console-toggle,
.vk-app[data-mobile-tab="activity"] .vk-ticker {
display: none;
}
.vk-app[data-mobile-tab="activity"] .vk-console-drawer {
display: flex !important;
height: auto;
flex: 1;
box-shadow: none;
}
.vk-settings-panel {
width: 100%;
}
.vk-tts-field,
.vk-tts-meta .btn,
.vk-tts .vk-select {
min-height: 44px;
}
.vk-send,
.vk-composer .vk-icon-btn {
min-height: 38px;
min-width: 38px;
}
}
/* Mobile tab bar (1c) */
.vk-tabbar {
display: none;
}
@media (max-width: 768px) {
.vk-tabbar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
flex: none;
padding: 6px 8px calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--color-surface);
box-shadow: inset 0 1px 0 var(--color-neutral-800);
}
.vk-tab {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-height: 44px;
padding: 7px 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--color-neutral-500);
font: 400 10px/1 var(--font-body);
cursor: pointer;
}
.vk-tab .vk-icon {
width: 19px;
height: 19px;
}
.vk-tab.active {
color: var(--color-accent);
background: var(--color-accent-900);
box-shadow: inset 0 0 0 1px var(--color-accent-700);
font-family: var(--font-heading);
font-weight: 500;
}
.vk-tab .vk-rail-badge {
position: absolute;
top: 2px;
right: calc(50% - 26px);
}
}