Skip to content

Commit d45f23d

Browse files
feat(glass-office): Directory / Front Desk as the fifth Basement room
The stable directory room now lands on Front Desk, the one rehomed People & Routing authority, beside direct Agent Mail and the temporarily retained File locks. - First hostile MailPage component suite (24 tests, red-first): direct Mail and File-lock paths, exact 8/6 pagination boundaries with shrink/shrink/regrow, mobile list/detail, filters/clear, create/ack/ download/compose/send/summarize, lease create/release, empty states, and honest failure retention. - Replace React-state-only submit guards with ref-based synchronous locks (send, thread create, lease create/release) and clamp stored pagination pages at render so a shrink can never resurrect a stale later page. - Extract People & Routing verbatim into one shared usePeopleRoutingController (mounted once in App) plus PeopleRoutingSection; Directory / Front Desk is its authoritative home with a ready-surface-only pin. Team's People & Routing tab becomes an exact stable-room handoff and its remove-agent cleanup delegates to the shared controller, so no second editable truth or fetch loop exists. - Mail landing seam: entering directory selects Front Desk; internal choices persist across unrelated rerenders; Messages and File locks stay one tap away and are never labeled Directory data. - Register the hidden directory office block with unit proofs for the tenth-shortcut full-canvas byte immutability (every earlier shortcut genuinely present, 24/24 cells) and the freed-canvas repeat. - Browser proof (p5-directory-slice, @core): room identity, live routing save, both paginations with live shrink/regrow, config-only pinning with zero sensitive mutations recorded, office door disable/restore/reload with exact restored-door execution, DF mark/badge non-overlap, and inner geometry at desktop and 390px, over new stateful mock-gateway agent-mail fixtures. - Fix two real presentation defects the proof exposed: the routing card's two-column field grid overflowed its surface at 390px, and thread rows painted underneath the sidebar pagination (the list is now a bounded scroll region). Validation: typecheck, lint, unit 545/545, build, core e2e 41/41 (including updated p4-staff handoff and the p5-models/p5-breakers anchors), ExecAss validator PASS, git diff --check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c9edb16 commit d45f23d

15 files changed

Lines changed: 3800 additions & 1167 deletions

apps/mission-control/e2e/mockGateway.mjs

Lines changed: 299 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,84 @@ const runtimeConfig = {
846846
};
847847
const runtimeSecrets = {};
848848

