diff --git a/frontend/src/components/TracePane.tsx b/frontend/src/components/TracePane.tsx
index 105e95d..0655f5f 100644
--- a/frontend/src/components/TracePane.tsx
+++ b/frontend/src/components/TracePane.tsx
@@ -318,6 +318,26 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult();
};
+ // Append the reversed hop chain (minus the current endpoint) to build a return
+ // path, e.g. [R1, R2, R3] -> [R1, R2, R3, R2, R1]. A single hop is left as-is.
+ // See issue #287. Reverses every queued hop, including custom prefixes.
+ const handleReverseLink = () => {
+ setDraftHops((current) => {
+ if (current.length < 2) return current;
+ const returnHops = [...current]
+ .reverse()
+ .slice(1)
+ .map(
+ (hop, i): TraceDraftHop => ({
+ ...hop,
+ id: nextDraftHopId(hop.kind, current.length + i),
+ })
+ );
+ return [...current, ...returnHops];
+ });
+ clearPendingResult();
+ };
+
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
@@ -616,18 +636,31 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
)}
{draftHops.length > 0 ? (
-
+
+
+
+
) : null}
diff --git a/frontend/src/test/tracePane.test.tsx b/frontend/src/test/tracePane.test.tsx
index cbab1e9..df1322f 100644
--- a/frontend/src/test/tracePane.test.tsx
+++ b/frontend/src/test/tracePane.test.tsx
@@ -134,6 +134,49 @@ describe('TracePane', () => {
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
+ it('reverse link appends the reversed hop chain to build a return path (issue #287)', async () => {
+ const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
+ const relayB = makeContact('22'.repeat(32), 'Relay Beta');
+ const relayC = makeContact('33'.repeat(32), 'Relay Charlie');
+ const onRunTracePath = vi.fn(
+ async (): Promise => ({
+ path_len: 0,
+ timeout_seconds: 6,
+ nodes: [],
+ })
+ );
+
+ render(
+
+ );
+
+ // Single hop: Reverse link is a no-op (and disabled).
+ fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
+ expect(screen.getByRole('button', { name: /reverse link/i })).toBeDisabled();
+
+ // R1, R2, R3 -> append R2, R1 => R1, R2, R3, R2, R1.
+ fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i }));
+ fireEvent.click(screen.getByRole('button', { name: /reverse link/i }));
+
+ expect(screen.getByText('5 hops selected ยท 4-byte trace')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
+ await waitFor(() => {
+ expect(onRunTracePath).toHaveBeenCalledWith(4, [
+ { public_key: relayA.public_key },
+ { public_key: relayB.public_key },
+ { public_key: relayC.public_key },
+ { public_key: relayB.public_key },
+ { public_key: relayA.public_key },
+ ]);
+ });
+ });
+
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');