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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down
21 changes: 1 addition & 20 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { connect, onMessage, send } from "./lib/ws";
import type { ToolDef, TimelineEntry, ServerMessage, ServerInfo } from "./lib/types";
import { describeReloadDiff } from "./lib/diff";
import ToolList from "./components/ToolList.svelte";
import ToolDetail from "./components/ToolDetail.svelte";
import Timeline from "./components/Timeline.svelte";
Expand Down Expand Up @@ -98,26 +99,6 @@
}
}

// Feature 9: describe what changed on reload
function describeReloadDiff(oldTools: ToolDef[], newTools: ToolDef[]): string {
const oldNames = new Set(oldTools.map((t) => t.name));
const newNames = new Set(newTools.map((t) => t.name));
const added = newTools.filter((t) => !oldNames.has(t.name));
const removed = oldTools.filter((t) => !newNames.has(t.name));
const changed = newTools.filter((t) => {
const old = oldTools.find((o) => o.name === t.name);
return old && JSON.stringify(old.inputSchema) !== JSON.stringify(t.inputSchema);
});

const parts: string[] = [];
if (added.length) parts.push(`${added.length} added`);
if (removed.length) parts.push(`${removed.length} removed`);
if (changed.length) parts.push(`${changed.length} schema changed`);

if (parts.length === 0) return "Server reloaded (no tool changes)";
return `Server reloaded: ${parts.join(", ")}`;
}

function handleSelectTool(tool: ToolDef) {
selectedTool = tool;
activeTab = "tool";
Expand Down
85 changes: 85 additions & 0 deletions frontend/src/lib/diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { describeReloadDiff } from "./diff";
import type { ToolDef } from "./types";

function tool(name: string, schema: Record<string, unknown> = {}): ToolDef {
return {
name,
description: `${name} tool`,
inputSchema: { type: "object", ...schema },
capabilities: [],
requiresConfirmation: false,
};
}

describe("describeReloadDiff", () => {
it("reports no changes when lists are identical", () => {
const tools = [tool("a"), tool("b")];
expect(describeReloadDiff(tools, tools)).toBe(
"Server reloaded (no tool changes)",
);
});

it("reports no changes for empty lists", () => {
expect(describeReloadDiff([], [])).toBe(
"Server reloaded (no tool changes)",
);
});

it("detects added tools", () => {
const old = [tool("a")];
const next = [tool("a"), tool("b"), tool("c")];
expect(describeReloadDiff(old, next)).toBe("Server reloaded: 2 added");
});

it("detects removed tools", () => {
const old = [tool("a"), tool("b")];
const next = [tool("a")];
expect(describeReloadDiff(old, next)).toBe("Server reloaded: 1 removed");
});

it("detects schema changes", () => {
const old = [tool("a", { properties: { x: { type: "string" } } })];
const next = [tool("a", { properties: { x: { type: "number" } } })];
expect(describeReloadDiff(old, next)).toBe(
"Server reloaded: 1 schema changed",
);
});

it("does not flag unchanged schemas", () => {
const schema = { properties: { x: { type: "string" } } };
const old = [tool("a", schema)];
const next = [tool("a", schema)];
expect(describeReloadDiff(old, next)).toBe(
"Server reloaded (no tool changes)",
);
});

it("combines added, removed, and changed in one message", () => {
const old = [
tool("keep", { properties: { v: { type: "string" } } }),
tool("gone"),
];
const next = [
tool("keep", { properties: { v: { type: "number" } } }),
tool("fresh"),
];
const result = describeReloadDiff(old, next);
expect(result).toContain("1 added");
expect(result).toContain("1 removed");
expect(result).toContain("1 schema changed");
});

it("reports singular count correctly", () => {
const result = describeReloadDiff([], [tool("a")]);
expect(result).toBe("Server reloaded: 1 added");
});

it("reports multiple counts correctly", () => {
const result = describeReloadDiff(
[],
[tool("a"), tool("b"), tool("c")],
);
expect(result).toBe("Server reloaded: 3 added");
});
});
21 changes: 21 additions & 0 deletions frontend/src/lib/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ToolDef } from "./types";

/** Describe what changed between two snapshots of the tool list (used on hot-reload). */
export function describeReloadDiff(oldTools: ToolDef[], newTools: ToolDef[]): string {
const oldNames = new Set(oldTools.map((t) => t.name));
const newNames = new Set(newTools.map((t) => t.name));
const added = newTools.filter((t) => !oldNames.has(t.name));
const removed = oldTools.filter((t) => !newNames.has(t.name));
const changed = newTools.filter((t) => {
const old = oldTools.find((o) => o.name === t.name);
return old && JSON.stringify(old.inputSchema) !== JSON.stringify(t.inputSchema);
});

const parts: string[] = [];
if (added.length) parts.push(`${added.length} added`);
if (removed.length) parts.push(`${removed.length} removed`);
if (changed.length) parts.push(`${changed.length} schema changed`);

if (parts.length === 0) return "Server reloaded (no tool changes)";
return `Server reloaded: ${parts.join(", ")}`;
}
56 changes: 56 additions & 0 deletions frontend/src/lib/ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { inputClass, buttonClass, buttonVariants, copyToClipboard } from "./ui";

describe("CSS class constants", () => {
it("exports inputClass as a non-empty string", () => {
expect(typeof inputClass).toBe("string");
expect(inputClass.length).toBeGreaterThan(0);
});

it("exports buttonClass as a non-empty string", () => {
expect(typeof buttonClass).toBe("string");
expect(buttonClass.length).toBeGreaterThan(0);
});

it("exports all button variants", () => {
expect(Object.keys(buttonVariants)).toEqual([
"default",
"destructive",
"outline",
"ghost",
]);
});

it("each variant includes the base buttonClass", () => {
for (const variant of Object.values(buttonVariants)) {
expect(variant).toContain(buttonClass);
}
});
});

describe("copyToClipboard", () => {
beforeEach(() => {
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});

it("calls navigator.clipboard.writeText with the given text", async () => {
await copyToClipboard("hello world");
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
});

it("passes through empty strings", async () => {
await copyToClipboard("");
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("");
});

it("propagates clipboard errors", async () => {
const error = new Error("Clipboard blocked");
vi.stubGlobal("navigator", {
clipboard: { writeText: vi.fn().mockRejectedValue(error) },
});

await expect(copyToClipboard("x")).rejects.toThrow("Clipboard blocked");
});
});
Loading