feat(#91): implement Connection Manager UI for federation

Implemented comprehensive UI for managing federation connections:

Features:
- View existing federation connections grouped by status
- Initiate new connections to remote instances
- Accept/reject pending connection requests
- Disconnect active connections
- Display connection status, metadata, and capabilities
- PDA-friendly design throughout (no demanding language)

Components:
- ConnectionCard: Display individual connections with actions
- ConnectionList: Grouped list view with status sections
- InitiateConnectionDialog: Modal for connecting to new instances
- Connections page: Main management interface

Implementation:
- Full test coverage (42 tests, 100% passing)
- TypeScript strict mode compliance
- ESLint passing with no warnings
- Mock data for development (ready for backend integration)
- Proper error handling and loading states
- PDA-friendly language (calm, supportive, stress-free)

Status indicators:
- 🟢 Active (soft green)
- 🔵 Pending (soft blue)
- ⏸️ Disconnected (soft yellow)
-  Rejected (light gray)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:03:44 -06:00
parent ca4f5ec011
commit 5cf02e824b
9 changed files with 1500 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
"use client";
/**
* Federation Connections Page
* Manage connections to remote Mosaic Stack instances
*/
import { useState, useEffect } from "react";
import { ConnectionList } from "@/components/federation/ConnectionList";
import { InitiateConnectionDialog } from "@/components/federation/InitiateConnectionDialog";
import {
mockConnections,
FederationConnectionStatus,
type ConnectionDetails,
} from "@/lib/api/federation";
// TODO: Replace with real API calls when backend is integrated
// import {
// fetchConnections,
// initiateConnection,
// acceptConnection,
// rejectConnection,
// disconnectConnection,
// } from "@/lib/api/federation";
export default function ConnectionsPage(): React.JSX.Element {
const [connections, setConnections] = useState<ConnectionDetails[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const [dialogLoading, setDialogLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
// Load connections on mount
useEffect(() => {
void loadConnections();
}, []);
const loadConnections = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with real API call when backend is integrated
// const data = await fetchConnections();
// Using mock data for now
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay
setConnections(mockConnections);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to load connections. Please try again."
);
} finally {
setIsLoading(false);
}
};
const handleInitiate = async (_url: string): Promise<void> => {
setDialogLoading(true);
setDialogError(null);
try {
// TODO: Replace with real API call
// const newConnection = await initiateConnection({ remoteUrl: url });
// Simulate API call for now
await new Promise((resolve) => setTimeout(resolve, 1000));
// Close dialog and reload connections
setShowDialog(false);
await loadConnections();
} catch (err) {
setDialogError(
err instanceof Error
? err.message
: "Unable to initiate connection. Please check the URL and try again."
);
} finally {
setDialogLoading(false);
}
};
const handleAccept = async (connectionId: string): Promise<void> => {
setError(null);
try {
// TODO: Replace with real API call
// await acceptConnection(connectionId);
// Simulate API call and optimistic update
await new Promise((resolve) => setTimeout(resolve, 500));
setConnections((prev) =>
prev.map((conn) =>
conn.id === connectionId
? {
...conn,
status: FederationConnectionStatus.ACTIVE,
connectedAt: new Date().toISOString(),
}
: conn
)
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to accept connection. Please try again."
);
}
};
const handleReject = async (connectionId: string): Promise<void> => {
setError(null);
try {
// TODO: Replace with real API call
// await rejectConnection(connectionId, { reason: "User declined" });
// Simulate API call and optimistic update
await new Promise((resolve) => setTimeout(resolve, 500));
setConnections((prev) =>
prev.map((conn) =>
conn.id === connectionId
? {
...conn,
status: FederationConnectionStatus.REJECTED,
}
: conn
)
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unable to reject connection. Please try again."
);
}
};
const handleDisconnect = async (connectionId: string): Promise<void> => {
setError(null);
try {
// TODO: Replace with real API call
// await disconnectConnection(connectionId);
// Simulate API call and optimistic update
await new Promise((resolve) => setTimeout(resolve, 500));
setConnections((prev) =>
prev.map((conn) =>
conn.id === connectionId
? {
...conn,
status: FederationConnectionStatus.DISCONNECTED,
disconnectedAt: new Date().toISOString(),
}
: conn
)
);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to disconnect. Please try again.");
}
};
return (
<main className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Federation Connections</h1>
<p className="text-gray-600 mt-2">Manage connections to other Mosaic Stack instances</p>
</div>
<button
onClick={() => {
setShowDialog(true);
}}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Connect to Instance
</button>
</div>
{/* Error Banner */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
<button
onClick={() => {
setError(null);
}}
className="text-sm text-red-700 underline mt-2"
>
Dismiss
</button>
</div>
)}
{/* Connection List */}
<ConnectionList
connections={connections}
isLoading={isLoading}
onAccept={handleAccept}
onReject={handleReject}
onDisconnect={handleDisconnect}
/>
{/* Initiate Connection Dialog */}
<InitiateConnectionDialog
open={showDialog}
onInitiate={handleInitiate}
onCancel={() => {
setShowDialog(false);
setDialogError(null);
}}
isLoading={dialogLoading}
{...(dialogError && { error: dialogError })}
/>
</main>
);
}

