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
40 changes: 15 additions & 25 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Settings2 } from "lucide-react";

import { ChannelPane } from "@/app/ChannelPane";
import { AgentsView } from "@/features/agents/ui/AgentsView";
Expand All @@ -12,6 +11,7 @@ import {
useSelectedChannel,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
Expand Down Expand Up @@ -48,7 +48,6 @@ import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
SidebarInset,
SidebarProvider,
Expand All @@ -58,18 +57,6 @@ import {
type AppView = "home" | "channel" | "settings" | "agents";
type MainView = Exclude<AppView, "settings">;

function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
return {
id: hit.eventId,
pubkey: hit.pubkey,
created_at: hit.createdAt,
kind: hit.kind,
tags: [["h", hit.channelId]],
content: hit.content,
sig: "",
};
}

export function AppShell() {
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
Expand Down Expand Up @@ -304,7 +291,15 @@ export function AppShell() {
(hit: SearchHit) => {
setSearchAnchor(hit);
setSearchAnchorChannelId(hit.channelId);
setSearchAnchorEvent(createSearchAnchorEvent(hit));
setSearchAnchorEvent({
id: hit.eventId,
pubkey: hit.pubkey,
created_at: hit.createdAt,
kind: hit.kind,
tags: [["h", hit.channelId]],
content: hit.content,
sig: "",
});
void handleOpenChannel(hit.channelId);

void getEventById(hit.eventId)
Expand Down Expand Up @@ -549,18 +544,13 @@ export function AppShell() {
<ChatHeader
actions={
activeChannel ? (
<Button
aria-label="Manage channel"
data-testid="channel-management-trigger"
onClick={() => {
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
</Button>
/>
) : null
}
channelType={activeChannel?.channelType}
Expand Down
273 changes: 273 additions & 0 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions";
import {
addChannelMembers,
createManagedAgent,
getChannelMembers,
listManagedAgents,
startManagedAgent,
stopManagedAgent,
} from "@/shared/api/tauri";
import type {
AcpProvider,
ChannelRole,
ManagedAgent,
} from "@/shared/api/types";

type ChannelAgentProvider = Pick<
AcpProvider,
"id" | "label" | "command" | "defaultArgs"
>;

export type AttachManagedAgentToChannelInput = {
agent: ManagedAgent;
role?: Exclude<ChannelRole, "owner">;
ensureRunning?: boolean;
};

export type AttachManagedAgentToChannelResult = {
agent: ManagedAgent;
membershipAdded: boolean;
restarted: boolean;
started: boolean;
};

export type EnsureChannelAgentPresetInput = {
provider: ChannelAgentProvider;
role?: Exclude<ChannelRole, "owner">;
ensureRunning?: boolean;
};

export type EnsureChannelAgentPresetResult =
AttachManagedAgentToChannelResult & {
created: boolean;
providerId: string;
};

export type CreateChannelManagedAgentInput = {
provider: ChannelAgentProvider;
name: string;
systemPrompt?: string;
role?: Exclude<ChannelRole, "owner">;
ensureRunning?: boolean;
};

export type CreateChannelManagedAgentResult =
AttachManagedAgentToChannelResult & {
created: true;
providerId: string;
};

function normalizePubkey(pubkey: string) {
return pubkey.trim().toLowerCase();
}

function commandBasename(command: string) {
const normalized = command.trim().replace(/\\/g, "/");
const parts = normalized.split("/");
return parts[parts.length - 1] ?? normalized;
}

function commandsMatch(left: string, right: string) {
return (
commandBasename(left).toLowerCase() === commandBasename(right).toLowerCase()
);
}

function parseTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}

const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : timestamp;
}

export async function attachManagedAgentToChannel(
channelId: string,
input: AttachManagedAgentToChannelInput,
) {
const role = input.role ?? "bot";
const ensureRunning = input.ensureRunning ?? true;
const members = await getChannelMembers(channelId);
const membershipAdded = !members.some(
(member) =>
normalizePubkey(member.pubkey) === normalizePubkey(input.agent.pubkey),
);

await addChannelMembers({
channelId,
pubkeys: [input.agent.pubkey],
role,
});

let agent = input.agent;
let started = false;
let restarted = false;

if (ensureRunning) {
if (membershipAdded && input.agent.status === "running") {
await stopManagedAgent(input.agent.pubkey);
agent = await startManagedAgent(input.agent.pubkey);
restarted = true;
} else if (input.agent.status !== "running") {
agent = await startManagedAgent(input.agent.pubkey);
started = true;
}
}

return {
agent,
membershipAdded,
restarted,
started,
} satisfies AttachManagedAgentToChannelResult;
}

function pickPreferredManagedAgent(agents: ManagedAgent[]) {
return [...agents].sort((left, right) => {
const leftRunningScore = left.status === "running" ? 1 : 0;
const rightRunningScore = right.status === "running" ? 1 : 0;
if (leftRunningScore !== rightRunningScore) {
return rightRunningScore - leftRunningScore;
}

const leftTokenScore = left.hasApiToken ? 1 : 0;
const rightTokenScore = right.hasApiToken ? 1 : 0;
if (leftTokenScore !== rightTokenScore) {
return rightTokenScore - leftTokenScore;
}

return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
})[0];
}

function buildChannelAgentName(providerId: string, providerLabel: string) {
const normalizedProviderId = providerId.trim().toLowerCase();
if (normalizedProviderId.length > 0) {
return normalizedProviderId;
}

return providerLabel.trim().toLowerCase() || "agent";
}

function pickPreferredChannelPresetAgent(
agents: ManagedAgent[],
memberPubkeys: ReadonlySet<string>,
providerCommand: string,
expectedName: string,
) {
const inChannelAgent = pickPreferredManagedAgent(
agents.filter(
(agent) =>
commandsMatch(agent.agentCommand, providerCommand) &&
memberPubkeys.has(normalizePubkey(agent.pubkey)),
),
);
if (inChannelAgent) {
return inChannelAgent;
}

return pickPreferredManagedAgent(
agents.filter(
(agent) =>
commandsMatch(agent.agentCommand, providerCommand) &&
agent.name.trim().toLowerCase() === expectedName.trim().toLowerCase(),
),
);
}

export async function ensureChannelAgentPresetInChannel(
channelId: string,
input: EnsureChannelAgentPresetInput,
): Promise<EnsureChannelAgentPresetResult> {
const role = input.role ?? "bot";
const ensureRunning = input.ensureRunning ?? true;
const members = await getChannelMembers(channelId);
const memberPubkeys = new Set(
members.map((member) => normalizePubkey(member.pubkey)),
);
const managedAgents = await listManagedAgents();
const expectedName = buildChannelAgentName(
input.provider.id,
input.provider.label,
);
const existingAgent = pickPreferredChannelPresetAgent(
managedAgents,
memberPubkeys,
input.provider.command,
expectedName,
);

if (existingAgent) {
const attached = await attachManagedAgentToChannel(channelId, {
agent: existingAgent,
role,
ensureRunning,
});
return {
...attached,
created: false,
providerId: input.provider.id,
};
}

const created = await createManagedAgent({
name: expectedName,
acpCommand: "sprout-acp",
agentCommand: input.provider.command,
agentArgs: input.provider.defaultArgs,
mcpCommand: "sprout-mcp-server",
mintToken: true,
tokenName: `${expectedName} agent`,
tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
spawnAfterCreate: false,
});
const attached = await attachManagedAgentToChannel(channelId, {
agent: created.agent,
role,
ensureRunning,
});

return {
...attached,
created: true,
providerId: input.provider.id,
};
}

export async function createChannelManagedAgent(
channelId: string,
input: CreateChannelManagedAgentInput,
): Promise<CreateChannelManagedAgentResult> {
const role = input.role ?? "bot";
const ensureRunning = input.ensureRunning ?? true;
const trimmedName = input.name.trim();

if (trimmedName.length === 0) {
throw new Error("Agent name is required.");
}

const created = await createManagedAgent({
name: trimmedName,
acpCommand: "sprout-acp",
agentCommand: input.provider.command,
agentArgs: input.provider.defaultArgs,
mcpCommand: "sprout-mcp-server",
mintToken: true,
tokenName: `${trimmedName} agent`,
tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
systemPrompt: input.systemPrompt?.trim() || undefined,
spawnAfterCreate: false,
});
const attached = await attachManagedAgentToChannel(channelId, {
agent: created.agent,
role,
ensureRunning,
});

return {
...attached,
created: true,
providerId: input.provider.id,
};
}
Loading
Loading