-
Notifications
You must be signed in to change notification settings - Fork 46k
feat(platform): copilot followups UI #13192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { | ||
| getDeleteV1DeleteExecutionScheduleMockHandler, | ||
| getDeleteV1DeleteExecutionScheduleMockHandler422, | ||
| getListCopilotFollowupSchedulesMockHandler, | ||
| } from "@/app/api/__generated__/endpoints/schedules/schedules.msw"; | ||
| import type { CopilotTurnJobInfo } from "@/app/api/__generated__/models/copilotTurnJobInfo"; | ||
| import { server } from "@/mocks/mock-server"; | ||
| import { | ||
| fireEvent, | ||
| render, | ||
| screen, | ||
| within, | ||
| } from "@/tests/integrations/test-utils"; | ||
| import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; | ||
| import FollowupsPage from "../page"; | ||
|
|
||
| const toastMock = vi.fn(); | ||
| vi.mock("@/components/molecules/Toast/use-toast", async (importOriginal) => { | ||
| const actual = | ||
| await importOriginal< | ||
| typeof import("@/components/molecules/Toast/use-toast") | ||
| >(); | ||
| return { | ||
| ...actual, | ||
| useToast: () => ({ toast: toastMock }), | ||
| }; | ||
| }); | ||
|
|
||
| function makeFollowup( | ||
| overrides: Partial<CopilotTurnJobInfo>, | ||
| ): CopilotTurnJobInfo { | ||
| const runAt = new Date(Date.now() + 60 * 60 * 1000); | ||
| return { | ||
| id: "sched-1", | ||
| name: "copilot-followup", | ||
| user_id: "user-1", | ||
| session_id: "session-abcdef0123", | ||
| message: "Check the build status and report back", | ||
| cron: null, | ||
| run_at: runAt, | ||
| next_run_time: runAt.toISOString(), | ||
| kind: "copilot_turn", | ||
| timezone: "UTC", | ||
| cap_retry_count: 0, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe("FollowupsPage", () => { | ||
| beforeEach(() => { | ||
| toastMock.mockClear(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| server.resetHandlers(); | ||
| }); | ||
|
|
||
| test("renders empty state when no follow-ups exist", async () => { | ||
| server.use(getListCopilotFollowupSchedulesMockHandler([])); | ||
|
|
||
| render(<FollowupsPage />); | ||
|
|
||
| expect(await screen.findByTestId("followups-empty")).toBeDefined(); | ||
| expect(screen.queryByTestId("followups-list")).toBeNull(); | ||
| }); | ||
|
|
||
| test("renders one row per follow-up returned by the API", async () => { | ||
| server.use( | ||
| getListCopilotFollowupSchedulesMockHandler([ | ||
| makeFollowup({ id: "f1", message: "First follow-up message" }), | ||
| makeFollowup({ | ||
| id: "f2", | ||
| message: "Second follow-up message", | ||
| session_id: "session-zzzzzzzzzz", | ||
| }), | ||
| ]), | ||
| ); | ||
|
|
||
| render(<FollowupsPage />); | ||
|
|
||
| const rows = await screen.findAllByTestId("followup-row"); | ||
|
Check failure on line 81 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx
|
||
| expect(rows).toHaveLength(2); | ||
| expect(rows[0].getAttribute("data-followup-id")).toBe("f1"); | ||
| expect(rows[1].getAttribute("data-followup-id")).toBe("f2"); | ||
| expect(screen.getByText("First follow-up message")).toBeDefined(); | ||
| expect(screen.getByText("Second follow-up message")).toBeDefined(); | ||
| }); | ||
|
|
||
| test("session link points to /copilot with the session id query param", async () => { | ||
| server.use( | ||
| getListCopilotFollowupSchedulesMockHandler([ | ||
| makeFollowup({ id: "f1", session_id: "session-abcdef0123" }), | ||
| ]), | ||
| ); | ||
|
|
||
| render(<FollowupsPage />); | ||
|
|
||
| const row = await screen.findByTestId("followup-row"); | ||
|
Check failure on line 98 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx
|
||
| const link = within(row).getByTestId("followup-open-session"); | ||
| expect(link.getAttribute("href")).toBe( | ||
| "/copilot?sessionId=session-abcdef0123", | ||
| ); | ||
| }); | ||
|
|
||
| test("Cancel button opens the confirmation dialog and calls the delete API", async () => { | ||
| server.use( | ||
| getListCopilotFollowupSchedulesMockHandler([makeFollowup({ id: "f1" })]), | ||
| getDeleteV1DeleteExecutionScheduleMockHandler(), | ||
| ); | ||
|
|
||
| render(<FollowupsPage />); | ||
|
|
||
| const cancelButton = await screen.findByTestId("followup-cancel-button"); | ||
|
Check failure on line 113 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx
|
||
| fireEvent.click(cancelButton); | ||
|
|
||
| const confirmButton = await screen.findByTestId("followup-confirm-cancel"); | ||
| fireEvent.click(confirmButton); | ||
|
|
||
| await vi.waitFor(() => { | ||
| expect(toastMock).toHaveBeenCalledWith( | ||
| expect.objectContaining({ title: "Follow-up deleted" }), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| test("shows a destructive toast when the delete API fails", async () => { | ||
| server.use( | ||
| getListCopilotFollowupSchedulesMockHandler([makeFollowup({ id: "f1" })]), | ||
| getDeleteV1DeleteExecutionScheduleMockHandler422(), | ||
| ); | ||
|
|
||
| render(<FollowupsPage />); | ||
|
|
||
| const cancelButton = await screen.findByTestId("followup-cancel-button"); | ||
|
Check failure on line 134 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx
|
||
| fireEvent.click(cancelButton); | ||
|
|
||
| const confirmButton = await screen.findByTestId("followup-confirm-cancel"); | ||
| fireEvent.click(confirmButton); | ||
|
|
||
| await vi.waitFor(() => { | ||
| expect(toastMock).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| title: "Failed to delete follow-up", | ||
| variant: "destructive", | ||
| }), | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { Text } from "@/components/atoms/Text/Text"; | ||
| import { ClockClockwiseIcon } from "@phosphor-icons/react"; | ||
|
|
||
| export function EmptyFollowups() { | ||
| return ( | ||
| <div | ||
| className="flex flex-col items-center justify-center gap-3 rounded-large border border-dashed border-zinc-200 px-6 py-16 text-center" | ||
| data-testid="followups-empty" | ||
| > | ||
| <div className="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-50"> | ||
| <ClockClockwiseIcon size={24} className="text-yellow-700" /> | ||
| </div> | ||
| <Text variant="h4" className="text-zinc-900"> | ||
| No copilot follow-ups | ||
| </Text> | ||
| <Text variant="body" className="max-w-md !text-zinc-500"> | ||
| Ask your copilot to schedule something for later and it will show up | ||
| here so you can edit or cancel it. | ||
| </Text> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| "use client"; | ||
|
|
||
| import type { CopilotTurnJobInfo } from "@/app/api/__generated__/models/copilotTurnJobInfo"; | ||
| import { Button } from "@/components/atoms/Button/Button"; | ||
| import { Text } from "@/components/atoms/Text/Text"; | ||
| import { Dialog } from "@/components/molecules/Dialog/Dialog"; | ||
| import { ChatCircleTextIcon, TrashIcon } from "@phosphor-icons/react"; | ||
| import Link from "next/link"; | ||
| import { useFollowupListItem } from "./useFollowupListItem"; | ||
|
|
||
| interface Props { | ||
| followup: CopilotTurnJobInfo; | ||
| } | ||
|
|
||
| export function FollowupListItem({ followup }: Props) { | ||
| const { | ||
| sessionHref, | ||
| nextRunLabel, | ||
| nextRunTitle, | ||
| recurrenceLabel, | ||
| messagePreview, | ||
| isDeleteOpen, | ||
| openDelete, | ||
| closeDelete, | ||
| isDeleting, | ||
| handleDelete, | ||
| } = useFollowupListItem({ followup }); | ||
|
|
||
| return ( | ||
| <div | ||
| className="flex w-full flex-col gap-3 rounded-large border border-zinc-200 bg-white p-4 sm:flex-row sm:items-center sm:justify-between" | ||
| data-testid="followup-row" | ||
| data-followup-id={followup.id} | ||
| > | ||
| <Link | ||
| href={sessionHref} | ||
| className="flex min-w-0 flex-1 items-start gap-3 hover:opacity-80" | ||
| data-testid="followup-open-session" | ||
| > | ||
| <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-large border border-slate-50 bg-yellow-50"> | ||
| <ChatCircleTextIcon | ||
| size={18} | ||
| className="text-yellow-700" | ||
| weight="bold" | ||
| /> | ||
| </div> | ||
| <div className="flex min-w-0 flex-col gap-1"> | ||
| <Text | ||
| variant="body-medium" | ||
| className="block w-full truncate text-ellipsis" | ||
| > | ||
| {messagePreview} | ||
| </Text> | ||
| <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> | ||
| <Text | ||
| variant="small" | ||
| className="!text-zinc-500" | ||
| title={nextRunTitle} | ||
| > | ||
| {nextRunLabel} | ||
| </Text> | ||
| <span className="text-zinc-300">•</span> | ||
| <Text variant="small" className="!text-zinc-500"> | ||
| {recurrenceLabel} | ||
| </Text> | ||
| <span className="text-zinc-300">•</span> | ||
| <Text variant="small" className="!text-zinc-400"> | ||
| Session {followup.session_id.slice(0, 8)} | ||
| </Text> | ||
| </div> | ||
| </div> | ||
| </Link> | ||
|
|
||
| <div className="flex flex-shrink-0 items-center gap-2"> | ||
| <Button | ||
| variant="secondary" | ||
| size="small" | ||
| onClick={openDelete} | ||
| data-testid="followup-cancel-button" | ||
| aria-label="Delete follow-up" | ||
| > | ||
| <TrashIcon className="mr-1 h-4 w-4" /> | ||
| Delete | ||
| </Button> | ||
| </div> | ||
|
|
||
| <Dialog | ||
| controlled={{ isOpen: isDeleteOpen, set: closeDelete }} | ||
| styling={{ maxWidth: "32rem" }} | ||
| title="Delete follow-up" | ||
| > | ||
| <Dialog.Content> | ||
| <div className="flex flex-col gap-4"> | ||
| <Text variant="large"> | ||
| Delete this scheduled follow-up? The copilot will not send the | ||
| message and you can recreate it from chat if needed. | ||
| </Text> | ||
| <Dialog.Footer> | ||
| <Button | ||
| variant="secondary" | ||
| disabled={isDeleting} | ||
| onClick={() => closeDelete(false)} | ||
| > | ||
| Keep it | ||
| </Button> | ||
| <Button | ||
| variant="destructive" | ||
| onClick={handleDelete} | ||
| loading={isDeleting} | ||
| data-testid="followup-confirm-cancel" | ||
| > | ||
| Yes, delete | ||
| </Button> | ||
| </Dialog.Footer> | ||
| </div> | ||
| </Dialog.Content> | ||
| </Dialog> | ||
| </div> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.