Compare commits

...

6 Commits

Author SHA1 Message Date
Lluis Agusti
b6b605f936 fix(frontend): navbar border, sidebar padding, QA fake pulse chips
- Navbar: add 1px solid #f1f1f1 border-bottom to nav element
- ChatSidebar: reduce SidebarHeader padding from pt-4/pb-4 to pt-3/pb-3
- PulseChips: re-add QA fake chips with long text for overflow testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:03:06 +07:00
Lluis Agusti
29215e9523 fix(frontend): check refreshSession return value instead of catching
refreshSession() never throws — it returns { error } on failure.
The try/catch was dead code, causing stale session data on refresh
failure. Now checks the return value and shows an error toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:58:39 +07:00
Lluis Agusti
6456e0f317 Merge branch 'fix/small-ui-fixes' of https://github.com/Significant-Gravitas/AutoGPT into fix/small-ui-fixes 2026-04-16 19:48:14 +07:00
Lluis Agusti
12fae3132a fix(frontend): validate and trim inputs in PUT /api/auth/user
Catch malformed JSON with a 400 instead of letting it become a 500.
Validate that email/full_name are strings and trim whitespace before
passing to Supabase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:48:09 +07:00
Ubbe
1a38974f19 Merge branch 'dev' into fix/small-ui-fixes 2026-04-16 19:28:39 +07:00
Lluis Agusti
9c5d1e1019 fix(frontend): small UI fixes — sort menu bg, name update auth, stats grid overflow, pulse chips
- LibrarySortMenu/AgentFilterMenu: force transparent bg on legacy SelectTrigger
- EditNameDialog: use server-side API route instead of client-side Supabase call
  to fix "Auth session missing!" error caused by httpOnly cookies
- /api/auth/user route: accept full_name alongside email
- StatsGrid: use OverflowText for tile labels to truncate with tooltip
- PulseChips: fixed-width chips (15rem) with horizontal scroll and styled scrollbar
- Tests: update EditNameDialog tests for fetch-based flow, add PulseChips tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:13:53 +07:00
11 changed files with 241 additions and 71 deletions

View File

