Skip to content

Commit ca85b9c

Browse files
authored
Merge pull request #37 from bertheto/feat/ui-nav-sidebar
feat: UI-07 message navigation sidebar (emoji minimap)
2 parents 2654eff + 5c61ee0 commit ca85b9c

6 files changed

Lines changed: 639 additions & 75 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
3+
/**
4+
* UI-07 - Message Navigation Sidebar (Emoji Minimap)
5+
* Scrollable column to the right of the chat area.
6+
*/
7+
8+
const STORAGE_KEY = 'acb-minimap-enabled';
9+
10+
function makeNavSidebar({ document, localStorage }) {
11+
let _observer = null;
12+
13+
function isEnabled() {
14+
const val = localStorage.getItem(STORAGE_KEY);
15+
return val === null ? true : val === 'true';
16+
}
17+
18+
function applyEnabledState() {
19+
document.body.classList.toggle('minimap-hidden', !isEnabled());
20+
}
21+
22+
function setEnabled(enabled) {
23+
localStorage.setItem(STORAGE_KEY, String(enabled));
24+
document.body.classList.toggle('minimap-hidden', !enabled);
25+
}
26+
27+
function buildSidebar() {
28+
const sidebar = document.getElementById('nav-sidebar');
29+
const messagesEl = document.getElementById('messages-scroll');
30+
const messagesInner = document.getElementById('messages');
31+
if (!sidebar || !messagesEl || !messagesInner) return;
32+
33+
if (_observer) { _observer.disconnect(); _observer = null; }
34+
sidebar.innerHTML = '';
35+
36+
const rows = messagesInner.querySelectorAll('.msg-row[data-seq]');
37+
if (rows.length === 0) {
38+
sidebar.classList.add('nav-sidebar-empty');
39+
return;
40+
}
41+
sidebar.classList.remove('nav-sidebar-empty');
42+
43+
rows.forEach((row) => {
44+
const seq = row.getAttribute('data-seq');
45+
const authorId = row.getAttribute('data-author-id') || 'unknown';
46+
const avatarEl = row.querySelector('.msg-avatar');
47+
const emoji = avatarEl ? avatarEl.textContent.trim() : '💬';
48+
const authorNameEl = row.querySelector('.msg-author-label');
49+
const authorName = authorNameEl ? authorNameEl.textContent.trim() : authorId;
50+
51+
const entry = document.createElement('button');
52+
entry.className = 'nav-entry';
53+
entry.setAttribute('data-seq', seq);
54+
entry.setAttribute('data-author-id', authorId);
55+
entry.setAttribute('title', authorName);
56+
entry.innerHTML =
57+
`<span class="nav-entry-emoji">${emoji}</span>` +
58+
`<span class="nav-entry-meta"><span class="nav-entry-name">${authorName}</span></span>`;
59+
60+
entry.addEventListener('click', () => {
61+
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
62+
row.classList.add('nav-highlight');
63+
setTimeout(() => row.classList.remove('nav-highlight'), 1200);
64+
});
65+
66+
sidebar.appendChild(entry);
67+
});
68+
}
69+
70+
return {
71+
isEnabled, applyEnabledState, setEnabled,
72+
rebuild: buildSidebar, onNewMessage: buildSidebar,
73+
};
74+
}
75+
76+
// ---------------------------------------------------------------------------
77+
// Helpers
78+
// ---------------------------------------------------------------------------
79+
80+
function createDOM(document) {
81+
const messagesWrap = document.createElement('div');
82+
messagesWrap.id = 'messages-wrap';
83+
84+
const messagesScroll = document.createElement('div');
85+
messagesScroll.id = 'messages-scroll';
86+
87+
const messagesEl = document.createElement('div');
88+
messagesEl.id = 'messages';
89+
messagesScroll.appendChild(messagesEl);
90+
messagesWrap.appendChild(messagesScroll);
91+
92+
const sidebar = document.createElement('nav');
93+
sidebar.id = 'nav-sidebar';
94+
messagesWrap.appendChild(sidebar);
95+
96+
document.body.appendChild(messagesWrap);
97+
return { messagesEl, sidebar };
98+
}
99+
100+
function addRow(messagesEl, { seq, authorId, authorName, emoji = '🤖' }) {
101+
const row = document.createElement('div');
102+
row.className = 'msg-row';
103+
row.setAttribute('data-seq', String(seq));
104+
row.setAttribute('data-author-id', authorId);
105+
const avatar = document.createElement('span');
106+
avatar.className = 'msg-avatar';
107+
avatar.textContent = emoji;
108+
row.appendChild(avatar);
109+
const label = document.createElement('span');
110+
label.className = 'msg-author-label';
111+
label.textContent = authorName;
112+
row.appendChild(label);
113+
messagesEl.appendChild(row);
114+
return row;
115+
}
116+
117+
// ---------------------------------------------------------------------------
118+
// Tests
119+
// ---------------------------------------------------------------------------
120+
121+
describe('UI-07 — Nav Sidebar (Emoji Minimap)', () => {
122+
let nav;
123+
let localStorageMock;
124+
125+
beforeEach(() => {
126+
document.body.className = '';
127+
document.body.innerHTML = '';
128+
const store = {};
129+
localStorageMock = {
130+
getItem: vi.fn((k) => store[k] ?? null),
131+
setItem: vi.fn((k, v) => { store[k] = v; }),
132+
_store: store,
133+
};
134+
nav = makeNavSidebar({ document, localStorage: localStorageMock });
135+
});
136+
137+
afterEach(() => vi.clearAllMocks());
138+
139+
it('isEnabled() returns true by default', () => {
140+
expect(nav.isEnabled()).toBe(true);
141+
});
142+
143+
it('isEnabled() returns false when localStorage is "false"', () => {
144+
localStorageMock._store[STORAGE_KEY] = 'false';
145+
expect(nav.isEnabled()).toBe(false);
146+
});
147+
148+
it('applyEnabledState() adds minimap-hidden when disabled', () => {
149+
localStorageMock._store[STORAGE_KEY] = 'false';
150+
nav.applyEnabledState();
151+
expect(document.body.classList.contains('minimap-hidden')).toBe(true);
152+
});
153+
154+
it('applyEnabledState() removes minimap-hidden when enabled', () => {
155+
document.body.classList.add('minimap-hidden');
156+
localStorageMock._store[STORAGE_KEY] = 'true';
157+
nav.applyEnabledState();
158+
expect(document.body.classList.contains('minimap-hidden')).toBe(false);
159+
});
160+
161+
it('setEnabled(false) writes localStorage and adds minimap-hidden', () => {
162+
nav.setEnabled(false);
163+
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, 'false');
164+
expect(document.body.classList.contains('minimap-hidden')).toBe(true);
165+
});
166+
167+
it('setEnabled(true) removes minimap-hidden', () => {
168+
document.body.classList.add('minimap-hidden');
169+
nav.setEnabled(true);
170+
expect(document.body.classList.contains('minimap-hidden')).toBe(false);
171+
});
172+
173+
it('rebuild() creates one .nav-entry per .msg-row', () => {
174+
const { messagesEl } = createDOM(document);
175+
addRow(messagesEl, { seq: 1, authorId: 'a', authorName: 'Agent A', emoji: '🤖' });
176+
addRow(messagesEl, { seq: 2, authorId: 'b', authorName: 'Human', emoji: '👤' });
177+
addRow(messagesEl, { seq: 3, authorId: 'a', authorName: 'Agent A', emoji: '🤖' });
178+
nav.rebuild();
179+
expect(document.querySelectorAll('.nav-entry')).toHaveLength(3);
180+
});
181+
182+
it('entries carry correct data-seq, data-author-id, and emoji', () => {
183+
const { messagesEl } = createDOM(document);
184+
addRow(messagesEl, { seq: 5, authorId: 'x', authorName: 'X', emoji: '🧠' });
185+
nav.rebuild();
186+
const entry = document.querySelector('.nav-entry');
187+
expect(entry.getAttribute('data-seq')).toBe('5');
188+
expect(entry.getAttribute('data-author-id')).toBe('x');
189+
expect(entry.querySelector('.nav-entry-emoji').textContent).toBe('🧠');
190+
});
191+
192+
it('empty thread adds nav-sidebar-empty class', () => {
193+
const { sidebar } = createDOM(document);
194+
nav.rebuild();
195+
expect(sidebar.classList.contains('nav-sidebar-empty')).toBe(true);
196+
});
197+
198+
it('non-empty thread removes nav-sidebar-empty class', () => {
199+
const { messagesEl, sidebar } = createDOM(document);
200+
sidebar.classList.add('nav-sidebar-empty');
201+
addRow(messagesEl, { seq: 1, authorId: 'a', authorName: 'A' });
202+
nav.rebuild();
203+
expect(sidebar.classList.contains('nav-sidebar-empty')).toBe(false);
204+
});
205+
206+
it('click on entry calls scrollIntoView on the msg-row', () => {
207+
const { messagesEl } = createDOM(document);
208+
const row = addRow(messagesEl, { seq: 7, authorId: 'b', authorName: 'B', emoji: '🤖' });
209+
row.scrollIntoView = vi.fn();
210+
nav.rebuild();
211+
document.querySelector('.nav-entry[data-seq="7"]').click();
212+
expect(row.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
213+
});
214+
215+
it('fallback emoji 💬 when no .msg-avatar', () => {
216+
const { messagesEl } = createDOM(document);
217+
const row = document.createElement('div');
218+
row.className = 'msg-row';
219+
row.setAttribute('data-seq', '1');
220+
row.setAttribute('data-author-id', 'x');
221+
messagesEl.appendChild(row);
222+
nav.rebuild();
223+
expect(document.querySelector('.nav-entry .nav-entry-emoji').textContent).toBe('💬');
224+
});
225+
226+
it('onNewMessage() adds entries for new messages', () => {
227+
const { messagesEl } = createDOM(document);
228+
addRow(messagesEl, { seq: 1, authorId: 'a', authorName: 'A' });
229+
nav.onNewMessage();
230+
expect(document.querySelectorAll('.nav-entry')).toHaveLength(1);
231+
addRow(messagesEl, { seq: 2, authorId: 'b', authorName: 'B' });
232+
nav.onNewMessage();
233+
expect(document.querySelectorAll('.nav-entry')).toHaveLength(2);
234+
});
235+
236+
it('rebuild() clears previous entries before rebuilding', () => {
237+
const { messagesEl } = createDOM(document);
238+
addRow(messagesEl, { seq: 1, authorId: 'a', authorName: 'A' });
239+
nav.rebuild();
240+
nav.rebuild();
241+
expect(document.querySelectorAll('.nav-entry')).toHaveLength(1);
242+
});
243+
});

0 commit comments

Comments
 (0)