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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Dependencies
node_modules/
/client/node_modules
/server/node_modules

# Build output
dist/
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,11 @@ Client- and server-specific conventions live in nested memory files that load wh

`server/lib/navManifest.js` is the single source of truth for navigation: `NAV_COMMANDS` + `resolveNavCommand()`, consumed by both the `⌘K` palette and the voice agent's `ui_navigate` tool. **Adding a `<Route>` without a `NAV_COMMANDS` entry leaves the page unreachable from `⌘K` and un-navigable by voice.** Invoke the `portos-add-page` skill for the entry shape, palette-action wiring, and the fail-fast guards.

**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: '<id>'` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `<Route>` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. Tag the sidebar row in `client/src/components/Layout.jsx` with the same id; `navManifest.test.js` scrapes both lists and fails when they drift, when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section.
**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: '<id>'` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `<Route>` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. A sidebar row still needs its own `NAV_PRESENTATION` entry in `client/src/components/Layout.jsx` (path → icon); `feature`, `section` and `label` are inherited from `NAV_COMMANDS`, so the path is the one thing declared twice — the icon lives only in `NAV_PRESENTATION` — and a manifest tag alone yields no sidebar row — `NAV_PRESENTATION`'s keys are the set the sidebar iterates. `Layout.test.jsx` pins that every Settings, Digital Twin and Messages sub-tab path has a `NAV_PRESENTATION` entry, and that each entry stays presentation-only (an icon, no `to`/`label`/`section`/`feature`) keyed to a live manifest path. `navManifest.test.js` fails when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section.

**Feature groups bucket related features under one toggle, additively.** `INSTANCE_FEATURE_GROUPS` in `server/lib/instanceFeatureRegistry.js` declares the groups (today: `comms`, holding FaceTime Audio, iMessage, Signal, X, Stacker News and Beeper); a feature joins one by carrying `group: '<groupId>'` on its descriptor, which is the whole edit. `resolveOne` in `server/services/instanceFeatures.js` resolves a grouped feature as **its own stored override → the group flag → the detector → `defaultEnabled`**: an explicit per-feature override always wins, and only a feature left on "inherit" answers to its group (group off hides it, group on hands it straight back to its normal resolution). Setting an override back to inherit deletes the stored key rather than writing a third sentinel, so it reads exactly like a feature nobody ever touched. **A group's own `enabled` defaults to `true` when no group state is stored** — that default is the parity guarantee: an install with no `instanceFeatureGroups` in settings resolves every member exactly as it did before the group existed, so registering a group is never a silent hide and needs no settings migration. Malformed group settings fail toward `false`, matching the per-feature override's own posture. An ungrouped feature is completely unaffected.

**A feature toggle that arms background work must reconcile that work at toggle time, not only at boot.** A subsystem gated on "feature on AND credential present" and started once in `services/bootstrap.js` is silently wrong the moment either half of that gate moves at runtime: on a live install, storing a Beeper credential left the realtime transport down and no sweep registered for 48 minutes, until a restart — and the mirror-image gap left a socket relaying on a token a disconnect had just revoked. Give the subsystem one idempotent `reconcile…()` that reads the gate and moves everything to match it, and call it from every path that can move the gate (each credential write, the feature toggle, the group toggle, disconnect). Make repeat calls no-ops rather than re-registrations — re-`schedule()`ing an existing event resets `nextRunAt` a full interval out, so an unrelated toggle would keep pushing the next run away — serialize overlapping calls on one tail, and log transitions only, so an install that has never enabled the feature still narrates nothing. `server/services/beeperArming.js` is the worked example.

### Slashdo Commands (`lib/slashdo`)

