Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions apps/web/src/components/NarrowFallback.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { useMemo, useState } from "react";
import type { TopologySnapshot } from "@amesh/protocol";
import { ArrowRight } from "lucide-react";

import { createTriggerRule } from "../api.js";
import { relativeTime } from "../lib/time.js";
import { NodeSettingsButton } from "./NodeSettingsButton.js";

type Props = { topology: TopologySnapshot };

export function NarrowFallback({ topology }: Props) {
const agentsById = new Map(topology.agents.map((agent) => [agent.id, agent]));
const [connectionSourceAgentId, setConnectionSourceAgentId] = useState<string | null>(null);
const agentsById = useMemo(
() => new Map(topology.agents.map((agent) => [agent.id, agent])),
[topology.agents]
);
const connectionSourceAgentName =
connectionSourceAgentId ? agentsById.get(connectionSourceAgentId)?.name ?? null : null;

async function pickConnectionEndpoint(agentId: string) {
if (!connectionSourceAgentId) {
setConnectionSourceAgentId(agentId);
return;
}
if (connectionSourceAgentId === agentId) {
setConnectionSourceAgentId(null);
return;
}
await createTriggerRule({
sourceAgentId: connectionSourceAgentId,
targetAgentId: agentId,
mode: "allow"
});
setConnectionSourceAgentId(null);
}

return (
<div className="narrow-fallback">
<div className="note">
Drag-to-connect needs a wider screen. Open amesh on a desktop (≥1024px) to add or remove
rules on the canvas. This view is read-only.
Compact topology view. Use the arrow controls on online agents to create allow rules.
</div>

{topology.nodes.length === 0 ? (
Expand Down Expand Up @@ -49,6 +74,23 @@ export function NarrowFallback({ topology }: Props) {
{agents.map((agent) => (
<li key={agent.id}>
{agent.name} <span className="host">({agent.status})</span>
{" "}
<button
type="button"
className="narrow-card__connect"
aria-label={
connectionSourceAgentId
? connectionSourceAgentId === agent.id
? `Cancel connection from ${agent.name}`
: `Connect ${connectionSourceAgentName ?? "selected agent"} to ${agent.name}`
: `Start connection from ${agent.name}`
}
aria-pressed={connectionSourceAgentId === agent.id}
disabled={node.status !== "online" || agent.status !== "online"}
onClick={() => void pickConnectionEndpoint(agent.id)}
>
<ArrowRight size={13} aria-hidden />
</button>
{typeof agent.capabilities.cwd === "string" ? (
<>
{" "}
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/components/NodeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Handle, Position } from "@xyflow/react";
import { useNavigate } from "@tanstack/react-router";
import { ArrowRight } from "lucide-react";

import type { AgentRecord, AgentStatus, NodeRecord, NodeStatus } from "@amesh/protocol";
import { relativeTime } from "../lib/time.js";
Expand All @@ -8,6 +9,9 @@ import { NodeSettingsButton } from "./NodeSettingsButton.js";
export type NodeCardData = {
node: NodeRecord;
agents: AgentRecord[];
connectionSourceAgentId: string | null;
connectionSourceAgentName: string | null;
onConnectionPick: (agent: AgentRecord) => void;
};

type NodeCardProps = {
Expand All @@ -29,7 +33,13 @@ function agentStatusLabel(status: AgentStatus) {
}

export function NodeCard({ data }: NodeCardProps) {
const { node, agents } = data.data;
const {
node,
agents,
connectionSourceAgentId,
connectionSourceAgentName,
onConnectionPick
} = data.data;
const isOffline = node.status === "offline";
const navigate = useNavigate();

Expand Down Expand Up @@ -62,6 +72,13 @@ export function NodeCard({ data }: NodeCardProps) {
<ul className="node-card__agents">
{agents.map((agent) => {
const chatDisabled = isOffline || agent.status !== "online";
const connectionSelected = connectionSourceAgentId === agent.id;
const connectionDisabled = isOffline || agent.status !== "online";
const connectionLabel = connectionSourceAgentId
? connectionSelected
? `Cancel connection from ${agent.name}`
: `Connect ${connectionSourceAgentName ?? "selected agent"} to ${agent.name}`
: `Start connection from ${agent.name}`;
return (
<li key={agent.id} className="node-card__agent">
<div>
Expand All @@ -72,6 +89,22 @@ export function NodeCard({ data }: NodeCardProps) {
) : null}
</div>
<div className="node-card__agent-tail">
<button
type="button"
className="node-card__connect"
title={connectionDisabled ? "Agent is not online" : connectionLabel}
aria-label={connectionLabel}
aria-pressed={connectionSelected}
disabled={connectionDisabled}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if (connectionDisabled) return;
onConnectionPick(agent);
}}
>
<ArrowRight size={13} aria-hidden />
</button>
<button
type="button"
className="node-card__chat"
Expand Down
99 changes: 73 additions & 26 deletions apps/web/src/components/TopologyCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,67 @@ function CanvasInner({ topology }: Props) {
const [edges, setEdges, onEdgesChange] = useEdgesState<TriggerEdgeRecord>([]);
const positionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const [toast, setToast] = useState<string | null>(null);
const [connectionSourceAgentId, setConnectionSourceAgentId] = useState<string | null>(null);
const toastTimerRef = useRef<number | null>(null);

const agentsById = useMemo(
() => new Map(topology.agents.map((agent) => [agent.id, agent])),
[topology.agents]
);
const connectionSourceAgentName =
connectionSourceAgentId ? agentsById.get(connectionSourceAgentId)?.name ?? null : null;

const createAllowRule = useCallback(
async (sourceAgentId: string, targetAgentId: string) => {
if (sourceAgentId === targetAgentId) {
showToast("An agent cannot trigger itself.");
return false;
}

const existing = topology.triggerRules.find(
(rule) =>
rule.sourceAgentId === sourceAgentId && rule.targetAgentId === targetAgentId
);
if (existing && existing.mode === "allow") {
showToast("Already connected. Click the edge to change.");
return false;
}

try {
await createTriggerRule({
sourceAgentId,
targetAgentId,
mode: "allow"
});
return true;
} catch {
showToast("Could not create rule.");
return false;
}
},
[topology.triggerRules]
);

const pickConnectionEndpoint = useCallback(
async (agent: TopologySnapshot["agents"][number]) => {
if (!connectionSourceAgentId) {
setConnectionSourceAgentId(agent.id);
showToast(`Pick a target for ${agent.name}.`);
return;
}
if (connectionSourceAgentId === agent.id) {
setConnectionSourceAgentId(null);
showToast("Connection cancelled.");
return;
}
const created = await createAllowRule(connectionSourceAgentId, agent.id);
if (created) {
setConnectionSourceAgentId(null);
showToast("Rule created.");
}
},
[connectionSourceAgentId, createAllowRule]
);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -88,7 +143,15 @@ function CanvasInner({ topology }: Props) {
id: node.id,
type: "nodeCard",
position,
data: { data: { node, agents } } as { data: NodeCardData },
data: {
data: {
node,
agents,
connectionSourceAgentId,
connectionSourceAgentName,
onConnectionPick: pickConnectionEndpoint
}
} as { data: NodeCardData },
draggable: true
};
});
Expand All @@ -107,7 +170,13 @@ function CanvasInner({ topology }: Props) {
return () => {
cancelled = true;
};
}, [topology, setNodes]);
}, [
topology,
setNodes,
connectionSourceAgentId,
connectionSourceAgentName,
pickConnectionEndpoint
]);

function showToast(message: string) {
setToast(message);
Expand Down Expand Up @@ -178,31 +247,9 @@ function CanvasInner({ topology }: Props) {
const sourceAgentId = params.sourceHandle;
const targetAgentId = params.targetHandle;
if (!sourceAgentId || !targetAgentId) return;
if (sourceAgentId === targetAgentId) {
showToast("An agent cannot trigger itself.");
return;
}

const existing = topology.triggerRules.find(
(rule) =>
rule.sourceAgentId === sourceAgentId && rule.targetAgentId === targetAgentId
);
if (existing && existing.mode === "allow") {
showToast("Already connected. Click the edge to change.");
return;
}

try {
await createTriggerRule({
sourceAgentId,
targetAgentId,
mode: "allow"
});
} catch {
showToast("Could not create rule.");
}
await createAllowRule(sourceAgentId, targetAgentId);
},
[topology.triggerRules]
[createAllowRule]
);

const isValidConnection: IsValidConnection = useCallback((connection) => {
Expand Down
36 changes: 32 additions & 4 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2500,7 +2500,8 @@ button:focus { outline: none; }
align-items: center;
gap: var(--s-2);
}
.node-card__chat {
.node-card__chat,
.node-card__connect {
border: 1px solid transparent;
background: transparent;
color: var(--c-mute);
Expand All @@ -2513,13 +2514,20 @@ button:focus { outline: none; }
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.node-card__chat:focus-visible { opacity: 1; }
.node-card__chat:hover {
.node-card__connect {
opacity: 1;
}
.node-card__chat:focus-visible,
.node-card__connect:focus-visible { opacity: 1; }
.node-card__chat:hover,
.node-card__connect:hover,
.node-card__connect[aria-pressed="true"] {
color: var(--c-accent-strong);
background: var(--c-accent-soft);
border-color: var(--c-accent-line);
}
.node-card__chat[disabled] { cursor: not-allowed; }
.node-card__chat[disabled],
.node-card__connect[disabled] { cursor: not-allowed; }
.node-card__agent:hover .node-card__chat:not([disabled]) { opacity: 1; }
.narrow-card__error-link {
border: 0;
Expand All @@ -2534,6 +2542,26 @@ button:focus { outline: none; }
text-decoration: underline;
text-underline-offset: 0.18em;
}
.narrow-card__connect {
border: 1px solid var(--c-line);
border-radius: var(--r-xs);
background: var(--c-surface);
color: var(--c-mute);
font: var(--t-mono);
width: 24px;
height: 24px;
cursor: pointer;
}
.narrow-card__connect:hover,
.narrow-card__connect[aria-pressed="true"] {
color: var(--c-accent-strong);
background: var(--c-accent-soft);
border-color: var(--c-accent-line);
}
.narrow-card__connect[disabled] {
cursor: not-allowed;
opacity: 0.55;
}

@media (max-width: 1024px) {
.sessions-route--live {
Expand Down
Loading
Loading