Skip to content

Commit 392b988

Browse files
Merge pull request #10 from verygoodplugins/codex/browser-web-persistence
Persist walkie web sessions in the browser
2 parents 3ae8298 + 54823c7 commit 392b988

7 files changed

Lines changed: 102 additions & 29 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ walkie web
109109
# walkie web UI → http://localhost:3000
110110
```
111111
112-
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.
112+
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.
113113
114114
## Skill
115115
@@ -152,7 +152,7 @@ npx skills add https://github.com/vikasprogrammer/walkie --skill walkie
152152
- **Join/leave announcements** — `[system] alice joined` / `[system] alice left` delivered to all subscribers when agents connect or disconnect
153153
- **Stdin send** — `echo "hello" | walkie send channel` — reads message from stdin when no argument given, avoids shell escaping issues
154154
- **Shell escaping fix** — `\!` automatically unescaped to `!` in sent messages (works around zsh/bash history expansion)
155-
- **Web UI** — `walkie web` starts a browser-based chat UI with real-time messages, renameable identity, and session persistence
155+
- **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
156156
- **Deprecation notices** — `create` and `join` still work but print a notice pointing to `connect`
157157
- **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
158158
- **P2P sync** — persistent channels exchange missed messages on peer reconnect via `sync_req`/`sync_resp`, with message deduplication via unique IDs

docs/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ <h3>3. Web UI</h3>
626626
<img src="assets/walkie-web.png" alt="walkie web UI showing multi-agent chat" style="width: 100%; display: block;">
627627
</div>
628628
<p style="color: var(--muted); text-align: center; margin-top: 16px; font-size: 0.9rem;">
629-
<code style="background: var(--surface); padding: 4px 8px; border-radius: 4px;">walkie web</code> — opens at localhost:3000
629+
<code style="background: var(--surface); padding: 4px 8px; border-radius: 4px;">walkie web</code> — opens at localhost:3000, remembers joined channels in your browser
630630
</p>
631631
</div>
632632
</section>
@@ -662,7 +662,7 @@ <h2 class="section-header">Commands</h2>
662662
</tr>
663663
<tr>
664664
<td>walkie web</td>
665-
<td>Browser chat UI. <code style="font-size:0.82em">-p PORT</code>, <code style="font-size:0.82em">-c channel:secret</code> to auto-join.</td>
665+
<td>Browser chat UI. <code style="font-size:0.82em">-p PORT</code>, <code style="font-size:0.82em">-c channel:secret</code> to auto-join. Same browser + origin remembers channels between restarts.</td>
666666
</tr>
667667
<tr>
668668
<td>walkie status</td>

docs/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ walkie connect <channel:secret> Connect to a channel. No colon = secret defau
3434
walkie send <channel> "msg" Send a message. Reads from stdin if no message given.
3535
walkie read <channel> Read pending messages. --wait blocks. --timeout N for deadline.
3636
walkie watch <channel:secret> Stream messages. --pretty for readable, --exec CMD per message.
37-
walkie web Browser chat UI at localhost:3000.
37+
walkie web Browser chat UI at localhost:3000. Same browser + origin remembers joined channels.
3838
walkie status Show active channels and peers.
3939
walkie leave <channel> Leave a channel.
4040
walkie stop Stop the background daemon.

src/daemon.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,12 @@ class WalkieDaemon {
9191
const isNew = !ch.subscribers.has(id)
9292
if (isNew) {
9393
ch.subscribers.set(id, { messages: [], waiters: [], lastReadTs: 0, lastSeen: Date.now() })
94-
} else {
95-
ch.subscribers.get(id).lastSeen = Date.now()
96-
// Announce join to local subscribers and remote peers
94+
// Announce new joins to local subscribers and remote peers.
9795
if (ch.subscribers.size > 1 || ch.peers.size > 0) {
9896
this._send(cmd.channel, `${id} joined`, 'system')
9997
}
98+
} else {
99+
ch.subscribers.get(id).lastSeen = Date.now()
100100
}
101101
reply({ ok: true, channel: cmd.channel })
102102
break

src/web-ui.js

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -549,16 +549,76 @@ module.exports = `<!DOCTYPE html>
549549
}
550550
551551
const MAX_MSGS = 200;
552+
const STORAGE_KEY = 'walkie:web:state:v1';
552553
let saveTimer = null;
553554
554-
function save() {
555+
function snapshotState() {
555556
const data = {};
556557
for (const [n, c] of ch) {
557558
data[n] = { secret: c.secret, msgs: c.msgs.slice(-MAX_MSGS) };
558559
}
559-
const state = { channels: data, active: active || null, name: storedName || null };
560+
return { channels: data, active: active || null, name: storedName || null };
561+
}
562+
563+
function normalizeState(state) {
564+
if (!state || typeof state !== 'object') return null;
565+
const channels = {};
566+
if (state.channels && typeof state.channels === 'object') {
567+
for (const [n, v] of Object.entries(state.channels)) {
568+
if (typeof n !== 'string' || !n) continue;
569+
if (typeof v === 'string') {
570+
if (v) channels[n] = { secret: v, msgs: [] };
571+
continue;
572+
}
573+
if (!v || typeof v !== 'object' || !v.secret) continue;
574+
channels[n] = {
575+
secret: v.secret,
576+
msgs: Array.isArray(v.msgs) ? v.msgs.slice(-MAX_MSGS) : []
577+
};
578+
}
579+
}
580+
return {
581+
channels,
582+
active: typeof state.active === 'string' ? state.active : null,
583+
name: typeof state.name === 'string' ? state.name : null
584+
};
585+
}
586+
587+
function applyState(state) {
588+
const normalized = normalizeState(state);
589+
if (!normalized) return false;
590+
for (const [n, v] of Object.entries(normalized.channels)) {
591+
ch.set(n, { secret: v.secret, msgs: v.msgs, unread: 0 });
592+
}
593+
storedName = normalized.name;
594+
if (normalized.active && ch.has(normalized.active)) active = normalized.active;
595+
return true;
596+
}
597+
598+
function readLocalState() {
599+
try {
600+
const raw = localStorage.getItem(STORAGE_KEY);
601+
if (!raw) return null;
602+
return normalizeState(JSON.parse(raw));
603+
} catch {
604+
return null;
605+
}
606+
}
607+
608+
function writeLocalState(state) {
609+
try {
610+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
611+
return true;
612+
} catch {
613+
return false;
614+
}
615+
}
616+
617+
function save() {
618+
const state = snapshotState();
560619
clearTimeout(saveTimer);
561620
saveTimer = setTimeout(() => {
621+
if (writeLocalState(state)) return;
562622
fetch('/state', {
563623
method: 'PUT',
564624
headers: { 'Content-Type': 'application/json' },
@@ -567,22 +627,28 @@ module.exports = `<!DOCTYPE html>
567627
}, 500);
568628
}
569629
570-
async function load() {
630+
async function loadLegacyState() {
571631
try {
572632
const resp = await fetch('/state');
573633
const state = await resp.json();
574-
if (state.channels) {
575-
for (const [n, v] of Object.entries(state.channels)) {
576-
if (typeof v === 'string') {
577-
if (v) ch.set(n, { secret: v, msgs: [], unread: 0 });
578-
} else if (v && v.secret) {
579-
ch.set(n, { secret: v.secret, msgs: v.msgs || [], unread: 0 });
580-
}
581-
}
582-
}
583-
storedName = state.name || null;
584-
if (state.active && ch.has(state.active)) active = state.active;
585-
} catch {}
634+
return normalizeState(state);
635+
} catch {
636+
return null;
637+
}
638+
}
639+
640+
async function load() {
641+
const localState = readLocalState();
642+
if (localState) {
643+
applyState(localState);
644+
return;
645+
}
646+
647+
const legacyState = await loadLegacyState();
648+
if (legacyState) {
649+
applyState(legacyState);
650+
writeLocalState(snapshotState());
651+
}
586652
}
587653
588654
function joinAll() {

test/api.test.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ describe('api.listen()', () => {
3030
await request({ action: 'join', channel: 'test-api', secret: 'secret', clientId: 'sender' })
3131

3232
const received = new Promise((resolve) => {
33-
ch.on('message', (msg) => resolve(msg))
33+
ch.on('message', (msg) => {
34+
if (msg.from !== 'system') resolve(msg)
35+
})
3436
})
3537

3638
await request({ action: 'send', channel: 'test-api', message: 'ping', clientId: 'sender' })
@@ -59,8 +61,9 @@ describe('api.listen()', () => {
5961
// Verify reader got it
6062
const resp = await request({ action: 'read', channel: 'test-send', clientId: 'reader' })
6163
assert.equal(resp.ok, true)
62-
assert.equal(resp.messages.length, 1)
63-
assert.equal(resp.messages[0].data, 'hello from API')
64+
const userMsgs = resp.messages.filter(msg => msg.from !== 'system')
65+
assert.equal(userMsgs.length, 1)
66+
assert.equal(userMsgs[0].data, 'hello from API')
6467

6568
await ch.close()
6669
})
@@ -84,8 +87,9 @@ describe('api.listen()', () => {
8487
// Give the stream loop time to deliver
8588
await new Promise(r => setTimeout(r, 500))
8689

87-
assert.equal(messages.length, 1)
88-
assert.equal(messages[0].data, 'from-other')
90+
const userMsgs = messages.filter(msg => msg.from !== 'system')
91+
assert.equal(userMsgs.length, 1)
92+
assert.equal(userMsgs[0].data, 'from-other')
8993

9094
await ch.close()
9195
})
@@ -104,6 +108,7 @@ describe('api.send()', () => {
104108

105109
// Verify receiver got it
106110
const resp = await request({ action: 'read', channel: 'test-oneshot', clientId: 'receiver' })
107-
assert.equal(resp.messages[0].data, 'fire-and-forget')
111+
const userMsgs = resp.messages.filter(msg => msg.from !== 'system')
112+
assert.equal(userMsgs[0].data, 'fire-and-forget')
108113
})
109114
})

test/web.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ describe('HTTP', () => {
9595
assert.equal(r.status, 200)
9696
assert.ok(r.headers['content-type'].includes('text/html'))
9797
assert.ok(r.body.includes('<'))
98+
assert.ok(r.body.includes("const STORAGE_KEY = 'walkie:web:state:v1';"))
99+
assert.ok(r.body.includes("fetch('/state')"))
98100
})
99101

100102
it('GET /unknown returns 404', async () => {

0 commit comments

Comments
 (0)