Expand Down
1 change: 1 addition & 0 deletions client/src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export const NAV_PRESENTATION = {
'/cos/jobs': { icon: Bot },
'/cos/tasks': { icon: FileText },
'/cos/workflow': { icon: ChartGantt },
'/messages/beeper': { icon: MessageCircle },
'/messages/config': { icon: Settings },
'/messages/contacts': { icon: Users },
'/messages/drafts': { icon: FilePen },
Expand Down
34 changes: 33 additions & 1 deletion client/src/components/Layout.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const allFeaturesOn = () => [
{ id: 'gsd', label: 'GSD', enabled: true },
{ id: 'openclaw', label: 'OpenClaw', enabled: true },
{ id: 'health', label: 'Health tracking', enabled: true },
{ id: 'beeper', label: 'Beeper', enabled: true },
];
const featureMock = vi.hoisted(() => ({ features: null }));

Expand Down Expand Up @@ -148,7 +149,7 @@ describe('Layout — manifest-derived sidebar structure', () => {
expect(NAV_PRESENTATION[p], `missing NAV_PRESENTATION for digital twin tab ${p}`).toBeDefined();
}

const messageTabs = ['inbox', 'drafts', 'imessage', 'signal', 'contacts', 'sync', 'config'];
const messageTabs = ['inbox', 'drafts', 'imessage', 'signal', 'beeper', 'contacts', 'sync', 'config'];
for (const tab of messageTabs) {
const p = `/messages/${tab}`;
expect(NAV_PRESENTATION[p], `missing NAV_PRESENTATION for message tab ${p}`).toBeDefined();
Expand Down Expand Up @@ -330,6 +331,37 @@ describe('Layout — instance feature gating', () => {
});
});

describe('Layout — Comms section (Beeper)', () => {
// #30 / real-browser pass: the Beeper row never rendered in the sidebar Comms
// section, feature on or off, because NAV_PRESENTATION had no '/messages/beeper'
// key. navRowForPath() throws the other way around — for a NAV_PRESENTATION
// path with no matching NAV_COMMANDS entry — so a path missing from
// NAV_PRESENTATION itself never reaches navRowForPath at all (presentedNavRows
// only maps over NAV_PRESENTATION's own keys), and the row was silently absent
// rather than a crash.
it('lists Beeper in the Comms section alongside the other messaging rows when the feature is on', async () => {
await renderLayout('/messages/inbox');

const beeper = screen.getByRole('link', { name: 'Beeper' });
expect(beeper).toHaveAttribute('href', '/messages/beeper');
// Same section as its siblings, not a stray top-level row.
expect(screen.getByRole('link', { name: 'Signal' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'iMessage' })).toBeTruthy();
});

it('drops only the Beeper row when the beeper feature is off, keeping the rest of Comms', async () => {
featureMock.features = allFeaturesOn()
.map((f) => (f.id === 'beeper' ? { ...f, enabled: false } : f));

await renderLayout('/messages/inbox');

expect(screen.queryByRole('link', { name: 'Beeper' })).toBeNull();
expect(screen.getByRole('link', { name: 'Signal' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'iMessage' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'Inbox' })).toBeTruthy();
});
});

describe('Layout — System Resources location state', () => {
it('keeps Dev Tools expanded and System Resources active on every subtab', async () => {
await renderLayout('/system-resources/storage');
Expand Down
14 changes: 2 additions & 12 deletions client/src/components/agents/tabs/WorldTab.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import toast from '../../ui/Toast';
import { FormField } from '../../ui/FormField';
import ConnectionStatusDot from '../../ui/ConnectionStatusDot';
import * as api from '../../../services/api';
import BrailleSpinner from '../../BrailleSpinner';
import socket from '../../../services/socket';
Expand Down Expand Up @@ -390,13 +391,6 @@ export default function WorldTab({ agentId }) {
// leaving stale phantoms.
const displayNearby = presence !== null ? presence : nearby;

const statusDotColor = {
connected: 'bg-port-success',
connecting: 'bg-port-warning animate-pulse',
reconnecting: 'bg-port-warning animate-pulse',
disconnected: 'bg-gray-600'
}[connectionStatus] || 'bg-gray-600';

// Dynamic param fields for add-to-queue form
const renderQueueParamFields = () => {
switch (newActionType) {
Expand Down Expand Up @@ -447,11 +441,7 @@ export default function WorldTab({ agentId }) {
<div className="p-4">
{/* Connection Banner */}
<div className="flex flex-wrap items-center gap-4 mb-4 p-3 bg-port-card border border-port-border rounded-lg">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${statusDotColor}`} />
<span className="text-sm text-gray-400">WebSocket:</span>
<span className="text-sm text-white font-medium">{connectionStatus}</span>
</div>
<ConnectionStatusDot status={connectionStatus} label="WebSocket:" />
<div className="flex gap-2 ml-auto">
{connectionStatus === 'disconnected' ? (
<button
Expand Down
173 changes: 173 additions & 0 deletions client/src/components/messages/BeeperTab.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
useCallback, useEffect, useRef, useState,
} from 'react';
import { useParams, useSearchParams } from 'react-router';
import toast from '../ui/Toast';
import Drawer from '../Drawer';
import useDrawerTab from '../../hooks/useDrawerTab';
import useBeeperRealtime from '../../hooks/useBeeperRealtime';
import useMounted from '../../hooks/useMounted';
import { getBeeperStatus } from '../../services/api';
import BeeperChatSurface from './beeper/BeeperChatSurface';
import BeeperSettingsPanel from './beeper/BeeperSettingsPanel';

/**
* Comms → Messages → Beeper. The page shell for the chat surface (#35).
*
* It owns exactly two things the surface and the settings panel must share:
*
* 1. **The page-level realtime subscription.** `useBeeperRealtime` pairs its
* own `beeper:subscribe`/`beeper:unsubscribe` per mount, and several
* subscribers may be live at once — `useBeeperOutbox`, reached through
* `BeeperChatSurface`, mounts its own instance to refetch the outbox on a
* `message.upserted` invalidation (`client/src/hooks/README.md` sanctions
* this). This is the ONE that owns the status card: its liveness snapshot
* plus an invalidation counter (and the frames behind it, for
* `BeeperChatSurface`'s own frame-scoped thread refetch) are handed down
* as props, so the settings drawer never needs a subscription of its own.
* Sweep state (`sweep`) and `tokenConfigured` ride the same status fetch
* (#80) — no second poller, and every invalidation frame the sweep itself
* fires as it goes (see `beeperSync.js`) refreshes it here too.
* 2. **The settings drawer.** #30's status card is not removed by the chat
* surface landing — it moves behind a header action, deep-linked as
* `?settings=1` exactly like the iMessage ingestion drawer, so ⌘K and voice
* can open it and an actionable fault still has a home that is not a
* global banner.
*
* The open conversation is the route param on `/messages/beeper/:conversationId`
* (Messages routes it as the shared `:chatKey` segment), never local state.
*/

// The OAuth 2.0 error codes Beeper's own consent screen can send back,
// mapped to a plain sentence rather than shown raw — the bare code read as
// implementation detail with no remedy. An unrecognized code still gets a
// generic sentence rather than disappearing, with the raw code kept as a
// trailing parenthetical either way so the exact server-reported reason is
// never lost, only never led with.
const OAUTH_ERROR_SENTENCES = {
access_denied: 'Beeper connect was not approved',
invalid_scope: 'Beeper could not grant the access PortOS asked for',
server_error: 'Beeper reported a server error during connect',
};
const oauthErrorSentence = (code) => `${OAUTH_ERROR_SENTENCES[code] || 'Beeper connect failed'} (${code})`;

export default function BeeperTab() {
const { chatKey } = useParams();
const [settingsParam, setSettingsParam] = useDrawerTab('settings', null, ['1']);
// `invalidationSeq` is the "something changed, re-read the mirror" pulse —
// still a bare counter, because the list/networks refetch it drives (design
// decision: other chats' previews and unread counts always refresh) needs no
// frame detail. `invalidationFramesRef` rides alongside it as a MAILBOX, not
// React state: `BeeperChatSurface` needs each frame's own `chatID` to decide
// whether the OPEN THREAD is in scope for a refetch (audit cluster 07,
// findings PERF-6/BEEP-5) — a counter alone cannot tell "another chat
// changed" from "this one did". It is a ref rather than state because the
// surface drains it itself once it has scheduled a refetch for everything
// currently in it, so relaying frames down costs no extra render and needs
// no hand-back of "how many did you consume".
const invalidationFramesRef = useRef([]);
const [invalidationSeq, setInvalidationSeq] = useState(0);

const mountedRef = useMounted();
const onInvalidate = useCallback((frame) => {
invalidationFramesRef.current.push(frame ?? null);
setInvalidationSeq((seq) => seq + 1);
}, []);
const { realtime, seedRealtime } = useBeeperRealtime({ onInvalidate });

// The outbound runaway breaker's read model (#36, decided on #8). The
// composer disables Send off this — the SAME status the settings drawer's
// `BeeperOutboxBreakerBanner` already reads — rather than a second banner on
// the chat surface, which #12 decision 4 reserves for the settings card.
const [breaker, setBreaker] = useState(null);

// Sweep visibility (#80): running/idle, started/finished, accounts done of
// the total, chats and messages mirrored so far — the list header's
// "Syncing… N of M accounts" / "Last synced HH:MM" strip reads this, and
// `tokenConfigured` is what the empty state branches on instead of
// `networks.length` alone. Both ride the SAME status fetch as `realtime`
// and `breaker` below, reusing the existing mount + invalidation-frame
// triggers rather than adding a second poller — a sweep's own per-account
// progress reaches here because `beeperSync.js` fires an invalidation frame
// as it goes (see beeperSync.js's `emitSweepInvalidation`).
const [sweep, setSweep] = useState(null);
const [tokenConfigured, setTokenConfigured] = useState(false);

// Seeded from the page, not from the settings drawer: `beeper:subscribe`
// does not push the current transport state, and the drawer's own status
// fetch only runs once it is opened — so without this the rail's dot would
// stay blank on a healthy install until something changed. The breaker flag
// rides the same fetch for the same reason: the composer needs it before the
// user has ever opened the settings drawer.
const seedStatus = useCallback(() => {
getBeeperStatus({ silent: true })
.then((status) => {
if (!mountedRef.current) return;
if (status?.realtime) seedRealtime(status.realtime);
setBreaker(status?.outbox?.breaker || null);
setSweep(status?.sweep || null);
setTokenConfigured(status?.tokenConfigured === true);
})
.catch(() => {});
}, [seedRealtime, mountedRef]);

// Re-read on the invalidation counter, not only at mount. `seedStatus` is a
// stable callback, so keying the effect on it alone made this a MOUNT-TIME
// SNAPSHOT: a breaker that trips during the session (a send loop, three
// refused sends in a row) never reached the composer, which stayed live
// against a server that would refuse every send until a human cleared it —
// and the only way to see the truth was a page reload. The counter is the
// one "something moved" signal this page already owns.
useEffect(() => { seedStatus(); }, [seedStatus, invalidationSeq]);

// Beeper redirects the BROWSER back to this PAGE after consent (#31), not to
// the settings drawer — so the outcome flag is read here, where something is
// always mounted, rather than in the panel that only exists while the drawer
// is open. The server callback already exchanged the code and vaulted the
// token; all that arrives is the outcome. Report it once, then strip it so a
// reload doesn't repeat the toast, and on a FAILURE open the settings drawer
// in the same URL write, because that is where the connect card that fixes it
// lives.
const [searchParams, setSearchParams] = useSearchParams();
const oauthConnected = searchParams.get('beeperConnected');
const oauthError = searchParams.get('beeperOauthError');
useEffect(() => {
if (!oauthConnected && !oauthError) return;
if (oauthError) toast.error(oauthErrorSentence(oauthError));
else {
toast.success('Beeper connected');
seedStatus();
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('beeperConnected');
next.delete('beeperOauthError');
if (oauthError) next.set('settings', '1');
return next;
}, { replace: true });
}, [oauthConnected, oauthError, setSearchParams, seedStatus]);

return (
<div className="h-full min-h-0">
<BeeperChatSurface
conversationId={chatKey || null}
realtime={realtime}
invalidationSeq={invalidationSeq}
invalidationFrames={invalidationFramesRef}
breaker={breaker}
sweep={sweep}
tokenConfigured={tokenConfigured}
onOpenSettings={() => setSettingsParam('1')}
/>

<Drawer
open={settingsParam === '1'}
onClose={() => setSettingsParam(null)}
title="Beeper Settings"
size="md"
>
<BeeperSettingsPanel realtime={realtime} onRealtimeSeed={seedRealtime} onBreakerCleared={seedStatus} />
</Drawer>
</div>
);
}
Loading