@agenticmail/api 0.9.15 → 0.9.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +10 -1
- package/package.json +2 -2
- package/public/branding/claude-color.svg +1 -0
- package/public/branding/openai-mark.svg +1 -0
- package/public/index.html +1 -0
- package/public/js/app.js +5 -1
- package/public/js/avatar.js +75 -11
- package/public/js/profile.js +271 -15
- package/public/js/state.js +15 -0
- package/public/styles.css +139 -0
package/dist/index.js
CHANGED
|
@@ -2246,7 +2246,16 @@ function createMailRoutes(accountManager, config, db, gatewayManager) {
|
|
|
2246
2246
|
}
|
|
2247
2247
|
const password = getAgentPassword(agent);
|
|
2248
2248
|
const receiver = await getReceiver(agent.stalwartPrincipal, password, config);
|
|
2249
|
-
|
|
2249
|
+
let raw;
|
|
2250
|
+
try {
|
|
2251
|
+
raw = await receiver.fetchMessage(uid, folder);
|
|
2252
|
+
} catch (err) {
|
|
2253
|
+
if (err && typeof err === "object" && err.code === "MESSAGE_NOT_FOUND") {
|
|
2254
|
+
res.status(404).json({ error: err.message, code: "MESSAGE_NOT_FOUND" });
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
throw err;
|
|
2258
|
+
}
|
|
2250
2259
|
const parsed = await parseEmail2(raw);
|
|
2251
2260
|
const attachments = Array.isArray(parsed.attachments) ? parsed.attachments.map((a, index) => ({
|
|
2252
2261
|
index,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenticmail/api",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"description": "REST API server for AgenticMail — email and SMS endpoints for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"prepublishOnly": "npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@agenticmail/core": "^0.9.
|
|
31
|
+
"@agenticmail/core": "^0.9.4",
|
|
32
32
|
"cors": "^2.8.5",
|
|
33
33
|
"dotenv": "^16.4.7",
|
|
34
34
|
"express": "^4.21.0",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
package/public/index.html
CHANGED
package/public/js/app.js
CHANGED
|
@@ -8,7 +8,7 @@ import { state, API_URL } from './state.js';
|
|
|
8
8
|
import { toast } from './utils.js';
|
|
9
9
|
import { apiGet } from './api.js';
|
|
10
10
|
import { isBridgeAgent } from './avatar.js';
|
|
11
|
-
import { renderProfile, toggleProfileMenu, closeProfileMenu } from './profile.js';
|
|
11
|
+
import { renderProfile, toggleProfileMenu, closeProfileMenu, bindHostSwitcher } from './profile.js';
|
|
12
12
|
import { renderSidebar } from './sidebar.js';
|
|
13
13
|
import { loadList, renderList, clearSearch, ensureFolderCache } from './list-view.js';
|
|
14
14
|
import { openMessage } from './message-view.js';
|
|
@@ -268,6 +268,10 @@ document.getElementById('refresh-btn').addEventListener('click', async () => {
|
|
|
268
268
|
});
|
|
269
269
|
document.getElementById('compose-btn').addEventListener('click', openCompose);
|
|
270
270
|
document.getElementById('profile-btn').addEventListener('click', toggleProfileMenu);
|
|
271
|
+
// Host-switcher pills inside the profile menu get their own delegated
|
|
272
|
+
// click handler — we bind once and let it survive every re-render of
|
|
273
|
+
// the switcher slot's innerHTML.
|
|
274
|
+
bindHostSwitcher();
|
|
271
275
|
document.getElementById('profile-menu').addEventListener('click', e => {
|
|
272
276
|
e.stopPropagation();
|
|
273
277
|
const item = e.target.closest('.profile-menu-item');
|
package/public/js/avatar.js
CHANGED
|
@@ -1,23 +1,84 @@
|
|
|
1
1
|
// Agent identity + avatar helpers.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
3
|
+
// Each host integration (Claude Code, Codex, …) gets its own branded
|
|
4
|
+
// avatar in the web UI so multiple co-installed bridges can be told
|
|
5
|
+
// apart at a glance. The HOST_BRANDING table below registers one entry
|
|
6
|
+
// per known host integration. Adding a new host = add one row + drop
|
|
7
|
+
// the SVG into /branding/.
|
|
8
8
|
import { escapeHtml } from './utils.js';
|
|
9
9
|
import { icon } from './icons.js';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Registry of host integrations → branding.
|
|
13
|
+
*
|
|
14
|
+
* key metadata.host value (lowercased)
|
|
15
|
+
* logoUrl path under /branding/, served as a static asset
|
|
16
|
+
* altText alt= text on the <img>
|
|
17
|
+
* aliases legacy / alternate names that should map to this host
|
|
18
|
+
* (some bridges historically used the host's product
|
|
19
|
+
* name instead of the host id)
|
|
20
|
+
*
|
|
21
|
+
* The lookup picks an entry by, in order:
|
|
22
|
+
* 1. agent.metadata.host
|
|
23
|
+
* 2. agent.name (so a bridge with name 'codex' but no metadata.host
|
|
24
|
+
* still renders correctly during the upgrade window)
|
|
25
|
+
* 3. any alias listed under a host
|
|
26
|
+
*/
|
|
27
|
+
const HOST_BRANDING = {
|
|
28
|
+
claudecode: {
|
|
29
|
+
logoUrl: '/branding/claude-color.svg',
|
|
30
|
+
altText: 'Claude',
|
|
31
|
+
aliases: ['claude'],
|
|
32
|
+
},
|
|
33
|
+
codex: {
|
|
34
|
+
logoUrl: '/branding/openai-mark.svg',
|
|
35
|
+
altText: 'OpenAI Codex',
|
|
36
|
+
aliases: ['openai', 'chatgpt'],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Fallback when we know an account is a bridge but can't identify which
|
|
41
|
+
// host owns it (legacy account with no host tag, unknown host name).
|
|
42
|
+
// Better than mis-attributing — a generic host badge plus a verified
|
|
43
|
+
// tick still reads as "this is a host, not a teammate".
|
|
44
|
+
const GENERIC_HOST_LOGO = '/branding/agenticmail-logo.png';
|
|
15
45
|
|
|
16
46
|
export function isBridgeAgent(agent) {
|
|
17
47
|
if (!agent) return false;
|
|
18
48
|
const name = (agent.name ?? '').toLowerCase();
|
|
19
49
|
const role = (agent.role ?? '').toLowerCase();
|
|
20
|
-
|
|
50
|
+
const meta = agent.metadata ?? {};
|
|
51
|
+
if (role === 'bridge') return true;
|
|
52
|
+
if (meta && meta.bridge === true) return true;
|
|
53
|
+
// Name-based fallback for pre-0.9.3 bridges that still use role='assistant'.
|
|
54
|
+
if (HOST_BRANDING[name]) return true;
|
|
55
|
+
for (const entry of Object.values(HOST_BRANDING)) {
|
|
56
|
+
if (entry.aliases?.includes(name)) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the host branding entry for an agent. Returns null when the
|
|
63
|
+
* agent isn't a bridge or can't be matched to a known host.
|
|
64
|
+
*/
|
|
65
|
+
function brandingFor(agent) {
|
|
66
|
+
if (!agent) return null;
|
|
67
|
+
const name = (agent.name ?? '').toLowerCase();
|
|
68
|
+
const metaHost = (agent.metadata?.host ?? '').toString().toLowerCase();
|
|
69
|
+
|
|
70
|
+
// 1. Trust the host tag first — it's the canonical source of truth.
|
|
71
|
+
if (metaHost && HOST_BRANDING[metaHost]) return HOST_BRANDING[metaHost];
|
|
72
|
+
|
|
73
|
+
// 2. Fall back to matching the bridge name itself.
|
|
74
|
+
if (HOST_BRANDING[name]) return HOST_BRANDING[name];
|
|
75
|
+
|
|
76
|
+
// 3. Check aliases (e.g. a bridge literally named 'claude').
|
|
77
|
+
for (const entry of Object.values(HOST_BRANDING)) {
|
|
78
|
+
if (entry.aliases?.includes(name)) return entry;
|
|
79
|
+
if (entry.aliases?.includes(metaHost)) return entry;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
21
82
|
}
|
|
22
83
|
|
|
23
84
|
// Deterministic colour per agent name — keeps teammate colours stable
|
|
@@ -35,7 +96,10 @@ function avatarColorFor(name) {
|
|
|
35
96
|
export function avatarHtml(agent, size = '') {
|
|
36
97
|
const cls = `avatar ${size}`.trim();
|
|
37
98
|
if (isBridgeAgent(agent)) {
|
|
38
|
-
|
|
99
|
+
const brand = brandingFor(agent);
|
|
100
|
+
const url = brand?.logoUrl ?? GENERIC_HOST_LOGO;
|
|
101
|
+
const alt = brand?.altText ?? 'Host';
|
|
102
|
+
return `<span class="${cls} avatar-host"><img src="${url}" alt="${escapeHtml(alt)}" class="avatar-img" /><span class="avatar-check">${icon('check', { size: 10 })}</span></span>`;
|
|
39
103
|
}
|
|
40
104
|
const initial = (agent.name ?? '?').slice(0, 1).toUpperCase();
|
|
41
105
|
const color = avatarColorFor(agent.name ?? '');
|
package/public/js/profile.js
CHANGED
|
@@ -1,28 +1,148 @@
|
|
|
1
1
|
// Top-right Gmail-style account switcher. Lists every AgenticMail
|
|
2
2
|
// agent the master key can see; clicking switches the active inbox.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
//
|
|
4
|
+
// ──────────────────────────────────────────────────────────────────
|
|
5
|
+
// Host-switcher (the Airbnb "switch to hosting" pattern)
|
|
6
|
+
// ──────────────────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// Above the inbox list we render a segmented pill toggle showing each
|
|
9
|
+
// known host (Claude / Codex / All). Clicking a pill flips the inbox
|
|
10
|
+
// list with a 3D Y-axis rotation, swapping the content at the exact
|
|
11
|
+
// orthogonal midpoint of the rotation so the "front" and "back" of
|
|
12
|
+
// the card appear to carry different rosters — same trick Airbnb's
|
|
13
|
+
// Host Passport uses for its book-flip illusion. (See the Airbnb
|
|
14
|
+
// engineering blog: "Animations: Bringing the Host Passport to Life".)
|
|
15
|
+
//
|
|
16
|
+
// The selected host persists in localStorage so the operator's view
|
|
17
|
+
// preference survives reloads and across browser tabs.
|
|
5
18
|
import { state } from './state.js';
|
|
6
19
|
import { escapeHtml } from './utils.js';
|
|
7
20
|
import { avatarHtml, isBridgeAgent } from './avatar.js';
|
|
8
21
|
import { icon } from './icons.js';
|
|
9
22
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Host registry mirrors the one in avatar.js. Kept duplicated rather
|
|
25
|
+
* than imported because that file's HOST_BRANDING table is concerned
|
|
26
|
+
* with logo paths; here we care about display labels + filter
|
|
27
|
+
* semantics. Adding a new host = one row in both places.
|
|
28
|
+
*/
|
|
29
|
+
const HOST_FILTERS = [
|
|
30
|
+
{
|
|
31
|
+
id: 'claudecode',
|
|
32
|
+
label: 'Claude',
|
|
33
|
+
logoUrl: '/branding/claude-color.svg',
|
|
34
|
+
aliases: ['claude'],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'codex',
|
|
38
|
+
label: 'Codex',
|
|
39
|
+
logoUrl: '/branding/openai-mark.svg',
|
|
40
|
+
aliases: ['openai', 'chatgpt'],
|
|
41
|
+
},
|
|
42
|
+
];
|
|
15
43
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The "All" pill is always present — it's the original behavior of
|
|
46
|
+
* showing every account regardless of host, useful for operators who
|
|
47
|
+
* want a global view (the same way Airbnb's host switcher always
|
|
48
|
+
* lets you fall back to the unified profile).
|
|
49
|
+
*/
|
|
50
|
+
const ALL_FILTER = { id: 'all', label: 'All', logoUrl: null };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Return the host id this agent belongs to, lowercased and normalised
|
|
54
|
+
* against the host filter table. Returns `null` when the agent has no
|
|
55
|
+
* host stamp at all (legacy / unclaimed accounts).
|
|
56
|
+
*/
|
|
57
|
+
function hostIdForAgent(agent) {
|
|
58
|
+
const meta = agent.metadata ?? {};
|
|
59
|
+
const raw = typeof meta.host === 'string' ? meta.host.toLowerCase().trim() : '';
|
|
60
|
+
if (!raw) return null;
|
|
61
|
+
for (const h of HOST_FILTERS) {
|
|
62
|
+
if (h.id === raw) return h.id;
|
|
63
|
+
if (h.aliases?.includes(raw)) return h.id;
|
|
21
64
|
}
|
|
65
|
+
return raw; // unknown future host — still let the user filter by it
|
|
66
|
+
}
|
|
22
67
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Compute the visible inbox list for the current `state.activeHost`.
|
|
70
|
+
*
|
|
71
|
+
* 'all' → every account, no filtering
|
|
72
|
+
* '<host id>' → only the matching bridge + sub-agents owned by it
|
|
73
|
+
*
|
|
74
|
+
* The bridge is forced to the top of the list within its host's view
|
|
75
|
+
* (it's the host's own identity — "you, the operator" — analogous to
|
|
76
|
+
* Airbnb pinning your own account row above your sub-listings).
|
|
77
|
+
*/
|
|
78
|
+
function visibleAgents() {
|
|
79
|
+
if (state.activeHost === 'all') return state.agents;
|
|
80
|
+
const wanted = state.activeHost.toLowerCase();
|
|
81
|
+
return state.agents.filter(a => hostIdForAgent(a) === wanted);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the host-switcher pill bar. Only renders pills for hosts that
|
|
86
|
+
* have at least one agent (bridge OR sub-agent) so the UI doesn't
|
|
87
|
+
* show a "Codex" pill if codex isn't installed yet.
|
|
88
|
+
*/
|
|
89
|
+
function renderHostSwitcher() {
|
|
90
|
+
const presentHosts = new Set(
|
|
91
|
+
state.agents
|
|
92
|
+
.map(a => hostIdForAgent(a))
|
|
93
|
+
.filter(Boolean),
|
|
94
|
+
);
|
|
95
|
+
const filters = [
|
|
96
|
+
ALL_FILTER,
|
|
97
|
+
...HOST_FILTERS.filter(h => presentHosts.has(h.id)),
|
|
98
|
+
];
|
|
99
|
+
// Single-host installs don't need a switcher — degrades to nothing.
|
|
100
|
+
if (filters.length <= 1) return '';
|
|
101
|
+
return `
|
|
102
|
+
<div class="host-switcher" role="tablist" aria-label="Filter inboxes by host">
|
|
103
|
+
${filters.map(f => {
|
|
104
|
+
const active = state.activeHost === f.id;
|
|
105
|
+
const logo = f.logoUrl
|
|
106
|
+
? `<img src="${f.logoUrl}" alt="" class="host-switcher-logo" />`
|
|
107
|
+
: `<span class="host-switcher-dot">${icon('dot', { size: 8 })}</span>`;
|
|
108
|
+
return `
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
class="host-switcher-pill ${active ? 'is-active' : ''}"
|
|
112
|
+
data-host="${escapeHtml(f.id)}"
|
|
113
|
+
role="tab"
|
|
114
|
+
aria-selected="${active}"
|
|
115
|
+
title="Show only ${escapeHtml(f.label)} agents"
|
|
116
|
+
>
|
|
117
|
+
${logo}
|
|
118
|
+
<span>${escapeHtml(f.label)}</span>
|
|
119
|
+
</button>
|
|
120
|
+
`;
|
|
121
|
+
}).join('')}
|
|
122
|
+
</div>
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the actual inbox-list HTML (a sibling-by-sibling string of
|
|
128
|
+
* `.profile-menu-item` divs). Factored out so we can pre-render BOTH
|
|
129
|
+
* the current view and the next view when animating a host switch.
|
|
130
|
+
*/
|
|
131
|
+
function renderInboxListHtml(agents) {
|
|
132
|
+
if (agents.length === 0) {
|
|
133
|
+
return `
|
|
134
|
+
<div class="profile-menu-empty">
|
|
135
|
+
<div class="empty-icon">${icon('inbox', { size: 28 })}</div>
|
|
136
|
+
<div class="empty-text">No agents in this view yet.</div>
|
|
137
|
+
<div class="empty-hint">
|
|
138
|
+
Switch to <strong>All</strong> above, or use<br/>
|
|
139
|
+
<code>agenticmail-<host> claim <name></code><br/>
|
|
140
|
+
to assign agents to this host.
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
return agents.map(agent => {
|
|
26
146
|
const selected = state.selectedAgent?.id === agent.id;
|
|
27
147
|
const badge = isBridgeAgent(agent)
|
|
28
148
|
? '<span class="role-badge role-badge-host">Host</span>'
|
|
@@ -50,6 +170,41 @@ export function renderProfile() {
|
|
|
50
170
|
}).join('');
|
|
51
171
|
}
|
|
52
172
|
|
|
173
|
+
export function renderProfile() {
|
|
174
|
+
const a = state.selectedAgent;
|
|
175
|
+
const totalOtherUnread = Object.entries(state.unread ?? {})
|
|
176
|
+
.filter(([id]) => id !== a?.id)
|
|
177
|
+
.reduce((sum, [, n]) => sum + n, 0);
|
|
178
|
+
|
|
179
|
+
const avatarEl = document.getElementById('profile-avatar');
|
|
180
|
+
if (avatarEl) {
|
|
181
|
+
avatarEl.innerHTML = a
|
|
182
|
+
? avatarHtml(a) + (totalOtherUnread > 0 ? `<span class="avatar-check" style="background:#dc2626">${icon('dot', { size: 8 })}</span>` : '')
|
|
183
|
+
: '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const switcherSlot = document.getElementById('profile-menu-switcher');
|
|
187
|
+
if (switcherSlot) switcherSlot.innerHTML = renderHostSwitcher();
|
|
188
|
+
|
|
189
|
+
const list = document.getElementById('profile-menu-list');
|
|
190
|
+
if (!list) return;
|
|
191
|
+
// Render into the .flip-face-front pane. The .flip-face-back pane
|
|
192
|
+
// exists for the in-flight animation only and gets populated on
|
|
193
|
+
// demand by `flipToHost`. After every plain re-render we also
|
|
194
|
+
// reset the wrapper so a stale rotation can't strand us mid-flip.
|
|
195
|
+
const front = list.querySelector('.flip-face-front');
|
|
196
|
+
if (front) {
|
|
197
|
+
front.innerHTML = renderInboxListHtml(visibleAgents());
|
|
198
|
+
} else {
|
|
199
|
+
list.innerHTML = `
|
|
200
|
+
<div class="flip-card">
|
|
201
|
+
<div class="flip-face flip-face-front">${renderInboxListHtml(visibleAgents())}</div>
|
|
202
|
+
<div class="flip-face flip-face-back"></div>
|
|
203
|
+
</div>
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
53
208
|
/**
|
|
54
209
|
* Render a host-ownership badge for an agent. The host name comes from
|
|
55
210
|
* `metadata.host` on the account. Three states:
|
|
@@ -83,6 +238,107 @@ function hostBadge(agent) {
|
|
|
83
238
|
return '<span class="role-badge role-badge-unclaimed" title="No host owner — any dispatcher will wake on this account. Run `agenticmail-<host> claim <name>` to settle ownership.">Unclaimed</span>';
|
|
84
239
|
}
|
|
85
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Animate a switch from the current `state.activeHost` to `nextHost`.
|
|
243
|
+
*
|
|
244
|
+
* Trick borrowed from Airbnb's Host Passport flip and the classic CSS
|
|
245
|
+
* "flip-card" pattern: the inbox-list wrapper has TWO stacked faces
|
|
246
|
+
* with `backface-visibility: hidden`. The front face shows the current
|
|
247
|
+
* roster; we pre-populate the back face with the next roster, then
|
|
248
|
+
* rotate the wrapper 180deg on the Y axis. CSS handles the rest — the
|
|
249
|
+
* front face is visible for the first 90deg, fades to edge-on (and
|
|
250
|
+
* thus invisible) at the midpoint, then the back face takes over for
|
|
251
|
+
* the second 90deg.
|
|
252
|
+
*
|
|
253
|
+
* After the transition completes, we swap the contents (so the
|
|
254
|
+
* now-visible "back" face becomes the new "front") and reset the
|
|
255
|
+
* rotation without animation, leaving the card ready for the next
|
|
256
|
+
* flip. This avoids ever-accumulating rotation values.
|
|
257
|
+
*
|
|
258
|
+
* Honors `prefers-reduced-motion`: skips the rotation entirely and
|
|
259
|
+
* just updates the roster in place.
|
|
260
|
+
*/
|
|
261
|
+
function flipToHost(nextHost) {
|
|
262
|
+
if (nextHost === state.activeHost) return;
|
|
263
|
+
state.activeHost = nextHost;
|
|
264
|
+
try { localStorage.setItem('agenticmail.activeHost', nextHost); } catch { /* private mode */ }
|
|
265
|
+
|
|
266
|
+
// Re-render the switcher pill highlights up front so the pressed
|
|
267
|
+
// pill flips highlighted state immediately. Visual feedback first,
|
|
268
|
+
// animation second.
|
|
269
|
+
const switcherSlot = document.getElementById('profile-menu-switcher');
|
|
270
|
+
if (switcherSlot) switcherSlot.innerHTML = renderHostSwitcher();
|
|
271
|
+
|
|
272
|
+
const list = document.getElementById('profile-menu-list');
|
|
273
|
+
if (!list) return;
|
|
274
|
+
const card = list.querySelector('.flip-card');
|
|
275
|
+
const front = list.querySelector('.flip-face-front');
|
|
276
|
+
const back = list.querySelector('.flip-face-back');
|
|
277
|
+
if (!card || !front || !back) {
|
|
278
|
+
// No flip scaffold (first render shouldn't hit this). Plain render.
|
|
279
|
+
renderProfile();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
284
|
+
const nextHtml = renderInboxListHtml(visibleAgents());
|
|
285
|
+
|
|
286
|
+
if (reduceMotion) {
|
|
287
|
+
front.innerHTML = nextHtml;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Pre-populate the back face with the destination roster so it's
|
|
292
|
+
// ready to be revealed past the 90deg midpoint.
|
|
293
|
+
back.innerHTML = nextHtml;
|
|
294
|
+
|
|
295
|
+
// Kick the flip. The transition is defined in CSS; we just toggle
|
|
296
|
+
// the .flipped class to start the rotation.
|
|
297
|
+
// requestAnimationFrame ensures the back-face innerHTML update has
|
|
298
|
+
// committed to the layout before the rotation begins, otherwise
|
|
299
|
+
// Safari can show the old back-face content briefly.
|
|
300
|
+
requestAnimationFrame(() => {
|
|
301
|
+
card.classList.add('flipped');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// After the animation completes, swap the faces so the "back" face
|
|
305
|
+
// becomes the new "front" and reset the rotation. This avoids the
|
|
306
|
+
// angle drifting to 360deg, 540deg, etc on repeated flips.
|
|
307
|
+
const onEnd = (event) => {
|
|
308
|
+
if (event.target !== card) return; // child transitions can fire too
|
|
309
|
+
card.removeEventListener('transitionend', onEnd);
|
|
310
|
+
front.innerHTML = nextHtml;
|
|
311
|
+
back.innerHTML = '';
|
|
312
|
+
// Disable transitions for the reset so it's an instant snap.
|
|
313
|
+
card.style.transition = 'none';
|
|
314
|
+
card.classList.remove('flipped');
|
|
315
|
+
// Force layout flush so the next .flipped toggle re-engages the transition.
|
|
316
|
+
// eslint-disable-next-line no-unused-expressions
|
|
317
|
+
card.offsetWidth;
|
|
318
|
+
card.style.transition = '';
|
|
319
|
+
};
|
|
320
|
+
card.addEventListener('transitionend', onEnd);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Wire the pill buttons up to `flipToHost`. We use event delegation
|
|
325
|
+
* on the switcher slot so the listener doesn't need re-binding after
|
|
326
|
+
* every `renderHostSwitcher()` re-write (the buttons inside the slot
|
|
327
|
+
* are replaced wholesale on each render).
|
|
328
|
+
*/
|
|
329
|
+
export function bindHostSwitcher() {
|
|
330
|
+
const slot = document.getElementById('profile-menu-switcher');
|
|
331
|
+
if (!slot || slot.dataset.bound === '1') return;
|
|
332
|
+
slot.dataset.bound = '1';
|
|
333
|
+
slot.addEventListener('click', (e) => {
|
|
334
|
+
const pill = e.target.closest('.host-switcher-pill');
|
|
335
|
+
if (!pill) return;
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
const host = pill.dataset.host;
|
|
338
|
+
if (host) flipToHost(host);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
86
342
|
export function toggleProfileMenu(e) {
|
|
87
343
|
if (e) e.stopPropagation();
|
|
88
344
|
document.getElementById('profile-menu').classList.toggle('open');
|
package/public/js/state.js
CHANGED
|
@@ -33,6 +33,21 @@ export const state = {
|
|
|
33
33
|
* back to page 1.
|
|
34
34
|
*/
|
|
35
35
|
pagination: { offset: 0, limit: 50, total: 0 },
|
|
36
|
+
/**
|
|
37
|
+
* Which host's inboxes the user wants visible in the account
|
|
38
|
+
* switcher. Borrowed from Airbnb's "switch to hosting / switch to
|
|
39
|
+
* traveling" mode toggle — same agent, two distinct contexts. We
|
|
40
|
+
* stash it in localStorage so the choice survives reloads.
|
|
41
|
+
*
|
|
42
|
+
* 'all' → every account regardless of host (the original view)
|
|
43
|
+
* 'claudecode' → only the Claude bridge + Claude-owned sub-agents
|
|
44
|
+
* 'codex' → only the Codex bridge + Codex-owned sub-agents
|
|
45
|
+
*
|
|
46
|
+
* Future hosts (grok-build, hermes) plug in here by matching their
|
|
47
|
+
* own bridge name. The dropdown self-discovers available hosts from
|
|
48
|
+
* `state.agents` so no UI work is needed when a new bridge appears.
|
|
49
|
+
*/
|
|
50
|
+
activeHost: localStorage.getItem('agenticmail.activeHost') || 'all',
|
|
36
51
|
};
|
|
37
52
|
|
|
38
53
|
export const API_URL = window.location.origin;
|
package/public/styles.css
CHANGED
|
@@ -298,6 +298,145 @@ a { color: var(--accent-strong); }
|
|
|
298
298
|
margin-left: 4px;
|
|
299
299
|
}
|
|
300
300
|
.profile-menu-divider { height: 1px; background: var(--line); margin: 8px 0; }
|
|
301
|
+
|
|
302
|
+
/* ─── Host switcher (Airbnb-style) ────────────────────────────────────
|
|
303
|
+
*
|
|
304
|
+
* Segmented pill bar at the top of the inbox menu. Each pill represents
|
|
305
|
+
* a host (Claude, Codex, All); clicking flips the inbox list with a 3D
|
|
306
|
+
* Y-axis rotation. The active pill is filled with the accent colour;
|
|
307
|
+
* inactive pills are subtle so the visual weight rests on the inbox
|
|
308
|
+
* roster itself rather than the chrome.
|
|
309
|
+
*/
|
|
310
|
+
.host-switcher {
|
|
311
|
+
display: flex;
|
|
312
|
+
gap: 4px;
|
|
313
|
+
margin: 0 12px 6px;
|
|
314
|
+
padding: 4px;
|
|
315
|
+
background: var(--bg-hover);
|
|
316
|
+
border-radius: 999px;
|
|
317
|
+
border: 1px solid var(--line);
|
|
318
|
+
}
|
|
319
|
+
.host-switcher-pill {
|
|
320
|
+
flex: 1;
|
|
321
|
+
display: flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
justify-content: center;
|
|
324
|
+
gap: 6px;
|
|
325
|
+
padding: 7px 10px;
|
|
326
|
+
border: none;
|
|
327
|
+
background: transparent;
|
|
328
|
+
border-radius: 999px;
|
|
329
|
+
color: var(--muted);
|
|
330
|
+
font-size: 13px;
|
|
331
|
+
font-weight: 500;
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
/* Match Airbnb's "switch to hosting" feel: brisk, eased, no bounce. */
|
|
334
|
+
transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
335
|
+
color 200ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
336
|
+
transform 120ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
337
|
+
box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
338
|
+
white-space: nowrap;
|
|
339
|
+
user-select: none;
|
|
340
|
+
}
|
|
341
|
+
.host-switcher-pill:hover { color: var(--ink); }
|
|
342
|
+
.host-switcher-pill:active { transform: scale(0.97); }
|
|
343
|
+
.host-switcher-pill.is-active {
|
|
344
|
+
background: var(--bg);
|
|
345
|
+
color: var(--ink);
|
|
346
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
|
347
|
+
}
|
|
348
|
+
.host-switcher-logo {
|
|
349
|
+
width: 16px;
|
|
350
|
+
height: 16px;
|
|
351
|
+
object-fit: contain;
|
|
352
|
+
display: block;
|
|
353
|
+
}
|
|
354
|
+
.host-switcher-dot {
|
|
355
|
+
width: 8px;
|
|
356
|
+
height: 8px;
|
|
357
|
+
display: flex;
|
|
358
|
+
align-items: center;
|
|
359
|
+
justify-content: center;
|
|
360
|
+
color: currentColor;
|
|
361
|
+
opacity: 0.7;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* ─── Flip card (3D rotation on host switch) ──────────────────────────
|
|
365
|
+
*
|
|
366
|
+
* The inbox roster lives inside `.flip-card`, which rotates 180deg on
|
|
367
|
+
* Y when a host is switched. Two stacked `.flip-face` panes carry the
|
|
368
|
+
* "before" and "after" rosters; `backface-visibility: hidden` ensures
|
|
369
|
+
* each pane is only visible on its own side of the rotation. Result:
|
|
370
|
+
* the user sees roster A → edge-on at 90deg → roster B, creating the
|
|
371
|
+
* illusion that each side of the card carries a distinct view.
|
|
372
|
+
*
|
|
373
|
+
* Same trick as Airbnb's Host Passport book-flip on iOS (Tech Blog,
|
|
374
|
+
* May 2023). The CSS port is straightforward because the swap happens
|
|
375
|
+
* naturally at the orthogonal midpoint where neither face is visible.
|
|
376
|
+
*/
|
|
377
|
+
#profile-menu-list {
|
|
378
|
+
perspective: 1200px;
|
|
379
|
+
/* perspective-origin: top so the flip pivots from the eye line, not
|
|
380
|
+
* the centre — feels more like turning a page than tumbling a coin. */
|
|
381
|
+
perspective-origin: 50% 0%;
|
|
382
|
+
}
|
|
383
|
+
.flip-card {
|
|
384
|
+
position: relative;
|
|
385
|
+
transform-style: preserve-3d;
|
|
386
|
+
/* 460ms is the sweet spot from the Airbnb engineering write-up:
|
|
387
|
+
* fast enough to feel responsive, slow enough to read as deliberate. */
|
|
388
|
+
transition: transform 460ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
389
|
+
/* min-height so the menu doesn't collapse to 0 during the flip when
|
|
390
|
+
* the back face is empty for the resetting frame. */
|
|
391
|
+
min-height: 80px;
|
|
392
|
+
}
|
|
393
|
+
.flip-card.flipped {
|
|
394
|
+
transform: rotateY(180deg);
|
|
395
|
+
}
|
|
396
|
+
.flip-face {
|
|
397
|
+
-webkit-backface-visibility: hidden;
|
|
398
|
+
backface-visibility: hidden;
|
|
399
|
+
width: 100%;
|
|
400
|
+
}
|
|
401
|
+
.flip-face-back {
|
|
402
|
+
position: absolute;
|
|
403
|
+
top: 0;
|
|
404
|
+
left: 0;
|
|
405
|
+
right: 0;
|
|
406
|
+
transform: rotateY(180deg);
|
|
407
|
+
}
|
|
408
|
+
@media (prefers-reduced-motion: reduce) {
|
|
409
|
+
.flip-card { transition: none; }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* ─── Empty state inside the profile menu (filtered view → 0 rows) ──── */
|
|
413
|
+
.profile-menu-empty {
|
|
414
|
+
padding: 24px 20px;
|
|
415
|
+
text-align: center;
|
|
416
|
+
color: var(--muted);
|
|
417
|
+
}
|
|
418
|
+
.profile-menu-empty .empty-icon {
|
|
419
|
+
display: flex;
|
|
420
|
+
justify-content: center;
|
|
421
|
+
margin-bottom: 10px;
|
|
422
|
+
opacity: 0.4;
|
|
423
|
+
}
|
|
424
|
+
.profile-menu-empty .empty-text {
|
|
425
|
+
font-size: 13px;
|
|
426
|
+
margin-bottom: 8px;
|
|
427
|
+
color: var(--ink);
|
|
428
|
+
}
|
|
429
|
+
.profile-menu-empty .empty-hint {
|
|
430
|
+
font-size: 12px;
|
|
431
|
+
line-height: 1.5;
|
|
432
|
+
}
|
|
433
|
+
.profile-menu-empty code {
|
|
434
|
+
font-size: 11px;
|
|
435
|
+
padding: 2px 6px;
|
|
436
|
+
border-radius: 4px;
|
|
437
|
+
background: var(--bg-hover);
|
|
438
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
439
|
+
}
|
|
301
440
|
.profile-menu-footer {
|
|
302
441
|
padding: 12px 20px; font-size: 13px; color: var(--muted);
|
|
303
442
|
display: flex; gap: 12px;
|