[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: ![Sidebar
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/sidebar-desktop.png)
- Inbox/task row selection and hover-state surface: ![Inbox rows
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/inbox-rows-desktop.png)
- Dev restart banner desktop: ![Dev restart banner
desktop](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/dev-restart-banner-desktop.png)
- Dev restart banner mobile: ![Dev restart banner
mobile](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-ui-dev-qol/docs/pr-screenshots/pr-6384/dev-restart-banner-mobile.png)

## 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:
Dotta 2026-05-19 15:52:39 -05:00 committed by GitHub
parent 43c5bb81b6
commit f257530537
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 870 additions and 45 deletions

View file

@ -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})`);
}
},
};

View 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");
});
});

View file

@ -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>

View file

@ -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();

View file

@ -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}
>

View file

@ -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);

View file

@ -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",

View file

@ -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();

View file

@ -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 />

View file

@ -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"

View file

@ -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",

View file

@ -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 () => {

View file

@ -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}

View file

@ -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}`}
>

View file

@ -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;
}
}

View file

@ -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", () => {

View file

@ -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

View 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 = {};