mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] UI and dev ops quality-of-life (#6384)
## Thinking Path > - Paperclip operators spend most of their time scanning the board, inbox, sidebar, and local dev status surfaces > - Small UI and dev-ops frictions make repeated operator workflows feel slower than they need to be > - The working branch contained several independent quality-of-life improvements mixed with larger cloud work > - Grouping these smaller UI/dev-ops changes together keeps review overhead reasonable without merging them into feature PRs > - This pull request collects the operator-facing QoL polish into one standalone branch > - The benefit is a cleaner board navigation and local dev recovery experience without depending on cloud upstream sync ## What Changed - Relaxed forced 44px touch targets for small inline widgets. - Fixed mobile mention menu scrolling and sidebar spacing on touch/mobile layouts. - Synced inbox hover state with j/k selection. - Moved plugin sidebar entries into the Work section. - Added manual dev-server restart action/banner behavior. - Logged plugin bridge 502 causes for better diagnosis. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm --filter @paperclipai/plugin-sdk build` - `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/SidebarProjects.test.tsx ui/src/pages/Inbox.test.tsx ui/src/components/DevRestartBanner.test.tsx server/src/__tests__/dev-server-status.test.ts server/src/__tests__/health-dev-server-token.test.ts server/src/__tests__/plugin-routes-authz.test.ts` initially failed only because plugin SDK `dist` was not built in the fresh worktree. - Rerun after build: `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts` passed. - The remaining targeted UI/dev-server tests passed on the first post-install run. ## Visual Evidence - Sidebar layout and plugin Work section:  - Inbox/task row selection and hover-state surface:  - Dev restart banner desktop:  - Dev restart banner mobile:  ## Risks - Mostly UI/dev ergonomics with low data risk. - Sidebar and inbox changes touch frequently used navigation surfaces, so visual review on desktop/mobile is still useful. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use. Exact hosted model ID and context-window size are not exposed by the local Paperclip adapter runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
43c5bb81b6
commit
f257530537
29 changed files with 870 additions and 45 deletions
|
|
@ -38,4 +38,15 @@ export const healthApi = {
|
|||
}
|
||||
return res.json();
|
||||
},
|
||||
requestDevServerRestart: async (): Promise<void> => {
|
||||
const res = await fetch("/api/health/dev-server/restart", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(payload?.error ?? `Failed to request restart (${res.status})`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
113
ui/src/components/DevRestartBanner.test.tsx
Normal file
113
ui/src/components/DevRestartBanner.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
|
||||
const mockHealthApi = vi.hoisted(() => ({
|
||||
requestDevServerRestart: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/health", () => ({
|
||||
healthApi: mockHealthApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const devServer = {
|
||||
enabled: true as const,
|
||||
restartRequired: true,
|
||||
reason: "backend_changes" as const,
|
||||
lastChangedAt: "2026-03-20T12:00:00.000Z",
|
||||
changedPathCount: 1,
|
||||
changedPathsSample: ["server/src/routes/health.ts"],
|
||||
pendingMigrations: [],
|
||||
autoRestartEnabled: true,
|
||||
activeRunCount: 1,
|
||||
waitingForIdle: true,
|
||||
lastRestartAt: "2026-03-20T11:30:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||
mockHealthApi.requestDevServerRestart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockHealthApi.requestDevServerRestart.mockReset();
|
||||
});
|
||||
|
||||
function render() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => root?.render(<DevRestartBanner devServer={devServer} />));
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("DevRestartBanner", () => {
|
||||
it("confirms and requests an immediate restart while waiting for live runs", async () => {
|
||||
const node = render();
|
||||
const button = [...node.querySelectorAll("button")]
|
||||
.find((entry) => entry.textContent?.includes("Restart now"));
|
||||
|
||||
expect(node.textContent).toContain("Waiting for 1 live run to finish");
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith("Restart Paperclip now? This may interrupt 1 live run.");
|
||||
expect(mockHealthApi.requestDevServerRestart).toHaveBeenCalledTimes(1);
|
||||
expect(node.textContent).toContain("Restart requested");
|
||||
});
|
||||
|
||||
it("does not request restart when confirmation is declined", async () => {
|
||||
vi.mocked(window.confirm).mockReturnValue(false);
|
||||
const node = render();
|
||||
const button = [...node.querySelectorAll("button")]
|
||||
.find((entry) => entry.textContent?.includes("Restart now"));
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
|
||||
import type { DevServerHealthStatus } from "../api/health";
|
||||
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;
|
||||
|
|
@ -27,10 +30,39 @@ 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;
|
||||
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
|
||||
const sample = devServer.changedPathsSample.slice(0, 3);
|
||||
const activeRunLabel = `${devServer.activeRunCount} live run${
|
||||
devServer.activeRunCount === 1 ? "" : "s"
|
||||
}`;
|
||||
|
||||
async function requestRestartNow() {
|
||||
const warning =
|
||||
currentDevServer.activeRunCount > 0
|
||||
? `Restart Paperclip now? This may interrupt ${activeRunLabel}.`
|
||||
: "Restart Paperclip now?";
|
||||
if (!window.confirm(warning)) return;
|
||||
|
||||
setRestartPending(true);
|
||||
try {
|
||||
await healthApi.requestDevServerRestart();
|
||||
} catch (error) {
|
||||
setRestartPending(false);
|
||||
window.alert(error instanceof Error ? error.message : "Failed to request restart");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
|
|
@ -65,11 +97,11 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-medium md:justify-end">
|
||||
{devServer.waitingForIdle ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
<TimerReset className="h-3.5 w-3.5" />
|
||||
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
|
||||
<span>Waiting for {activeRunLabel} to finish</span>
|
||||
</div>
|
||||
) : devServer.autoRestartEnabled ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
|
|
@ -82,6 +114,17 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
|
|||
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-950 px-3 py-1.5 text-xs font-semibold text-amber-50 transition-colors hover:bg-amber-900 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-amber-200 dark:text-amber-950 dark:hover:bg-amber-100"
|
||||
onClick={() => {
|
||||
void requestRestartNow();
|
||||
}}
|
||||
disabled={restartPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>{restartPending ? "Restart requested" : "Restart now"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ export function IssueRow({
|
|||
{showUnreadDot ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -200,6 +201,7 @@ export function IssueRow({
|
|||
) : onArchive ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -1706,6 +1706,7 @@ export function IssuesList({
|
|||
<button
|
||||
key={firstVisibleBlockerChip.blockerId}
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -1781,7 +1782,7 @@ export function IssuesList({
|
|||
className={isMutedIssue ? "opacity-70" : undefined}
|
||||
mobileLeading={
|
||||
hasChildren ? (
|
||||
<button type="button" onClick={toggleCollapse}>
|
||||
<button type="button" data-slot="icon-button" onClick={toggleCollapse}>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -1795,6 +1796,7 @@ export function IssuesList({
|
|||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="hidden shrink-0 items-center sm:inline-flex"
|
||||
onClick={toggleCollapse}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -686,7 +686,16 @@ describe("MarkdownEditor", () => {
|
|||
|
||||
async function openMentionMenuFor(
|
||||
handleChange: ReturnType<typeof vi.fn>,
|
||||
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot> }> {
|
||||
mentions = [
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project" as const,
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
],
|
||||
): Promise<{ option: HTMLButtonElement; root: ReturnType<typeof createRoot>; menu: HTMLElement }> {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -694,15 +703,7 @@ describe("MarkdownEditor", () => {
|
|||
<MarkdownEditor
|
||||
value="@Pap"
|
||||
onChange={handleChange}
|
||||
mentions={[
|
||||
{
|
||||
id: "project:project-123",
|
||||
kind: "project",
|
||||
name: "Paperclip App",
|
||||
projectId: "project-123",
|
||||
projectColor: "#336699",
|
||||
},
|
||||
]}
|
||||
mentions={mentions}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
|
@ -729,7 +730,9 @@ describe("MarkdownEditor", () => {
|
|||
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
|
||||
.find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined;
|
||||
expect(option).toBeTruthy();
|
||||
return { option: option!, root };
|
||||
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]') as HTMLElement | null;
|
||||
expect(menu).toBeTruthy();
|
||||
return { option: option!, root, menu: menu! };
|
||||
}
|
||||
|
||||
it("accepts mention selection from a touch tap", async () => {
|
||||
|
|
@ -783,6 +786,99 @@ describe("MarkdownEditor", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("renders all mention matches inside a bounded scroll container", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const mentions = Array.from({ length: 12 }, (_, 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(12);
|
||||
expect(menu.className).toContain("max-h-[208px]");
|
||||
expect(menu.className).toContain("overflow-y-auto");
|
||||
expect(menu.style.touchAction).toBe("pan-y");
|
||||
|
||||
const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 80 });
|
||||
act(() => {
|
||||
menu.dispatchEvent(wheel);
|
||||
});
|
||||
expect(wheel.defaultPrevented).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
const mentions = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `project:project-${index}`,
|
||||
kind: "project" as const,
|
||||
name: `Paperclip App ${index}`,
|
||||
projectId: `project-${index}`,
|
||||
projectColor: "#336699",
|
||||
}));
|
||||
const { root } = await openMentionMenuFor(handleChange, mentions);
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
const editorScope = container.querySelector('[data-testid="mdx-editor"]')?.parentElement;
|
||||
expect(editorScope).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
editorScope?.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
if (originalScrollIntoView) {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: originalScrollIntoView,
|
||||
});
|
||||
} else {
|
||||
delete (HTMLElement.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not select when the touch moves like a scroll", async () => {
|
||||
const handleChange = vi.fn();
|
||||
const { option, root } = await openMentionMenuFor(handleChange);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -603,6 +604,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const autocompleteOptionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const autocompleteSelectionHandledRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
|
|
@ -648,10 +650,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
if (!q) return true;
|
||||
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
||||
})
|
||||
.slice(0, 8);
|
||||
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
||||
}
|
||||
if (!mentions) return [];
|
||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||
return mentions
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.slice(0, MAX_AUTOCOMPLETE_OPTIONS);
|
||||
}, [mentionState, mentions, slashCommands]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
|
|
@ -896,6 +900,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
};
|
||||
}, [checkMention, mentionActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentionActive) return;
|
||||
autocompleteOptionRefs.current.length = filteredMentions.length;
|
||||
if (mentionIndex >= filteredMentions.length) {
|
||||
setMentionIndex(Math.max(0, filteredMentions.length - 1));
|
||||
return;
|
||||
}
|
||||
const activeOption = autocompleteOptionRefs.current[mentionIndex];
|
||||
if (!activeOption || typeof activeOption.scrollIntoView !== "function") return;
|
||||
activeOption.scrollIntoView({ block: "nearest" });
|
||||
}, [filteredMentions.length, mentionActive, mentionIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mentionActive) return;
|
||||
autocompleteSelectionHandledRef.current = false;
|
||||
|
|
@ -1242,6 +1258,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
createPortal(
|
||||
<div
|
||||
data-paperclip-floating-ui=""
|
||||
data-testid="mention-autocomplete-menu"
|
||||
className="pointer-events-auto fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[208px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{
|
||||
top: mentionMenuPosition.top,
|
||||
|
|
@ -1255,6 +1272,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
key={option.id}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
ref={(node) => {
|
||||
autocompleteOptionRefs.current[i] = node;
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||
i === mentionIndex && "bg-accent",
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ vi.mock("../hooks/useInboxBadge", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
PluginSlotOutlet: ({ slotTypes }: { slotTypes: string[] }) => (
|
||||
<div data-plugin-slot-types={slotTypes.join(",")}>Plugin slot outlet</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/launchers", () => ({
|
||||
|
|
@ -162,6 +164,28 @@ describe("Sidebar", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
const sidebarSlot = [...container.querySelectorAll("nav [data-plugin-slot-types]")]
|
||||
.find((node) => node.getAttribute("data-plugin-slot-types") === "sidebar");
|
||||
expect(sidebarSlot?.textContent).toContain("Plugin slot outlet");
|
||||
const workSectionContainer = sidebarSlot?.parentElement?.parentElement;
|
||||
const workText = workSectionContainer?.textContent ?? "";
|
||||
expect(workText).toContain("Work");
|
||||
expect(workText).toContain("Workspaces");
|
||||
expect(workText.indexOf("Workspaces")).toBeLessThan(workText.indexOf("Plugin slot outlet"));
|
||||
|
||||
const primaryNavText = container.querySelector("nav > div:first-child")?.textContent ?? "";
|
||||
expect(primaryNavText).toContain("Inbox");
|
||||
expect(primaryNavText).not.toContain("Plugin slot outlet");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flash the Workspaces link while experimental settings are loading", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = await renderSidebar();
|
||||
|
|
|
|||
|
|
@ -71,12 +71,13 @@ export function Sidebar() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
|
|
@ -90,6 +91,15 @@ export function Sidebar() {
|
|||
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
||||
alert={inboxBadge.failedRuns > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
|
|
@ -97,21 +107,12 @@ export function Sidebar() {
|
|||
itemClassName="text-[13px] font-medium"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<PluginLauncherOutlet
|
||||
placementZones={["sidebar"]}
|
||||
context={pluginContext}
|
||||
className="flex flex-col gap-0.5"
|
||||
itemClassName="text-[13px] font-medium"
|
||||
/>
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ function SidebarAgentItem({
|
|||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pr-8 text-[13px] font-medium transition-colors",
|
||||
"flex min-w-0 flex-1 items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 pr-8 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function SidebarNavItem({
|
|||
onClick={() => { if (isMobile) setSidebarOpen(false); }}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||
"flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ const mockAuthApi = vi.hoisted(() => ({
|
|||
const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||
const mockPersistOrder = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
const mockPointerState = vi.hoisted(() => ({ fine: true }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
|
|
@ -63,7 +65,7 @@ vi.mock("../context/DialogContext", () => ({
|
|||
|
||||
vi.mock("../context/SidebarContext", () => ({
|
||||
useSidebar: () => ({
|
||||
isMobile: false,
|
||||
isMobile: mockSidebarState.isMobile,
|
||||
setSidebarOpen: mockSetSidebarOpen,
|
||||
}),
|
||||
}));
|
||||
|
|
@ -192,6 +194,23 @@ describe("SidebarProjects", () => {
|
|||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
localStorage.clear();
|
||||
mockSidebarState.isMobile = false;
|
||||
mockPointerState.fine = true;
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("(hover: hover)") && query.includes("(pointer: fine)")
|
||||
? mockPointerState.fine
|
||||
: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
makeProject({
|
||||
id: "project-a",
|
||||
|
|
@ -254,6 +273,25 @@ describe("SidebarProjects", () => {
|
|||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[data-testid="project-slot-project-b"]')).toBeTruthy();
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses plain project rows for top mode on mobile", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
|
||||
await renderSidebarProjects();
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses plain project rows for top mode on coarse pointer screens", async () => {
|
||||
mockPointerState.fine = false;
|
||||
|
||||
await renderSidebarProjects();
|
||||
|
||||
expect(projectLinkLabels(container)).toEqual(["Bravo", "Alpha", "Charlie"]);
|
||||
expect(container.querySelector('[aria-roledescription="sortable"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the heading for section menu and the plus button for project creation", async () => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
|||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
{ value: "recent", label: "Recent" },
|
||||
];
|
||||
const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
|
||||
|
||||
type ProjectItemProps = {
|
||||
activeProjectRef: string | null;
|
||||
|
|
@ -74,6 +75,26 @@ function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Pr
|
|||
return sorted;
|
||||
}
|
||||
|
||||
function hasFineReorderPointer() {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
|
||||
return window.matchMedia(REORDER_POINTER_MEDIA).matches;
|
||||
}
|
||||
|
||||
function useFineReorderPointer() {
|
||||
const [matches, setMatches] = useState(hasFineReorderPointer);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia(REORDER_POINTER_MEDIA);
|
||||
const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
|
||||
setMatches(query.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
activeProjectRef,
|
||||
companyId,
|
||||
|
|
@ -99,7 +120,7 @@ function ProjectItem({
|
|||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||
"flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium transition-colors",
|
||||
activeProjectRef === routeRef || activeProjectRef === project.id
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
|
|
@ -167,6 +188,7 @@ export function SidebarProjects() {
|
|||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const fineReorderPointer = useFineReorderPointer();
|
||||
const location = useLocation();
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
|
|
@ -209,6 +231,7 @@ export function SidebarProjects() {
|
|||
[orderedProjects, sortMode],
|
||||
);
|
||||
const isTopMode = sortMode === "top";
|
||||
const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
|
||||
|
||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
|
|
@ -310,7 +333,7 @@ export function SidebarProjects() {
|
|||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{isTopMode ? (
|
||||
{canReorderProjects ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ function SidebarSectionHeader({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full items-center rounded-md px-1 py-0.5 text-left outline-none transition-colors",
|
||||
"hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||||
|
|
@ -150,12 +151,13 @@ function SidebarSectionHeader({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="group/sidebar-section px-3 py-1.5">
|
||||
<div className="group/sidebar-section px-3 py-1.5 pointer-coarse:py-1">
|
||||
<div className="relative flex min-h-6 min-w-0 items-center gap-1">
|
||||
{collapsible ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="absolute -left-4 flex h-5 w-5 items-center justify-center rounded-sm outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
aria-label={collapsible.open ? `Collapse ${label}` : `Expand ${label}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -183,7 +183,18 @@
|
|||
min-height: 44px;
|
||||
}
|
||||
|
||||
[data-slot="toggle"] {
|
||||
/* Small inline widgets keep their design size on touch devices —
|
||||
forcing 44px here stretches checkboxes, chip menus, and icon buttons
|
||||
that live inside dense rows (sidebar headers, issue rows, filter
|
||||
popovers). The surrounding row provides the touch area. */
|
||||
[data-slot="toggle"],
|
||||
[data-slot="checkbox"],
|
||||
[data-slot="icon-button"],
|
||||
[data-size="xs"],
|
||||
[data-size="icon-xs"],
|
||||
[data-size="icon-sm"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,11 @@ vi.mock("@/lib/router", () => ({
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
|
||||
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
|
|
@ -289,6 +294,59 @@ describe("Inbox toolbar", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs hover with j/k selection on inbox rows", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
||||
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
|
||||
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rows = container.querySelectorAll("[data-inbox-item]");
|
||||
expect(rows.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const linkOf = (row: Element): HTMLAnchorElement | null =>
|
||||
row.querySelector("a[data-inbox-issue-link]");
|
||||
|
||||
// Nothing selected before hover — both rows show the hover-accent class.
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
await act(async () => {
|
||||
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
// After hovering row 1, that row is "selected" — same visual state as j/k selection.
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
await act(async () => {
|
||||
rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
// Hovering a different row moves the selection to follow the mouse.
|
||||
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
|
||||
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FailedRunInboxRow", () => {
|
||||
|
|
|
|||
|
|
@ -2326,6 +2326,7 @@ export function Inbox() {
|
|||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -2358,6 +2359,7 @@ export function Inbox() {
|
|||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="icon-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -2438,6 +2440,9 @@ export function Inbox() {
|
|||
onClick={() => {
|
||||
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
|
||||
}}
|
||||
>
|
||||
<IssueGroupHeader
|
||||
label={group.label}
|
||||
|
|
@ -2474,6 +2479,7 @@ export function Inbox() {
|
|||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
onMouseEnter={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
|
|
@ -2641,7 +2647,12 @@ export function Inbox() {
|
|||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
onClick={() => {
|
||||
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
|
||||
}}
|
||||
>
|
||||
{canArchiveIssue ? (
|
||||
<SwipeToArchive
|
||||
|
|
|
|||
73
ui/storybook/stories/dev-ops-surfaces.stories.tsx
Normal file
73
ui/storybook/stories/dev-ops-surfaces.stories.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6 p-6">
|
||||
<section className="overflow-hidden border border-border bg-background">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Dev server restart banner
|
||||
</div>
|
||||
</div>
|
||||
<DevRestartBanner devServer={restartRequired} />
|
||||
</section>
|
||||
|
||||
<section className="max-w-[390px] overflow-hidden border border-border bg-background">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Mobile waiting state
|
||||
</div>
|
||||
</div>
|
||||
<DevRestartBanner devServer={restartWaitingForIdle} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof DevOpsSurfacesStory>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const DevOpsSurfaces: Story = {};
|
||||
Loading…
Add table
Add a link
Reference in a new issue