diff --git a/README.md b/README.md index 8eef26e..2f9e893 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ walkie web # walkie web UI → http://localhost:3000 ``` -Join a channel, see messages in real-time. Browser notifications when the tab is unfocused. Secret is optional — defaults to channel name, same as the CLI. +Join a channel, see messages in real-time. Browser notifications when the tab is unfocused. Secret is optional — defaults to channel name, same as the CLI. Channel state is remembered in the browser, so the same browser on the same origin can auto-rejoin after the portal restarts. ## Skill @@ -152,7 +152,7 @@ npx skills add https://github.com/vikasprogrammer/walkie --skill walkie - **Join/leave announcements** — `[system] alice joined` / `[system] alice left` delivered to all subscribers when agents connect or disconnect - **Stdin send** — `echo "hello" | walkie send channel` — reads message from stdin when no argument given, avoids shell escaping issues - **Shell escaping fix** — `\!` automatically unescaped to `!` in sent messages (works around zsh/bash history expansion) -- **Web UI** — `walkie web` starts a browser-based chat UI with real-time messages, renameable identity, and session persistence +- **Web UI** — `walkie web` starts a browser-based chat UI with real-time messages, renameable identity, and browser-local persistence across page reloads and same-origin restarts - **Deprecation notices** — `create` and `join` still work but print a notice pointing to `connect` - **Persistent message storage** — opt-in via `--persist` flag on `connect`/`watch`/`create`/`join`. Messages saved as JSONL in `~/.walkie/messages/`. No flag = no files, zero disk footprint - **P2P sync** — persistent channels exchange missed messages on peer reconnect via `sync_req`/`sync_resp`, with message deduplication via unique IDs diff --git a/docs/index.html b/docs/index.html index bd7ca39..496621f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -626,7 +626,7 @@

3. Web UI

walkie web UI showing multi-agent chat

- walkie web — opens at localhost:3000 + walkie web — opens at localhost:3000, remembers joined channels in your browser

@@ -662,7 +662,7 @@

Commands

