Compare commits

...
2 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 618a787b36 tests: UI + API smoke tests for chat plugin
Covers the flyout lifecycle (footer button, toggle, header-back
visibility tracking thread/list view), the pick-view detour, DOM
structural invariants (peer strip inside thread view, form/input/send
present) and the three read-only chat endpoints. Send/receive requires
a real relay and is intentionally skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:43 +02:00
thandClaude Opus 4.6 81d9ce0e87 chat: peer-switch strip, back button moved to flyout header
Switching conversations used to require bouncing back to the list view
and picking from there. Now the thread view has a horizontal peer
strip at the top: one click hops to another conversation, active tab
is highlighted and auto-scrolls into view, unread peers show a dot.
Scrollbar is hidden — vertical wheel delta is redirected to horizontal
scroll so the user can slide the strip with the mouse wheel when it
overflows.

In-thread header is gone; the back button moved next to "chat" in the
flyout header and only shows while a thread is open. Peer name stays
visible via the active tab.

Bumps to 1.9.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:30:38 +02:00
7 changed files with 214 additions and 14 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.2'); define('APP_VERSION', '1.9.3');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.2", "version": "1.9.3",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+47 -4
View File
@@ -142,15 +142,58 @@
/* ── Thread view ── */ /* ── Thread view ── */
#chat-thread-header { /* Horizontal peer strip above the thread header. Lets the user hop
between conversations without bouncing back to the list. Scrollbar
hidden — wheel-over scrolls horizontally (see chat.js). */
.chat-thread-peers {
display: flex; display: flex;
align-items: center; gap: 5px;
gap: 8px; padding: 6px 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--t-border); border-bottom: 1px solid var(--t-border);
background: var(--t-bg); background: var(--t-bg);
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.chat-thread-peers::-webkit-scrollbar { display: none; }
.chat-thread-peers:empty { display: none; }
.chat-peer-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-button, 3px);
background: var(--t-surface);
color: var(--t-muted);
font-family: var(--t-font-pixel);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s, background 0.12s;
position: relative;
}
.chat-peer-tab:hover {
border-color: var(--t-accent);
color: var(--t-accent);
}
.chat-peer-tab.active {
border-color: var(--t-accent);
background: var(--t-accent);
color: var(--t-bg);
}
.chat-peer-tab .chat-peer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--t-accent);
flex-shrink: 0; flex-shrink: 0;
} }
.chat-peer-tab.active .chat-peer-dot { background: var(--t-bg); }
.chat-thread-messages { .chat-thread-messages {
flex: 1; flex: 1;
+4 -6
View File
@@ -3,7 +3,9 @@
<div id="chat-flyout" class="hidden"> <div id="chat-flyout" class="hidden">
<div id="chat-flyout-header"> <div id="chat-flyout-header">
<i class="ph ph-chats-circle text-t-accent text-sm"></i> <i class="ph ph-chats-circle text-t-accent text-sm"></i>
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright flex-1">chat</span> <span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
<span class="flex-1"></span>
<button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button> <button id="chat-new-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="new conversation"><i class="ph ph-plus-square text-sm"></i></button>
<button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button> <button id="chat-refresh-btn" class="text-t-muted hover:text-t-accent transition-colors" data-tip="refresh"><i class="ph ph-arrows-clockwise text-sm" id="chat-refresh-icon"></i></button>
<button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button> <button id="chat-close-btn" class="text-t-muted hover:text-t-danger transition-colors"><i class="ph ph-x text-sm"></i></button>
@@ -21,11 +23,7 @@
<!-- Conversation thread view --> <!-- Conversation thread view -->
<div id="chat-thread-view" class="chat-view hidden"> <div id="chat-thread-view" class="chat-view hidden">
<div id="chat-thread-header"> <div id="chat-thread-peers" class="chat-thread-peers"></div>
<button id="chat-thread-back" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
<i class="ph ph-user-circle text-t-accent text-sm"></i>
<span id="chat-thread-peer" class="font-pixel text-[10px] text-t-bright flex-1 truncate"></span>
</div>
<div id="chat-thread-messages" class="chat-thread-messages"></div> <div id="chat-thread-messages" class="chat-thread-messages"></div>
<form id="chat-thread-form"> <form id="chat-thread-form">
<input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000"> <input id="chat-thread-input" type="text" autocomplete="off" placeholder="type a message..." maxlength="2000">
+58 -2
View File
@@ -142,6 +142,7 @@ function showListView() {
$('chat-thread-view').classList.add('hidden'); $('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.add('hidden'); $('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-header-back')?.classList.add('hidden');
currentPeer = null; currentPeer = null;
} }
@@ -150,14 +151,17 @@ function showThreadView(peer) {
$('chat-thread-view').classList.remove('hidden'); $('chat-thread-view').classList.remove('hidden');
$('chat-pick-view').classList.add('hidden'); $('chat-pick-view').classList.add('hidden');
$('chat-flyout-title').textContent = 'chat'; $('chat-flyout-title').textContent = 'chat';
$('chat-thread-peer').textContent = peer; $('chat-header-back')?.classList.remove('hidden');
currentPeer = peer; currentPeer = peer;
// Re-render tabs so the active highlight follows the thread switch.
renderPeerTabs();
} }
function showPickView() { function showPickView() {
$('chat-list-view').classList.add('hidden'); $('chat-list-view').classList.add('hidden');
$('chat-thread-view').classList.add('hidden'); $('chat-thread-view').classList.add('hidden');
$('chat-pick-view').classList.remove('hidden'); $('chat-pick-view').classList.remove('hidden');
$('chat-header-back')?.classList.add('hidden');
} }
// ── Conversation list ───────────────────────────────────────────── // ── Conversation list ─────────────────────────────────────────────
@@ -174,6 +178,7 @@ function renderConversations() {
const list = $('chat-list'); const list = $('chat-list');
const empty = $('chat-empty'); const empty = $('chat-empty');
if (!list || !empty) return; if (!list || !empty) return;
renderPeerTabs();
if (!conversations.length) { if (!conversations.length) {
list.innerHTML = ''; list.innerHTML = '';
empty.classList.remove('hidden'); empty.classList.remove('hidden');
@@ -205,6 +210,56 @@ function renderConversations() {
}); });
} }
// ── Peer tabs (thread-view quick-switch strip) ────────────────────
// Horizontal row above the thread so the user can hop between
// conversations without going back to the list. Hidden scrollbar;
// vertical wheel delta is redirected to horizontal scroll.
function renderPeerTabs() {
const strip = $('chat-thread-peers');
if (!strip) return;
// Fold the current peer in even if no messages exist yet (fresh
// pick-view selection), so switching back to it via tab is always
// possible.
const peers = conversations.slice();
if (currentPeer && !peers.some(c => c.peer === currentPeer)) {
peers.unshift({ peer: currentPeer, unread: 0 });
}
if (!peers.length) { strip.innerHTML = ''; return; }
strip.innerHTML = peers.map(c => {
const active = c.peer === currentPeer ? ' active' : '';
const dot = parseInt(c.unread) > 0 && c.peer !== currentPeer
? '<span class="chat-peer-dot"></span>' : '';
return `<button class="chat-peer-tab${active}" data-peer="${escapeHtml(c.peer)}" title="${escapeHtml(c.peer)}">${dot}<span>${escapeHtml(c.peer)}</span></button>`;
}).join('');
strip.querySelectorAll('.chat-peer-tab').forEach(tab => {
tab.addEventListener('click', () => {
const peer = tab.dataset.peer;
if (peer && peer !== currentPeer) openConversation(peer);
});
});
// Keep the active tab in view after a switch so the user can see
// what's selected when the list overflows.
const active = strip.querySelector('.chat-peer-tab.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
// Redirect vertical wheel scrolls to horizontal scroll on the peer
// strip. Attached once on init — preventDefault only when we actually
// consume the delta, so a trackpad user's native horizontal gesture
// still works.
function wirePeerStripWheel() {
const strip = $('chat-thread-peers');
if (!strip || strip.dataset.wheelWired) return;
strip.dataset.wheelWired = '1';
strip.addEventListener('wheel', (e) => {
if (e.deltaY === 0) return;
if (strip.scrollWidth <= strip.clientWidth) return;
e.preventDefault();
strip.scrollLeft += e.deltaY;
}, { passive: false });
}
// ── Thread ──────────────────────────────────────────────────────── // ── Thread ────────────────────────────────────────────────────────
async function openConversation(peer) { async function openConversation(peer) {
@@ -380,9 +435,10 @@ function init() {
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations)); $('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
$('chat-new-btn').addEventListener('click', openPickView); $('chat-new-btn').addEventListener('click', openPickView);
$('chat-empty-start')?.addEventListener('click', openPickView); $('chat-empty-start')?.addEventListener('click', openPickView);
$('chat-thread-back').addEventListener('click', () => { showListView(); loadConversations(); }); $('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
$('chat-pick-back').addEventListener('click', showListView); $('chat-pick-back').addEventListener('click', showListView);
$('chat-thread-form').addEventListener('submit', sendMessage); $('chat-thread-form').addEventListener('submit', sendMessage);
wirePeerStripWheel();
// Esc closes flyout when focus is inside it // Esc closes flyout when focus is inside it
$('chat-flyout').addEventListener('keydown', e => { $('chat-flyout').addEventListener('keydown', e => {
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, click, $, sleep, waitFor } from './runner.js?v=4';
// Chat plugin tests — UI-focused. No send/receive here: that would
// require a real relay, and the user's "no mocks" rule means we don't
// fake one. Runs against the real /api/chat endpoints, which respond
// cleanly on a fresh install (empty conversation list).
const ensureFlyoutClosed = async () => {
const f = $('#chat-flyout');
if (!f) return;
if (!f.classList.contains('hidden')) {
click('#chat-close-btn');
await waitFor(() => f.classList.contains('hidden'));
}
};
describe('Chat: flyout', () => {
it('footer button is injected', () => {
expect($('#chat-footer-btn')).toExist();
});
it('toggles open and closed', async () => {
await ensureFlyoutClosed();
const f = $('#chat-flyout');
click('#chat-footer-btn');
await waitFor(() => !f.classList.contains('hidden'));
expect(f.classList.contains('hidden')).toBe(false);
click('#chat-close-btn');
await waitFor(() => f.classList.contains('hidden'));
expect(f.classList.contains('hidden')).toBe(true);
});
it('header-back visibility matches active view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
// Wait for loadConversations + initial view pick to settle.
await sleep(150);
const back = $('#chat-header-back');
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
const listShown = !$('#chat-list-view').classList.contains('hidden');
// Exactly one of thread / list should be active here (pick view
// is only reached via the + button).
expect(threadShown || listShown).toBe(true);
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
it('new-conversation button opens pick view', async () => {
await ensureFlyoutClosed();
click('#chat-footer-btn');
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
await sleep(100);
click('#chat-new-btn');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
// Header back is only for thread view — not needed when pick has
// its own dedicated back button.
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
click('#chat-pick-back');
await sleep(50);
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
await ensureFlyoutClosed();
});
});
describe('Chat: thread DOM structure', () => {
it('peer-strip element lives inside thread view', () => {
const strip = $('#chat-thread-peers');
expect(strip).toExist();
expect(strip.closest('#chat-thread-view')).toExist();
});
it('thread form, input and send button exist', () => {
expect($('#chat-thread-form')).toExist();
expect($('#chat-thread-input')).toExist();
expect($('#chat-thread-send')).toExist();
});
});
describe('Chat: API', () => {
it('/api/chat/conversations returns expected shape', async () => {
const res = await fetch('api/chat/conversations').then(r => r.json());
expect(Array.isArray(res.conversations)).toBe(true);
expect(typeof res.total_unread).toBe('number');
});
it('/api/chat/messages without peer returns 400', async () => {
const r = await fetch('api/chat/messages');
expect(r.status).toBe(400);
});
it('/api/chat/messages with unknown peer returns empty array', async () => {
const res = await fetch('api/chat/messages?peer=__test_no_such_peer__')
.then(r => r.json());
expect(Array.isArray(res.messages)).toBe(true);
expect(res.messages.length).toBe(0);
});
});
+1
View File
@@ -54,6 +54,7 @@ const FILES = [
'./asset-picker.test.js', './asset-picker.test.js',
'./terminal.test.js', './terminal.test.js',
'./theme.test.js', './theme.test.js',
'./chat.test.js',
]; ];
// Await all imports so the runner can start in a deterministic state. // Await all imports so the runner can start in a deterministic state.