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>
3 files changed,
+42
−3
web/pages/index.html+3 −0web/static/chat.js+23 −1web/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 { |