Skip to content

Commit dfffbd8

Browse files
authored
feat(frontend): crosstalk tab — in-L2 threads + cross-Enterprise consult inbox/outbox (#171) (#188)
Adds the next L2 admin UI tab after Personas (#170). Three sub-tabs: 1. In-L2 threads — peer-to-peer agent communication inside this L2 (rendered from GET /crosstalk/threads). Subject, participants count + 5-min "live" indicator, status, opened-at. Click a row → drawer with full message timeline, admin-override close, and markdown export. 2. Consult inbox — peer-Enterprise consults addressed to this L2 (GET /consults/inbox). From-Enterprise + persona, topic, status, opened-at. Drawer shows envelope timeline + claimed_by. 3. Consult outbox — derived from /activity event_type=consult_open (no /consults/outbox endpoint yet, surfaced as backend gap). Best-effort; the inline note flags that receiver-side status is not preserved in the activity-log payload. Frontend-only. Reuses the cyan-eyebrow brand chrome from #181 and the row+drawer pattern from #183 — no new design primitives. Bundle delta: +4.56 KB gzipped (255.24 → 259.80) — under the 5 KB ceiling specified in the issue. Backend gaps (to be opened as follow-up issues): - GET /consults/outbox does not exist; the page reconstructs from activity log. - ThreadSummary lacks message_count + last_message_at; the list view defers to per-thread fetch for the authoritative count. - Per-thread routing health (AIGRP latency, signature success-rate) needs an aggregated read; placeholder copy in the drawer. Tests: 5 vitest cases covering empty-state, in-L2 listing, search filter, consult-inbox sub-tab, and outbox derivation. All 32 frontend tests pass; biome lint clean.
1 parent 528222d commit dfffbd8

7 files changed

Lines changed: 1717 additions & 0 deletions

File tree

server/frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthProvider, useAuth } from "./auth"
33
import { Layout } from "./components/Layout"
44
import { ProtectedRoute } from "./components/ProtectedRoute"
55
import { ApiKeysPage } from "./pages/ApiKeysPage"
6+
import { CrosstalkPage } from "./pages/CrosstalkPage"
67
import { DashboardPage } from "./pages/DashboardPage"
78
import { InvitesPage } from "./pages/InvitesPage"
89
import { LoginPage } from "./pages/LoginPage"
@@ -33,6 +34,7 @@ function AppRoutes() {
3334
<Route path="/review" element={<ReviewPage />} />
3435
<Route path="/dashboard" element={<DashboardPage />} />
3536
<Route path="/network" element={<NetworkPage />} />
37+
<Route path="/crosstalk" element={<CrosstalkPage />} />
3638
<Route path="/settings/api-keys" element={<ApiKeysPage />} />
3739
<Route path="/admin/personas" element={<PersonasPage />} />
3840
<Route path="/admin/invites" element={<InvitesPage />} />

server/frontend/src/api.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type {
2+
ActivityListResponse,
23
ApiKeysList,
4+
ConsultInboxResponse,
5+
ConsultMessagesResponse,
36
CreatedApiKey,
47
CreateInviteRequest,
58
CreatePersonaRequest,
69
CreatePersonaResponse,
10+
CrosstalkThreadListResponse,
11+
CrosstalkThreadWithMessages,
712
InvitePublic,
813
InviteStatus,
914
InvitesPublic,
@@ -167,6 +172,64 @@ export const api = {
167172
request<InvitePublic>(`/admin/invites/${id}`, {
168173
method: "DELETE",
169174
}),
175+
176+
// Activity-log read. The crosstalk tab uses it to derive the
177+
// cross-Enterprise consult outbox from consult_open events.
178+
listActivity: (
179+
params: {
180+
persona?: string
181+
since?: string
182+
until?: string
183+
event_type?: string
184+
limit?: number
185+
cursor?: string
186+
} = {},
187+
) => {
188+
const qs = new URLSearchParams()
189+
if (params.persona) qs.set("persona", params.persona)
190+
if (params.since) qs.set("since", params.since)
191+
if (params.until) qs.set("until", params.until)
192+
if (params.event_type) qs.set("event_type", params.event_type)
193+
if (params.limit != null) qs.set("limit", String(params.limit))
194+
if (params.cursor) qs.set("cursor", params.cursor)
195+
const query = qs.toString()
196+
return request<ActivityListResponse>(`/activity${query ? `?${query}` : ""}`)
197+
},
198+
199+
// Crosstalk threads (#171). The list endpoint returns ThreadSummary
200+
// (compact shape — no message-count or last-message timestamp). The
201+
// per-thread fetch returns the full CrosstalkThread + messages, which
202+
// the drawer uses to render the timeline.
203+
listCrosstalkThreads: (limit = 100) =>
204+
request<CrosstalkThreadListResponse>(`/crosstalk/threads?limit=${limit}`),
205+
206+
getCrosstalkThread: (threadId: string, limit = 200) =>
207+
request<CrosstalkThreadWithMessages>(
208+
`/crosstalk/threads/${encodeURIComponent(threadId)}?limit=${limit}`,
209+
),
210+
211+
closeCrosstalkThread: (threadId: string, reason?: string) =>
212+
request<{ thread_id: string; status: string }>(
213+
`/crosstalk/threads/${encodeURIComponent(threadId)}/close`,
214+
{
215+
method: "POST",
216+
body: JSON.stringify({ reason: reason ?? null }),
217+
},
218+
),
219+
220+
// Cross-Enterprise consults (#171). Inbox = consults addressed to this
221+
// L2; outbox endpoint does not exist on the backend yet (see issue
222+
// surfaced from #171 — derive from /activity event_type=consult_open
223+
// for now, or wait for the dedicated endpoint).
224+
consultInbox: (includeClosed = true, limit = 100) =>
225+
request<ConsultInboxResponse>(
226+
`/consults/inbox?include_closed=${includeClosed}&limit=${limit}`,
227+
),
228+
229+
consultMessages: (threadId: string) =>
230+
request<ConsultMessagesResponse>(
231+
`/consults/${encodeURIComponent(threadId)}/messages`,
232+
),
170233
}
171234

172235
export { ApiError }

0 commit comments

Comments
 (0)