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
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import type { ReactElement } from "react";
import type { FederationConnectionState } from "@pwragent/shared";
import { useDesktopApi } from "../../lib/desktop-api";
import { readRendererFederationTarget } from "../../lib/federation-window";

// A remote window targets another PwrAgent instance over federation. The
// federation target injected into the window carries only an instanceId, so we
// poll federation health to resolve the peer's human label and live connection
// state. Health is snapshot-on-read with no push channel, so a light poll is
// the pragmatic way to keep the badge fresh.
const POLL_INTERVAL_MS = 8000;

type RemotePeerState = {
label: string;
status: FederationConnectionState;
};

function dotModifier(
status: FederationConnectionState,
): "connected" | "pending" | "down" {
switch (status) {
case "connected":
return "connected";
case "connecting":
case "handshaking":
case "listening":
case "degraded":
return "pending";
default:
return "down";
}
}

function statusLabel(status: FederationConnectionState): string {
switch (status) {
case "connected":
return "Connected";
case "connecting":
case "handshaking":
return "Connecting";
case "degraded":
return "Degraded";
case "listening":
return "Listening";
case "rejected":
return "Rejected";
case "revoked":
return "Revoked";
default:
return "Offline";
}
}

export function RemoteWindowBadge(): ReactElement | null {
const desktopApi = useDesktopApi();
const target = readRendererFederationTarget();
const instanceId = target?.instanceId;
const [peer, setPeer] = useState<RemotePeerState | undefined>(undefined);

useEffect(() => {
if (!instanceId) {
setPeer(undefined);
return;
}
const shortId = instanceId.slice(0, 8);
const read = desktopApi?.readFederationHealth;
let cancelled = false;

async function poll(): Promise<void> {
if (!read) {
if (!cancelled) setPeer({ label: shortId, status: "disconnected" });
return;
}
try {
const { health } = await read();
if (cancelled) return;
const match = health.peers.find(
(candidate) => candidate.id === instanceId,
);
setPeer(
match
? { label: match.label || shortId, status: match.status }
: { label: shortId, status: "disconnected" },
);
} catch {
if (!cancelled) setPeer({ label: shortId, status: "disconnected" });
}
}

void poll();
const timer = window.setInterval(() => void poll(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [desktopApi, instanceId]);

if (!instanceId) return null;

const status = peer?.status ?? "connecting";
const label = peer?.label ?? instanceId.slice(0, 8);

return (
<span
className="remote-window-badge"
title={`Remote instance · ${statusLabel(status)}`}
aria-label={`Remote instance ${label}, ${statusLabel(status)}`}
>
<span
className={`remote-window-badge__dot remote-window-badge__dot--${dotModifier(status)}`}
aria-hidden
/>
<span className="remote-window-badge__kind">Remote</span>
<span className="remote-window-badge__label">{label}</span>
</span>
);
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/src/features/navigation/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from "../../lib/backend-status-format";
import { DirectoriesList } from "./DirectoriesList";
import { RecentsList } from "./RecentsList";
import { RemoteWindowBadge } from "./RemoteWindowBadge";

type ThreadContextMenuPosition = {
x: number;
Expand Down Expand Up @@ -742,6 +743,7 @@ export function Sidebar(props: SidebarProps) {
/>
<header className="sidebar__masthead">
<p className="sidebar__brand">Pwr<span className="sidebar__brand-accent">Agent</span></p>
<RemoteWindowBadge />

<div className="sidebar__masthead-actions">
<MastheadActionButton
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
FederationHealthStatus,
FederationPeerSummary,
} from "@pwragent/shared";
import type { DesktopApi } from "../../../lib/desktop-api";
import { RemoteWindowBadge } from "../RemoteWindowBadge";

type TestWindow = typeof window & {
__pwragentFederationTarget?: { scope: "remote"; instanceId: string };
pwragent?: DesktopApi;
};

function setRemoteTarget(instanceId: string | undefined): void {
const testWindow = window as TestWindow;
if (instanceId === undefined) {
delete testWindow.__pwragentFederationTarget;
return;
}
testWindow.__pwragentFederationTarget = { scope: "remote", instanceId };
}

function setDesktopApi(api: DesktopApi | undefined): void {
(window as TestWindow).pwragent = api;
}

function healthWithPeer(peer: FederationPeerSummary): FederationHealthStatus {
return {
enabled: true,
role: "gateway",
status: "listening",
peers: [peer],
};
}

afterEach(() => {
cleanup();
setRemoteTarget(undefined);
setDesktopApi(undefined);
});

describe("RemoteWindowBadge", () => {
it("renders nothing in a local window (no federation target)", () => {
setRemoteTarget(undefined);
setDesktopApi({ readFederationHealth: vi.fn() });

const { container } = render(<RemoteWindowBadge />);

expect(container).toBeEmptyDOMElement();
});

it("shows the peer label and a connected status dot for a healthy peer", async () => {
setRemoteTarget("client_studio_mac_01");
setDesktopApi({
readFederationHealth: vi.fn(async () => ({
health: healthWithPeer({
id: "client_studio_mac_01",
label: "Studio Mac",
role: "client",
status: "connected",
capabilities: ["thread_navigation"],
}),
})),
});

render(<RemoteWindowBadge />);

expect(await screen.findByText("Studio Mac")).toBeInTheDocument();
expect(screen.getByText("Remote")).toBeInTheDocument();
const dot = document.querySelector(".remote-window-badge__dot");
await waitFor(() =>
expect(dot).toHaveClass("remote-window-badge__dot--connected"),
);
});

it("shows a pending status dot while the peer is connecting", async () => {
setRemoteTarget("client_connecting_01");
setDesktopApi({
readFederationHealth: vi.fn(async () => ({
health: healthWithPeer({
id: "client_connecting_01",
label: "Laptop",
role: "client",
status: "connecting",
capabilities: [],
}),
})),
});

render(<RemoteWindowBadge />);

expect(await screen.findByText("Laptop")).toBeInTheDocument();
const dot = document.querySelector(".remote-window-badge__dot");
await waitFor(() =>
expect(dot).toHaveClass("remote-window-badge__dot--pending"),
);
});

it("falls back to a down dot and short id when the peer is absent from health", async () => {
setRemoteTarget("client_absent_peer_0123456789");
setDesktopApi({
readFederationHealth: vi.fn(async () => ({
health: healthWithPeer({
id: "some_other_peer",
label: "Other",
role: "client",
status: "connected",
capabilities: [],
}),
})),
});

render(<RemoteWindowBadge />);

// Short id fallback is the first 8 chars of the instance id.
expect(await screen.findByText("client_a")).toBeInTheDocument();
const dot = document.querySelector(".remote-window-badge__dot");
await waitFor(() =>
expect(dot).toHaveClass("remote-window-badge__dot--down"),
);
});

it("falls back to a down dot when health diagnostics throw", async () => {
setRemoteTarget("client_unreachable_abcdef");
setDesktopApi({
readFederationHealth: vi.fn(async () => {
throw new Error("federation unavailable");
}),
});

render(<RemoteWindowBadge />);

const dot = await waitFor(() => {
const node = document.querySelector(".remote-window-badge__dot");
expect(node).toHaveClass("remote-window-badge__dot--down");
return node;
});
expect(dot).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,72 @@ describe("useThreadNavigation", () => {
expect(result.current.directories[0]?.needsAttentionCount).toBe(1);
});

describe("remote federation window guards", () => {
function setRemoteFederationWindow(instanceId: string): void {
(
window as unknown as {
__pwragentFederationTarget?: { scope: "remote"; instanceId: string };
}
).__pwragentFederationTarget = { scope: "remote", instanceId };
}

afterEach(() => {
delete (
window as unknown as { __pwragentFederationTarget?: unknown }
).__pwragentFederationTarget;
});

const emptySnapshot = async (): Promise<NavigationSnapshot> => ({
backend: "all",
fetchedAt: Date.now(),
unchanged: false,
inboxThreadKeys: [],
threads: [],
directories: [],
launchpadDefaults: { backend: "codex", executionMode: "default" },
});

it("blocks createThread in a remote window without touching the local backend", async () => {
setRemoteFederationWindow("client_remote_01");
const ensureDirectoryLaunchpad = vi.fn();
const desktopApi: DesktopApi = {
getNavigationSnapshot: vi.fn(emptySnapshot),
ensureDirectoryLaunchpad,
onAgentEvent: () => () => undefined,
};

const { result } = renderHook(() => useThreadNavigation(desktopApi));

await act(async () => {
await result.current.createThread();
});

expect(ensureDirectoryLaunchpad).not.toHaveBeenCalled();
expect(result.current.createThreadError).toMatch(/remote window/i);
});

it("rejects materializeDirectoryLaunchpad in a remote window without touching the local backend", async () => {
setRemoteFederationWindow("client_remote_02");
const materializeDirectoryLaunchpad = vi.fn();
const desktopApi: DesktopApi = {
getNavigationSnapshot: vi.fn(emptySnapshot),
materializeDirectoryLaunchpad,
onAgentEvent: () => () => undefined,
};

const { result } = renderHook(() => useThreadNavigation(desktopApi));

await act(async () => {
await expect(
result.current.materializeDirectoryLaunchpad("directory:/tmp/whatever"),
).rejects.toThrow(/remote window/i);
});

expect(materializeDirectoryLaunchpad).not.toHaveBeenCalled();
expect(result.current.launchpadError).toMatch(/remote window/i);
});
});

it("carries the started review turn from launchpad materialization", async () => {
const directoryKey = "directory:/Users/huntharo/github/PwrAgent";
const getNavigationSnapshot = vi.fn(async (): Promise<NavigationSnapshot> => ({
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/renderer/src/lib/useThreadNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3475,6 +3475,12 @@ export function useThreadNavigation(
executionMode: ThreadExecutionMode = "default",
options?: { forceWorkspace?: boolean }
): Promise<void> => {
if (readRendererFederationTarget()) {
setCreateThreadError(
"New threads can't be created in a remote window. Create it on the remote instance, then open it here.",
);
return;
}
if (!desktopApi?.ensureDirectoryLaunchpad) {
setCreateThreadError("Desktop bridge is missing ensureDirectoryLaunchpad().");
return;
Expand Down Expand Up @@ -4174,6 +4180,12 @@ export function useThreadNavigation(
reviewTarget?: AppServerReviewTarget,
parentThreadId?: string,
): Promise<void> => {
if (readRendererFederationTarget()) {
const message =
"New threads can't be created in a remote window. Create it on the remote instance, then open it here.";
setLaunchpadError(message);
throw new Error(message);
}
if (!desktopApi?.materializeDirectoryLaunchpad) {
setLaunchpadError("Desktop bridge is missing materializeDirectoryLaunchpad().");
return;
Expand Down
Loading