walkie web - Browser chat UI. -p PORT, -c channel:secret to auto-join. + Browser chat UI. -p PORT, -c channel:secret to auto-join. Same browser + origin remembers channels between restarts. walkie status diff --git a/docs/llms.txt b/docs/llms.txt index 826ae7f..aebe01a 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -34,7 +34,7 @@ walkie connect Connect to a channel. No colon = secret defau walkie send "msg" Send a message. Reads from stdin if no message given. walkie read Read pending messages. --wait blocks. --timeout N for deadline. walkie watch Stream messages. --pretty for readable, --exec CMD per message. -walkie web Browser chat UI at localhost:3000. +walkie web Browser chat UI at localhost:3000. Same browser + origin remembers joined channels. walkie status Show active channels and peers. walkie leave Leave a channel. walkie stop Stop the background daemon. diff --git a/src/daemon.js b/src/daemon.js index a43cc06..26c39fe 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -91,12 +91,12 @@ class WalkieDaemon { const isNew = !ch.subscribers.has(id) if (isNew) { ch.subscribers.set(id, { messages: [], waiters: [], lastReadTs: 0, lastSeen: Date.now() }) - } else { - ch.subscribers.get(id).lastSeen = Date.now() - // Announce join to local subscribers and remote peers + // Announce new joins to local subscribers and remote peers. if (ch.subscribers.size > 1 || ch.peers.size > 0) { this._send(cmd.channel, `${id} joined`, 'system') } + } else { + ch.subscribers.get(id).lastSeen = Date.now() } reply({ ok: true, channel: cmd.channel }) break diff --git a/src/web-ui.js b/src/web-ui.js index dca8848..0deef21 100644 --- a/src/web-ui.js +++ b/src/web-ui.js @@ -549,16 +549,76 @@ module.exports = ` } const MAX_MSGS = 200; + const STORAGE_KEY = 'walkie:web:state:v1'; let saveTimer = null; - function save() { + function snapshotState() { const data = {}; for (const [n, c] of ch) { data[n] = { secret: c.secret, msgs: c.msgs.slice(-MAX_MSGS) }; } - const state = { channels: data, active: active || null, name: storedName || null }; + return { channels: data, active: active || null, name: storedName || null }; + } + + function normalizeState(state) { + if (!state || typeof state !== 'object') return null; + const channels = {}; + if (state.channels && typeof state.channels === 'object') { + for (const [n, v] of Object.entries(state.channels)) { + if (typeof n !== 'string' || !n) continue; + if (typeof v === 'string') { + if (v) channels[n] = { secret: v, msgs: [] }; + continue; + } + if (!v || typeof v !== 'object' || !v.secret) continue; + channels[n] = { + secret: v.secret, + msgs: Array.isArray(v.msgs) ? v.msgs.slice(-MAX_MSGS) : [] + }; + } + } + return { + channels, + active: typeof state.active === 'string' ? state.active : null, + name: typeof state.name === 'string' ? state.name : null + }; + } + + function applyState(state) { + const normalized = normalizeState(state); + if (!normalized) return false; + for (const [n, v] of Object.entries(normalized.channels)) { + ch.set(n, { secret: v.secret, msgs: v.msgs, unread: 0 }); + } + storedName = normalized.name; + if (normalized.active && ch.has(normalized.active)) active = normalized.active; + return true; + } + + function readLocalState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return normalizeState(JSON.parse(raw)); + } catch { + return null; + } + } + + function writeLocalState(state) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + return true; + } catch { + return false; + } + } + + function save() { + const state = snapshotState(); clearTimeout(saveTimer); saveTimer = setTimeout(() => { + if (writeLocalState(state)) return; fetch('/state', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -567,22 +627,28 @@ module.exports = ` }, 500); } - async function load() { + async function loadLegacyState() { try { const resp = await fetch('/state'); const state = await resp.json(); - if (state.channels) { - for (const [n, v] of Object.entries(state.channels)) { - if (typeof v === 'string') { - if (v) ch.set(n, { secret: v, msgs: [], unread: 0 }); - } else if (v && v.secret) { - ch.set(n, { secret: v.secret, msgs: v.msgs || [], unread: 0 }); - } - } - } - storedName = state.name || null; - if (state.active && ch.has(state.active)) active = state.active; - } catch {} + return normalizeState(state); + } catch { + return null; + } + } + + async function load() { + const localState = readLocalState(); + if (localState) { + applyState(localState); + return; + } + + const legacyState = await loadLegacyState(); + if (legacyState) { + applyState(legacyState); + writeLocalState(snapshotState()); + } } function joinAll() { diff --git a/test/api.test.js b/test/api.test.js index 3b4cde1..b0a09a3 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -30,7 +30,9 @@ describe('api.listen()', () => { await request({ action: 'join', channel: 'test-api', secret: 'secret', clientId: 'sender' }) const received = new Promise((resolve) => { - ch.on('message', (msg) => resolve(msg)) + ch.on('message', (msg) => { + if (msg.from !== 'system') resolve(msg) + }) }) await request({ action: 'send', channel: 'test-api', message: 'ping', clientId: 'sender' }) @@ -59,8 +61,9 @@ describe('api.listen()', () => { // Verify reader got it const resp = await request({ action: 'read', channel: 'test-send', clientId: 'reader' }) assert.equal(resp.ok, true) - assert.equal(resp.messages.length, 1) - assert.equal(resp.messages[0].data, 'hello from API') + const userMsgs = resp.messages.filter(msg => msg.from !== 'system') + assert.equal(userMsgs.length, 1) + assert.equal(userMsgs[0].data, 'hello from API') await ch.close() }) @@ -84,8 +87,9 @@ describe('api.listen()', () => { // Give the stream loop time to deliver await new Promise(r => setTimeout(r, 500)) - assert.equal(messages.length, 1) - assert.equal(messages[0].data, 'from-other') + const userMsgs = messages.filter(msg => msg.from !== 'system') + assert.equal(userMsgs.length, 1) + assert.equal(userMsgs[0].data, 'from-other') await ch.close() }) @@ -104,6 +108,7 @@ describe('api.send()', () => { // Verify receiver got it const resp = await request({ action: 'read', channel: 'test-oneshot', clientId: 'receiver' }) - assert.equal(resp.messages[0].data, 'fire-and-forget') + const userMsgs = resp.messages.filter(msg => msg.from !== 'system') + assert.equal(userMsgs[0].data, 'fire-and-forget') }) }) diff --git a/test/web.test.js b/test/web.test.js index 59aff64..abd271d 100644 --- a/test/web.test.js +++ b/test/web.test.js @@ -95,6 +95,8 @@ describe('HTTP', () => { assert.equal(r.status, 200) assert.ok(r.headers['content-type'].includes('text/html')) assert.ok(r.body.includes('<')) + assert.ok(r.body.includes("const STORAGE_KEY = 'walkie:web:state:v1';")) + assert.ok(r.body.includes("fetch('/state')")) }) it('GET /unknown returns 404', async () => {