View File

@@ -0,0 +1,201 @@
/**
* ConnectionCard Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionCard } from "./ConnectionCard";
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
describe("ConnectionCard", (): void => {
const mockActiveConnection: ConnectionDetails = {
id: "conn-1",
workspaceId: "workspace-1",
remoteInstanceId: "instance-work-001",
remoteUrl: "https://mosaic.work.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.ACTIVE,
metadata: {
name: "Work Instance",
description: "Corporate Mosaic instance",
},
createdAt: new Date("2026-02-01").toISOString(),
updatedAt: new Date("2026-02-01").toISOString(),
connectedAt: new Date("2026-02-01").toISOString(),
disconnectedAt: null,
};
const mockPendingConnection: ConnectionDetails = {
...mockActiveConnection,
id: "conn-2",
status: FederationConnectionStatus.PENDING,
metadata: {
name: "Partner Instance",
description: "Awaiting acceptance",
},
connectedAt: null,
};
const mockOnAccept = vi.fn();
const mockOnReject = vi.fn();
const mockOnDisconnect = vi.fn();
it("should render connection name from metadata", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
it("should render connection URL", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("https://mosaic.work.example.com")).toBeInTheDocument();
});
it("should render connection description from metadata", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Corporate Mosaic instance")).toBeInTheDocument();
});
it("should show Active status with green indicator for active connections", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("should show Pending status with blue indicator for pending connections", (): void => {
render(<ConnectionCard connection={mockPendingConnection} />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("should show Disconnected status for disconnected connections", (): void => {
const disconnectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.DISCONNECTED,
disconnectedAt: new Date("2026-02-02").toISOString(),
};
render(<ConnectionCard connection={disconnectedConnection} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
it("should show Rejected status for rejected connections", (): void => {
const rejectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.REJECTED,
};
render(<ConnectionCard connection={rejectedConnection} />);
expect(screen.getByText("Rejected")).toBeInTheDocument();
});
it("should show Disconnect button for active connections", (): void => {
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
expect(screen.getByRole("button", { name: /disconnect/i })).toBeInTheDocument();
});
it("should show Accept and Reject buttons for pending connections", (): void => {
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
expect(screen.getByRole("button", { name: /accept/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
});
it("should not show action buttons for disconnected connections", (): void => {
const disconnectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
render(<ConnectionCard connection={disconnectedConnection} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should call onAccept when accept button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
const acceptButton = screen.getByRole("button", { name: /accept/i });
await user.click(acceptButton);
expect(mockOnAccept).toHaveBeenCalledWith(mockPendingConnection.id);
});
it("should call onReject when reject button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
const rejectButton = screen.getByRole("button", { name: /reject/i });
await user.click(rejectButton);
expect(mockOnReject).toHaveBeenCalledWith(mockPendingConnection.id);
});
it("should call onDisconnect when disconnect button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
const disconnectButton = screen.getByRole("button", { name: /disconnect/i });
await user.click(disconnectButton);
expect(mockOnDisconnect).toHaveBeenCalledWith(mockActiveConnection.id);
});
it("should display capabilities when showDetails is true", (): void => {
render(<ConnectionCard connection={mockActiveConnection} showDetails={true} />);
expect(screen.getByText(/Query/i)).toBeInTheDocument();
expect(screen.getByText(/Command/i)).toBeInTheDocument();
expect(screen.getByText(/Events/i)).toBeInTheDocument();
expect(screen.getByText(/Agent Spawn/i)).toBeInTheDocument();
});
it("should not display capabilities by default", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
// Capabilities should not be visible without showDetails=true
const card = screen.getByText("Work Instance").closest("div");
expect(card?.textContent).not.toMatch(/Query.*Command.*Events/);
});
it("should use fallback name if metadata name is missing", (): void => {
const connectionWithoutName = {
...mockActiveConnection,
metadata: {},
};
render(<ConnectionCard connection={connectionWithoutName} />);
expect(screen.getByText("Remote Instance")).toBeInTheDocument();
});
it("should render with compact layout when compact prop is true", (): void => {
const { container } = render(
<ConnectionCard connection={mockActiveConnection} compact={true} />
);
// Verify compact class is applied
expect(container.querySelector(".p-3")).toBeInTheDocument();
});
it("should render with full layout by default", (): void => {
const { container } = render(<ConnectionCard connection={mockActiveConnection} />);
// Verify full padding is applied
expect(container.querySelector(".p-4")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
/**
* ConnectionCard Component
* Displays a single federation connection with PDA-friendly design
*/
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
interface ConnectionCardProps {
connection: ConnectionDetails;
onAccept?: (connectionId: string) => void;
onReject?: (connectionId: string) => void;
onDisconnect?: (connectionId: string) => void;
showDetails?: boolean;
compact?: boolean;
}
/**
* Get PDA-friendly status text and color
*/
function getStatusDisplay(status: FederationConnectionStatus): {
text: string;
colorClass: string;
icon: string;
} {
switch (status) {
case FederationConnectionStatus.ACTIVE:
return {
text: "Active",
colorClass: "text-green-600 bg-green-50",
icon: "🟢",
};
case FederationConnectionStatus.PENDING:
return {
text: "Pending",
colorClass: "text-blue-600 bg-blue-50",
icon: "🔵",
};
case FederationConnectionStatus.DISCONNECTED:
return {
text: "Disconnected",
colorClass: "text-yellow-600 bg-yellow-50",
icon: "⏸️",
};
case FederationConnectionStatus.REJECTED:
return {
text: "Rejected",
colorClass: "text-gray-600 bg-gray-50",
icon: "⚪",
};
}
}
export function ConnectionCard({
connection,
onAccept,
onReject,
onDisconnect,
showDetails = false,
compact = false,
}: ConnectionCardProps): React.JSX.Element {
const status = getStatusDisplay(connection.status);
const name =
typeof connection.metadata.name === "string" ? connection.metadata.name : "Remote Instance";
const description = connection.metadata.description as string | undefined;
const paddingClass = compact ? "p-3" : "p-4";
return (
<div
className={`border border-gray-200 rounded-lg ${paddingClass} hover:border-gray-300 transition-colors`}
>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">{name}</h3>
<p className="text-sm text-gray-600 mt-1">{connection.remoteUrl}</p>
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
</div>
{/* Status Badge */}
<div
className={`flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${status.colorClass}`}
>
<span>{status.icon}</span>
<span>{status.text}</span>
</div>
</div>
{/* Capabilities (when showDetails is true) */}
{showDetails && (
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2">Capabilities</p>
<div className="flex flex-wrap gap-2">
{connection.remoteCapabilities.supportsQuery && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Query</span>
)}
{connection.remoteCapabilities.supportsCommand && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Command</span>
)}
{connection.remoteCapabilities.supportsEvent && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Events</span>
)}
{connection.remoteCapabilities.supportsAgentSpawn && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">
Agent Spawn
</span>
)}
</div>
</div>
)}
{/* Actions */}
{connection.status === FederationConnectionStatus.PENDING && (onAccept ?? onReject) && (
<div className="mt-4 flex gap-2">
{onAccept && (
<button
onClick={() => {
onAccept(connection.id);
}}
className="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors"
>
Accept
</button>
)}
{onReject && (
<button
onClick={() => {
onReject(connection.id);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Reject
</button>
)}
</div>
)}
{connection.status === FederationConnectionStatus.ACTIVE && onDisconnect && (
<div className="mt-4">
<button
onClick={() => {
onDisconnect(connection.id);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Disconnect
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
/**
* ConnectionList Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ConnectionList } from "./ConnectionList";
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
describe("ConnectionList", (): void => {
const mockConnections: ConnectionDetails[] = [
{
id: "conn-1",
workspaceId: "workspace-1",
remoteInstanceId: "instance-work-001",
remoteUrl: "https://mosaic.work.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.ACTIVE,
metadata: {
name: "Work Instance",
description: "Corporate Mosaic instance",
},
createdAt: new Date("2026-02-01").toISOString(),
updatedAt: new Date("2026-02-01").toISOString(),
connectedAt: new Date("2026-02-01").toISOString(),
disconnectedAt: null,
},
{
id: "conn-2",
workspaceId: "workspace-1",
remoteInstanceId: "instance-partner-001",
remoteUrl: "https://mosaic.partner.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: false,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.PENDING,
metadata: {
name: "Partner Instance",
description: "Awaiting acceptance",
},
createdAt: new Date("2026-02-02").toISOString(),
updatedAt: new Date("2026-02-02").toISOString(),
connectedAt: null,
disconnectedAt: null,
},
];
const mockOnAccept = vi.fn();
const mockOnReject = vi.fn();
const mockOnDisconnect = vi.fn();
it("should render loading state", (): void => {
render(<ConnectionList connections={[]} isLoading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render empty state when no connections", (): void => {
render(<ConnectionList connections={[]} isLoading={false} />);
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
});
it("should render PDA-friendly empty state message", (): void => {
render(<ConnectionList connections={[]} isLoading={false} />);
expect(screen.getByText(/ready to connect/i)).toBeInTheDocument();
});
it("should render all connections", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
expect(screen.getByText("Partner Instance")).toBeInTheDocument();
});
it("should group connections by status", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} />);
expect(screen.getByRole("heading", { name: "Active" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Pending" })).toBeInTheDocument();
});
it("should pass handlers to connection cards", (): void => {
render(
<ConnectionList
connections={mockConnections}
isLoading={false}
onAccept={mockOnAccept}
onReject={mockOnReject}
onDisconnect={mockOnDisconnect}
/>
);
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
const disconnectButtons = screen.getAllByRole("button", { name: /disconnect/i });
expect(acceptButtons.length).toBeGreaterThan(0);
expect(disconnectButtons.length).toBeGreaterThan(0);
});
it("should handle null connections array", (): void => {
render(<ConnectionList connections={null} isLoading={false} />);
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
});
it("should render with compact layout when compact prop is true", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} compact={true} />);
// Verify connections are rendered
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
/**
* ConnectionList Component
* Displays a list of federation connections grouped by status
*/
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
import { ConnectionCard } from "./ConnectionCard";
interface ConnectionListProps {
connections: ConnectionDetails[] | null;
isLoading: boolean;
onAccept?: (connectionId: string) => void;
onReject?: (connectionId: string) => void;
onDisconnect?: (connectionId: string) => void;
compact?: boolean;
}
export function ConnectionList({
connections,
isLoading,
onAccept,
onReject,
onDisconnect,
compact = false,
}: ConnectionListProps): React.JSX.Element {
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading connections...</span>
</div>
);
}
// Handle null/undefined connections gracefully
if (!connections || connections.length === 0) {
return (
<div className="text-center p-8 text-gray-500">
<p className="text-lg">No federation connections</p>
<p className="text-sm mt-2">Ready to connect to remote instances</p>
</div>
);
}
// Group connections by status
const groupedConnections: Record<FederationConnectionStatus, ConnectionDetails[]> = {
[FederationConnectionStatus.PENDING]: [],
[FederationConnectionStatus.ACTIVE]: [],
[FederationConnectionStatus.DISCONNECTED]: [],
[FederationConnectionStatus.REJECTED]: [],
};
connections.forEach((connection) => {
groupedConnections[connection.status].push(connection);
});
// Define group order with PDA-friendly labels
const groups: {
status: FederationConnectionStatus;
label: string;
description: string;
}[] = [
{
status: FederationConnectionStatus.ACTIVE,
label: "Active",
description: "Currently connected instances",
},
{
status: FederationConnectionStatus.PENDING,
label: "Pending",
description: "Connections awaiting acceptance",
},
{
status: FederationConnectionStatus.DISCONNECTED,
label: "Disconnected",
description: "Previously connected instances",
},
{
status: FederationConnectionStatus.REJECTED,
label: "Rejected",
description: "Connection requests that were declined",
},
];
return (
<div className="space-y-8">
{groups.map((group) => {
const groupConnections = groupedConnections[group.status];
if (groupConnections.length === 0) {
return null;
}
return (
<section key={group.status}>
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-700">{group.label}</h2>
<p className="text-sm text-gray-500 mt-1">{group.description}</p>
</div>
<div className="space-y-3">
{groupConnections.map((connection) => (
<ConnectionCard
key={connection.id}
connection={connection}
{...(onAccept && { onAccept })}
{...(onReject && { onReject })}
{...(onDisconnect && { onDisconnect })}
compact={compact}
/>
))}
</div>
</section>
);
})}
</div>
);
}

View File

@@ -0,0 +1,189 @@
/**
* InitiateConnectionDialog Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InitiateConnectionDialog } from "./InitiateConnectionDialog";
describe("InitiateConnectionDialog", (): void => {
const mockOnInitiate = vi.fn();
const mockOnCancel = vi.fn();
it("should render when open is true", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
});
it("should not render when open is false", (): void => {
const { container } = render(
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(container.firstChild).toBeNull();
});
it("should render PDA-friendly title", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
});
it("should render PDA-friendly description", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/enter the url/i)).toBeInTheDocument();
});
it("should render URL input field", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByLabelText(/instance url/i)).toBeInTheDocument();
});
it("should render Connect button", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByRole("button", { name: /connect/i })).toBeInTheDocument();
});
it("should render Cancel button", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
it("should call onCancel when cancel button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should call onInitiate with URL when connect button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
const connectButton = screen.getByRole("button", { name: /connect/i });
await user.click(connectButton);
expect(mockOnInitiate).toHaveBeenCalledWith("https://mosaic.example.com");
});
it("should disable connect button when URL is empty", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const connectButton = screen.getByRole("button", { name: /connect/i });
expect(connectButton).toBeDisabled();
});
it("should enable connect button when URL is entered", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
const connectButton = screen.getByRole("button", { name: /connect/i });
expect(connectButton).not.toBeDisabled();
});
it("should show validation error for invalid URL", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "not-a-valid-url");
const connectButton = screen.getByRole("button", { name: /connect/i });
await user.click(connectButton);
expect(screen.getByText(/please enter a valid url/i)).toBeInTheDocument();
});
it("should clear input when dialog is closed", async (): Promise<void> => {
const user = userEvent.setup();
const { rerender } = render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
// Close dialog
rerender(
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
// Reopen dialog
rerender(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const newUrlInput = screen.getByLabelText(/instance url/i);
expect(newUrlInput).toHaveValue("");
});
it("should show loading state when isLoading is true", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
isLoading={true}
/>
);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
it("should disable buttons when isLoading is true", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
isLoading={true}
/>
);
const connectButton = screen.getByRole("button", { name: /connecting/i });
const cancelButton = screen.getByRole("button", { name: /cancel/i });
expect(connectButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
});
it("should display error message when error prop is provided", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
error="Unable to connect to remote instance"
/>
);
expect(screen.getByText("Unable to connect to remote instance")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
/**
* InitiateConnectionDialog Component
* Dialog for initiating a new federation connection
*/
import { useState, useEffect } from "react";
interface InitiateConnectionDialogProps {
open: boolean;
onInitiate: (url: string) => void;
onCancel: () => void;
isLoading?: boolean;
error?: string;
}
/**
* Validate if a string is a valid URL
*/
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
} catch {
return false;
}
}
export function InitiateConnectionDialog({
open,
onInitiate,
onCancel,
isLoading = false,
error,
}: InitiateConnectionDialogProps): React.JSX.Element | null {
const [url, setUrl] = useState("");
const [validationError, setValidationError] = useState("");
// Clear input when dialog closes
useEffect(() => {
if (!open) {
setUrl("");
setValidationError("");
}
}, [open]);
if (!open) {
return null;
}
const handleConnect = (): void => {
// Validate URL
if (!url.trim()) {
setValidationError("Please enter a URL");
return;
}
if (!isValidUrl(url)) {
setValidationError("Please enter a valid URL (must start with http:// or https://)");
return;
}
setValidationError("");
onInitiate(url);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter" && url.trim() && !isLoading) {
handleConnect();
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6">
{/* Header */}
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connect to Remote Instance</h2>
<p className="text-sm text-gray-600 mb-6">
Enter the URL of the Mosaic Stack instance you&apos;d like to connect to
</p>
{/* URL Input */}
<div className="mb-4">
<label htmlFor="instance-url" className="block text-sm font-medium text-gray-700 mb-2">
Instance URL
</label>
<input
id="instance-url"
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setValidationError("");
}}
onKeyDown={handleKeyPress}
disabled={isLoading}
placeholder="https://mosaic.example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
{validationError && <p className="text-sm text-red-600 mt-1">{validationError}</p>}
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConnect}
disabled={!url.trim() || isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,245 @@
/**
* Federation API Client
* Handles federation connection management API requests
*/
import { apiGet, apiPost } from "./client";
/**
* Federation connection status
*/
export enum FederationConnectionStatus {
PENDING = "PENDING",
ACTIVE = "ACTIVE",
DISCONNECTED = "DISCONNECTED",
REJECTED = "REJECTED",
}
/**
* Federation capabilities
*/
export interface FederationCapabilities {
supportsQuery: boolean;
supportsCommand: boolean;
supportsEvent: boolean;
supportsAgentSpawn: boolean;
protocolVersion: string;
}
/**
* Connection details
*/
export interface ConnectionDetails {
id: string;
workspaceId: string;
remoteInstanceId: string;
remoteUrl: string;
remotePublicKey: string;
remoteCapabilities: FederationCapabilities;
status: FederationConnectionStatus;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
connectedAt: string | null;
disconnectedAt: string | null;
}
/**
* Public instance identity
*/
export interface PublicInstanceIdentity {
id: string;
instanceId: string;
name: string;
url: string;
publicKey: string;
capabilities: FederationCapabilities;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/**
* Initiate connection request
*/
export interface InitiateConnectionRequest {
remoteUrl: string;
}
/**
* Accept connection request
*/
export interface AcceptConnectionRequest {
metadata?: Record<string, unknown>;
}
/**
* Reject connection request
*/
export interface RejectConnectionRequest {
reason: string;
}
/**
* Disconnect connection request
*/
export interface DisconnectConnectionRequest {
reason?: string;
}
/**
* Fetch all connections
*/
export async function fetchConnections(
status?: FederationConnectionStatus
): Promise<ConnectionDetails[]> {
const params = new URLSearchParams();
if (status) {
params.append("status", status);
}
const queryString = params.toString();
const endpoint = queryString
? `/api/v1/federation/connections?${queryString}`
: "/api/v1/federation/connections";
return apiGet<ConnectionDetails[]>(endpoint);
}
/**
* Fetch a single connection
*/
export async function fetchConnection(connectionId: string): Promise<ConnectionDetails> {
return apiGet<ConnectionDetails>(`/api/v1/federation/connections/${connectionId}`);
}
/**
* Initiate a new connection
*/
export async function initiateConnection(
request: InitiateConnectionRequest
): Promise<ConnectionDetails> {
return apiPost<ConnectionDetails>("/api/v1/federation/connections/initiate", request);
}
/**
* Accept a pending connection
*/
export async function acceptConnection(
connectionId: string,
request?: AcceptConnectionRequest
): Promise<ConnectionDetails> {
return apiPost<ConnectionDetails>(
`/api/v1/federation/connections/${connectionId}/accept`,
request ?? {}
);
}
/**
* Reject a pending connection
*/
export async function rejectConnection(
connectionId: string,
request: RejectConnectionRequest
): Promise<ConnectionDetails> {
return apiPost<ConnectionDetails>(
`/api/v1/federation/connections/${connectionId}/reject`,
request
);
}
/**
* Disconnect an active connection
*/
export async function disconnectConnection(
connectionId: string,
request?: DisconnectConnectionRequest
): Promise<ConnectionDetails> {
return apiPost<ConnectionDetails>(
`/api/v1/federation/connections/${connectionId}/disconnect`,
request ?? {}
);
}
/**
* Get this instance's public identity
*/
export async function fetchInstanceIdentity(): Promise<PublicInstanceIdentity> {
return apiGet<PublicInstanceIdentity>("/api/v1/federation/instance");
}
/**
* Mock connections for development
*/
export const mockConnections: ConnectionDetails[] = [
{
id: "conn-1",
workspaceId: "workspace-1",
remoteInstanceId: "instance-work-001",
remoteUrl: "https://mosaic.work.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.ACTIVE,
metadata: {
name: "Work Instance",
description: "Corporate Mosaic instance",
},
createdAt: new Date("2026-02-01").toISOString(),
updatedAt: new Date("2026-02-01").toISOString(),
connectedAt: new Date("2026-02-01").toISOString(),
disconnectedAt: null,
},
{
id: "conn-2",
workspaceId: "workspace-1",
remoteInstanceId: "instance-partner-001",
remoteUrl: "https://mosaic.partner.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: false,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.PENDING,
metadata: {
name: "Partner Instance",
description: "Shared project collaboration",
},
createdAt: new Date("2026-02-02").toISOString(),
updatedAt: new Date("2026-02-02").toISOString(),
connectedAt: null,
disconnectedAt: null,
},
{
id: "conn-3",
workspaceId: "workspace-1",
remoteInstanceId: "instance-old-001",
remoteUrl: "https://mosaic.old.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: false,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.DISCONNECTED,
metadata: {
name: "Previous Instance",
description: "No longer in use",
},
createdAt: new Date("2026-01-15").toISOString(),
updatedAt: new Date("2026-01-30").toISOString(),
connectedAt: new Date("2026-01-15").toISOString(),
disconnectedAt: new Date("2026-01-30").toISOString(),
},
];

View File

@@ -0,0 +1,128 @@
# Issue #91: Connection Manager UI
## Objective
Implement the Connection Manager UI to allow users to view, initiate, accept, reject, and disconnect federation connections to remote Mosaic Stack instances.
## Requirements
- View existing federation connections with their status
- Initiate new connections to remote instances
- Accept/reject pending connections
- Disconnect active connections
- Display connection status and metadata
- PDA-friendly design and language (no demanding language)
- Proper error handling and user feedback
- Test coverage for all components
## Backend API Endpoints (Already Available)
- `GET /api/v1/federation/connections` - List all connections
- `GET /api/v1/federation/connections/:id` - Get single connection
- `POST /api/v1/federation/connections/initiate` - Initiate connection
- `POST /api/v1/federation/connections/:id/accept` - Accept connection
- `POST /api/v1/federation/connections/:id/reject` - Reject connection
- `POST /api/v1/federation/connections/:id/disconnect` - Disconnect connection
- `GET /api/v1/federation/instance` - Get local instance identity
## Connection States
- `PENDING` - Connection initiated but not yet accepted
- `ACTIVE` - Connection established and working
- `DISCONNECTED` - Connection was active but now disconnected
- `REJECTED` - Connection was rejected
## Approach
### Phase 1: Core Components (TDD)
1. Create connection types and API client functions
2. Implement ConnectionCard component with tests
3. Implement ConnectionList component with tests
4. Implement InitiateConnectionDialog component with tests
5. Implement ConnectionActions component with tests
### Phase 2: Page Implementation
1. Create `/federation/connections` page
2. Integrate all components
3. Add loading and error states
4. Implement real-time updates (optional)
### Phase 3: PDA-Friendly Polish
1. Review all language for PDA-friendliness
2. Implement calm visual indicators
3. Add helpful empty states
4. Test error messaging
## Design Decisions
### Visual Status Indicators (PDA-Friendly)
- 🟢 Active - Soft green (#10b981)
- 🔵 Pending - Soft blue (#3b82f6)
- ⏸️ Disconnected - Soft yellow (#f59e0b)
- ⚪ Rejected - Light gray (#d1d5db)
### Language Guidelines
- "Target passed" instead of "overdue"
- "Approaching target" instead of "urgent"
- "Would you like to..." instead of "You must..."
- "Connection not responding" instead of "Connection failed"
- "Unable to connect" instead of "Connection error"
### Component Structure
```
apps/web/src/
├── app/(authenticated)/federation/
│ └── connections/
│ └── page.tsx
├── components/federation/
│ ├── ConnectionCard.tsx
│ ├── ConnectionCard.test.tsx
│ ├── ConnectionList.tsx
│ ├── ConnectionList.test.tsx
│ ├── InitiateConnectionDialog.tsx
│ ├── InitiateConnectionDialog.test.tsx
│ ├── ConnectionActions.tsx
│ └── ConnectionActions.test.tsx
└── lib/api/
└── federation.ts
```
## Progress
- [x] Create scratchpad
- [x] Research existing backend API
- [x] Review PDA-friendly design principles
- [x] Implement federation API client
- [x] Write tests for ConnectionCard
- [x] Implement ConnectionCard
- [x] Write tests for ConnectionList
- [x] Implement ConnectionList
- [x] Write tests for InitiateConnectionDialog
- [x] Implement InitiateConnectionDialog
- [x] Create connections page
- [x] Run all tests (42 tests passing)
- [x] TypeScript type checking (passing)
- [x] Linting (passing)
- [x] PDA-friendliness review (all language reviewed)
- [x] Final QA (ready for review)
## Testing Strategy
- Unit tests for each component
- Integration tests for the connections page
- Test error states and edge cases
- Test PDA-friendly language compliance
- Ensure all tests pass before commit
## Notes
- Backend API is complete from Phase 1-3
- Need to handle authentication with BetterAuth
- Consider WebSocket for real-time connection status updates (Phase 5)
- Connection metadata can be extended for future features