eleven / ocho Public

web: drop sidebar header divider and add a chat-list filter (#27)

The "Chats" header now flows straight into the list — only the thread
pane's header keeps a divider (the shared height rule still aligns the
two panes' headers).

A slim search field below the header filters threads by name as you
type: a pure local substring match, never sent to the server. It hides
itself when there are no chats and shows "No chats match your search."
when a query matches nothing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 1fd842c546dad69328704f7173179fdf9555e9e5 parent 40d24fe
3 files changed, +42 −3
  • web/pages/index.html +3 −0
  • web/static/chat.js +23 −1
  • web/static/style.css +16 −2
diff --git a/web/pages/index.html b/web/pages/index.html
index d517c51..9ef6ebc 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -43,6 +43,9 @@
</button>
</div>
</header>
+ <div class="chat-search hidden" id="chat-search">
+ <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off" />
+ </div>
<div class="thread-list" id="thread-list"></div>
</aside>
<section class="thread-pane">
diff --git a/web/static/chat.js b/web/static/chat.js
index c565a32..2f6192f 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -127,6 +127,7 @@ const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new me
const threadName = {}; // thread id -> display name
const threadPreview = {}; // thread id -> last-message snippet
const unread = {}; // thread id -> bool
+let chatFilter = ""; // lowercased substring filtering the thread list (search box)
let activeId = null;
let ws = null;
let pingTimer = null; // keepalive ping interval while the socket is open
@@ -409,6 +410,14 @@ function renderApp() {
document.getElementById("new-chat").onclick = showNewChat;
document.getElementById("back-btn").onclick = showList;
+ // Filter the chat list as you type (name match only; never sent anywhere).
+ const filter = document.getElementById("chat-filter");
+ if (filter)
+ filter.oninput = () => {
+ chatFilter = filter.value.trim().toLowerCase();
+ renderThreadList();
+ };
+
// Kebab menu (beside +): add device / rename / leave.
const menu = document.getElementById("menu");
const menuBtn = document.getElementById("menu-btn");
@@ -553,6 +562,9 @@ function renderThreadList() {
const list = document.getElementById("thread-list");
if (!list) return;
list.innerHTML = "";
+ // The search box only earns its space once there's a list to filter.
+ const search = document.getElementById("chat-search");
+ if (search) search.classList.toggle("hidden", !threads.length);
if (!threads.length) {
const empty = document.createElement("p");
empty.className = "thread-empty-note";
@@ -560,7 +572,17 @@ function renderThreadList() {
list.appendChild(empty);
return;
}
- for (const t of threads) {
+ const shown = chatFilter
+ ? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter))
+ : threads;
+ if (!shown.length) {
+ const empty = document.createElement("p");
+ empty.className = "thread-empty-note";
+ empty.textContent = "No chats match your search.";
+ list.appendChild(empty);
+ return;
+ }
+ for (const t of shown) {
const row = document.createElement("button");
row.className = "thread-row" + (t.id === activeId ? " active" : "");
diff --git a/web/static/style.css b/web/static/style.css
index 37410b2..8c89005 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -741,8 +741,11 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.conn-status.hidden { display: none; }
.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; }
-/* Both headers share a height so their bottom borders line up across the panes. */
-.side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; border-bottom: 1px solid var(--border); }
+/* Both headers share a height so the thread pane's header lines up with the
+ sidebar's. Only the thread header carries a divider — the sidebar's "Chats"
+ header flows straight into the chat list (with the search field below it). */
+.side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; }
+.thread-head { border-bottom: 1px solid var(--border); }
.side-head { display: flex; align-items: center; gap: 8px; }
.side-head .title { flex: 1; min-width: 0; font-weight: 700; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.head-actions { display: flex; align-items: center; gap: 2px; flex: none; }
@@ -754,6 +757,17 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.avatar-lg { width: 64px; height: 64px; font-size: 28px; }
.avatar.zoomable { cursor: pointer; } /* header avatar with a real photo: tap to zoom */
.field-label { display: block; font-size: 13px; color: var(--muted); margin: 2px 0 4px; }
+/* Filter the chat list. Sits between the header and the list; hidden when there
+ are no chats (renderThreadList toggles .hidden). */
+.chat-search { flex: none; padding: 4px 12px 6px; }
+.chat-search input {
+ width: 100%; box-sizing: border-box; font: inherit; font-size: 14px;
+ padding: 8px 12px; border: 1px solid transparent; border-radius: 12px;
+ background: var(--bubble-them); color: var(--ink); -webkit-appearance: none; appearance: none;
+}
+.chat-search input::placeholder { color: var(--muted); }
+/* Keep the app's standard focus ring (global input:focus); just lift the fill. */
+.chat-search input:focus { background: var(--panel); }
.thread-list { flex: 1; overflow-y: auto; min-height: 0; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
.thread-empty-note { color: var(--muted); padding: 16px; font-size: 14px; }
.thread-row {