[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
29 changed files with 870 additions and 45 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+17 -3
View File
@@ -36,6 +36,7 @@ const autoRestartPollIntervalMs = 2500;
const gracefulShutdownTimeoutMs = 10_000;
const changedPathSampleLimit = 5;
const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json");
const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json");
const devServerStatusToken = mode === "dev" ? randomUUID() : null;
const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token";
@@ -70,6 +71,7 @@ const ignoredDirectoryNames = new Set([
]);
const ignoredRelativePaths = new Set([
".paperclip/dev-server-restart-request.json",
".paperclip/dev-server-status.json",
]);
@@ -348,6 +350,13 @@ function writeDevServerStatus() {
function clearDevServerStatus() {
if (mode !== "dev") return;
rmSync(devServerStatusFilePath, { force: true });
rmSync(devServerRestartRequestFilePath, { force: true });
}
function consumeDevServerRestartRequest() {
if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false;
rmSync(devServerRestartRequestFilePath, { force: true });
return true;
}
async function updateDevServiceRecord(extra?: Record<string, unknown>) {
@@ -633,7 +642,8 @@ async function startServerChild() {
async function maybeAutoRestartChild() {
if (mode !== "dev" || restartInFlight || !child) return;
if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
const manualRestartRequested = consumeDevServerRestartRequest();
if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return;
restartInFlight = true;
let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null;
@@ -645,11 +655,15 @@ async function maybeAutoRestartChild() {
}
const devServer = health?.devServer;
if (!devServer?.enabled || devServer.autoRestartEnabled !== true) {
if (!devServer?.enabled) {
restartInFlight = false;
return;
}
if ((devServer.activeRunCount ?? 0) > 0) {
if (!manualRestartRequested && devServer.autoRestartEnabled !== true) {
restartInFlight = false;
return;
}
if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) {
restartInFlight = false;
return;
}
+29 -2
View File
@@ -1,8 +1,13 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import {
getDevServerRestartRequestFilePath,
readPersistedDevServerStatus,
toDevServerHealthStatus,
writeDevServerRestartRequest,
} from "../dev-server-status.js";
const tempDirs = [];
@@ -73,4 +78,26 @@ describe("dev server status helpers", () => {
expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull();
});
it("writes restart requests next to the persisted status file", () => {
const filePath = createTempStatusFile({
dirty: true,
changedPathsSample: ["server/src/app.ts"],
pendingMigrations: [],
});
const env = { PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath };
expect(writeDevServerRestartRequest({
requestedAt: "2026-03-20T12:05:00.000Z",
reason: "manual_restart_now",
}, env)).toBe(true);
const requestPath = getDevServerRestartRequestFilePath(env);
expect(requestPath).toBe(path.join(path.dirname(filePath), "dev-server-restart-request.json"));
expect(requestPath && existsSync(requestPath)).toBe(true);
expect(JSON.parse(readFileSync(requestPath!, "utf8"))).toEqual({
requestedAt: "2026-03-20T12:05:00.000Z",
reason: "manual_restart_now",
});
});
});
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
@@ -126,3 +126,80 @@ describe("GET /health dev-server supervisor access", () => {
}
});
});
describe("POST /health/dev-server/restart", () => {
it("records a manual restart request for the dev runner", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
try {
const app = express();
app.use("/health", healthRoutes(undefined));
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "restart_requested" });
const requestPath = path.join(
path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE),
"dev-server-restart-request.json",
);
expect(existsSync(requestPath)).toBe(true);
expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({
reason: "manual_restart_now",
});
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
it("rejects unauthenticated manual restarts in authenticated mode", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
});
try {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "none", source: "none" };
next();
});
app.use(
"/health",
healthRoutes(undefined, {
deploymentMode: "authenticated",
deploymentExposure: "private",
authReady: true,
companyDeletionEnabled: true,
}),
);
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "board_access_required" });
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
});
@@ -42,6 +42,7 @@ async function createApp(
jobDeps?: unknown;
toolDeps?: unknown;
bridgeDeps?: unknown;
captureJsonContext?: (context: unknown, body: unknown) => void;
} = {},
) {
const [{ pluginRoutes }, { errorHandler }] = await Promise.all([
@@ -56,6 +57,16 @@ async function createApp(
const app = express();
app.use(express.json());
if (routeOverrides.captureJsonContext) {
app.use((_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = ((body: unknown) => {
routeOverrides.captureJsonContext?.((res as any).__errorContext, body);
return originalJson(body);
}) as typeof res.json;
next();
});
}
app.use((req, _res, next) => {
req.actor = actor as typeof req.actor;
next();
@@ -627,6 +638,40 @@ describe.sequential("plugin tool and bridge authz", () => {
});
});
it("attaches worker bridge errors to the HTTP logger context", async () => {
readyPlugin();
const call = vi.fn().mockRejectedValue(new Error("missing source_objects column"));
const captured: Array<{ context: any; body: unknown }> = [];
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
captureJsonContext: (context, body) => {
captured.push({ context, body });
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/source-objects`)
.send({ companyId: companyA });
expect(res.status).toBe(502);
expect(res.body).toMatchObject({
code: "UNKNOWN",
message: "missing source_objects column",
});
expect(captured.at(-1)?.context?.error).toMatchObject({
message: "missing source_objects column",
details: {
pluginId,
pluginKey: "paperclip.example",
bridgeMethod: "getData",
dataKey: "source-objects",
bridgeCode: "UNKNOWN",
},
});
});
it("rejects manual job triggers for non-admin board users", async () => {
const scheduler = { triggerJob: vi.fn() };
const jobStore = { getJobByIdForPlugin: vi.fn() };
+27 -1
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024;
@@ -25,6 +26,31 @@ export type DevServerHealthStatus = {
lastRestartAt: string | null;
};
export type DevServerRestartRequest = {
requestedAt: string;
reason: "manual_restart_now";
};
export function getDevServerRestartRequestFilePath(
env: NodeJS.ProcessEnv = process.env,
): string | null {
const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim();
if (!statusFilePath) return null;
return path.join(path.dirname(statusFilePath), "dev-server-restart-request.json");
}
export function writeDevServerRestartRequest(
request: DevServerRestartRequest,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const filePath = getDevServerRestartRequestFilePath(env);
if (!filePath) return false;
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(request, null, 2)}\n`, "utf8");
return true;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
+35 -1
View File
@@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db";
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js";
import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js";
import { logger } from "../middleware/logger.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { serverVersion } from "../version.js";
@@ -44,6 +44,40 @@ export function healthRoutes(
) {
const router = Router();
router.post("/dev-server/restart", async (req, res) => {
const actorType = "actor" in req ? req.actor?.type : null;
if (opts.deploymentMode === "authenticated" && actorType !== "board") {
res.status(403).json({ error: "board_access_required" });
return;
}
const persistedDevServerStatus = readPersistedDevServerStatus();
if (!persistedDevServerStatus) {
res.status(404).json({ error: "dev_server_supervisor_unavailable" });
return;
}
const restartRequired =
persistedDevServerStatus.dirty ||
persistedDevServerStatus.changedPathCount > 0 ||
persistedDevServerStatus.pendingMigrations.length > 0;
if (!restartRequired) {
res.status(409).json({ error: "restart_not_required" });
return;
}
const written = writeDevServerRestartRequest({
requestedAt: new Date().toISOString(),
reason: "manual_restart_now",
});
if (!written) {
res.status(404).json({ error: "dev_server_supervisor_unavailable" });
return;
}
res.status(202).json({ status: "restart_requested" });
});
router.get("/", async (req, res) => {
const actorType = "actor" in req ? req.actor?.type : null;
const exposeFullDetails = shouldExposeFullHealthDetails(
+74
View File
@@ -1021,6 +1021,34 @@ export function pluginRoutes(
};
}
function attachPluginBridgeErrorContext(
req: Request,
res: Response,
err: unknown,
bridgeError: PluginBridgeErrorResponse,
metadata: Record<string, unknown>,
): void {
const rootError = err instanceof Error ? err : new Error(String(err));
(res as any).__errorContext = {
error: {
message: bridgeError.message,
stack: rootError.stack,
name: rootError.name,
details: {
...metadata,
bridgeCode: bridgeError.code,
bridgeDetails: bridgeError.details,
},
},
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
};
(res as any).err = rootError;
}
/**
* POST /api/plugins/:pluginId/bridge/data
*
@@ -1072,6 +1100,11 @@ export function pluginRoutes(
code: "WORKER_UNAVAILABLE",
message: `Plugin is not ready (current status: ${plugin.status})`,
};
attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "getData",
});
res.status(502).json(bridgeError);
return;
}
@@ -1098,6 +1131,12 @@ export function pluginRoutes(
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
attachPluginBridgeErrorContext(req, res, err, bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "getData",
dataKey: body.key,
});
res.status(502).json(bridgeError);
}
});
@@ -1153,6 +1192,11 @@ export function pluginRoutes(
code: "WORKER_UNAVAILABLE",
message: `Plugin is not ready (current status: ${plugin.status})`,
};
attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "performAction",
});
res.status(502).json(bridgeError);
return;
}
@@ -1179,6 +1223,12 @@ export function pluginRoutes(
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
attachPluginBridgeErrorContext(req, res, err, bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "performAction",
actionKey: body.key,
});
res.status(502).json(bridgeError);
}
});
@@ -1235,6 +1285,12 @@ export function pluginRoutes(
code: "WORKER_UNAVAILABLE",
message: `Plugin is not ready (current status: ${plugin.status})`,
};
attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "getData",
dataKey: key,
});
res.status(502).json(bridgeError);
return;
}
@@ -1260,6 +1316,12 @@ export function pluginRoutes(
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
attachPluginBridgeErrorContext(req, res, err, bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "getData",
dataKey: key,
});
res.status(502).json(bridgeError);
}
});
@@ -1312,6 +1374,12 @@ export function pluginRoutes(
code: "WORKER_UNAVAILABLE",
message: `Plugin is not ready (current status: ${plugin.status})`,
};
attachPluginBridgeErrorContext(req, res, new Error(bridgeError.message), bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "performAction",
actionKey: key,
});
res.status(502).json(bridgeError);
return;
}
@@ -1337,6 +1405,12 @@ export function pluginRoutes(
res.json({ data: result });
} catch (err) {
const bridgeError = mapRpcErrorToBridgeError(err);
attachPluginBridgeErrorContext(req, res, err, bridgeError, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
bridgeMethod: "performAction",
actionKey: key,
});
res.status(502).json(bridgeError);
}
});
+11
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})`);
}
},
};
+113
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");
});
});
+46 -3
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>
+2
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();
+3 -1
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}
>
+107 -11
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);
+22 -2
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",
+25 -1
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();
+12 -11
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 />
+1 -1
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"
+1 -1
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",
+39 -1
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 () => {
+25 -2
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}
+3 -1
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}`}
>
+12 -1
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;
}
}
+58
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", () => {
+12 -1
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
@@ -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 = {};