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
39 changes: 36 additions & 3 deletions desktop/renderer/src/components/agent/AgentCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@
<IconClose :size="14" />
{{ t("agentMarket.remove") }}
</button>
<button v-else class="agent-card-action" @click="$emit('add', agent.id)">
<IconPlus :size="14" />
{{ t("agentMarket.add") }}
<button
v-else
class="agent-card-action"
:disabled="adding"
:aria-busy="adding"
@click="$emit('add', agent.id)"
>
<span v-if="adding" class="agent-card-spinner" aria-hidden="true"></span>
<IconPlus v-else :size="14" />
{{ t(adding ? "agentMarket.adding" : "agentMarket.add") }}
</button>
</div>
</div>
Expand All @@ -38,6 +45,7 @@ import IconClose from "@/components/icons/IconClose.vue";

defineProps<{
agent: Agent;
adding?: boolean;
}>();

defineEmits<{
Expand Down Expand Up @@ -169,6 +177,31 @@ defineEmits<{
color: var(--bg-primary);
}

.agent-card-action:disabled {
cursor: wait;
opacity: 0.65;
}

.agent-card-action:disabled:hover {
background: transparent;
color: var(--text-primary);
}

.agent-card-spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: agent-card-spin 0.7s linear infinite;
}

@keyframes agent-card-spin {
to {
transform: rotate(360deg);
}
}

.agent-card-action-danger {
color: var(--danger, #c0392b);
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/renderer/src/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,9 @@ export default {
"agentMarket.title": "New Agent",
"agentMarket.pageTitle": "Popular Agents",
"agentMarket.add": "Add",
"agentMarket.adding": "Adding...",
"agentMarket.added": "Added",
"agentMarket.addSuccess": "{name} was added.",
"agentMarket.addFailed": "Couldn't add the agent to OpenClaw. Please try again.",
"agentMarket.remove": "Remove",
"agentMarket.cancel": "Cancel",
Expand Down
2 changes: 2 additions & 0 deletions desktop/renderer/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,9 @@ export default {
"agentMarket.title": "新建 Agent",
"agentMarket.pageTitle": "热门 Agent",
"agentMarket.add": "添加",
"agentMarket.adding": "添加中...",
"agentMarket.added": "已添加",
"agentMarket.addSuccess": "已添加 {name}。",
"agentMarket.addFailed": "无法将 Agent 添加到 OpenClaw,请重试。",
"agentMarket.remove": "移除",
"agentMarket.cancel": "取消",
Expand Down
75 changes: 75 additions & 0 deletions desktop/renderer/src/views/AgentMarketView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createPinia } from "pinia";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setLocale } from "@/i18n";
import AgentMarketView from "./AgentMarketView.vue";

const { successMessage, errorMessage } = vi.hoisted(() => ({
successMessage: vi.fn(),
errorMessage: vi.fn(),
}));

vi.mock("element-plus", () => ({
ElMessage: {
success: successMessage,
error: errorMessage,
},
ElMessageBox: {
confirm: vi.fn(),
},
}));

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

describe("AgentMarketView", () => {
const agentsAdd = vi.fn();

beforeEach(() => {
setLocale("en-US");
successMessage.mockReset();
errorMessage.mockReset();
agentsAdd.mockReset();
window.openclaw = {
agents: {
list: vi.fn(),
add: agentsAdd,
remove: vi.fn(),
},
} as unknown as typeof window.openclaw;
});

it("shows progress and confirms a successful agent addition", async () => {
const addition = deferred<{
agents: { id: string; name: string }[];
}>();
agentsAdd.mockReturnValueOnce(addition.promise);
const wrapper = mount(AgentMarketView, {
global: { plugins: [createPinia()] },
});
const addButton = wrapper.find(".agent-card-action");

await addButton.trigger("click");

expect(addButton.attributes("disabled")).toBeDefined();
expect(addButton.attributes("aria-busy")).toBe("true");
expect(addButton.text()).toBe("Adding...");

addition.resolve({
agents: [
{ id: "main", name: "Assistant" },
{ id: "master-archive", name: "Master Archive" },
],
});
await flushPromises();

expect(agentsAdd).toHaveBeenCalledWith("master-archive");
expect(successMessage).toHaveBeenCalledWith("Master Archive was added.");
expect(wrapper.find(".agent-card-action").text()).toBe("Remove");
});
});
11 changes: 10 additions & 1 deletion desktop/renderer/src/views/AgentMarketView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
v-for="agent in filteredAgents"
:key="agent.id"
:agent="agent"
:adding="addingAgentIds.has(agent.id)"
@add="handleAdd"
@remove="handleRemove"
/>
Expand All @@ -17,7 +18,7 @@
</template>

<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { useAgentStore } from "@/stores/agents";
import { useAgentList } from "@/composables/useAgentList";
Expand All @@ -26,12 +27,20 @@ import { t } from "@/i18n";

const agentStore = useAgentStore();
const { filteredAgents } = useAgentList(computed(() => agentStore.marketAgents));
const addingAgentIds = ref(new Set<string>());

async function handleAdd(agentId: string) {
const agent = agentStore.marketAgents.find((candidate) => candidate.id === agentId);
if (!agent || agent.isAdded || addingAgentIds.value.has(agentId)) return;

addingAgentIds.value.add(agentId);
try {
await agentStore.addAgent(agentId);
ElMessage.success(t("agentMarket.addSuccess", { name: agent.name }));
} catch {
ElMessage.error(t("agentMarket.addFailed"));
} finally {
addingAgentIds.value.delete(agentId);
}
}

Expand Down
Loading