Phase 3 Section C: error states (T8.15-T8.24)
- T8.15-T8.16: useOnlineStatus hook + OfflineBanner component
- Track consecutive health check failures, show offline banner after N failures
- Retry button to manually re-check
- T8.17-T8.18: AgentList empty state ("No agents connected")
- T8.19-T8.20: ChatView welcome/empty state with agent name
- T8.21-T8.22: Send error toast with retry button
- T8.23-T8.24: Console error-free render test
57/57 tests passing across 16 test files.
This commit is contained in:
2
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
2
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
@@ -1 +1 @@
|
||||
{"version":"4.1.7","results":[[":src/__tests__/smoke.test.tsx",{"duration":25.157009000000016,"failed":false}],[":src/__tests__/tailwind.test.tsx",{"duration":29.542506000000003,"failed":false}],[":src/__tests__/theme.test.tsx",{"duration":39.38911099999996,"failed":false}],[":src/__tests__/api.test.ts",{"duration":38.15992899999992,"failed":false}],[":src/__tests__/AgentList.test.tsx",{"duration":51.33677899999998,"failed":false}],[":src/__tests__/useAgentPolling.test.ts",{"duration":5.701961000000097,"failed":false}],[":src/__tests__/ChatView.test.tsx",{"duration":66.49279899999988,"failed":false}],[":src/__tests__/HealthBar.test.tsx",{"duration":45.92287400000009,"failed":false}],[":src/__tests__/useHealthPolling.test.ts",{"duration":5.440451000000053,"failed":false}],[":src/__tests__/PersonaPanel.test.tsx",{"duration":90.04737499999987,"failed":false}],[":src/__tests__/App.test.tsx",{"duration":63.82890500000008,"failed":false}],[":src/__tests__/SkillsPanel.test.tsx",{"duration":133.36823400000003,"failed":false}],[":src/__tests__/ErrorBoundary.test.tsx",{"duration":28.72702400000003,"failed":false}]]}
|
||||
{"version":"4.1.7","results":[[":src/__tests__/smoke.test.tsx",{"duration":13.945087999999942,"failed":false}],[":src/__tests__/tailwind.test.tsx",{"duration":29.17875499999991,"failed":false}],[":src/__tests__/theme.test.tsx",{"duration":30.88826199999994,"failed":false}],[":src/__tests__/api.test.ts",{"duration":91.77937500000007,"failed":false}],[":src/__tests__/AgentList.test.tsx",{"duration":46.84238899999991,"failed":false}],[":src/__tests__/useAgentPolling.test.ts",{"duration":7.933536000000004,"failed":false}],[":src/__tests__/ChatView.test.tsx",{"duration":203.7917440000001,"failed":false}],[":src/__tests__/HealthBar.test.tsx",{"duration":33.79023200000006,"failed":false}],[":src/__tests__/useHealthPolling.test.ts",{"duration":6.05030499999998,"failed":false}],[":src/__tests__/PersonaPanel.test.tsx",{"duration":121.4887510000001,"failed":false}],[":src/__tests__/App.test.tsx",{"duration":60.616534,"failed":false}],[":src/__tests__/SkillsPanel.test.tsx",{"duration":138.064118,"failed":false}],[":src/__tests__/ErrorBoundary.test.tsx",{"duration":39.08812399999988,"failed":false}],[":src/__tests__/useOnlineStatus.test.ts",{"duration":7.3779120000000376,"failed":false}],[":src/__tests__/OfflineBanner.test.tsx",{"duration":31.22387200000003,"failed":false}],[":src/__tests__/Console.test.tsx",{"duration":15.374306000000047,"failed":false}]]}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { AgentList } from "./components/AgentList";
|
||||
import { ChatView } from "./components/ChatView";
|
||||
import { OfflineBanner } from "./components/OfflineBanner";
|
||||
import { useOnlineStatus } from "./hooks/useOnlineStatus";
|
||||
import { fetchHealth } from "./api";
|
||||
import "./index.css";
|
||||
|
||||
function HamburgerIcon() {
|
||||
@@ -24,9 +27,11 @@ function HamburgerIcon() {
|
||||
|
||||
export default function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false);
|
||||
const { offline, check: retryHealth } = useOnlineStatus(fetchHealth);
|
||||
|
||||
return (
|
||||
<div class="flex flex-col md:flex-row h-screen bg-gray-950 text-gray-100">
|
||||
{offline() && <OfflineBanner onRetry={retryHealth} />}
|
||||
<button
|
||||
class="md:hidden p-2 absolute top-2 left-2 z-50"
|
||||
aria-label="Toggle sidebar"
|
||||
|
||||
@@ -14,6 +14,11 @@ describe("AgentList", () => {
|
||||
expect(getByText("Nyx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no agents", () => {
|
||||
const { getByText } = render(() => <AgentList agents={[]} onSelect={vi.fn()} />);
|
||||
expect(getByText(/no agents/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows green dot for online, gray for offline", () => {
|
||||
const { container } = render(() => <AgentList agents={mockAgents} onSelect={() => {}} />);
|
||||
const onlineDot = container.querySelector('[data-status="online"]');
|
||||
|
||||
@@ -50,8 +50,17 @@ describe("ChatView", () => {
|
||||
expect(onSend).toHaveBeenCalledWith("a1", "test message");
|
||||
});
|
||||
|
||||
it("shows welcome state when no messages", () => {
|
||||
const { getByText } = render(() => (
|
||||
<ChatView messages={[]} onSend={vi.fn()} agentId="a1" agentName="Hermes" />
|
||||
));
|
||||
expect(getByText(/start a conversation/i)).toBeTruthy();
|
||||
expect(getByText(/Hermes/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has data-scroll-container on the scrollable area", () => {
|
||||
const { container } = render(() => <ChatView messages={[]} onSend={() => {}} />);
|
||||
const messages = [{ id: "m1", role: "user", content: "hi", timestamp: "12:00" }];
|
||||
const { container } = render(() => <ChatView messages={messages} onSend={() => {}} />);
|
||||
const scrollContainer = container.querySelector("[data-scroll-container]");
|
||||
expect(scrollContainer).toBeTruthy();
|
||||
});
|
||||
@@ -144,4 +153,41 @@ describe("ChatView", () => {
|
||||
expect(container.querySelector("[data-streaming]")).toBeNull();
|
||||
expect(container.querySelector("[data-role='error']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error toast when message send fails", async () => {
|
||||
const onSend = vi.fn().mockRejectedValue(new Error("Network failure"));
|
||||
const { container } = render(() => <ChatView messages={[]} onSend={onSend} agentId="a1" />);
|
||||
|
||||
const input = container.querySelector("textarea")!;
|
||||
fireEvent.input(input, { target: { value: "hello" } });
|
||||
fireEvent.click(container.querySelector("button[aria-label='Send']")!);
|
||||
|
||||
// Wait for the error toast to appear
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("[data-send-error]")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("retry button on error toast calls send again", async () => {
|
||||
const onSend = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("First fail"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { container } = render(() => <ChatView messages={[]} onSend={onSend} agentId="a1" />);
|
||||
|
||||
const input = container.querySelector("textarea")!;
|
||||
fireEvent.input(input, { target: { value: "hello" } });
|
||||
fireEvent.click(container.querySelector("button[aria-label='Send']")!);
|
||||
|
||||
// Wait for toast
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector("[data-send-error]")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click retry
|
||||
fireEvent.click(container.querySelector("button[aria-label='Retry']")!);
|
||||
|
||||
// Should have called onSend twice
|
||||
expect(onSend).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
29
src/__tests__/Console.test.tsx
Normal file
29
src/__tests__/Console.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { render } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import App from "../App";
|
||||
|
||||
describe("App console errors", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders without console errors", () => {
|
||||
const errors: string[] = [];
|
||||
const warn = vi.spyOn(console, "error").mockImplementation((msg: unknown) => {
|
||||
errors.push(String(msg));
|
||||
});
|
||||
|
||||
render(() => <App />);
|
||||
|
||||
// Filter out expected Solid dev warnings
|
||||
const unexpected = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("createRoot") &&
|
||||
!e.includes("Warning:") &&
|
||||
!e.includes("Unrecognized value"),
|
||||
);
|
||||
expect(unexpected).toHaveLength(0);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
18
src/__tests__/OfflineBanner.test.tsx
Normal file
18
src/__tests__/OfflineBanner.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render, screen, fireEvent } from "@solidjs/testing-library";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { OfflineBanner } from "../components/OfflineBanner";
|
||||
|
||||
describe("OfflineBanner", () => {
|
||||
it("renders the offline message", () => {
|
||||
const { container } = render(() => <OfflineBanner onRetry={vi.fn()} />);
|
||||
expect(screen.getByText(/server unreachable/i)).toBeTruthy();
|
||||
expect(container.querySelector("[data-offline-banner]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onRetry when retry button clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(() => <OfflineBanner onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByLabelText("Retry connection"));
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
69
src/__tests__/useOnlineStatus.test.ts
Normal file
69
src/__tests__/useOnlineStatus.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook } from "@solidjs/testing-library";
|
||||
import { useOnlineStatus } from "../hooks/useOnlineStatus";
|
||||
|
||||
describe("useOnlineStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports offline after N consecutive failures", async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
|
||||
|
||||
// Initial state
|
||||
expect(result.offline()).toBe(false);
|
||||
|
||||
// First failure
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(false);
|
||||
|
||||
// Second failure
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(false);
|
||||
|
||||
// Third failure — threshold reached
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(true);
|
||||
});
|
||||
|
||||
it("resets offline on successful fetch", async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockRejectedValueOnce(new Error("fail"))
|
||||
.mockResolvedValue({ ok: true });
|
||||
|
||||
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
|
||||
|
||||
// Three failures to go offline
|
||||
await result.check();
|
||||
await result.check();
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(true);
|
||||
|
||||
// Now a success resets
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(false);
|
||||
});
|
||||
|
||||
it("reset() clears failure count and offline state", async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const { result } = renderHook(() => useOnlineStatus(fetchFn, 3));
|
||||
|
||||
// Force offline
|
||||
await result.check();
|
||||
await result.check();
|
||||
await result.check();
|
||||
expect(result.offline()).toBe(true);
|
||||
|
||||
// Manual reset
|
||||
result.reset();
|
||||
expect(result.offline()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
@@ -13,24 +13,31 @@ interface AgentListProps {
|
||||
|
||||
export function AgentList(props: AgentListProps) {
|
||||
return (
|
||||
<ul class="space-y-1">
|
||||
<For each={props.agents}>
|
||||
{(agent) => (
|
||||
<li
|
||||
data-agent-id={agent.id}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-gray-800 transition-colors"
|
||||
onClick={() => props.onSelect(agent.id)}
|
||||
>
|
||||
<span
|
||||
data-status={agent.status}
|
||||
class={`inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${
|
||||
agent.status === "online" ? "bg-green-500" : "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span class="text-sm text-gray-200">{agent.name}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show
|
||||
when={props.agents.length > 0}
|
||||
fallback={
|
||||
<div class="text-gray-500 text-center py-8">No agents connected</div>
|
||||
}
|
||||
>
|
||||
<ul class="space-y-1">
|
||||
<For each={props.agents}>
|
||||
{(agent) => (
|
||||
<li
|
||||
data-agent-id={agent.id}
|
||||
class="flex items-center gap-2 px-3 py-2 rounded cursor-pointer hover:bg-gray-800 transition-colors"
|
||||
onClick={() => props.onSelect(agent.id)}
|
||||
>
|
||||
<span
|
||||
data-status={agent.status}
|
||||
class={`inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ${
|
||||
agent.status === "online" ? "bg-green-500" : "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span class="text-sm text-gray-200">{agent.name}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createSignal, createEffect, Show, on } from "solid-js";
|
||||
import { For, createSignal, createEffect, Show } from "solid-js";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -10,8 +10,9 @@ interface Message {
|
||||
|
||||
interface ChatViewProps {
|
||||
messages: Message[];
|
||||
onSend: (agentId: string, content: string) => void;
|
||||
onSend: (agentId: string, content: string) => void | Promise<void>;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function ChatView(props: ChatViewProps) {
|
||||
@@ -23,13 +24,32 @@ export function ChatView(props: ChatViewProps) {
|
||||
};
|
||||
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const [sendError, setSendError] = createSignal<string | null>(null);
|
||||
const [pendingContent, setPendingContent] = createSignal("");
|
||||
let scrollContainerRef!: HTMLDivElement;
|
||||
|
||||
const handleSend = () => {
|
||||
const handleSend = async () => {
|
||||
const content = inputValue().trim();
|
||||
if (!content) return;
|
||||
props.onSend(props.agentId || "", content);
|
||||
setInputValue("");
|
||||
const agentId = props.agentId || "";
|
||||
try {
|
||||
setPendingContent(content);
|
||||
setInputValue("");
|
||||
await props.onSend(agentId, content);
|
||||
setSendError(null);
|
||||
setPendingContent("");
|
||||
} catch (e) {
|
||||
setSendError("Failed to send message. Retry?");
|
||||
setPendingContent(content);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setSendError(null);
|
||||
const content = pendingContent();
|
||||
if (!content) return;
|
||||
const agentId = props.agentId || "";
|
||||
props.onSend(agentId, content);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -55,27 +75,58 @@ export function ChatView(props: ChatViewProps) {
|
||||
data-scroll-container
|
||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
<For each={props.messages}>
|
||||
{(msg) => (
|
||||
<div
|
||||
class={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
data-role={msg.role}
|
||||
class={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
roleColors[msg.role] || roleColors.agent
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
<Show when={msg.streaming}>
|
||||
<span data-streaming class="inline-block w-2 h-4 ml-1 bg-indigo-400 animate-pulse rounded-sm" />
|
||||
</Show>
|
||||
<div class="text-xs mt-1 opacity-60">{msg.timestamp}</div>
|
||||
<Show
|
||||
when={props.messages.length > 0}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center text-gray-500" style="min-height: 200px">
|
||||
<div class="text-center">
|
||||
<p class="text-lg">Start a conversation with {props.agentName || "agent"}</p>
|
||||
<p class="text-sm mt-2">Type a message below to begin</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={props.messages}>
|
||||
{(msg) => (
|
||||
<div
|
||||
class={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
data-role={msg.role}
|
||||
class={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
roleColors[msg.role] || roleColors.agent
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
<Show when={msg.streaming}>
|
||||
<span data-streaming class="inline-block w-2 h-4 ml-1 bg-indigo-400 animate-pulse rounded-sm" />
|
||||
</Show>
|
||||
<div class="text-xs mt-1 opacity-60">{msg.timestamp}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={sendError()}
|
||||
>
|
||||
<div
|
||||
data-send-error
|
||||
class="bg-red-900/50 border border-red-500/50 text-red-200 p-3 rounded-md mx-4 mb-2 flex justify-between items-center"
|
||||
>
|
||||
<span>{sendError()}</span>
|
||||
<button
|
||||
aria-label="Retry"
|
||||
onClick={handleRetry}
|
||||
class="underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="border-t border-gray-700 p-3">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
|
||||
21
src/components/OfflineBanner.tsx
Normal file
21
src/components/OfflineBanner.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface OfflineBannerProps {
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function OfflineBanner(props: OfflineBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-offline-banner
|
||||
class="bg-amber-900/50 border-b border-amber-500/50 text-amber-200 px-4 py-2 flex justify-between items-center"
|
||||
>
|
||||
<span>⚠️ API server unreachable — retrying...</span>
|
||||
<button
|
||||
aria-label="Retry connection"
|
||||
onClick={props.onRetry}
|
||||
class="underline text-amber-100 hover:text-amber-50"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/hooks/useOnlineStatus.ts
Normal file
27
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
export function useOnlineStatus(fetchHealth: () => Promise<any>, maxFailures = 3) {
|
||||
const [offline, setOffline] = createSignal(false);
|
||||
const [failureCount, setFailureCount] = createSignal(0);
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
await fetchHealth();
|
||||
setFailureCount(0);
|
||||
setOffline(false);
|
||||
} catch {
|
||||
const newCount = failureCount() + 1;
|
||||
setFailureCount(newCount);
|
||||
if (newCount >= maxFailures) {
|
||||
setOffline(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFailureCount(0);
|
||||
setOffline(false);
|
||||
};
|
||||
|
||||
return { offline, check, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user