diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png new file mode 100644 index 00000000000..eaaff14cf49 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png new file mode 100644 index 00000000000..8d6794d9c2c Binary files /dev/null and b/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png differ diff --git a/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png new file mode 100644 index 00000000000..ebb28fc0d4c Binary files /dev/null and b/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png differ diff --git a/docs/pr-screenshots/pr-6384/sidebar-desktop.png b/docs/pr-screenshots/pr-6384/sidebar-desktop.png new file mode 100644 index 00000000000..9adca66b633 Binary files /dev/null and b/docs/pr-screenshots/pr-6384/sidebar-desktop.png differ diff --git a/ui/src/components/DevRestartBanner.test.tsx b/ui/src/components/DevRestartBanner.test.tsx index f9e6809151b..b8a44b62a27 100644 --- a/ui/src/components/DevRestartBanner.test.tsx +++ b/ui/src/components/DevRestartBanner.test.tsx @@ -47,6 +47,7 @@ afterEach(() => { container?.remove(); container = null; vi.restoreAllMocks(); + vi.useRealTimers(); mockHealthApi.requestDevServerRestart.mockReset(); }); @@ -88,4 +89,25 @@ describe("DevRestartBanner", () => { expect(mockHealthApi.requestDevServerRestart).not.toHaveBeenCalled(); }); + + it("re-enables the manual restart action when a request does not refresh the page", async () => { + vi.useFakeTimers(); + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")) as HTMLButtonElement | undefined; + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button?.disabled).toBe(true); + expect(node.textContent).toContain("Restart requested"); + + act(() => { + vi.advanceTimersByTime(30_000); + }); + + expect(button?.disabled).toBe(false); + expect(node.textContent).toContain("Restart now"); + }); }); diff --git a/ui/src/components/DevRestartBanner.tsx b/ui/src/components/DevRestartBanner.tsx index d7c36e32d5e..fec523be202 100644 --- a/ui/src/components/DevRestartBanner.tsx +++ b/ui/src/components/DevRestartBanner.tsx @@ -1,7 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react"; import { healthApi, type DevServerHealthStatus } from "../api/health"; +const RESTART_PENDING_RESET_MS = 30_000; + function formatRelativeTimestamp(value: string | null): string | null { if (!value) return null; const timestamp = new Date(value).getTime(); @@ -29,6 +31,14 @@ function describeReason(devServer: DevServerHealthStatus): string { export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) { const [restartPending, setRestartPending] = useState(false); + useEffect(() => { + if (!restartPending) return; + const timeout = window.setTimeout(() => { + setRestartPending(false); + }, RESTART_PENDING_RESET_MS); + return () => window.clearTimeout(timeout); + }, [restartPending]); + if (!devServer?.enabled || !devServer.restartRequired) return null; const currentDevServer = devServer; diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index e4d8f58e3ad..e5cc6468ffb 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -814,6 +814,26 @@ describe("MarkdownEditor", () => { }); }); + it("caps rendered mention matches while keeping the menu scrollable", async () => { + const handleChange = vi.fn(); + const mentions = Array.from({ length: 60 }, (_, index) => ({ + id: `project:project-${index}`, + kind: "project" as const, + name: `Paperclip App ${index}`, + projectId: `project-${index}`, + projectColor: "#336699", + })); + const { menu, root } = await openMentionMenuFor(handleChange, mentions); + + const options = Array.from(menu.querySelectorAll('button[type="button"]')); + expect(options).toHaveLength(50); + expect(menu.className).toContain("overflow-y-auto"); + + await act(async () => { + root.unmount(); + }); + }); + it("scrolls the active mention option into view during keyboard navigation", async () => { const handleChange = vi.fn(); const scrollIntoView = vi.fn(); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 5132dab5f1c..167a0a92103 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -212,6 +212,7 @@ const MENTION_MENU_HEIGHT = 208; const MENTION_MENU_PADDING = 8; const MENTION_MENU_ROW_HEIGHT = 34; const MENTION_MENU_CHROME_HEIGHT = 8; +const MAX_AUTOCOMPLETE_OPTIONS = 50; /** Roughly one space-width of breathing room between the caret and the menu. */ const MENTION_MENU_CARET_GAP = 10; @@ -648,10 +649,13 @@ export const MarkdownEditor = forwardRef .filter((command) => { if (!q) return true; return command.aliases.some((alias) => alias.toLowerCase().includes(q)); - }); + }) + .slice(0, MAX_AUTOCOMPLETE_OPTIONS); } if (!mentions) return []; - return mentions.filter((m) => m.name.toLowerCase().includes(q)); + return mentions + .filter((m) => m.name.toLowerCase().includes(q)) + .slice(0, MAX_AUTOCOMPLETE_OPTIONS); }, [mentionState, mentions, slashCommands]); useImperativeHandle(forwardedRef, () => ({ diff --git a/ui/storybook/stories/dev-ops-surfaces.stories.tsx b/ui/storybook/stories/dev-ops-surfaces.stories.tsx new file mode 100644 index 00000000000..6690392d15d --- /dev/null +++ b/ui/storybook/stories/dev-ops-surfaces.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DevRestartBanner } from "@/components/DevRestartBanner"; +import type { DevServerHealthStatus } from "@/api/health"; + +const restartRequired: DevServerHealthStatus = { + enabled: true, + restartRequired: true, + reason: "backend_changes_and_pending_migrations", + lastChangedAt: new Date(Date.now() - 7 * 60_000).toISOString(), + changedPathCount: 4, + changedPathsSample: [ + "server/src/routes/health.ts", + "server/src/dev-runner.ts", + "packages/shared/src/api.ts", + ], + pendingMigrations: ["0042_dev_server_health.sql"], + autoRestartEnabled: false, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: new Date(Date.now() - 45 * 60_000).toISOString(), +}; + +const restartWaitingForIdle: DevServerHealthStatus = { + ...restartRequired, + reason: "backend_changes", + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 2, + waitingForIdle: true, +}; + +function DevOpsSurfacesStory() { + return ( +
+
+
+
+ Dev server restart banner +
+
+ +
+ +
+
+
+ Mobile waiting state +
+
+ +
+
+ ); +} + +const meta = { + title: "Product/Dev Ops Surfaces", + component: DevOpsSurfacesStory, + parameters: { + docs: { + description: { + component: + "Shows local development recovery surfaces, including the restart-required banner and its manual restart action.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const DevOpsSurfaces: Story = {};