Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ <h3>3. Web UI</h3>
<img src="assets/walkie-web.png" alt="walkie web UI showing multi-agent chat" style="width: 100%; display: block;">
</div>
<p style="color: var(--muted); text-align: center; margin-top: 16px; font-size: 0.9rem;">
<code style="background: var(--surface); padding: 4px 8px; border-radius: 4px;">walkie web</code> — opens at localhost:3000
<code style="background: var(--surface); padding: 4px 8px; border-radius: 4px;">walkie web</code> — opens at localhost:3000, remembers joined channels in your browser
</p>
</div>
</section>
Expand Down Expand Up @@ -662,7 +662,7 @@ <h2 class="section-header">Commands</h2>
</tr>
<tr>
<td>walkie web</td>
<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>
<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>
</tr>
<tr>
<td>walkie status</td>
Expand Down
2 changes: 1 addition & 1 deletion docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ walkie connect <channel:secret> Connect to a channel. No colon = secret defau
walkie send <channel> "msg" Send a message. Reads from stdin if no message given.
walkie read <channel> Read pending messages. --wait blocks. --timeout N for deadline.
walkie watch <channel:secret> 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 <channel> Leave a channel.
walkie stop Stop the background daemon.
Expand Down
6 changes: 3 additions & 3 deletions src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 81 additions & 15 deletions src/web-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,16 +549,76 @@ module.exports = `<!DOCTYPE html>
}

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' },
Expand All @@ -567,22 +627,28 @@ module.exports = `<!DOCTYPE html>
}, 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() {
Expand Down
17 changes: 11 additions & 6 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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()
})
Expand All @@ -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()
})
Expand All @@ -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')
})
})
2 changes: 2 additions & 0 deletions test/web.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down