Retire channels/sessions/messages; A2A becomes primary agent comms (#306)

* feat(a2a): deliver latest incoming message preview into the claim briefing

list_unread_a2a now carries last_message_preview (the latest message from the
OTHER agent, never the agent's own reply), fetched via a correlated subquery in
the same query — no N+1 on the per-verb briefing path.

* feat(a2a): read_a2a verb delivers unread message bodies to the agent

A2AService.get_unread_messages returns the caller's unread INCOMING messages
(never its own sends), marking exactly those rows read atomically so a message
arriving mid-call is preserved. Wired as the read_a2a content verb (route +
do_server tool + granted to every delivery role) — the content-bearing read the
A2A inbox lacked (read_messages only zeroed the counter).

* docs(rag): document read_a2a as the A2A content-read path

* fix(task): backlog activation no longer requires a discussion session

Removes the SessionTaskTable gate in activate() (and its dangling log field),
deletes _inherit_parent_session + its create() call, and drops the now-unused
SessionTaskTable import. Coordination rides task state; the session subsystem is
being retired. Tests updated to the new (no-session) behavior.

* fix(orchestrator): drop session sweep from _run_sweep

Removes the messaging import + sweep_timed_out_sessions call. That import sat
outside the try/except, so once messaging.py is deleted it would have killed the
entire sweep cascade (budget kill-switch, token rollups, retention, image prune,
superseded-PR reconcile). Notification sweep + all maintenance sweeps unchanged.

* release-manager --no-tags read-clone fix

* test: update evidence_repo unit test for a2a last_message_preview

* refactor(gateway): drop session propagation on delegate

Removes propagate_sessions_to_subtask from delegate(), the ChoreographerDeps
messaging field + property, and the ChoreographerDeps messaging arg in deps.py
(ContentActions messaging + import stay until the verbs are removed). Deletes the
propagation test; strips the now-invalid messaging kwarg from ChoreographerDeps
test builders.

* refactor(gateway): remove say/open_session/link_session/channels verbs

Removes the four channel/session verbs across content_actions (impls +
ContentActionsDeps.messaging), do_server (tools + registry), role_config (grants
+ _CHANNEL_DISCOVERY), do.py (routes), schemas/v1/do.py (request models), and
deps.py (MessagingService import + construction). Regenerates the prompt verb
tables. dm/notify/read_messages/read_a2a stay. Tests deleted/updated accordingly.

* uv.lock Upgrade

* refactor: remove conversation RAG indexing; Secretary announces via notification

Drops the CONVERSATIONS index (index_conversation, ConversationsIndexPlugin,
IndexType.CONVERSATIONS enum, IndexConversationParams, mentor.py type-label, the
messaging index hook) and its chunk-table manifest entries. The Secretary's
ANNOUNCE/RELAY_MESSAGE now fan out a BROADCAST notification to every agent's
inbox (NotificationService.broadcast) instead of posting to a dead channel.

* fix(panel): label RAG health error lines by subsystem

A red llm_error (e.g. the glm-5.2:cloud weekly-limit 429) rendered under
the 'Embedding: ok' header with no label, reading as an embedding failure.
Prefix each error line with LLM / Embedding / Vector store.

* refactor: remove channel/message reads from metrics, dashboard, git, events

MetricsService drops get_communication_volume + the MessageTable
message-count in get_agent_metrics (and the now-dead messages_sent_week
field). DashboardService drops get_channel_feeds/_compute_channel_status
and the message read in get_recent_activity (task activity kept);
get_auditor_metrics no longer reports communication_volume.
GitService's two primary-session-id helpers always return None now
(callers already treat None as "no primary session"). events/handlers.py
drops the SESSION_CLOSED/SESSION_TIMEOUT subscriptions + the
handle_session_boundary handler.

Forced follow-on: api/routes/dashboard.py + api/schemas/dashboard.py
dropped the now-dangling live_feeds/ChannelFeed surface and the
/metrics/communication route, which wrapped the removed service calls
directly (mypy would otherwise fail on the missing attributes).

* refactor: delete MessagingService + channel seeding

Edited db/__init__.py and services/__init__.py first (drop the unconditional
Channel/Group/Message/Session table + MessagingService re-exports), then
deleted services/messaging.py, then trimmed db/seed.py to only create_agents
(create_channels/create_channel_memberships/create_initial_messages gone).

Forced expansion: api/routes/{channels,groups,sessions,messages}.py import
roboco.services.messaging directly (not through the package __init__), as
does api/routes/tasks.py (the session-links embed on GET /tasks/{id} and the
GET /{id}/sessions route). Deleting messaging.py without addressing these
breaks `import roboco.api.app` immediately, since app.py eagerly imports all
route modules at startup. Since the 4 CRUD route files are 100%
MessagingService-backed with zero independent logic (and are wholesale
deletes in the plan's later API-routes task anyway), deleted them now +
unmounted from app.py/routes/__init__.py; tasks.py got the same surgical
trim its later task already specified (drop session-links embed +
TaskSessionLinkResponse/TaskResponse.sessions). This pulls a slice of that
later work forward — the routes/schemas for channels/groups/sessions/messages
still need their own pass, but their messaging-coupled parts are gone.

Verified with a full-suite collection sweep (12010 tests collected, zero
import errors) beyond the directly touched test dirs, given the expanded
blast radius.

* refactor: remove channel/session/message models, tables, and channel policy

Models: deleted channel.py/group.py/session.py/messaging.py wholesale
(zero external consumers besides the models/__init__.py re-export).
message.py surgically trimmed: removed MessageCreate (dead) and MessageEdit
(never instantiated; ExtractedMessage.edit_history retyped to
list[dict[str, Any]] to match how it's actually persisted — confirmed
ExtractedMessage was never written to any DB table, so MessageTable's
removal carries no functional risk to the kept extraction pipeline).
base.py: removed SessionStatus + ChannelType, kept MessageType. Also
removed the confirmed-dead channels_read/channels_write fields from
models/agent.py:AgentPermissions and models/dashboard.py:ChannelFeedData.

db/tables.py: deleted ChannelTable/GroupTable/SessionTable/SessionTaskTable/
MessageTable, TaskTable.session_links, and JournalEntryTable.session_id —
cascaded through models/journal.py, services/journal.py, and
api/schemas+routes/journals.py (22 plumbing sites).

foundation/policy/communications.py: removed the ChannelSpec/CHANNELS
catalog + TEAM_SCOPED_ROLES/_CELL_*/_AUDITOR_ONLY helpers, kept the
notification policy (Priority/parse_priority/NOTIFY_SENDER_ROLES/
ACK_REQUIRED_BY_TYPE). enforcement/channel_access.py deleted (confirmed
fully dead in production). agents_config.py: removed CHANNEL_ACCESS
(kept A2A_ALLOWED_PAIRS). seeds/initial_data.py: removed
DEFAULT_CHANNELS/CHANNEL_MEMBERSHIPS/AUDITOR_SILENT_ACCESS + the
never-consumed INITIAL_MESSAGES. config.py: removed
session_idle_timeout_seconds (zero consumers). exceptions.py: removed
dead ChannelError/ChannelAccessDeniedError/SessionClosedError.

Forced expansion beyond the original file list — ChannelType cascaded
into a live, mounted surface the plan didn't trace: agents_config.
CHANNEL_ACCESS -> services/permissions.py's channel-RBAC methods (not
models/permissions.py, which turned out to have no channel code at all)
-> two real endpoints in api/routes/stream.py (GET /permissions,
GET /permissions/channel/{name}) and two dependency factories in
api/deps.py. Removed the channel methods + fields, deleted the
channel-specific stream.py endpoint, deleted require_channel_read/write.
Also deleted api/schemas/{channels,sessions}.py (hard dependency on the
removed enums; already fully dead after the Task 10 route deletions) and
api/schemas/messages.py (a TYPE_CHECKING-only import of the deleted
MessageTable; likewise already fully dead) + its dedicated test file.

Test updates: test_permissions.py -14 channel tests (matches the planned
count exactly), test_communications.py / test_communications_consumers.py
split to keep only notification-policy coverage, test_exceptions.py -9,
test_deps.py -4, plus the journal/stream/foundation-smoke fallout. Also
fixed a pre-existing (Task 7) broken assertion in
test_foundation_phase3_smoke.py that inspected a `say()` method already
removed from ContentActions.

Verified: full-suite collection (11961 tests, zero import errors) and a
complete test run (11567 passed, 394 skipped, 0 failed) in addition to
the targeted suites.

* migration: drop channels/groups/sessions/session_tasks/messages + enum types

alembic/versions/060_drop_messaging.py: drop_column journal_entries.
session_id (sidesteps hardcoding the FK constraint name — verified
empirically against a live migrated DB that it's actually
fk_journal_entries_session_id_sessions, but drop_column doesn't care
either way); drop_table in FK order (messages -> session_tasks ->
sessions -> groups -> channels); DROP TABLE IF EXISTS chunks_conversations
(runtime-provisioned, not alembic-managed, would otherwise orphan); DROP
TYPE IF EXISTS for messagetype/sessionstatus/sessionscope/channeltype
(messagetype's Python enum stays for ExtractedMessage, but the DB type
had zero live columns left once MessageTable was dropped in the prior
commit). downgrade() raises NotImplementedError — one-way removal.

Pruned scripts/reset_runtime_state.sql + .sh: removed the DELETE/COUNT
lines for messages/session_tasks/sessions/groups/channels and the
groups.active_session_id reset block.

Verified end-to-end against a scratch Postgres DB: full migration chain
001->060 applies cleanly, alembic heads shows a single head, all 6 dropped
tables + 4 enum types + the journal_entries.session_id column are
confirmed gone, journal_entries keeps only its journal_id/task_id FKs,
downgrade correctly raises NotImplementedError without corrupting DB
state, and the pruned reset_runtime_state.sql runs clean (no errors)
against a fully-migrated DB.

* refactor(api): remove channel/session/message routes + WS streams

Most of this task's file list was already forced through in earlier
commits (routes/{channels,groups,sessions,messages}.py + app.py/__init__.py
unmounting in the MessagingService-deletion commit; tasks.py's
session-links embed + GET /{id}/sessions + schemas/tasks.py's
TaskResponse.sessions in that same commit; deps.py's require_channel_read/
write + schemas/{channels,sessions}.py in the models/tables commit). This
closes out what was left:

- api/websocket.py: deleted the channel_stream + session_stream routes,
  ConnectionManager's channel_connections/session_connections dicts,
  connect_channel/connect_session, broadcast_to_channel/broadcast_to_session,
  get_channel_subscriber_count, and their cleanup lines in disconnect().
  Agent streams, notification streams, and the operator system stream are
  untouched.
- api/websocket_bridge.py: deleted _handle_session_event +
  _handle_message_event and their SESSION_CREATED/SESSION_CLOSED/
  SESSION_TIMEOUT/MESSAGE_SENT subscriptions. The A2A live-view, rate-limit,
  usage, agent-lifecycle, and notification bridges are untouched.
- api/schemas/websocket.py: removed NewMessageBroadcast, WSMessageNew,
  WSMessageEdit, WSMessageDelete, WSSessionClosed — kept the WSMessage base
  class (still subclassed by the kept WSAgentStream/WSNotification) plus
  those two.
- api/schemas/groups.py: deleted (already fully orphaned since routes/
  groups.py was removed; its GroupResponse/GroupDetailResponse had zero
  consumers).

Updated the 5 websocket test files accordingly (removed the channel/
session-specific tests + fixed imports); test_websocket_bridge.py's
registration-coverage test dropped the SESSION_*/MESSAGE_SENT assertions.

Verified: full-suite collection (11943 tests, zero import errors) and a
complete test run (11549 passed, 394 skipped, 0 failed).

* docs: retire channels/sessions/messages from agent-facing docs + CLAUDE.md

Rewrites docs/rag (RAG-indexed) + docs/map + CLAUDE.md to reflect A2A (dm +
read_a2a) as primary agent comms; deletes the channel docs, splits messaging-tools
+ messaging-notification (renamed notification.md), swaps the WS worked example to
A2A_MESSAGE_SENT. _complete_map.md still needs regeneration (generated file).

* refactor(panel): remove Communications surface (channels/sessions)

Deletes the /communications routes, message components, task-detail Sessions tab,
use-channels + channel/session WS hooks, and the channels/sessions/messages/groups
api clients; prunes the Channel/Session/Message/Group types + mock data. (Auditor
live-feeds + dashboard.ts dead-route cleanup is a follow-up.)

* refactor(panel): drop auditor channel-feed + dead communication-metric route

* docs(map): regenerate _complete_map from updated slices

* fix(a2a): reduce get_unread_messages complexity below xenon C + stale comments

Extract the per-conversation unread-counter recompute into _reset_unread_counter
(the CI quality gate flagged get_unread_messages as rank C). Also drop the deleted
open_session from a content_actions comment and reword an evidence_repo docstring
that cited the removed messaging._notify_mentions.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-04 03:10:33 +02:00
committed by GitHub
co-authored by Renn F
parent a807904999
commit 7901ea419e
249 changed files with 2101 additions and 19303 deletions
@@ -1,97 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Session } from "@/types";
// Bundle D / defect 3: posting to a CLOSED session silently redirects the
// message to a fresh active session, so it vanishes from the closed-session
// view the user is looking at. The page must guard the composer when the
// session is not active and tell the user why.
const { useSession, useSessionMessages, messageKeys, sessionKeys } = vi.hoisted(
() => ({
useSession: vi.fn(),
useSessionMessages: vi.fn(),
messageKeys: { list: (id: string) => ["messages", "list", id] },
sessionKeys: { detail: (id: string) => ["sessions", "detail", id] },
}),
);
vi.mock("next/navigation", () => ({
useParams: () => ({ sessionId: "s1" }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-channels", () => ({
useSession,
useSessionMessages,
messageKeys,
sessionKeys,
}));
vi.mock("@/hooks/use-websocket", () => ({
useSessionStream: () => ({ lastMessage: null }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
};
});
vi.mock("@/lib/api/messages", () => ({ messagesApi: { send: vi.fn() } }));
import SessionDetailPage from "../page";
function buildSession(status: string): Session {
return {
id: "s1",
group_id: "g1",
status: status as never,
scope: "cell" as never,
message_count: 2,
total_content_length: 10,
started_at: "2026-06-30T00:00:00Z",
last_activity_at: "2026-06-30T00:00:00Z",
closed_at: status === "closed" ? "2026-06-30T01:00:00Z" : null,
task_links: [],
};
}
describe("SessionDetailPage — closed-session composer guard", () => {
beforeEach(() => {
useSession.mockReset();
useSessionMessages.mockReset();
useSessionMessages.mockReturnValue({
data: { items: [] },
isLoading: false,
refetch: vi.fn(),
});
});
it("shows a closed-session notice instead of the composer when closed", () => {
useSession.mockReturnValue({
data: buildSession("closed"),
isLoading: false,
refetch: vi.fn(),
});
render(<SessionDetailPage />);
expect(screen.getByText(/session is closed/i)).toBeInTheDocument();
// The message textarea must not be available for a closed session.
expect(
screen.queryByPlaceholderText(/type a message/i),
).not.toBeInTheDocument();
});
it("shows the composer for an active session", () => {
useSession.mockReturnValue({
data: buildSession("active"),
isLoading: false,
refetch: vi.fn(),
});
render(<SessionDetailPage />);
expect(screen.getByPlaceholderText(/type a message/i)).toBeInTheDocument();
});
});
@@ -1,364 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useSession,
useSessionMessages,
messageKeys,
sessionKeys,
} from "@/hooks/use-channels";
import { useSessionStream } from "@/hooks/use-websocket";
import { messagesApi } from "@/lib/api/messages";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { MessageComposer } from "@/components/communications/message-composer";
import { MessageTypeBadge } from "@/components/communications/message-type-badge";
import { Markdown } from "@/components/ui/markdown";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
ArrowLeft,
MessageSquare,
ListTodo,
Clock,
Hash,
RefreshCw,
} from "lucide-react";
import { CopyButton } from "@/components/ui/copy-button";
import { formatDistanceToNow, format } from "date-fns";
import { toast } from "sonner";
import Link from "next/link";
import { Suspense } from "react";
function SessionDetailContent() {
const params = useParams();
const searchParams = useSearchParams();
const sessionId = params.sessionId as string;
// Read navigation context from URL params
const channelId = searchParams.get("channel");
const groupId = searchParams.get("group");
// Build back URL preserving context
const backUrl =
channelId && groupId
? `/communications?channel=${channelId}&group=${groupId}`
: "/communications";
const queryClient = useQueryClient();
const scrollRef = useRef<HTMLDivElement>(null);
// Fetch session details and messages.
//
// The message query loads the transcript once and then holds it (staleTime
// Infinity, no focus/reconnect refetch), so an OPEN session is read exactly
// once and a CLOSED session's immutable transcript stays loaded for review.
// The hooks treat a 404 (reaped session) as terminal and never retry it, which
// is what stops the panel from accumulating a 404 storm across every dead
// session it has opened. `refetchMessages` (the manual Refresh button) stays
// available for live sessions.
const {
data: session,
isLoading: loadingSession,
refetch: refetchSession,
} = useSession(sessionId);
const {
data: messagesData,
isLoading: loadingMessages,
refetch: refetchMessages,
} = useSessionMessages(sessionId);
// Live updates: subscribe to the session stream. On a new persisted message
// (MESSAGE_SENT → bridge → /ws/sessions/{id}) invalidate the transcript +
// session-detail queries so the held (staleTime Infinity) views refresh
// without the manual Refresh button.
const { lastMessage } = useSessionStream(sessionId);
useEffect(() => {
if (lastMessage?.type !== "message.new") return;
queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) });
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) });
}, [lastMessage, queryClient, sessionId]);
// Sort messages chronologically (oldest first for chat UI)
const messages = [...(messagesData?.items || [])].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
// Track if we've done the initial scroll
const hasScrolledRef = useRef(false);
// Auto-scroll to bottom only once on initial load
useEffect(() => {
if (scrollRef.current && messages.length > 0 && !hasScrolledRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
hasScrolledRef.current = true;
}
}, [messages.length]);
// Send message mutation
const sendMessage = useMutation({
mutationFn: async ({
content,
type,
}: {
content: string;
type: string;
}) => {
return messagesApi.send(sessionId, content, type);
},
onSuccess: (sent) => {
queryClient.invalidateQueries({
queryKey: ["messages", "list", sessionId],
});
// If the session closed between load and send, the server redirects the
// message to a fresh active session — tell the user it landed elsewhere
// instead of letting it appear to vanish from this transcript.
if (sent?.session_id && sent.session_id !== sessionId) {
toast.warning(
"This session had closed — your message was posted to the active session.",
);
} else {
toast.success("Message sent");
}
},
onError: (error: Error) => {
toast.error("Failed to send message: " + error.message);
},
});
const handleSendMessage = (message: { content: string; type: string }) => {
sendMessage.mutate(message);
};
const handleRefresh = () => {
refetchSession();
refetchMessages();
};
// Get primary task
const primaryTask =
session?.task_links?.find((t) => t.is_primary) || session?.task_links?.[0];
if (loadingSession) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<Link href={backUrl} prefetch={false}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Communications
</Button>
</Link>
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Session Not Found</h3>
<p className="text-sm text-muted-foreground">
The session you&apos;re looking for doesn&apos;t exist or has
been deleted.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<Link href={backUrl} prefetch={false}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Session {sessionId.slice(0, 8)}
</h1>
<p className="text-muted-foreground text-sm">
Started {formatDistanceToNow(new Date(session.started_at))} ago
</p>
</div>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Session Info Bar */}
<Card className="mb-4 shrink-0">
<CardContent className="py-3">
<div className="flex items-center gap-4 flex-wrap">
<Badge
variant={session.status === "active" ? "default" : "secondary"}
>
{session.status}
</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MessageSquare className="h-4 w-4" />
{session.message_count} messages
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{format(new Date(session.started_at), "MMM d, yyyy h:mm a")}
</div>
{session.closed_at && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
Closed:{" "}
{format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
</div>
)}
{/* Linked Tasks */}
{session.task_links && session.task_links.length > 0 && (
<>
<span className="text-muted-foreground">|</span>
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
{primaryTask && (
<Link
prefetch={false}
href={`/tasks/${primaryTask.task_id}`}
className="text-sm text-primary hover:underline"
>
{primaryTask.task_title ||
`Task ${primaryTask.task_id.slice(0, 8)}`}
</Link>
)}
{session.task_links.length > 1 && (
<Badge variant="outline" className="text-xs">
+{session.task_links.length - 1} more
</Badge>
)}
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Messages Area */}
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader className="pb-2 shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Hash className="h-4 w-4" />
Messages
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 min-h-0">
{/* Messages List */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
{loadingMessages ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-12 w-full" />
</div>
</div>
))}
</div>
) : messages.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No messages in this session</p>
<p className="text-sm">
Use the composer below to start the conversation
</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<div
key={message.id}
className="group relative flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
>
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(message.agent_id)}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-semibold text-sm">
{getAgentDisplayName(message.agent_id)}
</span>
<MessageTypeBadge type={message.type} />
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.timestamp))} ago
</span>
</div>
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
</div>
{/* Copy button — visible on hover */}
<CopyButton
value={message.content}
className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
))}
</div>
)}
</div>
{/* Message Composer. A closed session is read-only: posting to it
would silently redirect the message to a fresh active session
(server-side get-or-create), making it vanish from this view —
so guard the composer and say why. */}
<div className="shrink-0 border-t">
{session.status === "active" ? (
<MessageComposer
channelId={sessionId}
onSend={handleSendMessage}
isSending={sendMessage.isPending}
/>
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
This session is closed. New messages can&apos;t be posted here.
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function SessionDetailPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<SessionDetailContent />
</Suspense>
);
}
@@ -1,507 +0,0 @@
"use client";
import { Suspense, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
useChannels,
useChannelGroups,
useGroupSessions,
} from "@/hooks/use-channels";
import type { Channel } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OfflineState } from "@/components/ui/offline-state";
import { cn } from "@/lib/utils";
import {
Hash,
Lock,
Users,
MessageSquare,
RefreshCw,
Folder,
MessageCircle,
ArrowLeft,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
// =============================================================================
// Channel List Panel
// =============================================================================
interface ChannelListProps {
channels: Channel[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
}
function ChannelList({
channels,
selectedId,
onSelect,
isLoading,
}: ChannelListProps) {
const cellChannels = channels.filter((c) => c.type === "cell");
const crossCellChannels = channels.filter((c) => c.type === "cross_cell");
const managementChannels = channels.filter((c) => c.type === "management");
const otherChannels = channels.filter(
(c) => !["cell", "cross_cell", "management"].includes(c.type),
);
const renderGroup = (title: string, items: Channel[]) => {
if (items.length === 0) return null;
return (
<div className="mb-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2">
{title}
</h4>
{items.map((channel) => (
<Button
key={channel.id}
onClick={() => onSelect(channel.id)}
variant="ghost"
className={
"w-full h-auto justify-start gap-2 px-3 py-2 text-sm font-normal whitespace-normal " +
(selectedId === channel.id
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: "hover:bg-muted")
}
>
{channel.is_private ? (
<Lock className="h-4 w-4 shrink-0" />
) : (
<Hash className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{channel.name}</span>
</Button>
))}
</div>
);
};
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2">
{renderGroup("Cell Channels", cellChannels)}
{renderGroup("Cross-Cell", crossCellChannels)}
{renderGroup("Management", managementChannels)}
{renderGroup("Other", otherChannels)}
</div>
</ScrollArea>
);
}
// =============================================================================
// Group List Panel
// =============================================================================
interface GroupListProps {
channelId: string;
selectedId: string | null;
onSelect: (id: string) => void;
}
function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
const { data: groups, isLoading } = useChannelGroups(channelId);
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (!groups || groups.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Folder className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No groups in this channel</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
{groups.map((group) => (
<Button
key={group.id}
onClick={() => onSelect(group.id)}
variant="ghost"
className={
"w-full h-auto justify-between px-3 py-2 text-sm font-normal whitespace-normal " +
(selectedId === group.id
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: "hover:bg-muted")
}
>
<div className="flex items-center gap-2 min-w-0">
<Users className="h-4 w-4 shrink-0" />
<span className="truncate">{group.name}</span>
</div>
<Badge variant="secondary" className="text-xs shrink-0 ml-2">
{group.total_messages}
</Badge>
</Button>
))}
</div>
</ScrollArea>
);
}
// =============================================================================
// Session List Panel
// =============================================================================
interface SessionListProps {
channelId: string;
groupId: string;
}
function SessionList({ channelId, groupId }: SessionListProps) {
const { data: sessions, isLoading } = useGroupSessions(groupId);
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (!sessions || sessions.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No sessions in this group</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{sessions.map((session) => (
<Link
prefetch={false}
key={session.id}
href={`/communications/${session.id}?channel=${channelId}&group=${groupId}`}
className="block p-3 rounded-lg border bg-card hover:bg-muted/50 hover:border-primary/50 transition-all"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">
{session.task_links?.length > 0 ? (
<>
{session.task_links.find((l) => l.is_primary)
?.task_title ||
session.task_links[0]?.task_title ||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
</>
) : (
`Session ${session.id.slice(0, 8)}`
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(session.started_at))} ago
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge
variant={
session.status === "active" ? "default" : "secondary"
}
className="text-xs"
>
{session.status}
</Badge>
<span className="text-xs text-muted-foreground">
{session.message_count} msgs
</span>
</div>
</div>
</Link>
))}
</div>
</ScrollArea>
);
}
// =============================================================================
// Empty State Components
// =============================================================================
function EmptyPanel({
icon: Icon,
message,
}: {
icon: typeof MessageSquare;
message: string;
}) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
// =============================================================================
// Main Page
// =============================================================================
function CommunicationsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const channelId = searchParams.get("channel");
const groupId = searchParams.get("group");
const { data: channels, isLoading, error, refetch } = useChannels();
const isOffline =
error &&
(error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK");
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/communications?${query}` : "/communications");
},
[router, searchParams],
);
const handleSelectChannel = useCallback(
(id: string) => {
updateParams({ channel: id, group: null });
},
[updateParams],
);
const handleSelectGroup = useCallback(
(id: string) => {
updateParams({ group: id });
},
[updateParams],
);
// Below `lg` only one pane is shown at a time (list -> detail drill-down);
// at `lg`+ all three always show side by side (mobilePane classes below
// are overridden by their own `lg:flex`).
const handleBack = useCallback(() => {
if (groupId) {
updateParams({ group: null });
} else if (channelId) {
updateParams({ channel: null, group: null });
}
}, [channelId, groupId, updateParams]);
const selectedChannel = channels?.find((c) => c.id === channelId);
const showChannelsPane = !channelId;
const showGroupsPane = !!channelId && !groupId;
const showSessionsPane = !!channelId && !!groupId;
return (
// h-dvh (not h-vh): mobile Safari's dynamic toolbar resizes the viewport,
// and this height is unconditional now (not just lg:+) so the single
// visible mobile pane also gets a real height for its ScrollArea.
<div className="flex flex-col h-[calc(100dvh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Communications</h1>
<p className="text-muted-foreground">
Browse channels, groups, and sessions
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load Channels"
description="Start the RoboCo orchestrator to view communications."
onRetry={() => refetch()}
/>
) : (
<>
{/* Mobile-only back affordance — drills back up one level. */}
{channelId && (
<Button
variant="ghost"
size="sm"
className="mb-2 w-fit shrink-0 lg:hidden"
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
)}
<div className="grid flex-1 min-h-0 grid-cols-12 gap-4 lg:gap-6">
{/* Panel 1: Channels */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showChannelsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Channels</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<ChannelList
channels={channels || []}
selectedId={channelId}
onSelect={handleSelectChannel}
isLoading={isLoading}
/>
</div>
</CardContent>
</Card>
{/* Panel 2: Groups */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-3 lg:flex",
showGroupsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Groups</span>
{selectedChannel && (
<Badge
variant="outline"
className="ml-auto text-xs font-normal"
>
{selectedChannel.name}
</Badge>
)}
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId ? (
<GroupList
channelId={channelId}
selectedId={groupId}
onSelect={handleSelectGroup}
/>
) : (
<EmptyPanel icon={Folder} message="Select a channel" />
)}
</div>
</CardContent>
</Card>
{/* Panel 3: Sessions */}
<Card
className={cn(
"col-span-12 flex-col overflow-hidden lg:col-span-6 lg:flex",
showSessionsPane ? "flex" : "hidden",
)}
>
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Sessions</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId && groupId ? (
<SessionList channelId={channelId} groupId={groupId} />
) : (
<EmptyPanel
icon={MessageSquare}
message={
channelId
? "Select a group"
: "Select a channel and group"
}
/>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function CommunicationsPage() {
return (
<Suspense
fallback={
<div className="flex flex-col h-[calc(100dvh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-4 lg:gap-6">
<Card className="col-span-12 lg:col-span-3">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-3">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-12 lg:col-span-6" />
</div>
</div>
}
>
<CommunicationsPageContent />
</Suspense>
);
}
@@ -6,7 +6,6 @@ import {
useAuditorReports,
useCreateAuditorReport,
} from "@/hooks/use-dashboard";
import { LiveFeedsPanel } from "./live-feeds-panel";
import { QualityMetricsPanel } from "./quality-metrics-panel";
import { FlaggedItemsPanel } from "./flagged-items-panel";
import { ReportsPanel } from "./reports-panel";
@@ -70,17 +69,11 @@ export function AuditorDashboard() {
</div>
</div>
{/* Top Row: Live Feeds + Quality Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<LiveFeedsPanel
feeds={dashboard?.live_feeds}
isLoading={loadingDashboard}
/>
<QualityMetricsPanel
metrics={dashboard?.metrics}
isLoading={loadingDashboard}
/>
</div>
{/* Quality Metrics */}
<QualityMetricsPanel
metrics={dashboard?.metrics}
isLoading={loadingDashboard}
/>
{/* Bottom Row: Flagged Items + Reports */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
-2
View File
@@ -1,6 +1,4 @@
export { AuditorDashboard } from "./auditor-dashboard";
export { LiveFeedsPanel } from "./live-feeds-panel";
export { LiveFeedItem } from "./live-feed-item";
export { QualityMetricsPanel } from "./quality-metrics-panel";
export { FlaggedItemsPanel } from "./flagged-items-panel";
export { FlaggedItem } from "./flagged-item";
@@ -1,55 +0,0 @@
"use client";
import { ChannelFeed } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Radio, Clock } from "lucide-react";
interface LiveFeedItemProps {
feed: ChannelFeed;
}
function formatTime(timestamp: string | null): string {
if (!timestamp) return "No activity";
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return "Active now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function LiveFeedItem({ feed }: LiveFeedItemProps) {
const isActive = feed.status === "active" || feed.message_count_24h > 0;
return (
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<Radio
className={`h-4 w-4 ${isActive ? "text-green-500 animate-pulse" : "text-gray-400"}`}
/>
<div>
<span className="font-medium text-sm">#{feed.name}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatTime(feed.last_activity)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={isActive ? "default" : "secondary"} className="text-xs">
{feed.message_count_24h} msgs
</Badge>
<Badge
variant="outline"
className={isActive ? "text-green-600 border-green-300" : ""}
>
{isActive ? "Active" : "Idle"}
</Badge>
</div>
</div>
);
}
@@ -1,54 +0,0 @@
"use client";
import { ChannelFeed } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Radio } from "lucide-react";
import { LiveFeedItem } from "./live-feed-item";
interface LiveFeedsPanelProps {
feeds: ChannelFeed[] | undefined;
isLoading: boolean;
}
export function LiveFeedsPanel({ feeds, isLoading }: LiveFeedsPanelProps) {
const activeCount = (feeds ?? []).filter(
(f) => f.status === "active" || f.message_count_24h > 0,
).length;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Radio className="h-5 w-5" />
Live Feeds
</CardTitle>
<span className="text-sm text-muted-foreground">
{activeCount} active
</span>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14" />
))}
</div>
) : !feeds || feeds.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
No channel feeds available
</div>
) : (
<div className="space-y-2">
{feeds.map((feed) => (
<LiveFeedItem key={feed.id} feed={feed} />
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -1,2 +0,0 @@
export { MessageComposer } from "./message-composer";
export { MessageTypeBadge } from "./message-type-badge";
@@ -1,101 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send } from "lucide-react";
interface MessageComposerProps {
channelId: string;
onSend: (message: { content: string; type: string }) => void;
isSending?: boolean;
disabled?: boolean;
}
const MESSAGE_TYPES = [
{ value: "dialogue", label: "Dialogue" },
{ value: "reasoning", label: "Reasoning" },
{ value: "decision", label: "Decision" },
{ value: "action", label: "Action" },
{ value: "blocker", label: "Blocker" },
{ value: "technical", label: "Technical" },
];
export function MessageComposer({
onSend,
isSending,
disabled,
}: MessageComposerProps) {
const [content, setContent] = useState("");
const [type, setType] = useState("dialogue");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
onSend({ content: content.trim(), type });
setContent("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
// paddingBottom includes the safe-area inset so the composer clears the
// home indicator on notched phones instead of sitting flush under it.
<form
onSubmit={handleSubmit}
className="border-t p-4"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
className="min-h-[60px] resize-none"
disabled={disabled || isSending}
/>
</div>
<div className="flex flex-col gap-2">
<Select value={type} onValueChange={setType}>
<SelectTrigger className="w-auto min-w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MESSAGE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="submit"
size="sm"
disabled={!content.trim() || disabled || isSending}
>
<Send className="h-4 w-4 mr-1" />
Send
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Markdown supported. Use @agent to mention.
</p>
</form>
);
}
@@ -1,22 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
interface MessageTypeBadgeProps {
type: string;
}
const typeConfig: Record<string, { label: string; color: string }> = {
reasoning: { label: "reasoning", color: "bg-blue-100 text-blue-700" },
dialogue: { label: "dialogue", color: "bg-green-100 text-green-700" },
decision: { label: "decision", color: "bg-purple-100 text-purple-700" },
action: { label: "action", color: "bg-orange-100 text-orange-700" },
blocker: { label: "blocker", color: "bg-red-100 text-red-700" },
technical: { label: "technical", color: "bg-gray-100 text-gray-700" },
general: { label: "general", color: "bg-gray-100 text-gray-700" },
};
export function MessageTypeBadge({ type }: MessageTypeBadgeProps) {
const config = typeConfig[type] ?? typeConfig.general;
return <Badge className={config.color + " text-xs"}>{config.label}</Badge>;
}
@@ -2,14 +2,7 @@
import { Button } from "@/components/ui/button";
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
import {
Users,
Megaphone,
BookOpen,
Shield,
Sparkles,
Bot,
} from "lucide-react";
import { Users, BookOpen, Shield, Sparkles, Bot } from "lucide-react";
import Link from "next/link";
export function QuickActionsBar() {
@@ -38,13 +31,6 @@ export function QuickActionsBar() {
</Button>
</Link>
<Link href="/communications" prefetch={false}>
<Button variant="outline">
<Megaphone className="h-4 w-4 mr-2" />
Broadcast Message
</Button>
</Link>
<Link href="/journals" prefetch={false}>
<Button variant="outline">
<BookOpen className="h-4 w-4 mr-2" />
@@ -28,7 +28,6 @@ import {
CheckCircle,
XCircle,
UserPlus,
MessageSquare,
Clock,
Hash,
} from "lucide-react";
@@ -54,8 +53,6 @@ export function KanbanCard({
const isBacklog = task.status === TaskStatus.BACKLOG;
const [assignOpen, setAssignOpen] = useState(false);
const updateTask = useUpdateTask();
const hasSessions = task.sessions && task.sessions.length > 0;
const primarySession = task.sessions?.find((s) => s.is_primary);
const {
attributes,
@@ -169,32 +166,6 @@ export function KanbanCard({
</Tooltip>
</TooltipProvider>
)}
{hasSessions && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className={`text-xs gap-1 ${primarySession ? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300" : ""}`}
>
<MessageSquare className="h-3 w-3" />
{task.sessions.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{task.sessions.length} linked session
{task.sessions.length !== 1 ? "s" : ""}
</p>
{primarySession && (
<p className="text-xs text-muted-foreground">
Primary: #{primarySession.channel_slug}
</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<AssigneeAvatar agentId={task.assigned_to} />
</div>
@@ -456,13 +456,22 @@ function KnowledgeBaseBrowserContent() {
<div>LLM: {health.llm_status}</div>
<div>Vector: {health.vector_store_status}</div>
</div>
{(["llm_error", "embedding_error", "vector_store_error"] as const)
.filter((k) => typeof health.details?.[k] === "string")
.map((k) => (
{(
[
["llm_error", "LLM"],
["embedding_error", "Embedding"],
["vector_store_error", "Vector store"],
] as const
)
.filter(
([k]) => typeof health.details?.[k] === "string",
)
.map(([k, label]) => (
<p
key={k}
className="text-xs text-red-600 dark:text-red-400 break-words"
>
<span className="font-medium">{label}:</span>{" "}
{health.details[k] as string}
</p>
))}
-2
View File
@@ -8,7 +8,6 @@ import {
LayoutDashboard,
ListTodo,
Kanban,
MessageSquare,
Bell,
Activity,
ChevronLeft,
@@ -50,7 +49,6 @@ export const navItems = [
{ title: "Auditor", href: "/auditor", icon: Shield },
// History
{ title: "Communications", href: "/communications", icon: MessageSquare },
{ title: "A2A Live", href: "/a2a", icon: Radio },
{ title: "Journals", href: "/journals", icon: BookOpen },
@@ -31,7 +31,6 @@ import {
Pencil,
Trash2,
Clock,
MessageSquare,
} from "lucide-react";
import { toast } from "sonner";
import { EditTaskDialog } from "./edit-task-dialog";
@@ -138,7 +137,6 @@ export function TaskActions({
// Check if task is in backlog (needs PM activation)
const isBacklog = task.status === TaskStatus.BACKLOG;
const hasSessions = task.sessions && task.sessions.length > 0;
return (
<>
@@ -164,15 +162,6 @@ export function TaskActions({
<Clock className="h-4 w-4 mr-2" />
Awaiting PM Activation
</DropdownMenuItem>
{!hasSessions && (
<DropdownMenuItem
disabled
className="text-muted-foreground text-xs"
>
<MessageSquare className="h-4 w-4 mr-2" />
Needs session created
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
@@ -12,5 +12,4 @@ export { AcceptanceCriteria } from "./acceptance-criteria";
export { ProgressTimeline } from "./progress-timeline";
export { CheckpointCard } from "./checkpoint-card";
export { CommitCard } from "./commit-card";
export { TabSessions } from "./tab-sessions";
export { WorkSessionCard } from "./work-session-card";
@@ -1,138 +0,0 @@
"use client";
import { Task, TaskSessionLink, SessionScope } from "@/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageSquare, ExternalLink, Star, Hash } from "lucide-react";
import Link from "next/link";
interface TabSessionsProps {
task: Task;
}
const scopeColors: Record<SessionScope, string> = {
[SessionScope.INITIATIVE]:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
[SessionScope.CELL]:
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
[SessionScope.TASK]:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
};
const scopeLabels: Record<SessionScope, string> = {
[SessionScope.INITIATIVE]: "Initiative",
[SessionScope.CELL]: "Cell",
[SessionScope.TASK]: "Task",
};
const relationshipLabels: Record<string, string> = {
discussion: "Discussion",
planning: "Planning",
review: "Review",
retrospective: "Retrospective",
};
function SessionCard({ session }: { session: TaskSessionLink }) {
const shortSessionId = session.session_id.slice(0, 8);
return (
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base font-medium">
Session {shortSessionId}
</CardTitle>
{session.is_primary && (
<Badge variant="default" className="gap-1">
<Star className="h-3 w-3" />
Primary
</Badge>
)}
</div>
<Badge className={scopeColors[session.scope]}>
{scopeLabels[session.scope]}
</Badge>
</div>
<CardDescription className="mt-1 flex items-center gap-2">
<span>
{relationshipLabels[session.relationship_type] ||
session.relationship_type}
</span>
<span className="text-muted-foreground"></span>
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{session.channel_slug}
</span>
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex justify-end">
<Link href={`/communications/${session.session_id}`} prefetch={false}>
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="h-3 w-3" />
View Session
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
export function TabSessions({ task }: TabSessionsProps) {
const sessions = task.sessions || [];
if (sessions.length === 0) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">No Linked Sessions</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
This task does not have any linked discussion sessions yet. A PM
will create a session when work begins.
</p>
</div>
</CardContent>
</Card>
);
}
// Sort: primary first, then by scope
const sortedSessions = [...sessions].sort((a, b) => {
if (a.is_primary !== b.is_primary) return a.is_primary ? -1 : 1;
return 0;
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Linked Sessions</h3>
<p className="text-sm text-muted-foreground">
Discussion sessions related to this task
</p>
</div>
<Badge variant="outline">
{sessions.length} session{sessions.length !== 1 ? "s" : ""}
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-2">
{sortedSessions.map((session) => (
<SessionCard key={session.session_id} session={session} />
))}
</div>
</div>
);
}
@@ -9,7 +9,6 @@ import { TabProgress } from "./tab-progress";
import { TabCommits } from "./tab-commits";
import { TabNotes } from "./tab-notes";
import { TabDependencies } from "./tab-dependencies";
import { TabSessions } from "./tab-sessions";
import {
FileText,
Layout,
@@ -17,7 +16,6 @@ import {
GitCommit,
StickyNote,
Link2,
MessageSquare,
} from "lucide-react";
interface TaskTabsProps {
@@ -34,11 +32,10 @@ export function TaskTabs({ task }: TaskTabsProps) {
(task.auditor_notes ? 1 : 0) +
(task.quick_context ? 1 : 0);
const depsCount = task.dependency_ids.length + task.blocker_ids.length;
const sessionsCount = task.sessions?.length || 0;
return (
<Tabs defaultValue="overview" className="mt-6">
<TabsList className="grid w-full grid-cols-7 lg:w-auto lg:inline-grid">
<TabsList className="grid w-full grid-cols-6 lg:w-auto lg:inline-grid">
<TabsTrigger value="overview" className="gap-2">
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">Overview</span>
@@ -61,15 +58,6 @@ export function TaskTabs({ task }: TaskTabsProps) {
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-2">
<MessageSquare className="h-4 w-4" />
<span className="hidden sm:inline">Sessions</span>
{sessionsCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{sessionsCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="commits" className="gap-2">
<GitCommit className="h-4 w-4" />
<span className="hidden sm:inline">Commits</span>
@@ -109,9 +97,6 @@ export function TaskTabs({ task }: TaskTabsProps) {
<TabsContent value="progress">
<TabProgress task={task} />
</TabsContent>
<TabsContent value="sessions">
<TabSessions task={task} />
</TabsContent>
<TabsContent value="commits">
<TabCommits task={task} />
</TabsContent>
@@ -1,114 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import { useEffect } from "react";
import type {
ConnectionState,
WebSocketOptions,
} from "@/lib/websocket/connection";
// Bundle A: the session detail view had no live subscription — send_message now
// publishes MESSAGE_SENT → bridge → /ws/sessions/{id}, and useSessionStream is
// the panel half that subscribes so an open transcript updates without a manual
// Refresh. Mock the connection so the test can drive onMessage frames.
const hoisted = vi.hoisted(() => {
const instances: MockConnection[] = [];
class MockConnection {
url: string;
onMessage?: (data: unknown) => void;
onStateChange?: (state: ConnectionState) => void;
didConnect = false;
didDisconnect = false;
constructor(opts: WebSocketOptions) {
this.url = opts.url;
this.onMessage = opts.onMessage;
this.onStateChange = opts.onStateChange;
instances.push(this);
}
connect() {
this.didConnect = true;
this.onStateChange?.("connecting");
this.onStateChange?.("connected");
}
disconnect() {
this.didDisconnect = true;
this.onStateChange?.("disconnected");
}
}
return { instances, MockConnection };
});
vi.mock("@/lib/websocket/connection", () => ({
getWebSocketUrl: () => "ws://test/ws",
WebSocketConnection: hoisted.MockConnection,
}));
vi.mock("@/lib/constants", () => ({
CEO_AGENT_ID: "00000000-0000-0000-0000-000000000001",
STREAM_MAX_MESSAGES: 100,
}));
import { useSessionStream } from "../use-websocket";
const resultRef: {
current: ReturnType<typeof useSessionStream> | null;
} = { current: null };
function Harness({ sessionId }: { sessionId: string | null }) {
const ws = useSessionStream(sessionId);
useEffect(() => {
resultRef.current = ws;
});
return null;
}
describe("useSessionStream", () => {
beforeEach(() => {
hoisted.instances.length = 0;
resultRef.current = null;
});
afterEach(() => {
vi.clearAllMocks();
});
it("connects to the session endpoint with the CEO agent_id", () => {
render(<Harness sessionId="sess-1" />);
expect(hoisted.instances).toHaveLength(1);
expect(hoisted.instances[0].url).toContain("/sessions/sess-1");
expect(hoisted.instances[0].url).toContain(
"agent_id=00000000-0000-0000-0000-000000000001",
);
expect(resultRef.current?.isConnected).toBe(true);
});
it("does not connect when sessionId is null", () => {
render(<Harness sessionId={null} />);
expect(hoisted.instances).toHaveLength(0);
});
it("surfaces a message.new frame in sessionMessages and lastMessage", () => {
render(<Harness sessionId="sess-1" />);
const conn = hoisted.instances[0];
act(() => {
conn.onMessage?.({
type: "message.new",
message_id: "m1",
session_id: "sess-1",
agent_id: "a1",
content: "hello",
message_type: "dialogue",
});
});
expect(resultRef.current?.sessionMessages).toHaveLength(1);
expect(resultRef.current?.sessionMessages[0].message_id).toBe("m1");
expect(resultRef.current?.lastMessage?.type).toBe("message.new");
});
it("ignores the initial connected frame (not a real message)", () => {
render(<Harness sessionId="sess-1" />);
const conn = hoisted.instances[0];
act(() => {
conn.onMessage?.({ type: "connected", session_id: "sess-1" });
});
expect(resultRef.current?.sessionMessages).toHaveLength(0);
});
});
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { createElement } from "react";
// Bundle C: GET /sessions/{id} now returns task_links (with titles) in one shot.
// useSession must rely on that single read and stop the N+1 re-fetch
// (getTasksForSession → tasksApi.get per link) it previously did.
const sessionGet = vi.fn();
const getTasksForSession = vi.fn();
const taskGet = vi.fn();
vi.mock("@/lib/api/sessions", () => ({
sessionsApi: {
get: (...args: unknown[]) => sessionGet(...args),
getTasksForSession: (...args: unknown[]) => getTasksForSession(...args),
},
}));
vi.mock("@/lib/api/tasks", () => ({
tasksApi: { get: (...args: unknown[]) => taskGet(...args) },
}));
vi.mock("@/lib/api/channels", () => ({ channelsApi: {} }));
vi.mock("@/lib/api/messages", () => ({ messagesApi: {} }));
import { useSession } from "../use-channels";
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return createElement(QueryClientProvider, { client }, children);
}
describe("useSession", () => {
beforeEach(() => {
sessionGet.mockReset();
getTasksForSession.mockReset();
taskGet.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("returns task_links from the single get() call without per-task fetches", async () => {
sessionGet.mockResolvedValue({
id: "s1",
group_id: "g1",
status: "active",
scope: "cell",
message_count: 0,
total_content_length: 0,
started_at: "2026-06-30T00:00:00Z",
last_activity_at: "2026-06-30T00:00:00Z",
closed_at: null,
task_links: [
{
task_id: "t1",
task_title: "Build it",
is_primary: true,
relationship_type: "discussion",
},
],
});
const { result } = renderHook(() => useSession("s1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.task_links).toHaveLength(1);
expect(result.current.data?.task_links?.[0].task_title).toBe("Build it");
// The redundant N+1 path must be gone.
expect(getTasksForSession).not.toHaveBeenCalled();
expect(taskGet).not.toHaveBeenCalled();
expect(sessionGet).toHaveBeenCalledTimes(1);
});
});
-1
View File
@@ -2,7 +2,6 @@ export * from "./use-tasks";
export * from "./use-rate-limit-websocket";
export * from "./use-rate-limit-sync";
export * from "./use-agents";
export * from "./use-channels";
export * from "./use-notifications";
// Re-export dashboard hooks excluding duplicates from use-agents
export {
+1 -1
View File
@@ -241,7 +241,7 @@ export function useAgentDefinitions() {
/**
* Register the live `/api/agents` roster into the display-name resolver
* (agent-utils). Mount once near the app root so every surface that resolves an
* assignee (task table, task detail, journals, communications, commits) shows
* assignee (task table, task detail, journals, commits) shows
* the real agent name instead of a raw UUID, and never drifts as agents are
* added backend-side. Returns nothing it's a side-effecting sync.
*/
-110
View File
@@ -1,110 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { channelsApi, type ChannelFilters } from "@/lib/api/channels";
import { sessionsApi } from "@/lib/api/sessions";
import { messagesApi } from "@/lib/api/messages";
// A 404 on a session/message read is terminal: the session has closed and been
// reaped server-side, so it will never come back. Retrying (or continuing to
// poll) a 404'd session is exactly what produces the "404 storm" as dead
// session-ids accumulate. Treat 404 as final — fail fast, never retry.
function isNotFound(error: unknown): boolean {
return isAxiosError(error) && error.response?.status === 404;
}
function retryUnlessNotFound(failureCount: number, error: unknown): boolean {
if (isNotFound(error)) return false;
return failureCount < 1;
}
export const channelKeys = {
all: ["channels"] as const,
list: (filters?: ChannelFilters) =>
[...channelKeys.all, "list", filters] as const,
detail: (id: string) => [...channelKeys.all, "detail", id] as const,
groups: (channelId: string) =>
[...channelKeys.all, "groups", channelId] as const,
};
export const sessionKeys = {
all: ["sessions"] as const,
list: (groupId: string) => [...sessionKeys.all, "list", groupId] as const,
detail: (id: string) => [...sessionKeys.all, "detail", id] as const,
};
export const messageKeys = {
all: ["messages"] as const,
list: (sessionId: string) => [...messageKeys.all, "list", sessionId] as const,
};
// Fetch channel list once - manual refresh available
export function useChannels(filters?: ChannelFilters) {
return useQuery({
queryKey: channelKeys.list(filters),
queryFn: () => channelsApi.list(filters),
staleTime: Infinity,
});
}
export function useChannel(channelId: string | null) {
return useQuery({
queryKey: channelKeys.detail(channelId || ""),
queryFn: () => channelsApi.get(channelId!),
enabled: !!channelId,
staleTime: Infinity,
});
}
// Fetch groups for a channel
export function useChannelGroups(channelId: string | null) {
return useQuery({
queryKey: channelKeys.groups(channelId || ""),
queryFn: () => channelsApi.getGroups(channelId!),
enabled: !!channelId,
staleTime: Infinity,
});
}
// Fetch sessions for a group
export function useGroupSessions(groupId: string | null) {
return useQuery({
queryKey: sessionKeys.list(groupId || ""),
queryFn: () => sessionsApi.listByGroup(groupId!),
enabled: !!groupId,
staleTime: Infinity,
});
}
// Fetch a single session by ID. GET /sessions/{id} returns task_links (with
// titles) in one shot, so no separate per-link task fetch is needed.
export function useSession(sessionId: string | null) {
return useQuery({
queryKey: sessionKeys.detail(sessionId || ""),
queryFn: () => sessionsApi.get(sessionId!),
enabled: !!sessionId,
staleTime: 1000 * 60 * 5, // 5 minutes
// A reaped (404) session must not be retried — that is the storm source.
retry: retryUnlessNotFound,
});
}
// Fetch messages for a session - WebSocket handles new messages.
//
// The transcript is read once and held (staleTime Infinity, no focus/reconnect
// refetch in the global defaults), so an open session is fetched a single time
// and a closed session's immutable transcript stays loaded for review. A 404
// means the session was reaped server-side; that is terminal, so we never retry
// it — retrying reaped sessions is what produced the growing 404 storm as dead
// session-ids accumulated. When the consumer unmounts, React Query deactivates
// the query (no background refetch loop survives) and GCs it after gcTime.
export function useSessionMessages(sessionId: string | null) {
return useQuery({
queryKey: messageKeys.list(sessionId || ""),
queryFn: () => messagesApi.listBySession(sessionId!),
enabled: !!sessionId,
staleTime: Infinity,
retry: retryUnlessNotFound,
});
}
+5 -8
View File
@@ -62,17 +62,14 @@ export function useMetrics() {
queryKey: dashboardKeys.metrics(),
queryFn: async (): Promise<MetricsSummary> => {
// Fetch all metrics in parallel, including real agent status
const [velocity, blockers, communication, agentStatus] =
await Promise.all([
dashboardApi.getVelocityMetrics(),
dashboardApi.getBlockerMetrics(),
dashboardApi.getCommunicationMetrics(),
dashboardApi.getAgentStatus(),
]);
const [velocity, blockers, agentStatus] = await Promise.all([
dashboardApi.getVelocityMetrics(),
dashboardApi.getBlockerMetrics(),
dashboardApi.getAgentStatus(),
]);
return {
velocity,
blockers,
communication,
agents: {
total_agents: agentStatus?.total_agents ?? 0,
running: agentStatus?.by_state?.running ?? 0,
-91
View File
@@ -23,17 +23,6 @@ export interface AgentStreamMessage {
timestamp?: string;
}
export interface ChannelMessage {
type: "connected" | "message.new" | "session.closed";
channel_id?: string;
message_id?: string;
agent_id?: string;
content?: string;
message_type?: string;
subscriber_count?: number;
timestamp?: string;
}
export interface NotificationMessage {
type: "connected" | "notification";
agent_id?: string;
@@ -56,19 +45,6 @@ export interface A2ASystemMessage {
timestamp?: string;
}
export interface SessionMessage {
type: "connected" | "message.new";
message_id?: string;
session_id?: string;
channel_id?: string;
agent_id?: string;
content?: string;
message_type?: string;
is_reply?: boolean;
reply_to?: string | null;
timestamp?: string;
}
// =============================================================================
// Generic WebSocket Hook
// =============================================================================
@@ -191,73 +167,6 @@ export function useAgentStream(agentId: string | null) {
};
}
/**
* Subscribe to a channel's message stream
*/
export function useChannelStream(channelId: string | null) {
const {
state,
lastMessage,
messages,
clearMessages,
isConnected,
isConnecting,
} = useWebSocket<ChannelMessage>(
channelId ? "/channels/" + channelId : "",
{ agent_id: CEO_AGENT_ID },
!!channelId,
);
// Filter to only actual messages
const channelMessages = messages.filter((m) => m.type === "message.new");
return {
state,
lastMessage,
channelMessages,
allMessages: messages,
clearMessages,
isConnected,
isConnecting,
};
}
/**
* Subscribe to a session's live message stream (`/ws/sessions/{id}`).
*
* The backend publishes MESSAGE_SENT on every persisted send; the websocket
* bridge fans it to this stream as a `message.new` frame. The session detail
* view consumes `lastMessage` to refresh its transcript live instead of
* relying on the manual Refresh button.
*/
export function useSessionStream(sessionId: string | null) {
const {
state,
lastMessage,
messages,
clearMessages,
isConnected,
isConnecting,
} = useWebSocket<SessionMessage>(
sessionId ? "/sessions/" + sessionId : "",
{ agent_id: CEO_AGENT_ID },
!!sessionId,
);
// Filter to only actual messages (drop the initial `connected` frame).
const sessionMessages = messages.filter((m) => m.type === "message.new");
return {
state,
lastMessage,
sessionMessages,
allMessages: messages,
clearMessages,
isConnected,
isConnecting,
};
}
/**
* Subscribe to notifications for the CEO
*/
-147
View File
@@ -1,147 +0,0 @@
import api from "./client";
import type { Channel, PaginatedResponse, Group } from "@/types";
import { ChannelType } from "@/types";
import { isMockMode, mockChannels, mockGroups } from "@/lib/mock-data";
export interface ChannelFilters {
type?: string;
is_private?: boolean;
}
export interface ChannelCreate {
name: string;
slug: string;
type?: string;
description?: string;
topic?: string;
is_private?: boolean;
}
export interface ChannelUpdate {
name?: string;
description?: string;
topic?: string;
is_archived?: boolean;
}
export const channelsApi = {
// List all channels
list: async (filters?: ChannelFilters): Promise<Channel[]> => {
if (isMockMode()) {
let channels = [...mockChannels] as Channel[];
if (filters?.type) {
channels = channels.filter((c) => c.type === filters.type);
}
if (filters?.is_private !== undefined) {
channels = channels.filter((c) => c.is_private === filters.is_private);
}
return channels;
}
const { data } = await api.get<PaginatedResponse<Channel>>("/channels", {
params: filters,
});
return data.items;
},
// Get channel by ID
get: async (channelId: string): Promise<Channel> => {
if (isMockMode()) {
const channel = mockChannels.find((c) => c.id === channelId);
if (channel) return channel as Channel;
throw new Error("Channel not found");
}
const { data } = await api.get<Channel>("/channels/" + channelId);
return data;
},
// Get channel by slug
getBySlug: async (slug: string): Promise<Channel> => {
if (isMockMode()) {
const channel = mockChannels.find((c) => c.slug === slug);
if (channel) return channel as Channel;
throw new Error("Channel not found");
}
// Backend uses query param filter, not path segment
const { data } = await api.get<PaginatedResponse<Channel>>("/channels", {
params: { slug },
});
if (!data.items.length) {
throw new Error("Channel not found");
}
return data.items[0];
},
// Get groups for a channel
getGroups: async (channelId: string): Promise<Group[]> => {
if (isMockMode()) {
return mockGroups as Group[];
}
const { data } = await api.get<Group[]>(
"/channels/" + channelId + "/groups",
);
return data;
},
// Create a new channel (PM/CEO only)
create: async (channel: ChannelCreate): Promise<Channel> => {
if (isMockMode()) {
const newChannel: Channel = {
id: `channel-${Date.now()}`,
name: channel.name,
slug: channel.slug,
type: (channel.type as ChannelType) || ChannelType.CELL,
description: channel.description || null,
topic: channel.topic || null,
is_private: channel.is_private || false,
is_archived: false,
member_count: 0,
message_count: 0,
group_count: 0,
can_write: true,
};
(mockChannels as Channel[]).push(newChannel);
return newChannel;
}
const { data } = await api.post<Channel>("/channels", channel);
return data;
},
// Update a channel (PM/CEO only)
update: async (
channelId: string,
updates: ChannelUpdate,
): Promise<Channel> => {
if (isMockMode()) {
const idx = mockChannels.findIndex((c) => c.id === channelId);
if (idx === -1) throw new Error("Channel not found");
const updated = { ...mockChannels[idx], ...updates } as Channel;
(mockChannels as Channel[])[idx] = updated;
return updated;
}
const { data } = await api.patch<Channel>(
"/channels/" + channelId,
updates,
);
return data;
},
// Add a member to a channel (PM/CEO only)
addMember: async (channelId: string, agentId: string): Promise<void> => {
if (isMockMode()) {
return;
}
await api.post("/channels/" + channelId + "/add-member", {
agent_id: agentId,
});
},
// Remove a member from a channel (PM/CEO only)
removeMember: async (channelId: string, agentId: string): Promise<void> => {
if (isMockMode()) {
return;
}
await api.delete("/channels/" + channelId + "/remove-member", {
data: { agent_id: agentId },
});
},
};
-21
View File
@@ -35,7 +35,6 @@ export interface TeamHealth {
export interface MetricsSummary {
velocity: VelocityMetric;
blockers: BlockerMetric;
communication: CommunicationMetric;
agents: AgentMetric;
}
@@ -51,12 +50,6 @@ export interface BlockerMetric {
longest_blocked_hours: number;
}
export interface CommunicationMetric {
messages_today: number;
active_channels: number;
notifications_pending: number;
}
export interface AgentMetric {
total_agents: number;
running: number;
@@ -209,20 +202,6 @@ export const dashboardApi = {
return data;
},
getCommunicationMetrics: async (): Promise<CommunicationMetric> => {
if (isMockMode()) {
return {
messages_today: 45,
active_channels: 5,
notifications_pending: 3,
};
}
const { data } = await api.get<CommunicationMetric>(
"/dashboard/metrics/communication",
);
return data;
},
getHealthMetrics: async () => {
if (isMockMode()) {
return mockTeamHealth;
-61
View File
@@ -1,61 +0,0 @@
/**
* Groups API Client
*
* API functions for group management within channels.
*/
import api from "./client";
import { isMockMode } from "@/lib/mock-data";
import type { Group } from "@/types";
// =============================================================================
// Types
// =============================================================================
export interface GroupCreate {
channel_id: string;
name: string;
hierarchy_level?: number;
}
// =============================================================================
// API Client
// =============================================================================
export const groupsApi = {
/**
* Create a new group within a channel
*/
create: async (group: GroupCreate): Promise<Group> => {
if (isMockMode()) {
return {
id: `group-${Date.now()}`,
name: group.name,
hierarchy_level: group.hierarchy_level ?? 0,
is_active: true,
total_messages: 0,
active_session_id: null,
};
}
const { data } = await api.post<Group>("/groups", group);
return data;
},
/**
* Get a group by ID
*/
get: async (groupId: string): Promise<Group> => {
if (isMockMode()) {
return {
id: groupId,
name: "Mock Group",
hierarchy_level: 0,
is_active: true,
total_messages: 0,
active_session_id: null,
};
}
const { data } = await api.get<Group>(`/groups/${groupId}`);
return data;
},
};
-2
View File
@@ -2,7 +2,6 @@ export { api, API_URL } from "./client";
export { usageApi } from "./usage";
export { tasksApi } from "./tasks";
export { orchestratorApi } from "./orchestrator";
export { channelsApi } from "./channels";
export { notificationsApi } from "./notifications";
export { dashboardApi } from "./dashboard";
export { knowledgeBaseApi } from "./knowledge-base";
@@ -12,7 +11,6 @@ export { workSessionsApi } from "./work-sessions";
export { gitApi } from "./git";
export { a2aApi } from "./a2a";
export { streamApi } from "./stream";
export { groupsApi } from "./groups";
export { settingsApi } from "./settings";
export { companyGoalsApi } from "./company-goals";
export { releaseApi } from "./release";
-135
View File
@@ -1,135 +0,0 @@
import api from "./client";
import type { Message, MessageType } from "@/types";
import {
isMockMode,
getMockMessages,
AGENT_IDS,
CHANNEL_IDS,
} from "@/lib/mock-data";
// Store for mock messages (persists during session)
let mockMessagesStore: Message[] | null = null;
const getMessages = (): Message[] => {
if (!mockMessagesStore) {
mockMessagesStore = getMockMessages() as Message[];
}
return mockMessagesStore;
};
export const messagesApi = {
// List messages for a session
listBySession: async (
sessionId: string,
limit: number = 50,
before?: string,
after?: string,
): Promise<{ items: Message[]; has_more: boolean }> => {
if (isMockMode()) {
let messages = getMessages().filter((m) => m.session_id === sessionId);
if (before) {
messages = messages.filter(
(m) => new Date(m.timestamp) < new Date(before),
);
}
if (after) {
messages = messages.filter(
(m) => new Date(m.timestamp) > new Date(after),
);
}
return {
items: messages.slice(0, limit),
has_more: messages.length > limit,
};
}
const { data } = await api.get<{ items: Message[]; has_more: boolean }>(
"/messages",
{
params: { session_id: sessionId, limit, before, after },
},
);
return data;
},
// Get message by ID
get: async (messageId: string): Promise<Message> => {
if (isMockMode()) {
const message = getMessages().find((m) => m.id === messageId);
if (message) return message;
throw new Error("Message not found");
}
const { data } = await api.get<Message>("/messages/" + messageId);
return data;
},
// Send a message
send: async (
sessionId: string,
content: string,
type: string = "dialogue",
): Promise<Message> => {
if (isMockMode()) {
const newMessage: Message = {
id: `msg-${Date.now()}`,
agent_id: AGENT_IDS.ceo,
channel_id: CHANNEL_IDS.backendCell,
group_id: `msg-${Date.now()}`,
session_id: sessionId,
type: type as MessageType,
content,
content_length: content.length,
is_reply: false,
reply_to: null,
mentions: [],
task_id: null,
commit_ref: null,
timestamp: new Date().toISOString(),
edited_at: null,
was_edited: false,
};
getMessages().push(newMessage);
return newMessage;
}
const { data } = await api.post<Message>("/messages", {
session_id: sessionId,
content,
type,
});
return data;
},
// Edit a message
edit: async (messageId: string, content: string): Promise<Message> => {
if (isMockMode()) {
const messages = getMessages();
const idx = messages.findIndex((m) => m.id === messageId);
if (idx !== -1) {
const message = messages[idx];
const editedMessage: Message = {
...message,
content,
content_length: content.length,
edited_at: new Date().toISOString(),
was_edited: true,
};
messages[idx] = editedMessage;
return editedMessage;
}
throw new Error("Message not found");
}
const { data } = await api.patch<Message>("/messages/" + messageId, {
content,
});
return data;
},
// Delete a message
delete: async (messageId: string): Promise<void> => {
if (isMockMode()) {
const messages = getMessages();
const idx = messages.findIndex((m) => m.id === messageId);
if (idx !== -1) messages.splice(idx, 1);
return;
}
await api.delete("/messages/" + messageId);
},
};
-146
View File
@@ -1,146 +0,0 @@
import api from "./client";
import type { Session } from "@/types";
import { SessionStatus, SessionScope } from "@/types";
import { isMockMode, mockSessions } from "@/lib/mock-data";
// Session-Task link response from API
export interface SessionTaskLinkResponse {
id: string;
session_id: string;
task_id: string;
is_primary: boolean;
relationship_type: string;
added_at: string;
added_by: string | null;
}
export interface SessionCreate {
group_id: string;
scope?: string;
}
export const sessionsApi = {
// List sessions for a group
listByGroup: async (
groupId: string,
limit: number = 50,
): Promise<Session[]> => {
if (isMockMode()) {
return (mockSessions as Session[]).slice(0, limit);
}
const { data } = await api.get<{ items: Session[]; total: number }>(
"/sessions",
{
params: { group_id: groupId, limit },
},
);
return data.items;
},
// Get session by ID
get: async (sessionId: string): Promise<Session> => {
if (isMockMode()) {
const session = mockSessions.find((s) => s.id === sessionId);
if (session) return session as Session;
throw new Error("Session not found");
}
const { data } = await api.get<Session>("/sessions/" + sessionId);
return data;
},
// Get sessions linked to a task
getForTask: async (taskId: string): Promise<SessionTaskLinkResponse[]> => {
if (isMockMode()) {
return []; // No mock session-task links
}
const { data } = await api.get<SessionTaskLinkResponse[]>(
"/sessions/for-task/" + taskId,
);
return data;
},
// Close a session
close: async (sessionId: string): Promise<Session> => {
if (isMockMode()) {
const idx = mockSessions.findIndex((s) => s.id === sessionId);
if (idx !== -1) {
const session = mockSessions[idx] as Session;
const closedSession: Session = {
...session,
status: "closed" as SessionStatus,
closed_at: new Date().toISOString(),
};
(mockSessions as Session[])[idx] = closedSession;
return closedSession;
}
throw new Error("Session not found");
}
const { data } = await api.post<Session>(
"/sessions/" + sessionId + "/close",
);
return data;
},
// Link a task to a session (PM only)
linkTask: async (
sessionId: string,
taskId: string,
isPrimary: boolean = false,
relationshipType: string = "discussion",
): Promise<SessionTaskLinkResponse> => {
const { data } = await api.post<SessionTaskLinkResponse>(
"/sessions/" + sessionId + "/tasks",
{
task_id: taskId,
is_primary: isPrimary,
relationship_type: relationshipType,
},
);
return data;
},
// Unlink a task from a session (PM only)
unlinkTask: async (sessionId: string, taskId: string): Promise<void> => {
await api.delete("/sessions/" + sessionId + "/tasks/" + taskId);
},
// Create a session for tasks (PM only)
createForTasks: async (
taskIds: string[],
channelSlug: string,
relationshipType: string = "discussion",
): Promise<{ session: Session; links: SessionTaskLinkResponse[] }> => {
const { data } = await api.post<{
session: Session;
links: SessionTaskLinkResponse[];
}>("/sessions/for-tasks", {
task_ids: taskIds,
channel_slug: channelSlug,
relationship_type: relationshipType,
});
return data;
},
// Create a new session directly
create: async (session: SessionCreate): Promise<Session> => {
if (isMockMode()) {
const now = new Date().toISOString();
const newSession: Session = {
id: `session-${Date.now()}`,
group_id: session.group_id,
status: SessionStatus.ACTIVE,
scope: (session.scope as SessionScope) || SessionScope.CELL,
message_count: 0,
total_content_length: 0,
started_at: now,
last_activity_at: now,
closed_at: null,
task_links: [],
};
(mockSessions as Session[]).push(newSession);
return newSession;
}
const { data } = await api.post<Session>("/sessions", session);
return data;
},
};
-2
View File
@@ -87,7 +87,6 @@ const summaryToTask = (s: TaskSummaryWire): Task => ({
quick_context: null,
self_verified: false,
qa_verified: null,
sessions: [],
});
export const tasksApi = {
@@ -202,7 +201,6 @@ export const tasksApi = {
qa_notes: null,
auditor_notes: null,
quick_context: null,
sessions: [],
branch_name: null,
pr_number: null,
pr_url: null,
-261
View File
@@ -7,10 +7,6 @@ import {
NotificationType,
NotificationPriority,
JournalEntryType,
ChannelType,
SessionStatus,
SessionScope,
MessageType,
FlagSeverity,
TaskNature,
TaskType,
@@ -64,17 +60,6 @@ export const PROJECT_IDS = {
robocoPanel: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
};
export const CHANNEL_IDS = {
backendCell: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
frontendCell: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
uxuiCell: "cccccccc-cccc-cccc-cccc-cccccccccccc",
devAll: "dddddddd-dddd-dddd-dddd-dddddddddddd",
qaAll: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
pmAll: "ffffffff-ffff-ffff-ffff-ffffffffffff",
announcements: "00000000-0000-0000-0000-000000000100",
allHands: "00000000-0000-0000-0000-000000000101",
};
// Timestamps - computed dynamically to stay relative
const getNow = () => new Date();
const getMinutesAgo = (mins: number) => new Date(Date.now() - mins * 60 * 1000);
@@ -299,7 +284,6 @@ Implement a complete user authentication system including:
completed_at: null,
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: "feature/TASK-001-user-auth",
pr_number: null,
pr_url: null,
@@ -442,7 +426,6 @@ Implement a complete user authentication system including:
completed_at: null,
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: null,
pr_number: null,
pr_url: null,
@@ -497,7 +480,6 @@ Pagination should maintain filter state.`,
completed_at: null,
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: "fix/TASK-003-pagination-bug",
pr_number: null,
pr_url: null,
@@ -548,7 +530,6 @@ The flow should guide new users through:
completed_at: hourAgo.toISOString(),
self_verified: true,
qa_verified: null,
sessions: [],
branch_name: "feature/TASK-004-onboarding-design",
pr_number: 42,
pr_url: "https://github.com/roboco/roboco/pull/42",
@@ -659,7 +640,6 @@ Reduce response time to < 100ms for all endpoints.`,
completed_at: null,
self_verified: false,
qa_verified: null,
sessions: [],
branch_name: "perf/TASK-005-db-optimization",
pr_number: null,
pr_url: null,
@@ -718,7 +698,6 @@ Include:
completed_at: hourAgo.toISOString(),
self_verified: true,
qa_verified: true,
sessions: [],
branch_name: "docs/TASK-006-api-documentation",
pr_number: 38,
pr_url: "https://github.com/roboco/roboco/pull/38",
@@ -930,83 +909,6 @@ export const mockWaitingAgents = [
},
];
// =============================================================================
// MOCK CHANNELS - Matching backend ChannelResponse schema
// =============================================================================
export const mockChannels = [
{
id: CHANNEL_IDS.backendCell,
name: "Backend Cell",
slug: "backend-cell",
type: ChannelType.CELL,
description: "Backend development team channel",
topic: null,
member_count: 6,
message_count: 150,
group_count: 3,
is_archived: false,
is_private: false,
can_write: true,
},
{
id: CHANNEL_IDS.frontendCell,
name: "Frontend Cell",
slug: "frontend-cell",
type: ChannelType.CELL,
description: "Frontend development team channel",
topic: null,
member_count: 6,
message_count: 120,
group_count: 2,
is_archived: false,
is_private: false,
can_write: true,
},
{
id: CHANNEL_IDS.uxuiCell,
name: "UX/UI Cell",
slug: "uxui-cell",
type: ChannelType.CELL,
description: "UX/UI design team channel",
topic: null,
member_count: 5,
message_count: 80,
group_count: 2,
is_archived: false,
is_private: false,
can_write: true,
},
{
id: CHANNEL_IDS.devAll,
name: "All Developers",
slug: "dev-all",
type: ChannelType.CROSS_CELL,
description: "Cross-cell developer discussion",
topic: null,
member_count: 10,
message_count: 200,
group_count: 5,
is_archived: false,
is_private: false,
can_write: true,
},
{
id: CHANNEL_IDS.announcements,
name: "Announcements",
slug: "announcements",
type: ChannelType.SPECIAL,
description: "Company-wide announcements",
topic: null,
member_count: 19,
message_count: 25,
group_count: 1,
is_archived: false,
is_private: false,
can_write: false,
},
];
// =============================================================================
// MOCK NOTIFICATIONS - Matching backend NotificationResponse schema
// =============================================================================
@@ -1215,146 +1117,6 @@ export const mockKanbanDevBoard = {
blocked_count: 1,
};
// =============================================================================
// MOCK SESSIONS - Matching backend SessionResponse schema
// =============================================================================
export const mockSessions = [
{
id: mockId(),
group_id: mockId(),
status: SessionStatus.ACTIVE,
scope: SessionScope.TASK,
message_count: 25,
total_content_length: 5000,
started_at: hourAgo.toISOString(),
last_activity_at: now.toISOString(),
closed_at: null,
},
{
id: mockId(),
group_id: mockId(),
status: SessionStatus.CLOSED,
scope: SessionScope.CELL,
message_count: 50,
total_content_length: 12000,
started_at: dayAgo.toISOString(),
last_activity_at: new Date(
now.getTime() - 2 * 60 * 60 * 1000,
).toISOString(),
closed_at: new Date(now.getTime() - 2 * 60 * 60 * 1000).toISOString(),
},
];
// =============================================================================
// MOCK MESSAGES - Matching backend MessageResponse schema
// =============================================================================
const messageGroupId = mockId();
// Use getter for fresh timestamps
export const getMockMessages = () => [
{
id: mockId(),
agent_id: AGENT_IDS.beDev1,
channel_id: CHANNEL_IDS.backendCell,
group_id: messageGroupId,
session_id: mockSessions[0].id,
type: MessageType.DIALOGUE,
content:
"Just finished the auth service implementation. Ready to start on the API endpoints.",
content_length: 78,
is_reply: false,
reply_to: null,
mentions: [],
task_id: TASK_IDS.task1,
commit_ref: null,
timestamp: getMinutesAgo(60).toISOString(),
edited_at: null,
was_edited: false,
},
{
id: mockId(),
agent_id: AGENT_IDS.bePm,
channel_id: CHANNEL_IDS.backendCell,
group_id: messageGroupId,
session_id: mockSessions[0].id,
type: MessageType.DECISION,
content: "Great progress! Let's prioritize the OAuth integration next.",
content_length: 58,
is_reply: true,
reply_to: null,
mentions: [AGENT_IDS.beDev1],
task_id: TASK_IDS.task1,
commit_ref: null,
timestamp: getMinutesAgo(55).toISOString(),
edited_at: null,
was_edited: false,
},
{
id: mockId(),
agent_id: AGENT_IDS.beDev2,
channel_id: CHANNEL_IDS.backendCell,
group_id: messageGroupId,
session_id: mockSessions[0].id,
type: MessageType.BLOCKER,
content:
"I'm blocked on the database optimization task. Need the auth changes to merge first.",
content_length: 82,
is_reply: false,
reply_to: null,
mentions: [AGENT_IDS.beDev1],
task_id: TASK_IDS.task5,
commit_ref: null,
timestamp: getMinutesAgo(50).toISOString(),
edited_at: null,
was_edited: false,
},
{
id: mockId(),
agent_id: AGENT_IDS.beDev1,
channel_id: CHANNEL_IDS.backendCell,
group_id: messageGroupId,
session_id: mockSessions[0].id,
type: MessageType.TECHNICAL,
content: "Commit pushed: feat(auth): add user authentication schema",
content_length: 55,
is_reply: false,
reply_to: null,
mentions: [],
task_id: TASK_IDS.task1,
commit_ref: "abc123def456",
timestamp: getMinutesAgo(45).toISOString(),
edited_at: null,
was_edited: false,
},
];
// Legacy export for backwards compatibility
export const mockMessages = getMockMessages();
// =============================================================================
// MOCK GROUPS - Matching backend GroupResponse schema
// =============================================================================
export const mockGroups = [
{
id: mockId(),
name: "General Discussion",
hierarchy_level: 0,
is_active: true,
total_messages: 150,
active_session_id: mockSessions[0].id,
},
{
id: mockId(),
name: "Tech Talk",
hierarchy_level: 1,
is_active: true,
total_messages: 80,
active_session_id: null,
},
];
// =============================================================================
// MOCK AUDITOR DATA - Matching backend dashboard.py schemas
// =============================================================================
@@ -1409,29 +1171,6 @@ export const mockAuditorReports = [
];
export const mockAuditorDashboard = {
live_feeds: [
{
id: CHANNEL_IDS.backendCell,
name: "Backend Cell",
status: "streaming",
last_activity: now.toISOString(),
message_count_24h: 25,
},
{
id: CHANNEL_IDS.frontendCell,
name: "Frontend Cell",
status: "idle",
last_activity: hourAgo.toISOString(),
message_count_24h: 15,
},
{
id: CHANNEL_IDS.uxuiCell,
name: "UX/UI Cell",
status: "idle",
last_activity: dayAgo.toISOString(),
message_count_24h: 5,
},
],
flagged_items: mockAuditorFlags,
metrics: {
total_flags: 2,
-114
View File
@@ -114,13 +114,6 @@ export enum AssignmentScope {
AGENT_SLUG = "agent_slug",
}
export enum SessionTaskRelationshipType {
DISCUSSION = "discussion",
PLANNING = "planning",
REVIEW = "review",
RETROSPECTIVE = "retrospective",
}
export enum NotificationType {
TASK_ASSIGNMENT = "task_assignment",
PRIORITY_CHANGE = "priority_change",
@@ -139,34 +132,6 @@ export enum NotificationPriority {
URGENT = "urgent",
}
export enum SessionStatus {
ACTIVE = "active",
CLOSED = "closed",
TIMED_OUT = "timed_out",
}
export enum SessionScope {
INITIATIVE = "initiative",
CELL = "cell",
TASK = "task",
}
export enum MessageType {
REASONING = "reasoning",
DIALOGUE = "dialogue",
DECISION = "decision",
ACTION = "action",
BLOCKER = "blocker",
TECHNICAL = "technical",
}
export enum ChannelType {
CELL = "cell",
CROSS_CELL = "cross_cell",
MANAGEMENT = "management",
SPECIAL = "special",
}
export enum AgentStatus {
ACTIVE = "active",
IDLE = "idle",
@@ -220,14 +185,6 @@ export interface ExecutionLog {
total_duration_seconds: number | null;
}
export interface TaskSessionLink {
session_id: string;
channel_slug: string;
scope: SessionScope;
is_primary: boolean;
relationship_type: string;
}
export interface SubTask {
id: string;
title: string;
@@ -315,8 +272,6 @@ export interface Task {
// Review Status
self_verified: boolean;
qa_verified: boolean | null;
// Linked Sessions
sessions: TaskSessionLink[];
// Git/Development Context
branch_name: string | null;
pr_number: number | null;
@@ -482,53 +437,6 @@ export interface WaitingAgent {
context: Record<string, unknown>;
}
export interface Channel {
id: string;
name: string;
slug: string;
type: ChannelType;
description: string | null;
topic: string | null;
member_count: number;
message_count: number;
group_count: number;
is_archived: boolean;
is_private: boolean;
can_write: boolean;
}
export interface ChannelDetail extends Channel {
groups: Group[];
}
export interface Group {
id: string;
name: string;
hierarchy_level: number;
is_active: boolean;
total_messages: number;
active_session_id: string | null;
}
export interface Message {
id: string;
agent_id: string;
channel_id: string;
group_id: string;
session_id: string;
type: MessageType;
content: string;
content_length: number;
is_reply: boolean;
reply_to: string | null;
mentions: string[];
task_id: string | null;
commit_ref: string | null;
timestamp: string;
edited_at: string | null;
was_edited: boolean;
}
export interface SessionTaskInfo {
task_id: string;
task_title: string | null;
@@ -536,19 +444,6 @@ export interface SessionTaskInfo {
relationship_type: string;
}
export interface Session {
id: string;
group_id: string;
status: SessionStatus;
scope: SessionScope;
message_count: number;
total_content_length: number;
started_at: string;
last_activity_at: string;
closed_at: string | null;
task_links: SessionTaskInfo[];
}
export interface Notification {
id: string;
type: NotificationType;
@@ -707,16 +602,7 @@ export interface AuditorReport {
sent_at: string | null;
}
export interface ChannelFeed {
id: string;
name: string;
status: string;
last_activity: string | null;
message_count_24h: number;
}
export interface AuditorDashboard {
live_feeds: ChannelFeed[];
flagged_items: AuditorFlag[];
metrics: Record<string, number>;
audit_queue: Array<{