feat: polish inbox and issue list workflows

This commit is contained in:
Dotta 2026-04-10 22:26:21 -05:00
parent 548721248e
commit dab95740be
37 changed files with 1674 additions and 411 deletions

View file

@ -1,6 +1,8 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const issueId = "11111111-1111-4111-8111-111111111111"; const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222"; const companyId = "22222222-2222-4222-8222-222222222222";
@ -50,11 +52,7 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}), workProductService: () => ({}),
})); }));
async function createApp() { function createApp() {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
import("../routes/issues.js"),
import("../middleware/index.js"),
]);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
@ -74,7 +72,6 @@ async function createApp() {
describe("issue document revision routes", () => { describe("issue document revision routes", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules();
vi.resetAllMocks(); vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue({ mockIssueService.getById.mockResolvedValue({
id: issueId, id: issueId,
@ -125,10 +122,9 @@ describe("issue document revision routes", () => {
}); });
it("returns revision snapshots including title and format", async () => { it("returns revision snapshots including title and format", async () => {
const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan");
expect(res.body).toEqual([ expect(res.body).toEqual([
expect.objectContaining({ expect.objectContaining({
revisionNumber: 2, revisionNumber: 2,
@ -140,7 +136,7 @@ describe("issue document revision routes", () => {
}); });
it("restores a revision through the append-only route and logs the action", async () => { it("restores a revision through the append-only route and logs the action", async () => {
const res = await request(await createApp()) const res = await request(createApp())
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
.send({}); .send({});
@ -172,7 +168,7 @@ describe("issue document revision routes", () => {
}); });
it("rejects invalid document keys before attempting restore", async () => { it("rejects invalid document keys before attempting restore", async () => {
const res = await request(await createApp()) const res = await request(createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
.send({}); .send({});

View file

@ -26,56 +26,53 @@ import {
getEmbeddedPostgresTestSupport, getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase, startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js"; } from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { accessService } from "../services/access.js"; import { accessService } from "../services/access.js";
function registerServiceMocks() { vi.mock("../services/index.js", async () => {
vi.doMock("../services/index.js", async () => { const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
const actual = await vi.importActual<typeof import("../services/index.js")>("../services/index.js");
return { return {
...actual, ...actual,
routineService: (db: any) => routineService: (db: any) =>
actual.routineService(db, { actual.routineService(db, {
heartbeat: { heartbeat: {
wakeup: async (agentId: string, wakeupOpts: any) => { wakeup: async (agentId: string, wakeupOpts: any) => {
const issueId = const issueId =
(typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null; null;
if (!issueId) return null; if (!issueId) return null;
const issue = await db const issue = await db
.select({ companyId: issues.companyId }) .select({ companyId: issues.companyId })
.from(issues) .from(issues)
.where(eq(issues.id, issueId)) .where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null); .then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
if (!issue) return null; if (!issue) return null;
const queuedRunId = randomUUID(); const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({ await db.insert(heartbeatRuns).values({
id: queuedRunId, id: queuedRunId,
companyId: issue.companyId, companyId: issue.companyId,
agentId, agentId,
invocationSource: wakeupOpts?.source ?? "assignment", invocationSource: wakeupOpts?.source ?? "assignment",
triggerDetail: wakeupOpts?.triggerDetail ?? null, triggerDetail: wakeupOpts?.triggerDetail ?? null,
status: "queued", status: "queued",
contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId },
}); });
await db await db
.update(issues) .update(issues)
.set({ .set({
executionRunId: queuedRunId, executionRunId: queuedRunId,
executionLockedAt: new Date(), executionLockedAt: new Date(),
}) })
.where(eq(issues.id, issueId)); .where(eq(issues.id, issueId));
return { id: queuedRunId }; return { id: queuedRunId };
},
}, },
}), },
}; }),
}); };
} });
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -95,11 +92,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
db = createDb(tempDb.connectionString); db = createDb(tempDb.connectionString);
}, 20_000); }, 20_000);
beforeEach(() => {
vi.resetModules();
registerServiceMocks();
});
afterEach(async () => { afterEach(async () => {
await db.delete(activityLog); await db.delete(activityLog);
await db.delete(routineRuns); await db.delete(routineRuns);
@ -123,8 +115,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
await tempDb?.cleanup(); await tempDb?.cleanup();
}); });
beforeEach(() => {
vi.resetModules();
});
async function createApp(actor: Record<string, unknown>) { async function createApp(actor: Record<string, unknown>) {
const { routineRoutes } = await import("../routes/routines.js"); const [{ routineRoutes }, { errorHandler }] = await Promise.all([
import("../routes/routines.js"),
import("../middleware/index.js"),
]);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {

View file

@ -1,6 +1,8 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { routineRoutes } from "../routes/routines.js";
const companyId = "22222222-2222-4222-8222-222222222222"; const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111"; const agentId = "11111111-1111-4111-8111-111111111111";
@ -83,28 +85,22 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
function registerRouteMocks() { vi.mock("@paperclipai/shared/telemetry", () => ({
vi.doMock("@paperclipai/shared/telemetry", () => ({ trackRoutineCreated: mockTrackRoutineCreated,
trackRoutineCreated: mockTrackRoutineCreated, trackErrorHandlerCrash: vi.fn(),
trackErrorHandlerCrash: vi.fn(), }));
}));
vi.doMock("../telemetry.js", () => ({ vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient, getTelemetryClient: mockGetTelemetryClient,
})); }));
vi.doMock("../services/index.js", () => ({ vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService, accessService: () => mockAccessService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
routineService: () => mockRoutineService, routineService: () => mockRoutineService,
})); }));
}
async function createApp(actor: Record<string, unknown>) { function createApp(actor: Record<string, unknown>) {
const [{ routineRoutes }, { errorHandler }] = await Promise.all([
import("../routes/routines.js"),
import("../middleware/index.js"),
]);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
@ -118,9 +114,7 @@ async function createApp(actor: Record<string, unknown>) {
describe("routine routes", () => { describe("routine routes", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetAllMocks();
registerRouteMocks();
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.create.mockResolvedValue(routine);
mockRoutineService.get.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine);
@ -136,7 +130,7 @@ describe("routine routes", () => {
}); });
it("requires tasks:assign permission for non-admin board routine creation", async () => { it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -158,7 +152,7 @@ describe("routine routes", () => {
}); });
it("requires tasks:assign permission to retarget a routine assignee", async () => { it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -179,7 +173,7 @@ describe("routine routes", () => {
it("requires tasks:assign permission to reactivate a routine", async () => { it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine); mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -199,7 +193,7 @@ describe("routine routes", () => {
}); });
it("requires tasks:assign permission to create a trigger", async () => { it("requires tasks:assign permission to create a trigger", async () => {
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -221,7 +215,7 @@ describe("routine routes", () => {
}); });
it("requires tasks:assign permission to update a trigger", async () => { it("requires tasks:assign permission to update a trigger", async () => {
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -241,7 +235,7 @@ describe("routine routes", () => {
}); });
it("requires tasks:assign permission to manually run a routine", async () => { it("requires tasks:assign permission to manually run a routine", async () => {
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",
@ -260,7 +254,7 @@ describe("routine routes", () => {
it("allows routine creation when the board user has tasks:assign", async () => { it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true); mockAccessService.canUser.mockResolvedValue(true);
const app = await createApp({ const app = createApp({
type: "board", type: "board",
userId: "board-user", userId: "board-user",
source: "session", source: "session",

View file

@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-start gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span> <span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
<div className="flex items-center gap-1.5 min-w-0">{children}</div> <div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
</div> </div>
); );
} }
@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
)} )}
{runtimeState?.lastError && ( {runtimeState?.lastError && (
<PropertyRow label="Last error"> <PropertyRow label="Last error">
<span className="text-xs text-red-600 dark:text-red-400 truncate max-w-[160px]">{runtimeState.lastError}</span> <span className="text-xs text-red-600 dark:text-red-400 break-words min-w-0">{runtimeState.lastError}</span>
</PropertyRow> </PropertyRow>
)} )}
{agent.lastHeartbeatAt && ( {agent.lastHeartbeatAt && (

View file

@ -20,9 +20,9 @@ interface GoalPropertiesProps {
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-start gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span> <span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
<div className="flex items-center gap-1.5 min-w-0">{children}</div> <div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
</div> </div>
); );
} }

View file

@ -12,6 +12,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import type { InboxIssueColumn } from "../lib/inbox"; import type { InboxIssueColumn } from "../lib/inbox";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -50,12 +51,12 @@ export function issueActivityText(issue: Issue): string {
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string { function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns return columns
.map((column) => { .map((column) => {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)"; if (column === "assignee") return "minmax(6rem, 8rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)"; if (column === "project") return "minmax(4.5rem, 7rem)";
if (column === "workspace") return "minmax(9rem, 12rem)"; if (column === "workspace") return "minmax(6rem, 9rem)";
if (column === "parent") return "minmax(5rem, 7rem)"; if (column === "parent") return "minmax(3.5rem, 5.5rem)";
if (column === "labels") return "minmax(8rem, 10rem)"; if (column === "labels") return "minmax(3rem, 6rem)";
return "minmax(4rem, 5.5rem)"; return "minmax(3.5rem, 4.5rem)";
}) })
.join(" "); .join(" ");
} }
@ -66,24 +67,27 @@ export function IssueColumnPicker({
onToggleColumn, onToggleColumn,
onResetColumns, onResetColumns,
title, title,
iconOnly = false,
}: { }: {
availableColumns: InboxIssueColumn[]; availableColumns: InboxIssueColumn[];
visibleColumnSet: ReadonlySet<InboxIssueColumn>; visibleColumnSet: ReadonlySet<InboxIssueColumn>;
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void; onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
onResetColumns: () => void; onResetColumns: () => void;
title: string; title: string;
iconOnly?: boolean;
}) { }) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant={iconOnly ? "outline" : "ghost"}
size="sm" size={iconOnly ? "icon" : "sm"}
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex" className={iconOnly ? "h-8 w-8 shrink-0" : "hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"}
title="Columns"
> >
<Columns3 className="mr-1 h-3.5 w-3.5" /> <Columns3 className={iconOnly ? "h-3.5 w-3.5" : "mr-1 h-3.5 w-3.5"} />
Columns {!iconOnly && "Columns"}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10"> <DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
@ -189,23 +193,27 @@ export function InboxIssueTrailingColumns({
columns, columns,
projectName, projectName,
projectColor, projectColor,
workspaceId,
workspaceName, workspaceName,
assigneeName, assigneeName,
currentUserId, currentUserId,
parentIdentifier, parentIdentifier,
parentTitle, parentTitle,
assigneeContent, assigneeContent,
onFilterWorkspace,
}: { }: {
issue: Issue; issue: Issue;
columns: InboxIssueColumn[]; columns: InboxIssueColumn[];
projectName: string | null; projectName: string | null;
projectColor: string | null; projectColor: string | null;
workspaceId?: string | null;
workspaceName: string | null; workspaceName: string | null;
assigneeName: string | null; assigneeName: string | null;
currentUserId: string | null; currentUserId: string | null;
parentIdentifier: string | null; parentIdentifier: string | null;
parentTitle: string | null; parentTitle: string | null;
assigneeContent?: ReactNode; assigneeContent?: ReactNode;
onFilterWorkspace?: (workspaceId: string) => void;
}) { }) {
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
@ -276,20 +284,22 @@ export function InboxIssueTrailingColumns({
if (column === "labels") { if (column === "labels") {
if ((issue.labels ?? []).length > 0) { if ((issue.labels ?? []).length > 0) {
return ( return (
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]"> <span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden">
{(issue.labels ?? []).slice(0, 2).map((label) => ( {(issue.labels ?? []).slice(0, 2).map((label) => (
<span <span
key={label.id} key={label.id}
className="inline-flex min-w-0 max-w-full items-center font-medium" className="inline-flex min-w-0 max-w-full shrink-0 items-center rounded-full border px-1.5 py-0 text-[10px] font-medium"
style={{ style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12), color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}} }}
> >
<span className="truncate">{label.name}</span> <span className="truncate">{label.name}</span>
</span> </span>
))} ))}
{(issue.labels ?? []).length > 2 ? ( {(issue.labels ?? []).length > 2 ? (
<span className="shrink-0 text-[11px] font-medium text-muted-foreground"> <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
+{(issue.labels ?? []).length - 2} +{(issue.labels ?? []).length - 2}
</span> </span>
) : null} ) : null}
@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({
return ( return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground"> <span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
{workspaceName} {workspaceId && onFilterWorkspace ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="truncate rounded-sm text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onFilterWorkspace(workspaceId);
}}
>
{workspaceName}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Filter by workspace
</TooltipContent>
</Tooltip>
) : (
workspaceName
)}
</span> </span>
); );
} }

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Filter, X, User } from "lucide-react"; import { Filter, X, User, HardDrive } from "lucide-react";
import { PriorityIcon } from "./PriorityIcon"; import { PriorityIcon } from "./PriorityIcon";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { import {
@ -31,6 +31,11 @@ type LabelOption = {
color: string; color: string;
}; };
type WorkspaceOption = {
id: string;
name: string;
};
export function IssueFiltersPopover({ export function IssueFiltersPopover({
state, state,
onChange, onChange,
@ -41,6 +46,8 @@ export function IssueFiltersPopover({
currentUserId, currentUserId,
enableRoutineVisibilityFilter = false, enableRoutineVisibilityFilter = false,
buttonVariant = "ghost", buttonVariant = "ghost",
iconOnly = false,
workspaces,
}: { }: {
state: IssueFilterState; state: IssueFilterState;
onChange: (patch: Partial<IssueFilterState>) => void; onChange: (patch: Partial<IssueFilterState>) => void;
@ -51,15 +58,18 @@ export function IssueFiltersPopover({
currentUserId?: string | null; currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean; enableRoutineVisibilityFilter?: boolean;
buttonVariant?: "ghost" | "outline"; buttonVariant?: "ghost" | "outline";
iconOnly?: boolean;
workspaces?: WorkspaceOption[];
}) { }) {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant={buttonVariant} size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}> <Button variant={buttonVariant} size={iconOnly ? "icon" : "sm"} className={`text-xs ${iconOnly ? "relative h-8 w-8 shrink-0" : ""} ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`} title={iconOnly ? (activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter") : undefined}>
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" /> <Filter className={iconOnly ? "h-3.5 w-3.5" : "h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1"} />
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span> {!iconOnly && <span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>}
{activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null} {!iconOnly && activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
{activeFilterCount > 0 ? ( {iconOnly && activeFilterCount > 0 ? <span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">{activeFilterCount}</span> : null}
{!iconOnly && activeFilterCount > 0 ? (
<X <X
className="ml-1 hidden h-3 w-3 sm:block" className="ml-1 hidden h-3 w-3 sm:block"
onClick={(event) => { onClick={(event) => {
@ -211,6 +221,24 @@ export function IssueFiltersPopover({
</div> </div>
) : null} ) : null}
{workspaces && workspaces.length > 0 ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Workspace</span>
<div className="max-h-32 space-y-0.5 overflow-y-auto">
{workspaces.map((workspace) => (
<label key={workspace.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.workspaces.includes(workspace.id)}
onCheckedChange={() => onChange({ workspaces: toggleIssueFilterValue(state.workspaces, workspace.id) })}
/>
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{workspace.name}</span>
</label>
))}
</div>
</div>
) : null}
{enableRoutineVisibilityFilter ? ( {enableRoutineVisibilityFilter ? (
<div className="space-y-1"> <div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span> <span className="text-xs text-muted-foreground">Visibility</span>

View file

@ -0,0 +1,135 @@
import * as React from "react";
import { useMemo, useState } from "react";
import * as RouterDom from "react-router-dom";
import type { Issue } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { timeAgo } from "@/lib/timeAgo";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "@/components/StatusIcon";
function summarizeIssueDescription(description: string | null | undefined) {
if (!description) return null;
const summary = description
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!summary) return null;
return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary;
}
export function IssueQuicklookCard({
issue,
linkTo,
linkState,
compact = false,
}: {
issue: Issue;
linkTo: RouterDom.To;
linkState?: unknown;
compact?: boolean;
}) {
const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]);
return (
<div className={cn("space-y-2", compact && "space-y-1.5")}>
<div className="flex items-start gap-2">
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<RouterDom.Link
to={linkTo}
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
>
{issue.title}
</RouterDom.Link>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span>&middot;</span>
<span>{issue.status.replace(/_/g, " ")}</span>
<span>&middot;</span>
<span>{timeAgo(new Date(issue.updatedAt))}</span>
</div>
{description ? (
<p className="text-xs leading-5 text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:4] overflow-hidden">
{description}
</p>
) : null}
</div>
);
}
export const IssueLinkQuicklook = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<typeof RouterDom.Link> & { issuePathId: string }
>(function IssueLinkQuicklookImpl(
{
issuePathId,
to,
children,
className,
onClick,
...props
},
ref,
) {
const [open, setOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: queryKeys.issues.detail(issuePathId),
queryFn: () => issuesApi.get(issuePathId),
enabled: open,
staleTime: 60_000,
});
const detailPath = createIssueDetailPath(issuePathId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
asChild
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<RouterDom.Link
ref={ref}
to={to}
className={className}
onClick={(event) => {
setOpen(false);
onClick?.(event);
}}
{...props}
>
{children}
</RouterDom.Link>
</PopoverTrigger>
<PopoverContent
className="w-72 p-3"
side="top"
align="start"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onOpenAutoFocus={(event) => event.preventDefault()}
>
{data ? (
<IssueQuicklookCard issue={data} linkTo={detailPath} compact />
) : (
<div className="space-y-2">
<div className="h-4 w-24 rounded bg-accent/50" />
<div className="h-4 w-full rounded bg-accent/40" />
<div className="h-4 w-3/4 rounded bg-accent/30" />
{!isLoading ? (
<p className="text-xs text-muted-foreground">Unable to load issue preview.</p>
) : null}
</div>
)}
</PopoverContent>
</Popover>
);
});

View file

@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({
})); }));
const mockIssuesApi = vi.hoisted(() => ({ const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listLabels: vi.fn(), listLabels: vi.fn(),
})); }));
@ -193,6 +194,7 @@ describe("IssueProperties", () => {
document.body.appendChild(container); document.body.appendChild(container);
mockAgentsApi.list.mockResolvedValue([]); mockAgentsApi.list.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listLabels.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
}); });
@ -227,6 +229,119 @@ describe("IssueProperties", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
it("shows an add-label button when labels already exist and opens the picker", async () => {
const root = renderProperties(container, {
issue: createIssue({
labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }],
labelIds: ["label-1"],
}),
childIssues: [],
onUpdate: vi.fn(),
inline: true,
});
await flush();
const addLabelButton = container.querySelector('button[aria-label="Add label"]');
expect(addLabelButton).not.toBeNull();
expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull();
await act(async () => {
addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull();
expect(container.querySelector('button[title="Delete Bug"]')).toBeNull();
act(() => root.unmount());
});
it("allows setting and clearing a parent issue from the properties pane", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }),
]);
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const parentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(parentTrigger).not.toBeUndefined();
await act(async () => {
parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" });
onUpdate.mockClear();
const rerenderedIssue = createIssue({
parentId: "issue-2",
ancestors: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Candidate parent",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
goalId: null,
project: null,
goal: null,
},
],
});
act(() => root.unmount());
const rerenderedRoot = renderProperties(container, {
issue: rerenderedIssue,
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
expect(selectedParentTrigger).not.toBeUndefined();
await act(async () => {
selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const clearParentButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("No parent"));
expect(clearParentButton).not.toBeUndefined();
await act(async () => {
clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ parentId: null });
act(() => rerenderedRoot.unmount());
});
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => { it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const root = renderProperties(container, { const root = renderProperties(container, {

View file

@ -20,7 +20,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
@ -82,9 +82,9 @@ interface IssuePropertiesProps {
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-start gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0 w-20">{label}</span> <span className="text-xs text-muted-foreground shrink-0 w-20 mt-0.5">{label}</span>
<div className="flex items-center gap-1.5 min-w-0 flex-1">{children}</div> <div className="flex items-center gap-1.5 min-w-0 flex-1 flex-wrap">{children}</div>
</div> </div>
); );
} }
@ -114,7 +114,7 @@ function PropertyPicker({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const btnCn = cn( const btnCn = cn(
"inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors", "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left",
triggerClassName, triggerClassName,
); );
@ -167,6 +167,8 @@ export function IssueProperties({
const [projectSearch, setProjectSearch] = useState(""); const [projectSearch, setProjectSearch] = useState("");
const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedByOpen, setBlockedByOpen] = useState(false);
const [blockedBySearch, setBlockedBySearch] = useState(""); const [blockedBySearch, setBlockedBySearch] = useState("");
const [parentOpen, setParentOpen] = useState(false);
const [parentSearch, setParentSearch] = useState("");
const [reviewersOpen, setReviewersOpen] = useState(false); const [reviewersOpen, setReviewersOpen] = useState(false);
const [reviewerSearch, setReviewerSearch] = useState(""); const [reviewerSearch, setReviewerSearch] = useState("");
const [approversOpen, setApproversOpen] = useState(false); const [approversOpen, setApproversOpen] = useState(false);
@ -212,7 +214,7 @@ export function IssueProperties({
const { data: allIssues } = useQuery({ const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(companyId!), queryKey: queryKeys.issues.list(companyId!),
queryFn: () => issuesApi.list(companyId!), queryFn: () => issuesApi.list(companyId!),
enabled: !!companyId && blockedByOpen, enabled: !!companyId && (blockedByOpen || parentOpen),
}); });
const createLabel = useMutation({ const createLabel = useMutation({
@ -224,15 +226,6 @@ export function IssueProperties({
}, },
}); });
const deleteLabel = useMutation({
mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
},
});
const toggleLabel = (labelId: string) => { const toggleLabel = (labelId: string) => {
const ids = issue.labelIds ?? []; const ids = issue.labelIds ?? [];
const next = ids.includes(labelId) const next = ids.includes(labelId)
@ -304,10 +297,10 @@ export function IssueProperties({
return value; return value;
}; };
const reviewerTrigger = reviewerValues.length > 0 const reviewerTrigger = reviewerValues.length > 0
? <span className="text-sm truncate">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span> ? <span className="text-sm break-words min-w-0">{reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
: <span className="text-sm text-muted-foreground">None</span>; : <span className="text-sm text-muted-foreground">None</span>;
const approverTrigger = approverValues.length > 0 const approverTrigger = approverValues.length > 0
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span> ? <span className="text-sm break-words min-w-0">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
: <span className="text-sm text-muted-foreground">None</span>; : <span className="text-sm text-muted-foreground">None</span>;
const nextRunnableExecutionStage = (() => { const nextRunnableExecutionStage = (() => {
if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
@ -369,6 +362,17 @@ export function IssueProperties({
<span className="text-sm text-muted-foreground">No labels</span> <span className="text-sm text-muted-foreground">No labels</span>
</> </>
); );
const labelsExtra = (issue.labelIds ?? []).length > 0 ? (
<button
type="button"
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={() => setLabelsOpen(true)}
aria-label="Add label"
title="Add label"
>
<Plus className="h-3 w-3" />
</button>
) : undefined;
const labelsContent = ( const labelsContent = (
<> <>
@ -388,26 +392,17 @@ export function IssueProperties({
.map((label) => { .map((label) => {
const selected = (issue.labelIds ?? []).includes(label.id); const selected = (issue.labelIds ?? []).includes(label.id);
return ( return (
<div key={label.id} className="flex items-center gap-1"> <button
<button key={label.id}
className={cn( className={cn(
"flex items-center gap-2 flex-1 px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
selected && "bg-accent" selected && "bg-accent"
)} )}
onClick={() => toggleLabel(label.id)} onClick={() => toggleLabel(label.id)}
> >
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} /> <span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
<span className="truncate">{label.name}</span> <span className="truncate">{label.name}</span>
</button> </button>
<button
type="button"
className="p-1 text-muted-foreground hover:text-destructive rounded"
onClick={() => deleteLabel.mutate(label.id)}
title={`Delete ${label.name}`}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
); );
})} })}
</div> </div>
@ -609,7 +604,7 @@ export function IssueProperties({
className="shrink-0 h-3 w-3 rounded-sm" className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }} style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
/> />
<span className="text-sm truncate">{projectName(issue.projectId)}</span> <span className="text-sm break-words min-w-0">{projectName(issue.projectId)}</span>
</> </>
) : ( ) : (
<> <>
@ -685,6 +680,100 @@ export function IssueProperties({
); );
const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? [];
const descendantIssueIds = useMemo(() => {
if (!allIssues?.length) return new Set<string>();
const childrenByParentId = new Map<string, string[]>();
for (const candidate of allIssues) {
if (!candidate.parentId) continue;
const children = childrenByParentId.get(candidate.parentId) ?? [];
children.push(candidate.id);
childrenByParentId.set(candidate.parentId, children);
}
const descendants = new Set<string>();
const stack = [...(childrenByParentId.get(issue.id) ?? [])];
while (stack.length > 0) {
const candidateId = stack.pop();
if (!candidateId || descendants.has(candidateId)) continue;
descendants.add(candidateId);
stack.push(...(childrenByParentId.get(candidateId) ?? []));
}
return descendants;
}, [allIssues, issue.id]);
const currentParentIssue = useMemo(() => {
if (!issue.parentId) return null;
return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null;
}, [allIssues, issue.parentId]);
const parentTrigger = issue.parentId ? (
<span className="text-sm break-words min-w-0">
{issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier
? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} `
: ""}
{issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)}
</span>
) : (
<span className="text-sm text-muted-foreground">No parent</span>
);
const parentOptions = (allIssues ?? [])
.filter((candidate) => candidate.id !== issue.id)
.filter((candidate) => !descendantIssueIds.has(candidate.id))
.filter((candidate) => {
if (!parentSearch.trim()) return true;
const query = parentSearch.toLowerCase();
return (
(candidate.identifier ?? "").toLowerCase().includes(query) ||
candidate.title.toLowerCase().includes(query)
);
})
.sort((a, b) => {
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
return aLabel.localeCompare(bLabel);
});
const parentContent = (
<>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search issues..."
value={parentSearch}
onChange={(e) => setParentSearch(e.target.value)}
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.parentId && "bg-accent",
)}
onClick={() => {
onUpdate({ parentId: null });
setParentOpen(false);
}}
>
No parent
</button>
{parentOptions.map((candidate) => (
<button
key={candidate.id}
className={cn(
"flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs rounded hover:bg-accent/50",
candidate.id === issue.parentId && "bg-accent",
)}
onClick={() => {
onUpdate({ parentId: candidate.id });
setParentOpen(false);
}}
>
<StatusIcon status={candidate.status} />
<span className="truncate">
{candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title}
</span>
</button>
))}
</div>
</>
);
const blockedByTrigger = blockedByIds.length > 0 ? ( const blockedByTrigger = blockedByIds.length > 0 ? (
<div className="flex items-center gap-1 flex-wrap min-w-0"> <div className="flex items-center gap-1 flex-wrap min-w-0">
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( {(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
@ -793,6 +882,7 @@ export function IssueProperties({
triggerContent={labelsTrigger} triggerContent={labelsTrigger}
triggerClassName="min-w-0 max-w-full" triggerClassName="min-w-0 max-w-full"
popoverClassName="w-64" popoverClassName="w-64"
extra={labelsExtra}
> >
{labelsContent} {labelsContent}
</PropertyPicker> </PropertyPicker>
@ -838,6 +928,30 @@ export function IssueProperties({
{projectContent} {projectContent}
</PropertyPicker> </PropertyPicker>
<PropertyPicker
inline={inline}
label="Parent"
open={parentOpen}
onOpenChange={(open) => {
setParentOpen(open);
if (!open) setParentSearch("");
}}
triggerContent={parentTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
extra={issue.parentId ? (
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier ?? issue.parentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined}
>
{parentContent}
</PropertyPicker>
<PropertyPicker <PropertyPicker
inline={inline} inline={inline}
label="Blocked by" label="Blocked by"
@ -939,16 +1053,6 @@ export function IssueProperties({
</PropertyRow> </PropertyRow>
)} )}
{issue.parentId && (
<PropertyRow label="Parent">
<Link
to={`/issues/${issue.ancestors?.[0]?.identifier ?? issue.parentId}`}
className="text-sm hover:underline"
>
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
</Link>
</PropertyRow>
)}
{issue.requestDepth > 0 && ( {issue.requestDepth > 0 && (
<PropertyRow label="Depth"> <PropertyRow label="Depth">
<span className="text-sm font-mono">{issue.requestDepth}</span> <span className="text-sm font-mono">{issue.requestDepth}</span>

View file

@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow"; import { IssueRow } from "./IssueRow";
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => (
<a className={className} {...props}>{children}</a> <a
className={className}
data-disable-issue-quicklook={_disableIssueQuicklook ? "true" : undefined}
{...props}
>
{children}
</a>
), ),
})); }));
@ -135,6 +141,22 @@ describe("IssueRow", () => {
}); });
}); });
it("opts issue quicklook out for dense inbox rows", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true");
act(() => {
root.unmount();
});
});
it("renders titleSuffix inline after the issue title", () => { it("renders titleSuffix inline after the issue title", () => {
const root = createRoot(container); const root = createRoot(container);
const issue = createIssue({ title: "Parent task" }); const issue = createIssue({ title: "Parent task" });

View file

@ -58,6 +58,7 @@ export function IssueRow({
<Link <Link
to={createIssueDetailPath(issuePathId)} to={createIssueDetailPath(issuePathId)}
state={detailState} state={detailState}
disableIssueQuicklook
data-inbox-issue-link data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn( className={cn(

View file

@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssuesList } from "./IssuesList"; import { IssuesList } from "./IssuesList";
import { TooltipProvider } from "@/components/ui/tooltip";
const companyState = vi.hoisted(() => ({ const companyState = vi.hoisted(() => ({
selectedCompanyId: "company-1", selectedCompanyId: "company-1",
@ -161,7 +162,9 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
act(() => { act(() => {
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{node} <TooltipProvider>
{node}
</TooltipProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
}); });
@ -297,7 +300,10 @@ describe("IssuesList", () => {
); );
await waitForAssertion(() => { await waitForAssertion(() => {
expect(container.textContent).toContain("Columns"); const columnsButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.getAttribute("title") === "Columns",
);
expect(columnsButton).not.toBeUndefined();
expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("PAP-9");
expect(container.textContent).toContain("Agent One"); expect(container.textContent).toContain("Agent One");
expect(container.textContent).not.toContain("Updated"); expect(container.textContent).not.toContain("Updated");
@ -308,6 +314,77 @@ describe("IssuesList", () => {
}); });
}); });
it("filters the list to a single workspace when a workspace name is clicked", async () => {
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"]));
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-alpha",
name: "Alpha",
mode: "isolated_workspace",
status: "active",
projectWorkspaceId: null,
},
{
id: "workspace-beta",
name: "Beta",
mode: "isolated_workspace",
status: "active",
projectWorkspaceId: null,
},
]);
const alphaIssue = createIssue({
id: "issue-alpha",
identifier: "PAP-20",
title: "Alpha issue",
executionWorkspaceId: "workspace-alpha",
});
const betaIssue = createIssue({
id: "issue-beta",
identifier: "PAP-21",
title: "Beta issue",
executionWorkspaceId: "workspace-beta",
});
const { root } = renderWithQueryClient(
<IssuesList
issues={[alphaIssue, betaIssue]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
expect(container.textContent).toContain("Alpha issue");
expect(container.textContent).toContain("Beta issue");
const workspaceButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Alpha",
);
expect(workspaceButton).not.toBeUndefined();
});
await act(async () => {
const workspaceButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Alpha",
);
workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
await waitForAssertion(() => {
expect(container.textContent).toContain("Alpha issue");
expect(container.textContent).not.toContain("Beta issue");
});
act(() => {
root.unmount();
});
});
it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => {
const manualIssue = createIssue({ const manualIssue = createIssue({
id: "issue-manual", id: "issue-manual",
@ -341,7 +418,7 @@ describe("IssuesList", () => {
await act(async () => { await act(async () => {
const filterButton = Array.from(document.body.querySelectorAll("button")).find( const filterButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Filter"), (button) => button.getAttribute("title") === "Filter",
); );
filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
@ -370,4 +447,75 @@ describe("IssuesList", () => {
root.unmount(); root.unmount();
}); });
}); });
it("blurs the search input on Enter without clearing the query", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialSearch="bug"
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
input?.focus();
expect(document.activeElement).toBe(input);
});
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
}));
});
expect(document.activeElement).not.toBe(input);
expect(input.value).toBe("bug");
act(() => {
root.unmount();
});
});
it("blurs the search input on Escape once the field is empty", async () => {
const { root } = renderWithQueryClient(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
initialSearch=""
onUpdateIssue={() => undefined}
/>,
container,
);
await waitForAssertion(() => {
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
input?.focus();
expect(document.activeElement).toBe(input);
});
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement;
act(() => {
input.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
}));
});
expect(document.activeElement).not.toBe(input);
act(() => {
root.unmount();
});
});
}); });

View file

@ -7,6 +7,10 @@ import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import {
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "../lib/keyboardShortcuts";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy"; import { groupBy } from "../lib/groupBy";
import { import {
@ -15,6 +19,7 @@ import {
defaultIssueFilterState, defaultIssueFilterState,
issueFilterLabel, issueFilterLabel,
issuePriorityOrder, issuePriorityOrder,
resolveIssueFilterWorkspaceId,
issueStatusOrder, issueStatusOrder,
type IssueFilterState, type IssueFilterState,
} from "../lib/issue-filters"; } from "../lib/issue-filters";
@ -170,9 +175,27 @@ function IssueSearchInput({
onChange={(e) => { onChange={(e) => {
setDraftValue(e.target.value); setDraftValue(e.target.value);
}} }}
onKeyDown={(e) => {
if (shouldBlurPageSearchOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
})) {
e.currentTarget.blur();
return;
}
if (shouldBlurPageSearchOnEscape({
key: e.key,
isComposing: e.nativeEvent.isComposing,
currentValue: e.currentTarget.value,
})) {
e.currentTarget.blur();
}
}}
placeholder="Search issues..." placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm" className="pl-7 text-xs sm:text-sm"
aria-label="Search issues" aria-label="Search issues"
data-page-search-target="true"
/> />
</div> </div>
); );
@ -346,6 +369,16 @@ export function IssuesList({
return map; return map;
}, [executionWorkspaceById, projectWorkspaceById]); }, [executionWorkspaceById, projectWorkspaceById]);
const workspaceOptions = useMemo(() => {
const options = new Map<string, string>();
for (const [workspaceId, workspaceName] of workspaceNameMap) {
options.set(workspaceId, workspaceName);
}
return [...options.entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, name]) => ({ id, name }));
}, [workspaceNameMap]);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo( const availableIssueColumns = useMemo(
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
@ -404,7 +437,7 @@ export function IssuesList({
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
} }
if (viewState.groupBy === "workspace") { if (viewState.groupBy === "workspace") {
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace");
return Object.keys(groups) return Object.keys(groups)
.sort((a, b) => { .sort((a, b) => {
// Groups with items first, "no workspace" last // Groups with items first, "no workspace" last
@ -467,6 +500,10 @@ export function IssuesList({
return defaults; return defaults;
}, [projectId, viewState.groupBy]); }, [projectId, viewState.groupBy]);
const filterToWorkspace = useCallback((workspaceId: string) => {
updateView({ workspaces: [workspaceId] });
}, [updateView]);
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
const normalized = normalizeInboxIssueColumns(next); const normalized = normalizeInboxIssueColumns(next);
setVisibleIssueColumns(normalized); setVisibleIssueColumns(normalized);
@ -531,6 +568,7 @@ export function IssuesList({
onToggleColumn={toggleIssueColumn} onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which issue columns stay visible" title="Choose which issue columns stay visible"
iconOnly
/> />
<IssueFiltersPopover <IssueFiltersPopover
@ -542,15 +580,16 @@ export function IssuesList({
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId} currentUserId={currentUserId}
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
iconOnly
workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined}
/> />
{/* Sort (list view only) */} {/* Sort (list view only) */}
{viewState.viewMode === "list" && ( {viewState.viewMode === "list" && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs"> <Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" /> <ArrowUpDown className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Sort</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-48 p-0"> <PopoverContent align="end" className="w-48 p-0">
@ -592,9 +631,8 @@ export function IssuesList({
{viewState.viewMode === "list" && ( {viewState.viewMode === "list" && (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs"> <Button variant="outline" size="icon" className="h-8 w-8 shrink-0" title="Group">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" /> <Layers className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Group</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0"> <PopoverContent align="end" className="w-44 p-0">
@ -751,11 +789,13 @@ export function IssuesList({
columns={visibleTrailingIssueColumns} columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null} projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null} projectColor={issueProject?.color ?? null}
workspaceId={resolveIssueFilterWorkspaceId(issue)}
workspaceName={resolveIssueWorkspaceName(issue, { workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById, executionWorkspaceById,
projectWorkspaceById, projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId, defaultProjectWorkspaceIdByProjectId,
})} })}
onFilterWorkspace={filterToWorkspace}
assigneeName={agentName(issue.assigneeAgentId)} assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId} currentUserId={currentUserId}
parentIdentifier={parentIssue?.identifier ?? null} parentIdentifier={parentIssue?.identifier ?? null}

View file

@ -1,10 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "./StatusIcon";
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
import { timeAgo } from "../lib/timeAgo"; import { IssueQuicklookCard } from "./IssueLinkQuicklook";
interface IssuesQuicklookProps { interface IssuesQuicklookProps {
issue: Issue; issue: Issue;
@ -24,32 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
{children} {children}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-64 p-3" className="w-72 p-3"
side="top" side="top"
align="start" align="start"
onMouseEnter={() => setOpen(true)} onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)} onMouseLeave={() => setOpen(false)}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="space-y-2"> <IssueQuicklookCard
<div className="flex items-start gap-2"> issue={issue}
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" /> linkTo={createIssueDetailPath(issue.identifier ?? issue.id)}
<Link linkState={withIssueDetailHeaderSeed(null, issue)}
to={createIssueDetailPath(issue.identifier ?? issue.id)} />
state={withIssueDetailHeaderSeed(null, issue)}
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
>
{issue.title}
</Link>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span>·</span>
<span>{issue.status.replace(/_/g, " ")}</span>
<span>·</span>
<span>{timeAgo(new Date(issue.updatedAt))}</span>
</div>
</div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View file

@ -148,6 +148,7 @@ function KanbanCard({
> >
<Link <Link
to={`/issues/${issue.identifier ?? issue.id}`} to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook
className="block no-underline text-inherit" className="block no-underline text-inherit"
onClick={(e) => { onClick={(e) => {
// Prevent navigation during drag // Prevent navigation during drag

View file

@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [
{ {
title: "Global", title: "Global",
shortcuts: [ shortcuts: [
{ keys: ["/"], label: "Search current page or quick search" },
{ keys: ["c"], label: "New issue" }, { keys: ["c"], label: "New issue" },
{ keys: ["["], label: "Toggle sidebar" }, { keys: ["["], label: "Toggle sidebar" },
{ keys: ["]"], label: "Toggle panel" }, { keys: ["]"], label: "Toggle panel" },

View file

@ -154,12 +154,21 @@ export function Layout() {
]); ]);
const togglePanel = togglePanelVisible; const togglePanel = togglePanelVisible;
const openSearch = useCallback(() => {
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "k",
metaKey: true,
bubbles: true,
cancelable: true,
}));
}, []);
useCompanyPageMemory(); useCompanyPageMemory();
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled, enabled: keyboardShortcutsEnabled,
onNewIssue: () => openNewIssue(), onNewIssue: () => openNewIssue(),
onSearch: openSearch,
onToggleSidebar: toggleSidebar, onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel, onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true), onShowShortcuts: () => setShortcutsOpen(true),

View file

@ -222,18 +222,6 @@ async function flush() {
}); });
} }
async function waitForValue<T>(getValue: () => T | null | undefined, attempts = 10): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const value = getValue();
if (value != null) {
return value;
}
await flush();
}
throw new Error("Timed out waiting for value");
}
function renderDialog(container: HTMLDivElement) { function renderDialog(container: HTMLDivElement) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -394,10 +382,15 @@ describe("NewIssueDialog", () => {
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]"); expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("overflow-hidden"); expect(dialogContent?.className).toContain("overflow-hidden");
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement; const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
expect(descriptionScrollRegion?.className).toContain("flex-1"); typeof element.className === "string" && element.className.includes("overscroll-contain"),
expect(descriptionScrollRegion?.className).toContain("overflow-y-auto"); );
expect(bodyScrollRegion?.className).toContain("flex-1");
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true);
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
act(() => root.unmount()); act(() => root.unmount());
}); });
@ -452,13 +445,13 @@ describe("NewIssueDialog", () => {
expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
const modeSelect = await waitForValue( const selects = Array.from(container.querySelectorAll("select"));
() => container.querySelector("select") as HTMLSelectElement | null, const modeSelect = selects[0] as HTMLSelectElement | undefined;
); expect(modeSelect).not.toBeUndefined();
await act(async () => { await act(async () => {
modeSelect.value = "shared_workspace"; modeSelect!.value = "shared_workspace";
modeSelect.dispatchEvent(new Event("change", { bubbles: true })); modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
}); });
await flush(); await flush();

View file

@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
</div> </div>
</div> </div>
{/* Title */} <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="px-4 pt-4 pb-2 shrink-0"> {/* Title */}
<textarea <div className="px-4 pt-4 pb-2">
<textarea
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50" className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
placeholder="Issue title" placeholder="Issue title"
rows={1} rows={1}
@ -1094,12 +1095,12 @@ export function NewIssueDialog() {
} }
}} }}
autoFocus autoFocus
/> />
</div> </div>
<div className="px-4 pb-2 shrink-0"> <div className="px-4 pb-2">
<div className="overflow-x-auto overscroll-x-contain"> <div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max"> <div className="inline-flex items-center gap-2 text-sm text-muted-foreground flex-wrap sm:flex-nowrap sm:min-w-max">
<span className="w-6 shrink-0 text-center">For</span> <span className="w-6 shrink-0 text-center">For</span>
<InlineEntitySelector <InlineEntitySelector
ref={assigneeSelectorRef} ref={assigneeSelectorRef}
@ -1235,14 +1236,14 @@ export function NewIssueDialog() {
</button> </button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
</div> </div>
</div>
{/* Reviewer row */} {/* Reviewer row */}
{showReviewerRow && ( {showReviewerRow && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span> <span className="w-6 shrink-0 flex items-center justify-center"><Eye className="h-3.5 w-3.5" /></span>
<InlineEntitySelector <InlineEntitySelector
value={reviewerValue} value={reviewerValue}
options={assigneeOptions} options={assigneeOptions}
placeholder="Reviewer" placeholder="Reviewer"
@ -1278,15 +1279,15 @@ export function NewIssueDialog() {
</> </>
); );
}} }}
/> />
</div> </div>
)} )}
{/* Approver row */} {/* Approver row */}
{showApproverRow && ( {showApproverRow && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1"> <div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span> <span className="w-6 shrink-0 flex items-center justify-center"><ShieldCheck className="h-3.5 w-3.5" /></span>
<InlineEntitySelector <InlineEntitySelector
value={approverValue} value={approverValue}
options={assigneeOptions} options={assigneeOptions}
placeholder="Approver" placeholder="Approver"
@ -1322,13 +1323,13 @@ export function NewIssueDialog() {
</> </>
); );
}} }}
/> />
</div> </div>
)} )}
</div> </div>
{isSubIssueMode ? ( {isSubIssueMode ? (
<div className="px-4 pb-2 shrink-0"> <div className="px-4 pb-2">
<div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground"> <div className="max-w-full rounded-md border border-border bg-muted/30 px-2.5 py-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<ListTree className="h-3.5 w-3.5 shrink-0" /> <ListTree className="h-3.5 w-3.5 shrink-0" />
@ -1341,11 +1342,11 @@ export function NewIssueDialog() {
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) : null} ) : null}
{currentProject && currentProjectSupportsExecutionWorkspace && ( {currentProject && currentProjectSupportsExecutionWorkspace && (
<div className="px-4 py-3 shrink-0 space-y-2"> <div className="px-4 py-3 space-y-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div> <div className="text-xs font-medium">Execution workspace</div>
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
@ -1392,11 +1393,11 @@ export function NewIssueDialog() {
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
)} )}
{supportsAssigneeOverrides && ( {supportsAssigneeOverrides && (
<div className="px-4 pb-2 shrink-0"> <div className="px-4 pb-2">
<button <button
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors" className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setAssigneeOptionsOpen((open) => !open)} onClick={() => setAssigneeOptionsOpen((open) => !open)}
@ -1447,39 +1448,39 @@ export function NewIssueDialog() {
)} )}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Description */} {/* Description */}
<div
className="min-h-0 flex-1 overflow-y-auto border-t border-border/60 px-4 pb-2 pt-3"
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
<div <div
className={cn( className="border-t border-border/60 px-4 pb-2 pt-3"
"rounded-md transition-colors", onDragEnter={handleFileDragEnter}
isFileDragOver && "bg-accent/20", onDragOver={handleFileDragOver}
)} onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
> >
<MarkdownEditor <div
ref={descriptionEditorRef} className={cn(
value={description} "rounded-md transition-colors",
onChange={setDescription} isFileDragOver && "bg-accent/20",
placeholder="Add description..." )}
bordered={false} >
mentions={mentionOptions} <MarkdownEditor
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")} ref={descriptionEditorRef}
imageUploadHandler={async (file) => { value={description}
const asset = await uploadDescriptionImage.mutateAsync(file); onChange={setDescription}
return asset.contentPath; placeholder="Add description..."
}} bordered={false}
/> mentions={mentionOptions}
</div> contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
{stagedFiles.length > 0 ? ( imageUploadHandler={async (file) => {
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3"> const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
/>
</div>
{stagedFiles.length > 0 ? (
<div className="mt-4 space-y-3 rounded-lg border border-border/70 p-3">
{stagedDocuments.length > 0 ? ( {stagedDocuments.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Documents</div> <div className="text-xs font-medium text-muted-foreground">Documents</div>
@ -1546,8 +1547,9 @@ export function NewIssueDialog() {
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
</div>
</div> </div>
{/* Property chips bar */} {/* Property chips bar */}

View file

@ -109,9 +109,9 @@ function PropertyRow({
valueClassName?: string; valueClassName?: string;
}) { }) {
return ( return (
<div className={cn("flex gap-3 py-1.5", alignStart ? "items-start" : "items-center")}> <div className={cn("flex gap-3 py-1.5 items-start")}>
<div className="shrink-0 w-20">{label}</div> <div className="shrink-0 w-20 mt-0.5">{label}</div>
<div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5", valueClassName)}> <div className={cn("min-w-0 flex-1", alignStart ? "pt-0.5" : "flex items-center gap-1.5 flex-wrap", valueClassName)}>
{children} {children}
</div> </div>
</div> </div>
@ -551,7 +551,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
key={goal.id} key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs" className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
> >
<Link to={`/goals/${goal.id}`} className="hover:underline max-w-[220px] truncate"> <Link to={`/goals/${goal.id}`} className="hover:underline break-words min-w-0">
{goal.title} {goal.title}
</Link> </Link>
{(onUpdate || onFieldUpdate) && ( {(onUpdate || onFieldUpdate) && (
@ -668,13 +668,13 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline" className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
> >
<Github className="h-3 w-3 shrink-0" /> <Github className="h-3 w-3 shrink-0" />
<span className="truncate">{formatRepoUrl(codebase.repoUrl)}</span> <span className="break-all min-w-0">{formatRepoUrl(codebase.repoUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" /> <ExternalLink className="h-3 w-3 shrink-0" />
</a> </a>
) : ( ) : (
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> <div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<Github className="h-3 w-3 shrink-0" /> <Github className="h-3 w-3 shrink-0" />
<span className="truncate">{codebase.repoUrl}</span> <span className="break-all min-w-0">{codebase.repoUrl}</span>
</div> </div>
)} )}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -723,7 +723,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Local folder</div> <div className="text-[11px] uppercase tracking-wide text-muted-foreground">Local folder</div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<div className="min-w-0 truncate font-mono text-xs text-muted-foreground"> <div className="min-w-0 break-all font-mono text-xs text-muted-foreground">
{codebase.effectiveLocalFolder} {codebase.effectiveLocalFolder}
</div> </div>
{codebase.origin === "managed_checkout" && ( {codebase.origin === "managed_checkout" && (

View file

@ -10,10 +10,10 @@ export function PropertiesPanel() {
return ( return (
<aside <aside
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-in-out" className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-[width,opacity] duration-200 ease-in-out h-full"
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }} style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
> >
<div className="w-80 flex-1 flex flex-col min-w-[320px]"> <div className="w-80 flex-1 flex flex-col min-w-[320px] min-h-0">
<div className="flex items-center justify-between px-4 py-2 border-b border-border"> <div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-sm font-medium">Properties</span> <span className="text-sm font-medium">Properties</span>
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}> <Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>

View file

@ -99,7 +99,7 @@ export function Sidebar() {
<SidebarSection label="Work"> <SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} /> <SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} textBadge="Beta" textBadgeTone="amber" /> <SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} /> <SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection> </SidebarSection>

View file

@ -8,6 +8,7 @@ import { useSidebar } from "../context/SidebarContext";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, agentRouteRef, agentUrl } from "../lib/utils"; import { cn, agentRouteRef, agentUrl } from "../lib/utils";
import { useAgentOrder } from "../hooks/useAgentOrder"; import { useAgentOrder } from "../hooks/useAgentOrder";
@ -105,6 +106,7 @@ export function SidebarAgents() {
<NavLink <NavLink
key={agent.id} key={agent.id}
to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)} to={activeTab ? `${agentUrl(agent)}/${activeTab}` : agentUrl(agent)}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => { onClick={() => {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
}} }}

View file

@ -1,4 +1,5 @@
import { NavLink } from "@/lib/router"; import { NavLink } from "@/lib/router";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
@ -35,6 +36,7 @@ export function SidebarNavItem({
return ( return (
<NavLink <NavLink
to={to} to={to}
state={SIDEBAR_SCROLL_RESET_STATE}
end={end} end={end}
onClick={() => { if (isMobile) setSidebarOpen(false); }} onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) => className={({ isActive }) =>

View file

@ -17,6 +17,7 @@ import { useDialog } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef } from "../lib/utils"; import { cn, projectRouteRef } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
@ -74,6 +75,7 @@ function SortableProjectItem({
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<NavLink <NavLink
to={`/projects/${routeRef}/issues`} to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={() => { onClick={() => {
if (isMobile) setSidebarOpen(false); if (isMobile) setSidebarOpen(false);
}} }}

View file

@ -10,12 +10,15 @@ import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
function TestHarness({ function TestHarness({
onNewIssue, onNewIssue,
onSearch,
}: { }: {
onNewIssue: () => void; onNewIssue: () => void;
onSearch?: () => void;
}) { }) {
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: true, enabled: true,
onNewIssue, onNewIssue,
onSearch,
}); });
return <div>keyboard shortcuts test</div>; return <div>keyboard shortcuts test</div>;
@ -55,4 +58,52 @@ describe("useKeyboardShortcuts", () => {
root.unmount(); root.unmount();
}); });
}); });
it("focuses the current page search target on slash", () => {
const root = createRoot(container);
const onSearch = vi.fn();
const input = document.createElement("input");
input.setAttribute("data-page-search-target", "true");
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
document.body.appendChild(input);
act(() => {
root.render(<TestHarness onNewIssue={vi.fn()} onSearch={onSearch} />);
});
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "/",
bubbles: true,
cancelable: true,
}));
expect(document.activeElement).toBe(input);
expect(onSearch).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
input.remove();
});
it("falls back to quick search when the page has no search target", () => {
const root = createRoot(container);
const onSearch = vi.fn();
act(() => {
root.render(<TestHarness onNewIssue={vi.fn()} onSearch={onSearch} />);
});
document.dispatchEvent(new KeyboardEvent("keydown", {
key: "/",
bubbles: true,
cancelable: true,
}));
expect(onSearch).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
}); });

View file

@ -1,9 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import {
focusPageSearchShortcutTarget,
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
} from "../lib/keyboardShortcuts";
interface ShortcutHandlers { interface ShortcutHandlers {
enabled?: boolean; enabled?: boolean;
onNewIssue?: () => void; onNewIssue?: () => void;
onSearch?: () => void;
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
onTogglePanel?: () => void; onTogglePanel?: () => void;
onShowShortcuts?: () => void; onShowShortcuts?: () => void;
@ -12,6 +17,7 @@ interface ShortcutHandlers {
export function useKeyboardShortcuts({ export function useKeyboardShortcuts({
enabled = true, enabled = true,
onNewIssue, onNewIssue,
onSearch,
onToggleSidebar, onToggleSidebar,
onTogglePanel, onTogglePanel,
onShowShortcuts, onShowShortcuts,
@ -29,6 +35,19 @@ export function useKeyboardShortcuts({
return; return;
} }
// / → Page search when available, otherwise quick search
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (hasBlockingShortcutDialog()) {
return;
}
e.preventDefault();
if (!focusPageSearchShortcutTarget()) {
onSearch?.();
}
return;
}
// ? → Show keyboard shortcuts cheatsheet // ? → Show keyboard shortcuts cheatsheet
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) { if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault(); e.preventDefault();
@ -57,5 +76,5 @@ export function useKeyboardShortcuts({
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]); }, [enabled, onNewIssue, onSearch, onToggleSidebar, onTogglePanel, onShowShortcuts]);
} }

View file

@ -15,6 +15,7 @@ import {
buildInboxDismissedAtByKey, buildInboxDismissedAtByKey,
computeInboxBadgeData, computeInboxBadgeData,
filterInboxIssues, filterInboxIssues,
getArchivedInboxSearchIssues,
getAvailableInboxIssueColumns, getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
@ -24,13 +25,16 @@ import {
groupInboxWorkItems, groupInboxWorkItems,
isInboxEntityDismissed, isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns, loadInboxIssueColumns,
loadLastInboxTab, loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns, normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT, RECENT_ISSUES_LIMIT,
resolveInboxNestingEnabled, resolveInboxNestingEnabled,
resolveIssueWorkspaceName, resolveIssueWorkspaceName,
resolveInboxSelectionIndex, resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveInboxIssueColumns, saveInboxIssueColumns,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
@ -134,6 +138,7 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
logCompressed: false, logCompressed: false,
errorCode: null, errorCode: null,
externalRunId: null, externalRunId: null,
processGroupId: null,
processPid: null, processPid: null,
processStartedAt: null, processStartedAt: null,
retryOfRunId: null, retryOfRunId: null,
@ -547,6 +552,65 @@ describe("inbox helpers", () => {
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]); expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
}); });
it("matches workspace names when inbox issue search includes workspace labels", () => {
const issue = makeIssue("workspace", false);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
issue.executionWorkspaceId = "execution-workspace-1";
expect(matchesInboxIssueSearch(
issue,
"feature",
{
isolatedWorkspacesEnabled: true,
executionWorkspaceById: new Map([
["execution-workspace-1", { name: "Feature Branch", mode: "isolated_workspace" as const, projectWorkspaceId: "project-workspace-1" }],
]),
projectWorkspaceById: new Map([
["project-workspace-1", { name: "Primary workspace" }],
]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "project-workspace-2"]]),
},
)).toBe(true);
});
it("returns archived search matches that are not already visible in the inbox", () => {
const visibleIssue = makeIssue("visible", false);
visibleIssue.title = "Alpha visible task";
const archivedMatch = makeIssue("archived-match", false);
archivedMatch.title = "Alpha archived task";
const archivedMiss = makeIssue("archived-miss", false);
archivedMiss.title = "Different task";
expect(
getArchivedInboxSearchIssues({
visibleIssues: [visibleIssue],
searchableIssues: [visibleIssue, archivedMatch, archivedMiss],
query: "alpha",
}).map((issue) => issue.id),
).toEqual(["archived-match"]);
});
it("sorts archived search matches by most recent activity", () => {
const older = makeIssue("older", false);
older.title = "Alpha older";
older.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const newer = makeIssue("newer", false);
newer.title = "Alpha newer";
newer.lastActivityAt = new Date("2026-03-11T03:00:00.000Z");
expect(
getArchivedInboxSearchIssues({
visibleIssues: [],
searchableIssues: [older, newer],
query: "alpha",
}).map((issue) => issue.id),
).toEqual(["newer", "older"]);
});
it("defaults the remembered inbox tab to mine and persists all", () => { it("defaults the remembered inbox tab to mine and persists all", () => {
localStorage.clear(); localStorage.clear();
expect(loadLastInboxTab()).toBe("mine"); expect(loadLastInboxTab()).toBe("mine");
@ -555,6 +619,92 @@ describe("inbox helpers", () => {
expect(loadLastInboxTab()).toBe("all"); expect(loadLastInboxTab()).toBe("all");
}); });
it("persists inbox filters per company", () => {
saveInboxFilterPreferences("company-1", {
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
},
});
saveInboxFilterPreferences("company-2", {
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
},
});
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "approvals",
allApprovalFilter: "resolved",
issueFilters: {
statuses: ["todo"],
priorities: ["high"],
assignees: ["agent-1"],
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: true,
},
});
expect(loadInboxFilterPreferences("company-2")).toEqual({
allCategoryFilter: "failed_runs",
allApprovalFilter: "actionable",
issueFilters: {
statuses: ["done"],
priorities: [],
assignees: [],
labels: [],
projects: [],
workspaces: [],
showRoutineExecutions: false,
},
});
});
it("normalizes invalid inbox filter storage back to safe defaults", () => {
localStorage.setItem("paperclip:inbox:filters:company-1", JSON.stringify({
allCategoryFilter: "bogus",
allApprovalFilter: "bogus",
issueFilters: {
statuses: ["todo", 123],
priorities: "high",
assignees: ["agent-1"],
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
showRoutineExecutions: "yes",
},
}));
expect(loadInboxFilterPreferences("company-1")).toEqual({
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: {
statuses: ["todo"],
priorities: [],
assignees: ["agent-1"],
labels: [],
projects: ["project-1"],
workspaces: ["workspace-1"],
showRoutineExecutions: false,
},
});
});
it("keeps nesting enabled on desktop when the saved preference is on", () => { it("keeps nesting enabled on desktop when the saved preference is on", () => {
expect(resolveInboxNestingEnabled(true, false)).toBe(true); expect(resolveInboxNestingEnabled(true, false)).toBe(true);
}); });

View file

@ -6,6 +6,10 @@ import type {
Issue, Issue,
JoinRequest, JoinRequest,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import {
defaultIssueFilterState,
type IssueFilterState,
} from "./issue-filters";
export const RECENT_ISSUES_LIMIT = 100; export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@ -16,12 +20,34 @@ export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns"; export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting"; export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by"; export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export type InboxTab = "mine" | "recent" | "unread" | "all"; export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type"; export type InboxWorkItemGroupBy = "none" | "type";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const; export const inboxIssueColumns = [
"status",
"id",
"assignee",
"project",
"workspace",
"parent",
"labels",
"updated",
] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number]; export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"]; export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
export interface InboxFilterPreferences {
allCategoryFilter: InboxCategoryFilter;
allApprovalFilter: InboxApprovalFilter;
issueFilters: IssueFilterState;
}
export type InboxWorkItem = export type InboxWorkItem =
| { | {
kind: "issue"; kind: "issue";
@ -59,6 +85,104 @@ export interface InboxWorkItemGroup {
items: InboxWorkItem[]; items: InboxWorkItem[];
} }
const defaultInboxFilterPreferences: InboxFilterPreferences = {
allCategoryFilter: "everything",
allApprovalFilter: "all",
issueFilters: defaultIssueFilterState,
};
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}
function normalizeIssueFilterState(value: unknown): IssueFilterState {
if (!value || typeof value !== "object") return { ...defaultIssueFilterState };
const candidate = value as Partial<Record<keyof IssueFilterState, unknown>>;
return {
statuses: normalizeStringArray(candidate.statuses),
priorities: normalizeStringArray(candidate.priorities),
assignees: normalizeStringArray(candidate.assignees),
labels: normalizeStringArray(candidate.labels),
projects: normalizeStringArray(candidate.projects),
workspaces: normalizeStringArray(candidate.workspaces),
showRoutineExecutions: candidate.showRoutineExecutions === true,
};
}
function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter {
return value === "issues_i_touched"
|| value === "join_requests"
|| value === "approvals"
|| value === "failed_runs"
|| value === "alerts"
? value
: "everything";
}
function normalizeInboxApprovalFilter(value: unknown): InboxApprovalFilter {
return value === "actionable" || value === "resolved" ? value : "all";
}
function getInboxFilterPreferencesStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_FILTER_PREFERENCES_KEY_PREFIX}:${companyId}`;
}
export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter),
issueFilters: normalizeIssueFilterState(parsed.issueFilters),
};
} catch {
return {
...defaultInboxFilterPreferences,
issueFilters: { ...defaultIssueFilterState },
};
}
}
export function saveInboxFilterPreferences(
companyId: string | null | undefined,
preferences: InboxFilterPreferences,
) {
const storageKey = getInboxFilterPreferencesStorageKey(companyId);
if (!storageKey) return;
try {
localStorage.setItem(
storageKey,
JSON.stringify({
allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter),
allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter),
issueFilters: normalizeIssueFilterState(preferences.issueFilters),
}),
);
} catch {
// Ignore localStorage failures.
}
}
export function loadDismissedInboxAlerts(): Set<string> { export function loadDismissedInboxAlerts(): Set<string> {
try { try {
const raw = localStorage.getItem(DISMISSED_KEY); const raw = localStorage.getItem(DISMISSED_KEY);
@ -174,6 +298,78 @@ export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolea
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions)); return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
} }
export function matchesInboxIssueSearch(
issue: Pick<Issue, "title" | "identifier" | "description" | "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
query: string,
{
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
} = {},
): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return true;
if (issue.title.toLowerCase().includes(normalizedQuery)) return true;
if (issue.identifier?.toLowerCase().includes(normalizedQuery)) return true;
if (issue.description?.toLowerCase().includes(normalizedQuery)) return true;
if (!isolatedWorkspacesEnabled) return false;
const workspaceName = resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
return workspaceName?.toLowerCase().includes(normalizedQuery) ?? false;
}
export function getArchivedInboxSearchIssues({
visibleIssues,
searchableIssues,
query,
isolatedWorkspacesEnabled = false,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
visibleIssues: Issue[];
searchableIssues: Issue[];
query: string;
isolatedWorkspacesEnabled?: boolean;
executionWorkspaceById?: ReadonlyMap<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>;
projectWorkspaceById?: ReadonlyMap<string, { name: string }>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
const visibleIssueIds = new Set(visibleIssues.map((issue) => issue.id));
return searchableIssues
.filter((issue) => !visibleIssueIds.has(issue.id))
.filter((issue) =>
matchesInboxIssueSearch(issue, normalizedQuery, {
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}),
)
.sort(sortIssuesByMostRecentActivity);
}
export function resolveIssueWorkspaceName( export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">, issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{ {

View file

@ -6,6 +6,7 @@ export type IssueFilterState = {
assignees: string[]; assignees: string[];
labels: string[]; labels: string[];
projects: string[]; projects: string[];
workspaces: string[];
showRoutineExecutions: boolean; showRoutineExecutions: boolean;
}; };
@ -15,6 +16,7 @@ export const defaultIssueFilterState: IssueFilterState = {
assignees: [], assignees: [],
labels: [], labels: [],
projects: [], projects: [],
workspaces: [],
showRoutineExecutions: false, showRoutineExecutions: false,
}; };
@ -43,6 +45,12 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value]; return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
} }
export function resolveIssueFilterWorkspaceId(
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
): string | null {
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
}
export function applyIssueFilters( export function applyIssueFilters(
issues: Issue[], issues: Issue[],
state: IssueFilterState, state: IssueFilterState,
@ -71,6 +79,12 @@ export function applyIssueFilters(
if (state.projects.length > 0) { if (state.projects.length > 0) {
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId)); result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
} }
if (state.workspaces.length > 0) {
result = result.filter((issue) => {
const workspaceId = resolveIssueFilterWorkspaceId(issue);
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
return result; return result;
} }
@ -84,6 +98,7 @@ export function countActiveIssueFilters(
if (state.assignees.length > 0) count += 1; if (state.assignees.length > 0) count += 1;
if (state.labels.length > 0) count += 1; if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1; if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1; if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
return count; return count;
} }

View file

@ -1,11 +1,15 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
findPageSearchShortcutTarget,
focusPageSearchShortcutTarget,
hasBlockingShortcutDialog, hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget, isKeyboardShortcutTextInputTarget,
resolveIssueDetailGoKeyAction, resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction, resolveInboxQuickArchiveKeyAction,
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "./keyboardShortcuts"; } from "./keyboardShortcuts";
describe("keyboardShortcuts helpers", () => { describe("keyboardShortcuts helpers", () => {
@ -40,6 +44,72 @@ describe("keyboardShortcuts helpers", () => {
expect(hasBlockingShortcutDialog(root)).toBe(false); expect(hasBlockingShortcutDialog(root)).toBe(false);
}); });
it("finds the visible page search shortcut target", () => {
const root = document.createElement("div");
const hidden = document.createElement("input");
hidden.setAttribute("data-page-search-target", "true");
vi.spyOn(hidden, "getClientRects").mockReturnValue([] as unknown as DOMRectList);
const visible = document.createElement("input");
visible.setAttribute("data-page-search-target", "true");
vi.spyOn(visible, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
root.append(hidden, visible);
document.body.appendChild(root);
expect(findPageSearchShortcutTarget(root)).toBe(visible);
root.remove();
});
it("focuses and selects the page search shortcut target", () => {
const root = document.createElement("div");
const input = document.createElement("input");
input.value = "existing query";
input.setAttribute("data-page-search-target", "true");
vi.spyOn(input, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
root.appendChild(input);
document.body.appendChild(root);
expect(focusPageSearchShortcutTarget(root)).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(input.value.length);
root.remove();
});
it("blurs page search on a plain Enter press", () => {
expect(shouldBlurPageSearchOnEnter({
key: "Enter",
isComposing: false,
})).toBe(true);
});
it("keeps focus while composing with an IME", () => {
expect(shouldBlurPageSearchOnEnter({
key: "Enter",
isComposing: true,
})).toBe(false);
});
it("blurs page search on Escape when the field is already empty", () => {
expect(shouldBlurPageSearchOnEscape({
key: "Escape",
isComposing: false,
currentValue: "",
})).toBe(true);
});
it("keeps focus on the first Escape while the field still has text", () => {
expect(shouldBlurPageSearchOnEscape({
key: "Escape",
isComposing: false,
currentValue: "query",
})).toBe(false);
});
it("archives only the first clean y press", () => { it("archives only the first clean y press", () => {
const button = document.createElement("button"); const button = document.createElement("button");

View file

@ -8,6 +8,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
"[role='combobox']", "[role='combobox']",
].join(", "); ].join(", ");
const PAGE_SEARCH_SHORTCUT_SELECTOR = "[data-page-search-target='true']";
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]); const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm"; export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
@ -23,6 +24,56 @@ export function hasBlockingShortcutDialog(root: ParentNode = document): boolean
return !!root.querySelector("[role='dialog'][aria-modal='true']"); return !!root.querySelector("[role='dialog'][aria-modal='true']");
} }
function isVisibleShortcutTarget(element: HTMLElement): boolean {
if (!element.isConnected) return false;
if ("disabled" in element && typeof element.disabled === "boolean" && element.disabled) return false;
if (element.closest("[hidden], [aria-hidden='true'], [inert]")) return false;
if (element.closest("[role='dialog'][aria-modal='true']")) return false;
const style = window.getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") return false;
return element.getClientRects().length > 0 || element === document.activeElement;
}
export function findPageSearchShortcutTarget(root: ParentNode = document): HTMLElement | null {
const candidates = Array.from(root.querySelectorAll<HTMLElement>(PAGE_SEARCH_SHORTCUT_SELECTOR));
return candidates.find((candidate) => isVisibleShortcutTarget(candidate)) ?? null;
}
export function focusPageSearchShortcutTarget(root: ParentNode = document): boolean {
const target = findPageSearchShortcutTarget(root);
if (!target) return false;
target.focus();
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
target.select();
}
return true;
}
export function shouldBlurPageSearchOnEnter({
key,
isComposing,
}: {
key: string;
isComposing: boolean;
}): boolean {
return key === "Enter" && !isComposing;
}
export function shouldBlurPageSearchOnEscape({
key,
isComposing,
currentValue,
}: {
key: string;
isComposing: boolean;
currentValue: string;
}): boolean {
return key === "Escape" && !isComposing && currentValue.length === 0;
}
export function isModifierOnlyKey(key: string): boolean { export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEYS.has(key); return MODIFIER_ONLY_KEYS.has(key);
} }

View file

@ -22,7 +22,6 @@ function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssu
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue { function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
return { return {
id: "run-1", id: "run-1",
companyId: "company-1",
agentId: "agent-1", agentId: "agent-1",
agentName: "CodexCoder", agentName: "CodexCoder",
adapterType: "codex_local", adapterType: "codex_local",
@ -31,30 +30,7 @@ function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunF
status: "running", status: "running",
startedAt: new Date("2026-04-08T21:00:00.000Z"), startedAt: new Date("2026-04-08T21:00:00.000Z"),
finishedAt: null, finishedAt: null,
error: null,
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: { inputTokens: 1 },
resultJson: { summary: "partial" },
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
contextSnapshot: null,
createdAt: new Date("2026-04-08T21:00:00.000Z"), createdAt: new Date("2026-04-08T21:00:00.000Z"),
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
...overrides, ...overrides,
}; };
} }

View file

@ -98,6 +98,7 @@ describe("FailedRunInboxRow", () => {
logCompressed: false, logCompressed: false,
errorCode: null, errorCode: null,
externalRunId: null, externalRunId: null,
processGroupId: null,
processPid: null, processPid: null,
processStartedAt: null, processStartedAt: null,
retryOfRunId: null, retryOfRunId: null,

View file

@ -21,7 +21,6 @@ import { queryKeys } from "../lib/queryKeys";
import { import {
applyIssueFilters, applyIssueFilters,
countActiveIssueFilters, countActiveIssueFilters,
defaultIssueFilterState,
type IssueFilterState, type IssueFilterState,
} from "../lib/issue-filters"; } from "../lib/issue-filters";
import { import {
@ -31,7 +30,12 @@ import {
rememberIssueDetailLocationState, rememberIssueDetailLocationState,
withIssueDetailHeaderSeed, withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb"; } from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import {
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
shouldBlurPageSearchOnEnter,
shouldBlurPageSearchOnEscape,
} from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { import {
@ -91,13 +95,16 @@ import {
buildInboxNesting, buildInboxNesting,
getAvailableInboxIssueColumns, getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
getArchivedInboxSearchIssues,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
matchesInboxIssueSearch,
getRecentTouchedIssues, getRecentTouchedIssues,
groupInboxWorkItems, groupInboxWorkItems,
isInboxEntityDismissed, isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxFilterPreferences,
loadInboxIssueColumns, loadInboxIssueColumns,
loadInboxNesting, loadInboxNesting,
loadInboxWorkItemGroupBy, loadInboxWorkItemGroupBy,
@ -105,10 +112,13 @@ import {
resolveInboxNestingEnabled, resolveInboxNestingEnabled,
resolveIssueWorkspaceName, resolveIssueWorkspaceName,
resolveInboxSelectionIndex, resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveInboxIssueColumns, saveInboxIssueColumns,
saveInboxNesting, saveInboxNesting,
saveInboxWorkItemGroupBy, saveInboxWorkItemGroupBy,
type InboxApprovalFilter, type InboxApprovalFilter,
type InboxCategoryFilter,
type InboxFilterPreferences,
type InboxIssueColumn, type InboxIssueColumn,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
@ -119,14 +129,6 @@ import {
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns"; export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
type InboxCategoryFilter =
| "everything"
| "issues_i_touched"
| "join_requests"
| "approvals"
| "failed_runs"
| "alerts";
type SectionKey = type SectionKey =
| "work_items" | "work_items"
| "alerts"; | "alerts";
@ -141,8 +143,32 @@ type InboxGroupedSection = {
label: string | null; label: string | null;
displayItems: InboxWorkItem[]; displayItems: InboxWorkItem[];
childrenByIssueId: Map<string, Issue[]>; childrenByIssueId: Map<string, Issue[]>;
isArchivedSearch: boolean;
}; };
function buildGroupedInboxSections(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
nestingEnabled: boolean,
options?: { keyPrefix?: string; isArchivedSearch?: boolean },
): InboxGroupedSection[] {
const keyPrefix = options?.keyPrefix ?? "";
const isArchivedSearch = options?.isArchivedSearch ?? false;
return groupInboxWorkItems(items, groupBy).map((group) => {
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
? buildInboxNesting(group.items)
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
return {
key: `${keyPrefix}${group.key}`,
label: group.label,
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
isArchivedSearch,
};
});
}
function firstNonEmptyLine(value: string | null | undefined): string | null { function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -616,14 +642,15 @@ export function Inbox() {
retry: false, retry: false,
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [filterPreferences, setFilterPreferences] = useState<InboxFilterPreferences>(
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); () => loadInboxFilterPreferences(selectedCompanyId),
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState); );
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy()); const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns); const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts(); const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId); const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const { allCategoryFilter, allApprovalFilter, issueFilters } = filterPreferences;
const pathSegment = location.pathname.split("/").pop() ?? "mine"; const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab = const tab: InboxTab =
@ -681,6 +708,14 @@ export function Inbox() {
setSearchQuery(""); setSearchQuery("");
}, [tab]); }, [tab]);
const previousSelectedCompanyIdRef = useRef<string | null>(selectedCompanyId);
useEffect(() => {
if (previousSelectedCompanyIdRef.current !== selectedCompanyId) {
previousSelectedCompanyIdRef.current = selectedCompanyId;
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
}
}, [selectedCompanyId]);
const { const {
data: approvals, data: approvals,
isLoading: isApprovalsLoading, isLoading: isApprovalsLoading,
@ -754,6 +789,13 @@ export function Inbox() {
queryFn: () => heartbeatsApi.list(selectedCompanyId!), queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const currentUserId = session?.user.id ?? session?.session.userId ?? null; const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
@ -852,13 +894,11 @@ export function Inbox() {
); );
const liveIssueIds = useMemo(() => { const liveIssueIds = useMemo(() => {
const ids = new Set<string>(); const ids = new Set<string>();
for (const run of heartbeatRuns ?? []) { for (const run of liveRuns ?? []) {
if (run.status !== "running" && run.status !== "queued") continue; if (run.issueId) ids.add(run.issueId);
const issueId = readIssueIdFromRun(run);
if (issueId) ids.add(issueId);
} }
return ids; return ids;
}, [heartbeatRuns]); }, [liveRuns]);
const approvalsToRender = useMemo(() => { const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter); let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
@ -909,19 +949,12 @@ export function Inbox() {
if (!q) return workItemsToRender; if (!q) return workItemsToRender;
return workItemsToRender.filter((item) => { return workItemsToRender.filter((item) => {
if (item.kind === "issue") { if (item.kind === "issue") {
const issue = item.issue; return matchesInboxIssueSearch(item.issue, q, {
if (issue.title.toLowerCase().includes(q)) return true; isolatedWorkspacesEnabled,
if (issue.identifier?.toLowerCase().includes(q)) return true; executionWorkspaceById,
if (issue.description?.toLowerCase().includes(q)) return true; projectWorkspaceById,
if (isolatedWorkspacesEnabled) { defaultProjectWorkspaceIdByProjectId,
const workspaceName = resolveIssueWorkspaceName(issue, { });
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
if (workspaceName?.toLowerCase().includes(q)) return true;
}
return false;
} }
if (item.kind === "approval") { if (item.kind === "approval") {
const a = item.approval; const a = item.approval;
@ -963,6 +996,35 @@ export function Inbox() {
projectWorkspaceById, projectWorkspaceById,
]); ]);
const archivedSearchIssues = useMemo(
() =>
tab === "mine"
? getArchivedInboxSearchIssues({
visibleIssues: visibleMineIssues,
searchableIssues: visibleTouchedIssues,
query: searchQuery,
isolatedWorkspacesEnabled,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})
: [],
[
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
isolatedWorkspacesEnabled,
projectWorkspaceById,
searchQuery,
tab,
visibleMineIssues,
visibleTouchedIssues,
],
);
const archivedSearchIssueIds = useMemo(
() => new Set(archivedSearchIssues.map((issue) => issue.id)),
[archivedSearchIssues],
);
// --- Parent-child nesting for inbox issues --- // --- Parent-child nesting for inbox issues ---
const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting()); const [nestingPreferenceEnabled, setNestingPreferenceEnabled] = useState(() => loadInboxNesting());
const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile); const nestingEnabled = resolveInboxNestingEnabled(nestingPreferenceEnabled, isMobile);
@ -974,33 +1036,15 @@ export function Inbox() {
}); });
}, []); }, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set()); const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
const groupedSections = useMemo<InboxGroupedSection[]>(() => { const groupedSections = useMemo<InboxGroupedSection[]>(() => [
return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => { ...buildGroupedInboxSections(filteredWorkItems, groupBy, nestingEnabled),
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue") ...buildGroupedInboxSections(
? buildInboxNesting(group.items) getInboxWorkItems({ issues: archivedSearchIssues, approvals: [] }),
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() }; groupBy,
nestingEnabled,
return { { keyPrefix: "archived-search:", isArchivedSearch: true },
key: group.key, ),
label: group.label, ], [archivedSearchIssues, filteredWorkItems, groupBy, nestingEnabled]);
displayItems: nestedGroup.displayItems,
childrenByIssueId: nestedGroup.childrenByIssueId,
};
});
}, [filteredWorkItems, groupBy, nestingEnabled]);
const nestedWorkItems = useMemo(
() => groupedSections.flatMap((group) => group.displayItems),
[groupedSections],
);
const childrenByIssueId = useMemo(() => {
const merged = new Map<string, Issue[]>();
for (const group of groupedSections) {
for (const [issueId, children] of group.childrenByIssueId) {
merged.set(issueId, children);
}
}
return merged;
}, [groupedSections]);
const totalVisibleWorkItems = useMemo( const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0), () => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections], [groupedSections],
@ -1052,9 +1096,28 @@ export function Inbox() {
} }
setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]); }, [setIssueColumns, visibleIssueColumns]);
const updateFilterPreferences = useCallback(
(updater: (previous: InboxFilterPreferences) => InboxFilterPreferences) => {
setFilterPreferences((previous) => {
const next = updater(previous);
saveInboxFilterPreferences(selectedCompanyId, next);
return next;
});
},
[selectedCompanyId],
);
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => { const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
setIssueFilters((previous) => ({ ...previous, ...patch })); updateFilterPreferences((previous) => ({
}, []); ...previous,
issueFilters: { ...previous.issueFilters, ...patch },
}));
}, [updateFilterPreferences]);
const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => {
updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value }));
}, [updateFilterPreferences]);
const updateAllApprovalFilter = useCallback((value: InboxApprovalFilter) => {
updateFilterPreferences((previous) => ({ ...previous, allApprovalFilter: value }));
}, [updateFilterPreferences]);
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => { const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
setGroupBy(nextGroupBy); setGroupBy(nextGroupBy);
saveInboxWorkItemGroupBy(nextGroupBy); saveInboxWorkItemGroupBy(nextGroupBy);
@ -1325,6 +1388,7 @@ export function Inbox() {
flatNavItems, flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivedSearchIssueIds,
archivingIssueIds, archivingIssueIds,
archivingNonIssueIds, archivingNonIssueIds,
fadingOutIssues, fadingOutIssues,
@ -1335,6 +1399,7 @@ export function Inbox() {
flatNavItems, flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivedSearchIssueIds,
archivingIssueIds, archivingIssueIds,
archivingNonIssueIds, archivingNonIssueIds,
fadingOutIssues, fadingOutIssues,
@ -1415,10 +1480,12 @@ export function Inbox() {
e.preventDefault(); e.preventDefault();
const { issue, item } = resolveNavEntry(st.selectedIndex); const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) { if (issue) {
if (!st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id); if (!st.archivedSearchIssueIds.has(issue.id) && !st.archivingIssueIds.has(issue.id)) act.archiveIssue(issue.id);
} else if (item) { } else if (item) {
if (item.kind === "issue") { if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) act.archiveIssue(item.issue.id); if (!st.archivedSearchIssueIds.has(item.issue.id) && !st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else { } else {
const key = getWorkItemKey(item); const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key); if (!st.archivingNonIssueIds.has(key)) act.archiveNonIssue(key);
@ -1553,10 +1620,28 @@ export function Inbox() {
placeholder="Search inbox…" placeholder="Search inbox…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (shouldBlurPageSearchOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
})) {
e.currentTarget.blur();
return;
}
if (shouldBlurPageSearchOnEscape({
key: e.key,
isComposing: e.nativeEvent.isComposing,
currentValue: e.currentTarget.value,
})) {
e.currentTarget.blur();
}
}}
className="h-8 w-full pl-8 text-xs" className="h-8 w-full pl-8 text-xs"
data-page-search-target="true"
/> />
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}> <Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar <PageTabBar
items={[ items={[
@ -1582,7 +1667,25 @@ export function Inbox() {
placeholder="Search inbox…" placeholder="Search inbox…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (shouldBlurPageSearchOnEnter({
key: e.key,
isComposing: e.nativeEvent.isComposing,
})) {
e.currentTarget.blur();
return;
}
if (shouldBlurPageSearchOnEscape({
key: e.key,
isComposing: e.nativeEvent.isComposing,
currentValue: e.currentTarget.value,
})) {
e.currentTarget.blur();
}
}}
className="h-8 w-[220px] pl-8 text-xs" className="h-8 w-[220px] pl-8 text-xs"
data-page-search-target="true"
/> />
</div> </div>
<Button <Button
@ -1604,17 +1707,20 @@ export function Inbox() {
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
currentUserId={currentUserId} currentUserId={currentUserId}
enableRoutineVisibilityFilter enableRoutineVisibilityFilter
buttonVariant="outline"
iconOnly
workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace" && w.status === "active").map((w) => ({ id: w.id, name: w.name })) : undefined}
/> />
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="icon"
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")} className={cn("h-8 w-8 shrink-0", groupBy !== "none" && "bg-accent")}
title="Group"
> >
<Layers className="mr-1.5 h-3.5 w-3.5" /> <Layers className="h-3.5 w-3.5" />
Group
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-40 p-2"> <PopoverContent align="end" className="w-40 p-2">
@ -1645,6 +1751,7 @@ export function Inbox() {
onToggleColumn={toggleIssueColumn} onToggleColumn={toggleIssueColumn}
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
title="Choose which inbox columns stay visible" title="Choose which inbox columns stay visible"
iconOnly
/> />
{canMarkAllRead && ( {canMarkAllRead && (
<> <>
@ -1691,7 +1798,7 @@ export function Inbox() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Select <Select
value={allCategoryFilter} value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)} onValueChange={(value) => updateAllCategoryFilter(value as InboxCategoryFilter)}
> >
<SelectTrigger className="h-8 w-[170px] text-xs"> <SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" /> <SelectValue placeholder="Category" />
@ -1709,7 +1816,7 @@ export function Inbox() {
{showApprovalsCategory && ( {showApprovalsCategory && (
<Select <Select
value={allApprovalFilter} value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)} onValueChange={(value) => updateAllApprovalFilter(value as InboxApprovalFilter)}
> >
<SelectTrigger className="h-8 w-[170px] text-xs"> <SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" /> <SelectValue placeholder="Approval status" />
@ -1783,6 +1890,7 @@ export function Inbox() {
isExpanded = false, isExpanded = false,
childCount = 0, childCount = 0,
collapseParentId = null, collapseParentId = null,
allowArchive = canArchiveFromTab,
}: { }: {
issue: Issue; issue: Issue;
depth: number; depth: number;
@ -1791,6 +1899,7 @@ export function Inbox() {
isExpanded?: boolean; isExpanded?: boolean;
childCount?: number; childCount?: number;
collapseParentId?: string | null; collapseParentId?: string | null;
allowArchive?: boolean;
}) => { }) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id);
@ -1857,7 +1966,7 @@ export function Inbox() {
} }
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
onMarkRead={() => markReadMutation.mutate(issue.id)} onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined} onArchive={allowArchive ? () => archiveIssueMutation.mutate(issue.id) : undefined}
archiveDisabled={isArchiving || archiveIssueMutation.isPending} archiveDisabled={isArchiving || archiveIssueMutation.isPending}
desktopTrailing={ desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? ( visibleTrailingIssueColumns.length > 0 ? (
@ -1885,6 +1994,20 @@ export function Inbox() {
let previousTimestamp = Number.POSITIVE_INFINITY; let previousTimestamp = Number.POSITIVE_INFINITY;
return groupedSections.flatMap((group, groupIndex) => { return groupedSections.flatMap((group, groupIndex) => {
const elements: ReactNode[] = []; const elements: ReactNode[] = [];
if (group.isArchivedSearch && (groupIndex === 0 || !groupedSections[groupIndex - 1]?.isArchivedSearch)) {
elements.push(
<div
key="archived-search-divider"
className="flex items-center gap-3 border-y border-border/70 bg-muted/30 px-4 py-2"
>
<div className="h-px flex-1 bg-border/80" />
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Archived
</span>
<div className="h-px flex-1 bg-border/80" />
</div>,
);
}
if (group.label) { if (group.label) {
elements.push( elements.push(
<div <div
@ -2044,6 +2167,7 @@ export function Inbox() {
const childIssues = group.childrenByIssueId.get(issue.id) ?? []; const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
const hasChildren = childIssues.length > 0; const hasChildren = childIssues.length > 0;
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
const canArchiveIssue = canArchiveFromTab && !group.isArchivedSearch;
const parentRow = renderInboxIssue({ const parentRow = renderInboxIssue({
issue, issue,
depth: 0, depth: 0,
@ -2052,9 +2176,10 @@ export function Inbox() {
isExpanded, isExpanded,
childCount: childIssues.length, childCount: childIssues.length,
collapseParentId: issue.id, collapseParentId: issue.id,
allowArchive: canArchiveIssue,
}); });
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveIssue ? (
<SwipeToArchive <SwipeToArchive
key={`issue:${issue.id}`} key={`issue:${issue.id}`}
selected={isSelected} selected={isSelected}
@ -2073,6 +2198,7 @@ export function Inbox() {
issue: child, issue: child,
depth: 1, depth: 1,
selected: isChildSelected, selected: isChildSelected,
allowArchive: canArchiveIssue,
}); });
const isChildArchiving = archivingIssueIds.has(child.id); const isChildArchiving = archivingIssueIds.has(child.id);
elements.push( elements.push(
@ -2082,7 +2208,7 @@ export function Inbox() {
className="relative" className="relative"
onClick={() => setSelectedIndex(childNavIdx)} onClick={() => setSelectedIndex(childNavIdx)}
> >
{canArchiveFromTab ? ( {canArchiveIssue ? (
<SwipeToArchive <SwipeToArchive
key={`issue:${child.id}`} key={`issue:${child.id}`}
selected={isChildSelected} selected={isChildSelected}

View file

@ -1,6 +1,6 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router"; import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines"; import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
@ -182,7 +182,7 @@ function RoutineListRow({
agentById, agentById,
runningRoutineId, runningRoutineId,
statusMutationRoutineId, statusMutationRoutineId,
onNavigate, href,
onRunNow, onRunNow,
onToggleEnabled, onToggleEnabled,
onToggleArchived, onToggleArchived,
@ -192,7 +192,7 @@ function RoutineListRow({
agentById: Map<string, { name: string; icon?: string | null }>; agentById: Map<string, { name: string; icon?: string | null }>;
runningRoutineId: string | null; runningRoutineId: string | null;
statusMutationRoutineId: string | null; statusMutationRoutineId: string | null;
onNavigate: (routineId: string) => void; href: string;
onRunNow: (routine: RoutineListItem) => void; onRunNow: (routine: RoutineListItem) => void;
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void; onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
onToggleArchived: (routine: RoutineListItem) => void; onToggleArchived: (routine: RoutineListItem) => void;
@ -205,9 +205,9 @@ function RoutineListRow({
const isDraft = !isArchived && !routine.assigneeAgentId; const isDraft = !isArchived && !routine.assigneeAgentId;
return ( return (
<div <Link
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center" to={href}
onClick={() => onNavigate(routine.id)} className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
> >
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -237,7 +237,7 @@ function RoutineListRow({
</div> </div>
</div> </div>
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}> <div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ToggleSwitch <ToggleSwitch
size="lg" size="lg"
@ -258,8 +258,8 @@ function RoutineListRow({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigate(routine.id)}> <DropdownMenuItem asChild>
Edit <Link to={href}>Edit</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived} disabled={runningRoutineId === routine.id || isArchived}
@ -283,7 +283,7 @@ function RoutineListRow({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </Link>
); );
} }
@ -566,9 +566,8 @@ export function Routines() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-semibold tracking-tight">
Routines Routines
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">Beta</span>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Recurring work definitions that materialize into auditable execution issues. Recurring work definitions that materialize into auditable execution issues.
@ -953,7 +952,7 @@ export function Routines() {
agentById={agentById} agentById={agentById}
runningRoutineId={runningRoutineId} runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId} statusMutationRoutineId={statusMutationRoutineId}
onNavigate={(routineId) => navigate(`/routines/${routineId}`)} href={`/routines/${routine.id}`}
onRunNow={handleRunNow} onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled} onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived} onToggleArchived={handleToggleArchived}