@@ -246,7 +246,7 @@ export function ChatSidebar() {
</SidebarHeader>
)}
{!isCollapsed && (
<SidebarHeader className="shrink-0 px-4 pb-4 pt-4 shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05)]">
<SidebarHeader className="shrink-0 px-4 pb-3 pt-3 shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05)]">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}

View File

@@ -16,7 +16,7 @@ export function EditNameDialog({ currentName }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState(currentName);
const [isSaving, setIsSaving] = useState(false);
const { supabase, refreshSession } = useSupabase();
const { refreshSession } = useSupabase();
const { toast } = useToast();
function handleOpenChange(open: boolean) {
@@ -26,29 +26,31 @@ export function EditNameDialog({ currentName }: Props) {
async function handleSave() {
const trimmed = name.trim();
if (!trimmed || !supabase) return;
if (!trimmed) return;
setIsSaving(true);
try {
const { error } = await supabase.auth.updateUser({
data: { full_name: trimmed },
const res = await fetch("/api/auth/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_name: trimmed }),
});
if (error) {
if (!res.ok) {
const body = await res.json();
toast({
title: "Failed to update name",
description: error.message,
description: body.error ?? "Unknown error",
variant: "destructive",
});
return;
}
try {
await refreshSession();
} catch (e) {
const session = await refreshSession();
if (session?.error) {
toast({
title: "Name saved, but session refresh failed",
description: e instanceof Error ? e.message : "Please reload.",
description: session.error,
variant: "destructive",
});
setIsOpen(false);

View File

@@ -5,31 +5,37 @@ import {
screen,
waitFor,
} from "@/tests/integrations/test-utils";
import { server } from "@/mocks/mock-server";
import { http, HttpResponse } from "msw";
import { EditNameDialog } from "../EditNameDialog";
const mockToast = vi.hoisted(() => vi.fn());
const mockUseSupabase = vi.hoisted(() => vi.fn());
const mockRefreshSession = vi.hoisted(() => vi.fn());
vi.mock("@/components/molecules/Toast/use-toast", () => ({
useToast: () => ({ toast: mockToast }),
}));
vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
useSupabase: mockUseSupabase,
useSupabase: () => ({
refreshSession: mockRefreshSession,
}),
}));
function setup({
updateUser = vi.fn().mockResolvedValue({ error: null }),
refreshSession = vi.fn().mockResolvedValue(undefined),
}: {
updateUser?: ReturnType<typeof vi.fn>;
refreshSession?: ReturnType<typeof vi.fn>;
} = {}) {
mockUseSupabase.mockReturnValue({
supabase: { auth: { updateUser } },
refreshSession,
});
return { updateUser, refreshSession };
function mockUpdateNameSuccess() {
server.use(
http.put("/api/auth/user", () => {
return HttpResponse.json({ user: { id: "u1" } });
}),
);
}
function mockUpdateNameError(message = "Network error") {
server.use(
http.put("/api/auth/user", () => {
return HttpResponse.json({ error: message }, { status: 400 });
}),
);
}
async function openDialogAndGetInput() {
@@ -49,19 +55,20 @@ function getSaveButton() {
describe("EditNameDialog", () => {
beforeEach(() => {
mockToast.mockReset();
mockUseSupabase.mockReset();
mockRefreshSession.mockReset();
mockRefreshSession.mockResolvedValue({ user: { id: "u1" } });
});
test("opens dialog with current name prefilled", async () => {
setup();
mockUpdateNameSuccess();
render(<EditNameDialog currentName="Alice" />);
const input = await openDialogAndGetInput();
expect(input.value).toBe("Alice");
});
test("saves name successfully and closes dialog", async () => {
const { updateUser, refreshSession } = setup();
test("saves name via API route and closes dialog", async () => {
mockUpdateNameSuccess();
render(<EditNameDialog currentName="Alice" />);
const input = await openDialogAndGetInput();
@@ -69,21 +76,13 @@ describe("EditNameDialog", () => {
fireEvent.click(getSaveButton());
await waitFor(() => {
expect(updateUser).toHaveBeenCalledWith({ data: { full_name: "Bob" } });
});
expect(refreshSession).toHaveBeenCalled();
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith({ title: "Name updated" });
expect(mockRefreshSession).toHaveBeenCalled();
});
expect(mockToast).toHaveBeenCalledWith({ title: "Name updated" });
});
test("shows error toast when updateUser fails and keeps dialog open", async () => {
const updateUser = vi
.fn()
.mockResolvedValue({ error: { message: "Network error" } });
const refreshSession = vi.fn();
setup({ updateUser, refreshSession });
test("shows error toast when API returns error", async () => {
mockUpdateNameError("Network error");
render(<EditNameDialog currentName="Alice" />);
const input = await openDialogAndGetInput();
@@ -99,15 +98,12 @@ describe("EditNameDialog", () => {
}),
);
});
expect(refreshSession).not.toHaveBeenCalled();
expect(mockRefreshSession).not.toHaveBeenCalled();
});
test("closes dialog and toasts failure when refreshSession throws", async () => {
const updateUser = vi.fn().mockResolvedValue({ error: null });
const refreshSession = vi
.fn()
.mockRejectedValue(new Error("refresh failed"));
setup({ updateUser, refreshSession });
test("shows warning toast when refreshSession returns an error", async () => {
mockUpdateNameSuccess();
mockRefreshSession.mockResolvedValue({ error: "refresh failed" });
render(<EditNameDialog currentName="Alice" />);
@@ -119,6 +115,7 @@ describe("EditNameDialog", () => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Name saved, but session refresh failed",
description: "refresh failed",
variant: "destructive",
}),
);
@@ -126,8 +123,8 @@ describe("EditNameDialog", () => {
expect(mockToast).not.toHaveBeenCalledWith({ title: "Name updated" });
});
test("disables Save button while empty input", async () => {
setup();
test("disables Save button while input is empty", async () => {
mockUpdateNameSuccess();
render(<EditNameDialog currentName="Alice" />);
const input = await openDialogAndGetInput();

View File

@@ -34,7 +34,7 @@ export function PulseChips({ chips, onChipClick }: Props) {
View all <ArrowRightIcon size={12} />
</NextLink>
</div>
<div className="flex gap-2 overflow-x-auto">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300">
{chips.map((chip) => (
<PulseChip key={chip.id} chip={chip} onAsk={onChipClick} />
))}
@@ -56,7 +56,7 @@ function PulseChip({ chip, onAsk }: ChipProps) {
return (
<div
className={`${styles.chip} relative flex shrink-0 flex-col items-start gap-2 rounded-medium border border-zinc-100 bg-white px-3 py-2`}
className={`${styles.chip} relative flex w-[15rem] shrink-0 flex-col items-start gap-2 rounded-medium border border-zinc-100 bg-white px-3 py-2`}
>
<div className={`${styles.chipContent} w-full text-left`}>
{chip.priority === "success" ? (

View File

@@ -0,0 +1,105 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent } from "@/tests/integrations/test-utils";
import { PulseChips } from "../PulseChips";
import type { PulseChipData } from "../types";
function makeChip(overrides: Partial<PulseChipData> = {}): PulseChipData {
return {
id: "chip-1",
agentID: "agent-1",
name: "Test Agent",
status: "running",
priority: "running",
shortMessage: "Doing work…",
...overrides,
};
}
describe("PulseChips", () => {
test("renders nothing when chips array is empty", () => {
const { container } = render(<PulseChips chips={[]} />);
expect(container.innerHTML).toBe("");
});
test("renders chip names and messages", () => {
const chips = [
makeChip({ id: "1", name: "Alpha Bot", shortMessage: "Running task A" }),
makeChip({ id: "2", name: "Beta Bot", shortMessage: "Running task B" }),
];
render(<PulseChips chips={chips} />);
expect(screen.getByText("Alpha Bot")).toBeDefined();
expect(screen.getByText("Running task A")).toBeDefined();
expect(screen.getByText("Beta Bot")).toBeDefined();
expect(screen.getByText("Running task B")).toBeDefined();
});
test("renders section heading and View all link", () => {
render(<PulseChips chips={[makeChip()]} />);
expect(screen.getByText("What's happening with your agents")).toBeDefined();
expect(screen.getByText("View all")).toBeDefined();
});
test("shows Completed badge for success priority chips", () => {
render(
<PulseChips
chips={[makeChip({ priority: "success", status: "idle" })]}
/>,
);
expect(screen.getByText("Completed")).toBeDefined();
});
test("calls onChipClick with generated prompt when Ask is clicked", () => {
const onChipClick = vi.fn();
render(
<PulseChips
chips={[
makeChip({
name: "Error Agent",
status: "error",
priority: "error",
}),
]}
onChipClick={onChipClick}
/>,
);
fireEvent.click(screen.getByText("Ask"));
expect(onChipClick).toHaveBeenCalledWith(
"What happened with Error Agent? It has an error — can you check?",
);
});
test("generates success prompt for completed chips", () => {
const onChipClick = vi.fn();
render(
<PulseChips
chips={[
makeChip({
name: "Done Agent",
priority: "success",
status: "idle",
}),
]}
onChipClick={onChipClick}
/>,
);
fireEvent.click(screen.getByText("Ask"));
expect(onChipClick).toHaveBeenCalledWith(
"Done Agent just finished a run — can you summarize what it did?",
);
});
test("renders See link pointing to agent detail page", () => {
render(<PulseChips chips={[makeChip({ agentID: "agent-xyz" })]} />);
const seeLink = screen.getByText("See").closest("a");
expect(seeLink?.getAttribute("href")).toBe("/library/agents/agent-xyz");
});
});

View File

@@ -5,13 +5,53 @@ import { useSitrepItems } from "@/app/(platform)/library/components/SitrepItem/u
import type { PulseChipData } from "./types";
import { useMemo } from "react";
// TODO: remove QA fakes before merging
const QA_FAKES: PulseChipData[] = [
{
id: "qa-1",
agentID: "fake-1",
name: "SEO Blog Writer with Advanced Keyword Research and Content Optimization",
status: "running",
priority: "running",
shortMessage:
"Writing a comprehensive long-form article on the latest AI trends in enterprise software development and deployment",
},
{
id: "qa-2",
agentID: "fake-2",
name: "Multi-Cloud Data Pipeline Monitor and Alerting System",
status: "error",
priority: "error",
shortMessage:
"Connection to the primary data warehouse timed out after 30 retries — fallback region also unreachable",
},
{
id: "qa-3",
agentID: "fake-3",
name: "Social Media Cross-Platform Scheduler and Analytics Dashboard",
status: "idle",
priority: "success",
shortMessage:
"All 12 scheduled posts across Twitter, LinkedIn, and Instagram were published successfully with engagement tracking enabled",
},
{
id: "qa-4",
agentID: "fake-4",
name: "Customer Support Triage and Automatic Escalation Handler",
status: "running",
priority: "stale",
shortMessage:
"3 high-priority tickets awaiting classification — SLA breach warning for 2 enterprise accounts pending review",
},
];
export function usePulseChips(): PulseChipData[] {
const { agents } = useLibraryAgents();
const sitrepItems = useSitrepItems(agents, 5);
return useMemo(() => {
return sitrepItems.map((item) => ({
const real = sitrepItems.map((item) => ({
id: item.id,
agentID: item.agentID,
name: item.agentName,
@@ -19,5 +59,6 @@ export function usePulseChips(): PulseChipData[] {
priority: item.priority,
shortMessage: item.message,
}));
return [...real, ...QA_FAKES];
}, [sitrepItems]);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { OverflowText } from "@/components/atoms/OverflowText/OverflowText";
import { Emoji } from "@/components/atoms/Emoji/Emoji";
import { cn } from "@/lib/utils";
import type { FleetSummary, AgentStatusFilter } from "../../types";
@@ -78,17 +79,19 @@ export function StatsGrid({ summary, activeTab, onTabChange }: Props) {
type="button"
onClick={() => onTabChange(tile.filter)}
className={cn(
"flex flex-col gap-1 rounded-medium border p-3 text-left shadow-md transition-all hover:shadow-lg",
"flex min-w-0 flex-col gap-1 rounded-medium border p-3 text-left shadow-md transition-all hover:shadow-lg",
isActive
? "border-zinc-900 bg-zinc-50"
: "border-zinc-100 bg-white",
)}
>
<div className="flex items-center gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<Emoji text={tile.emoji} size={18} />
<Text variant="body" className="text-zinc-800">
{tile.label}
</Text>
<OverflowText
value={tile.label}
variant="body"
className="text-zinc-800"
/>
</div>
<Text variant="h4">{value}</Text>
</button>

View File

@@ -1,7 +1,7 @@
"use client";
import { Select } from "@/components/atoms/Select/Select";
import type { SelectOption } from "@/components/atoms/Select/Select";
import { Select } from "@/components/atoms/Select/Select";
import { FunnelIcon } from "@phosphor-icons/react";
import type { AgentStatusFilter, FleetSummary } from "../../types";
@@ -32,7 +32,9 @@ export function AgentFilterMenu({ value, onChange, summary }: Props) {
return (
<div className="flex items-center" data-testid="agent-filter-dropdown">
<span className="hidden whitespace-nowrap text-sm sm:inline">filter</span>
<span className="hidden whitespace-nowrap text-sm text-zinc-500 sm:inline">
filter
</span>
<FunnelIcon className="ml-1 h-4 w-4 sm:hidden" />
<Select
id="agent-status-filter"
@@ -42,7 +44,7 @@ export function AgentFilterMenu({ value, onChange, summary }: Props) {
onValueChange={handleChange}
options={options}
size="small"
className="ml-1 w-fit border-none px-0 text-sm underline underline-offset-4 shadow-none"
className="ml-1 w-fit border-none !bg-transparent text-sm underline underline-offset-4 shadow-none"
wrapperClassName="mb-0"
/>
</div>

View File

@@ -19,11 +19,11 @@ export function LibrarySortMenu({ setLibrarySort }: Props) {
const { handleSortChange } = useLibrarySortMenu({ setLibrarySort });
return (
<div className="flex items-center" data-testid="sort-by-dropdown">
<span className="hidden whitespace-nowrap text-sm sm:inline">
<span className="hidden whitespace-nowrap text-sm text-zinc-500 sm:inline">
sort by
</span>
<Select onValueChange={handleSortChange}>
<SelectTrigger className="ml-1 w-fit space-x-1 border-none px-0 text-sm underline underline-offset-4 shadow-none">
<SelectTrigger className="!m-0 ml-1 w-fit space-x-1 border-none !bg-transparent px-[1rem] text-sm underline underline-offset-4 !shadow-none !ring-offset-transparent">
<ArrowDownNarrowWideIcon className="h-4 w-4 sm:hidden" />
<SelectValue placeholder="Last Modified" />
</SelectTrigger>

View File

@@ -15,15 +15,35 @@ export async function GET() {
export async function PUT(request: Request) {
try {
const supabase = await getServerSupabase();
const { email } = await request.json();
if (!email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { data, error } = await supabase.auth.updateUser({
email,
});
const { email: rawEmail, full_name: rawFullName } = body as {
email?: unknown;
full_name?: unknown;
};
const email = typeof rawEmail === "string" ? rawEmail.trim() : undefined;
const fullName =
typeof rawFullName === "string" ? rawFullName.trim() : undefined;
if (!email && !fullName) {
return NextResponse.json(
{ error: "Email or full_name is required" },
{ status: 400 },
);
}
const updatePayload: Parameters<typeof supabase.auth.updateUser>[0] = {};
if (email) updatePayload.email = email;
if (fullName) updatePayload.data = { full_name: fullName };
const { data, error } = await supabase.auth.updateUser(updatePayload);
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
@@ -32,7 +52,7 @@ export async function PUT(request: Request) {
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: "Failed to update user email" },
{ error: "Failed to update user" },
{ status: 500 },
);
}

View File

@@ -60,7 +60,7 @@ export function Navbar() {
<PreviewBanner branchName={previewBranchName} />
) : null}
<nav
className="inline-flex w-full items-center bg-[#FAFAFA]/80 p-3 backdrop-blur-xl"
className="inline-flex w-full items-center border-b border-[#f1f1f1] bg-[#FAFAFA]/80 p-3 backdrop-blur-xl"
style={{ height: NAVBAR_HEIGHT_PX }}
>
{/* Left section */}