Skip to content
Closed
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,7 @@ reconnects preserve pending avatar verification work):
- `resetAgentObserverStore()` — agent observer relay store
- `resetActiveAgentTurnsStore()` — active agent turn timers
- `resetAgentWorkingSignal()` — agent working indicator signal
- `resetFleetTurnMetricsStore()` — fleet turn-metric usage store (kind 44200)
- `resetAvatarProfileSync()` — pending verified-avatar profile writes
- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export default defineConfig({
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/fleet-screenshots.spec.ts",
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/app/AppShell.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type AppView =
| "channel"
| "messages"
| "agents"
| "fleet"
| "workflows"
| "pulse"
| "projects";
Expand Down Expand Up @@ -132,6 +133,13 @@ export function deriveShellRoute(pathname: string): {
};
}

if (pathname === "/fleet") {
return {
selectedChannelId: null,
selectedView: "fleet",
};
}

if (pathname === "/workflows" || pathname.startsWith("/workflows/")) {
return {
selectedChannelId: null,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function AppShell() {
const {
goAgents,
goChannel,
goFleet,
goHome,
goNewMessage,
goProjects,
Expand Down Expand Up @@ -875,6 +876,7 @@ export function AppShell() {
onSelectChannel={(channelId) =>
void goChannel(channelId)
}
onSelectFleet={() => void goFleet()}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequest={searchFocusRequest}
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ export function useAppNavigation() {
[commitNavigation],
);

const goFleet = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/fleet",
},
behavior,
),
[commitNavigation],
);

const goPulse = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
Expand Down Expand Up @@ -297,6 +308,7 @@ export function useAppNavigation() {
closeWorkflowDetail,
goAgents,
goChannel,
goFleet,
goForumPost,
goHome,
goNewMessage,
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings";
import { Route as remindersRouteImport } from "./routes/reminders";
import { Route as pulseRouteImport } from "./routes/pulse";
import { Route as projectsRouteImport } from "./routes/projects";
import { Route as fleetRouteImport } from "./routes/fleet";
import { Route as agentsRouteImport } from "./routes/agents";
import { Route as indexRouteImport } from "./routes/index";
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
Expand Down Expand Up @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({
path: "/projects",
getParentRoute: () => rootRouteImport,
} as any);
const fleetRoute = fleetRouteImport.update({
id: "/fleet",
path: "/fleet",
getParentRoute: () => rootRouteImport,
} as any);
const agentsRoute = agentsRouteImport.update({
id: "/agents",
path: "/agents",
Expand Down Expand Up @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute =
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/fleet": typeof fleetRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
Expand All @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/fleet": typeof fleetRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
Expand All @@ -112,6 +120,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/fleet": typeof fleetRoute;
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
Expand All @@ -128,6 +137,7 @@ export interface FileRouteTypes {
fullPaths:
| "/"
| "/agents"
| "/fleet"
| "/projects"
| "/pulse"
| "/reminders"
Expand All @@ -142,6 +152,7 @@ export interface FileRouteTypes {
to:
| "/"
| "/agents"
| "/fleet"
| "/projects"
| "/pulse"
| "/reminders"
Expand All @@ -156,6 +167,7 @@ export interface FileRouteTypes {
| "__root__"
| "/"
| "/agents"
| "/fleet"
| "/projects"
| "/pulse"
| "/reminders"
Expand All @@ -171,6 +183,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
agentsRoute: typeof agentsRoute;
fleetRoute: typeof fleetRoute;
projectsRoute: typeof projectsRoute;
pulseRoute: typeof pulseRoute;
remindersRoute: typeof remindersRoute;
Expand Down Expand Up @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof projectsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/fleet": {
id: "/fleet";
path: "/fleet";
fullPath: "/fleet";
preLoaderRoute: typeof fleetRouteImport;
parentRoute: typeof rootRouteImport;
};
"/agents": {
id: "/agents";
path: "/agents";
Expand Down Expand Up @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" {
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
agentsRoute: agentsRoute,
fleetRoute: fleetRoute,
projectsRoute: projectsRoute,
pulseRoute: pulseRoute,
remindersRoute: remindersRoute,
Expand Down
1 change: 1 addition & 0 deletions desktop/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/agents", "agents.tsx"),
route("/fleet", "fleet.tsx"),
route("/pulse", "pulse.tsx"),
route("/reminders", "reminders.tsx"),
route("/settings", "settings.tsx"),
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/app/routes/fleet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

const FleetScreen = React.lazy(async () => {
const module = await import("@/features/fleet/ui/FleetScreen");
return { default: module.FleetScreen };
});

export const Route = createFileRoute("/fleet")({
component: FleetRouteComponent,
});

function FleetRouteComponent() {
return (
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<FleetScreen />
</React.Suspense>
);
}
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
restoreActiveAgentTurnsForCommunity,
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetFleetTurnMetricsStore } from "@/features/fleet/turnMetricsStore";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
Expand Down Expand Up @@ -53,6 +54,7 @@ function resetCommunityState({
resetAgentObserverStore();
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
resetFleetTurnMetricsStore();
if (resetAvatarState) {
resetAvatarProfileSync();
resetAvatarPresentations();
Expand Down
160 changes: 160 additions & 0 deletions desktop/src/features/fleet/fleetAgents.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { combineFleetAgents } from "./fleetAgents.ts";

const ME = "a1".repeat(32);
const OTHER_OWNER = "b2".repeat(32);
const MANAGED_PUBKEY = "c3".repeat(32);
const REMOTE_PUBKEY = "d4".repeat(32);
const UNOWNED_PUBKEY = "e5".repeat(32);

function makeManagedAgent(overrides = {}) {
return {
pubkey: MANAGED_PUBKEY,
name: "Local Agent",
personaId: null,
runtime: null,
relayUrl: "ws://localhost:3000",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentCommandOverride: null,
agentArgs: [],
mcpCommand: "buzz-dev-mcp",
turnTimeoutSeconds: 600,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: null,
avatarUrl: "https://example.com/a.png",
model: "gpt-x",
modelSource: null,
provider: null,
personaOutOfDate: false,
personaOrphaned: false,
needsRestart: false,
envVars: {},
status: "running",
pid: 123,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
lastStartedAt: "2026-07-29T08:00:00Z",
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
lastErrorCode: null,
logPath: "/tmp/agent.log",
startOnAppLaunch: false,
autoRestartOnConfigChange: false,
backend: { type: "local" },
backendAgentId: null,
respondTo: "owner-only",
respondToAllowlist: [],
...overrides,
};
}

function makeRelayAgent(overrides = {}) {
return {
pubkey: REMOTE_PUBKEY,
name: "Remote Agent",
agentType: "goose",
channels: [],
channelIds: [],
capabilities: [],
status: "online",
respondTo: null,
respondToAllowlist: [],
...overrides,
};
}

describe("combineFleetAgents", () => {
it("includes managed agents with their full metadata", () => {
const fleet = combineFleetAgents(
[makeManagedAgent()],
[],
new Map(),
new Map(),
ME,
);
assert.equal(fleet.length, 1);
assert.equal(fleet[0].isManaged, true);
assert.equal(fleet[0].status, "running");
assert.equal(fleet[0].model, "gpt-x");
});

it("adds declared-owned relay agents and skips agents owned by others", () => {
const fleet = combineFleetAgents(
[],
[
makeRelayAgent(),
makeRelayAgent({ pubkey: UNOWNED_PUBKEY, name: "Someone else's" }),
],
new Map([
[REMOTE_PUBKEY, ME],
[UNOWNED_PUBKEY, OTHER_OWNER],
]),
new Map([[REMOTE_PUBKEY, "https://example.com/r.png"]]),
ME,
);
assert.equal(fleet.length, 1);
assert.equal(fleet[0].pubkey, REMOTE_PUBKEY);
assert.equal(fleet[0].isManaged, false);
assert.equal(fleet[0].status, "deployed");
assert.equal(fleet[0].avatarUrl, "https://example.com/r.png");
});

it("maps offline remote presence to not_deployed", () => {
const fleet = combineFleetAgents(
[],
[makeRelayAgent({ status: "offline" })],
new Map([[REMOTE_PUBKEY, ME]]),
new Map(),
ME,
);
assert.equal(fleet[0].status, "not_deployed");
});

it("managed agents win pubkey collisions with the relay roster", () => {
const fleet = combineFleetAgents(
[makeManagedAgent()],
[makeRelayAgent({ pubkey: MANAGED_PUBKEY, name: "Duplicate" })],
new Map([[MANAGED_PUBKEY, ME]]),
new Map(),
ME,
);
assert.equal(fleet.length, 1);
assert.equal(fleet[0].isManaged, true);
assert.equal(fleet[0].name, "Local Agent");
});

it("ignores relay agents entirely before identity resolves", () => {
const fleet = combineFleetAgents(
[makeManagedAgent()],
[makeRelayAgent()],
new Map([[REMOTE_PUBKEY, ME]]),
new Map(),
undefined,
);
assert.equal(fleet.length, 1);
assert.equal(fleet[0].isManaged, true);
});

it("sorts by name then pubkey for a stable grid", () => {
const fleet = combineFleetAgents(
[
makeManagedAgent({ name: "Zeta" }),
makeManagedAgent({ pubkey: REMOTE_PUBKEY, name: "Alpha" }),
],
[],
new Map(),
new Map(),
ME,
);
assert.deepEqual(
fleet.map((agent) => agent.name),
["Alpha", "Zeta"],
);
});
});
Loading