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 — opens at localhost:3000
+ walkie web — opens at localhost:3000, remembers joined channels in your browser
@@ -662,7 +662,7 @@
| 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 () => {