849+
let nextAgentMailCounter = 1;
850+
851+
function createAgentMailThreadFixture({ kind, subject, unreadCount, ageMs }) {
852+
const id = nextAgentMailCounter++;
853+
const at = Date.now() - ageMs;
854+
return {
855+
thread_id: `mail-thread-${id}`,
856+
kind,
857+
subject,
858+
created_by_principal: "agent-root",
859+
participant_count: 2,
860+
message_count: 1,
861+
latest_message_at: at,
862+
latest_message_preview: `Latest note for ${subject}`,
863+
latest_sender_principal: "agent-root",
864+
unread_count: unreadCount,
865+
created_at: at - 60_000,
866+
updated_at: at,
867+
};
868+
}
869+
870+
// Nine direct threads make the exact eight-item Mail pagination provable in a
871+
// real browser; the first thread carries unread mail so the Directory room's
872+
// nav badge is genuinely nonzero beside its DF mark.
873+
const agentMailThreads = Array.from({ length: 9 }, (_, index) =>
874+
createAgentMailThreadFixture({
875+
kind: "direct",
876+
subject: `Front desk handoff ${index + 1}`,
877+
unreadCount: index === 0 ? 3 : 0,
878+
ageMs: (index + 1) * 120_000,
879+
}),
880+
);
881+
882+
const agentMailMessagesByThread = new Map(
883+
agentMailThreads.map((thread) => [thread.thread_id, []]),
884+
);
885+
agentMailMessagesByThread.set(agentMailThreads[0].thread_id, [
886+
{
887+
message_id: `mail-msg-${nextAgentMailCounter++}`,
888+
thread_id: agentMailThreads[0].thread_id,
889+
sender_principal: "agent-root",
890+
sender_kind: "agent",
891+
body_text: "Front desk: the visitor log is ready for review.",
892+
metadata_json: null,
893+
created_at: Date.now() - 300_000,
894+
recipients: [
895+
{
896+
recipient_principal: "default",
897+
delivered_at: Date.now() - 300_000,
898+
acked_at: null,
899+
},
900+
{
901+
recipient_principal: "agent-root",
902+
delivered_at: Date.now() - 300_000,
903+
acked_at: Date.now() - 240_000,
904+
},
905+
],
906+
attachments: [],
907+
},
908+
]);
909+
910+
// Seven active leases make the exact six-item File-lock pagination provable,
911+
// and releasing one proves the live shrink path against the stored page.
912+
const agentMailLeases = Array.from({ length: 7 }, (_, index) => {
913+
const id = nextAgentMailCounter++;
914+
return {
915+
lease_id: `mail-lease-${id}`,
916+
holder_principal: "agent-root",
917+
glob_pattern: `src/area-${index + 1}/**`,
918+
exclusive: index % 2 === 0,
919+
ttl_ms: 900_000,
920+
note: null,
921+
created_at: Date.now() - 60_000,
922+
expires_at: Date.now() + 840_000,
923+
released_at: null,
924+
};
925+
});
926+
849927
const authProfiles = [];
850928
const connectorCatalog = [
851929
{
@@ -947,6 +1025,14 @@ const seedState = {
9471025
connectorAssignments: cloneSeed(connectorAssignments),
9481026
connectorAuthBindings: cloneSeed(connectorAuthBindings),
9491027
connectorInteractions: cloneSeed(connectorInteractions),
1028+
agentMailThreads: cloneSeed(agentMailThreads),
1029+
agentMailMessages: Object.fromEntries(
1030+
Array.from(agentMailMessagesByThread.entries(), ([threadId, messages]) => [
1031+
threadId,
1032+
cloneSeed(messages),
1033+
]),
1034+
),
1035+
agentMailLeases: cloneSeed(agentMailLeases),
9501036
};
9511037

9521038

@@ -1340,6 +1426,13 @@ function resetMockState() {
13401426
replaceArray(connectorAssignments, seedState.connectorAssignments);
13411427
replaceArray(connectorAuthBindings, seedState.connectorAuthBindings);
13421428
replaceArray(connectorInteractions, seedState.connectorInteractions);
1429+
replaceArray(agentMailThreads, seedState.agentMailThreads);
1430+
agentMailMessagesByThread.clear();
1431+
for (const [threadId, messages] of Object.entries(seedState.agentMailMessages)) {
1432+
agentMailMessagesByThread.set(threadId, cloneSeed(messages));
1433+
}
1434+
replaceArray(agentMailLeases, seedState.agentMailLeases);
1435+
nextAgentMailCounter = 1000;
13431436
providerOrder.clear();
13441437
nextAuthProfileCounter = 1;
13451438
nextEventCounter = 1;
@@ -5245,19 +5338,223 @@ async function routeRequest(req, res) {
52455338
}
52465339

52475340
if (req.method === "GET" && requestUrl.pathname === "/api/v1/agent-mail/threads") {
5341+
const kind = requestUrl.searchParams.get("kind");
5342+
const search = (requestUrl.searchParams.get("search") ?? "").trim().toLowerCase();
5343+
const items = agentMailThreads
5344+
.filter((thread) => (kind ? thread.kind === kind : true))
5345+
.filter((thread) =>
5346+
search ? thread.subject.toLowerCase().includes(search) : true,
5347+
);
52485348
sendJson(res, 200, {
5249-
items: [],
5349+
items,
52505350
});
52515351
return;
52525352
}
52535353

5354+
if (req.method === "POST" && requestUrl.pathname === "/api/v1/agent-mail/threads") {
5355+
const payload = await readJson(req);
5356+
const thread = createAgentMailThreadFixture({
5357+
kind: typeof payload.kind === "string" ? payload.kind : "direct",
5358+
subject: typeof payload.subject === "string" ? payload.subject : "Untitled",
5359+
unreadCount: 0,
5360+
ageMs: 0,
5361+
});
5362+
agentMailThreads.unshift(thread);
5363+
agentMailMessagesByThread.set(thread.thread_id, []);
5364+
sendJson(res, 200, {
5365+
thread,
5366+
});
5367+
return;
5368+
}
5369+
5370+
{
5371+
const threadDetailMatch = requestUrl.pathname.match(
5372+
/^\/api\/v1\/agent-mail\/threads\/([^/]+)$/,
5373+
);
5374+
if (req.method === "GET" && threadDetailMatch) {
5375+
const thread = agentMailThreads.find(
5376+
(item) => item.thread_id === decodeURIComponent(threadDetailMatch[1]),
5377+
);
5378+
if (!thread) {
5379+
sendJson(res, 404, { error: "thread not found" });
5380+
return;
5381+
}
5382+
sendJson(res, 200, {
5383+
thread,
5384+
participants: [
5385+
{
5386+
principal_id: thread.created_by_principal,
5387+
role: "owner",
5388+
joined_at: thread.created_at,
5389+
last_read_at: null,
5390+
muted: false,
5391+
},
5392+
],
5393+
});
5394+
return;
5395+
}
5396+
}
5397+
5398+
{
5399+
const threadMessagesMatch = requestUrl.pathname.match(
5400+
/^\/api\/v1\/agent-mail\/threads\/([^/]+)\/messages$/,
5401+
);
5402+
if (threadMessagesMatch) {
5403+
const threadId = decodeURIComponent(threadMessagesMatch[1]);
5404+
const thread = agentMailThreads.find((item) => item.thread_id === threadId);
5405+
if (!thread) {
5406+
sendJson(res, 404, { error: "thread not found" });
5407+
return;
5408+
}
5409+
const messages = agentMailMessagesByThread.get(threadId) ?? [];
5410+
if (req.method === "GET") {
5411+
sendJson(res, 200, {
5412+
items: messages,
5413+
});
5414+
return;
5415+
}
5416+
if (req.method === "POST") {
5417+
const payload = await readJson(req);
5418+
const bodyText =
5419+
typeof payload.body_text === "string" ? payload.body_text : "";
5420+
const message = {
5421+
message_id: `mail-msg-${nextAgentMailCounter++}`,
5422+
thread_id: threadId,
5423+
sender_principal:
5424+
typeof payload.sender_principal === "string" &&
5425+
payload.sender_principal.trim()
5426+
? payload.sender_principal
5427+
: "agent-root",
5428+
sender_kind:
5429+
typeof payload.sender_kind === "string" ? payload.sender_kind : "agent",
5430+
body_text: bodyText,
5431+
metadata_json: null,
5432+
created_at: Date.now(),
5433+
recipients: (Array.isArray(payload.recipients) &&
5434+
payload.recipients.length > 0
5435+
? payload.recipients
5436+
: ["default"]
5437+
).map((principal) => ({
5438+
recipient_principal: String(principal),
5439+
delivered_at: Date.now(),
5440+
acked_at: null,
5441+
})),
5442+
attachments: [],
5443+
};
5444+
messages.push(message);
5445+
agentMailMessagesByThread.set(threadId, messages);
5446+
thread.message_count = messages.length;
5447+
thread.latest_message_at = message.created_at;
5448+
thread.latest_message_preview = bodyText.slice(0, 80);
5449+
thread.latest_sender_principal = message.sender_principal;
5450+
thread.updated_at = message.created_at;
5451+
sendJson(res, 200, {
5452+
message,
5453+
});
5454+
return;
5455+
}
5456+
}
5457+
}
5458+
5459+
{
5460+
const ackMatch = requestUrl.pathname.match(
5461+
/^\/api\/v1\/agent-mail\/messages\/([^/]+)\/ack$/,
5462+
);
5463+
if (req.method === "POST" && ackMatch) {
5464+
const messageId = decodeURIComponent(ackMatch[1]);
5465+
const payload = await readJson(req);
5466+
const wantedPrincipal =
5467+
typeof payload.recipient_principal === "string" &&
5468+
payload.recipient_principal.trim()
5469+
? payload.recipient_principal.trim()
5470+
: null;
5471+
for (const messages of agentMailMessagesByThread.values()) {
5472+
const message = messages.find((item) => item.message_id === messageId);
5473+
if (!message) continue;
5474+
const recipient = wantedPrincipal
5475+
? message.recipients.find(
5476+
(item) => item.recipient_principal === wantedPrincipal,
5477+
)
5478+
: message.recipients.find((item) => item.acked_at === null);
5479+
if (!recipient) {
5480+
sendJson(res, 404, { error: "recipient not found" });
5481+
return;
5482+
}
5483+
recipient.acked_at = Date.now();
5484+
const thread = agentMailThreads.find(
5485+
(item) => item.thread_id === message.thread_id,
5486+
);
5487+
if (thread && thread.unread_count > 0) {
5488+
thread.unread_count -= 1;
5489+
}
5490+
sendJson(res, 200, {
5491+
message_id: messageId,
5492+
recipient_principal: recipient.recipient_principal,
5493+
acked_at: recipient.acked_at,
5494+
});
5495+
return;
5496+
}
5497+
sendJson(res, 404, { error: "message not found" });
5498+
return;
5499+
}
5500+
}
5501+
52545502
if (req.method === "GET" && requestUrl.pathname === "/api/v1/agent-mail/leases") {
5503+
const includeReleased =
5504+
requestUrl.searchParams.get("include_released") === "true";
52555505
sendJson(res, 200, {
5256-
items: [],
5506+
items: agentMailLeases.filter((lease) =>
5507+
includeReleased ? true : lease.released_at === null,
5508+
),
5509+
});
5510+
return;
5511+
}
5512+
5513+
if (req.method === "POST" && requestUrl.pathname === "/api/v1/agent-mail/leases") {
5514+
const payload = await readJson(req);
5515+
const ttlMs = Number.isFinite(payload.ttl_ms) ? Number(payload.ttl_ms) : 900000;
5516+
const lease = {
5517+
lease_id: `mail-lease-${nextAgentMailCounter++}`,
5518+
holder_principal:
5519+
typeof payload.holder_principal === "string" && payload.holder_principal.trim()
5520+
? payload.holder_principal
5521+
: "agent-root",
5522+
glob_pattern:
5523+
typeof payload.glob_pattern === "string" ? payload.glob_pattern : "**/*",
5524+
exclusive: Boolean(payload.exclusive),
5525+
ttl_ms: ttlMs,
5526+
note: typeof payload.note === "string" ? payload.note : null,
5527+
created_at: Date.now(),
5528+
expires_at: Date.now() + ttlMs,
5529+
released_at: null,
5530+
};
5531+
agentMailLeases.push(lease);
5532+
sendJson(res, 200, {
5533+
lease,
52575534
});
52585535
return;
52595536
}
52605537

5538+
{
5539+
const releaseMatch = requestUrl.pathname.match(
5540+
/^\/api\/v1\/agent-mail\/leases\/([^/]+)\/release$/,
5541+
);
5542+
if (req.method === "POST" && releaseMatch) {
5543+
const lease = agentMailLeases.find(
5544+
(item) => item.lease_id === decodeURIComponent(releaseMatch[1]),
5545+
);
5546+
if (!lease) {
5547+
sendJson(res, 404, { error: "lease not found" });
5548+
return;
5549+
}
5550+
lease.released_at = Date.now();
5551+
sendJson(res, 200, {
5552+
lease,
5553+
});
5554+
return;
5555+
}
5556+
}
5557+
52615558
if (req.method === "POST" && requestUrl.pathname === "/api/v1/e2e/ws-event") {
52625559
const payload = await readJson(req);
52635560
const event = broadcastWsEvent({

apps/mission-control/e2e/p4-staff-slice.spec.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,24 @@ test("@core @p4-staff staff parity, pin-to-office, and the office shortcut hold
7272
await page.keyboard.press("Escape");
7373
await expect(roleCard).toHaveCount(0);
7474

75+
// People & Routing is now an exact stable-room handoff to Basement ·
76+
// Directory / Front Desk. The affordance stays reachable; the authoritative
77+
// editable surface lives in the Directory room and lights its lamp.
7578
await teamPage.getByRole("button", { name: "People & Routing" }).click();
76-
await expect(teamPage).toContainText("People And Routing");
77-
await expect(teamPage).toContainText("local-operator");
78-
await teamPage.getByRole("button", { name: "Routing Setup" }).click();
79-
await expect(teamPage).toContainText("Humans currently active in routing.");
79+
await expect(activeRooms).toHaveCount(1);
80+
await expect(activeRooms).toHaveAttribute(
81+
"title",
82+
"BF · Directory / Front Desk",
83+
);
84+
const mailPage = page.getByTestId("mail-page");
85+
await expect(mailPage).toContainText("People And Routing");
86+
await expect(mailPage).toContainText("local-operator");
87+
await mailPage.getByRole("button", { name: "Routing Setup" }).click();
88+
await expect(mailPage).toContainText("Humans currently active in routing.");
89+
90+
// Returning to the Staff room resumes the Team parity checks.
91+
await page.locator('button[title="2F · Staff Directory"]').click();
92+
await expect(activeRooms).toHaveAttribute("title", "2F · Staff Directory");
8093

8194
await teamPage.getByRole("button", { name: "Presets", exact: true }).click();
8295
await expect(teamPage).toContainText("Bootstrap Presets");

0 commit comments

Comments
 (0)