mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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're looking for doesn'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'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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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<{
|
||||
|
||||
Reference in New Issue
Block a user