Merge pull request #2540 from paperclipai/pap-1078-inbox-operator-polish

feat(inbox): add operator search and keyboard controls
This commit is contained in:
Dotta 2026-04-02 13:02:33 -05:00 committed by GitHub
commit ca8d35fd99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1344 additions and 114 deletions

View file

@ -2,6 +2,7 @@ import type { FeedbackDataSharingPreference } from "./feedback.js";
export interface InstanceGeneralSettings { export interface InstanceGeneralSettings {
censorUsernameInLogs: boolean; censorUsernameInLogs: boolean;
keyboardShortcuts: boolean;
feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackDataSharingPreference: FeedbackDataSharingPreference;
} }

View file

@ -4,6 +4,7 @@ import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
export const instanceGeneralSettingsSchema = z.object({ export const instanceGeneralSettingsSchema = z.object({
censorUsernameInLogs: z.boolean().default(false), censorUsernameInLogs: z.boolean().default(false),
keyboardShortcuts: z.boolean().default(false),
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
), ),

View file

@ -231,11 +231,31 @@ describe("agent skill routes", () => {
); );
}); });
it("keeps runtime materialization for persistent skill adapters", async () => { it("skips runtime materialization when listing Codex skills", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("codex_local")); mockAgentService.getById.mockResolvedValue(makeAgent("codex_local"));
mockAdapter.listSkills.mockResolvedValue({ mockAdapter.listSkills.mockResolvedValue({
adapterType: "codex_local", adapterType: "codex_local",
supported: true, supported: true,
mode: "ephemeral",
desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [],
warnings: [],
});
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", {
materializeMissing: false,
});
});
it("keeps runtime materialization for persistent skill adapters", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("cursor"));
mockAdapter.listSkills.mockResolvedValue({
adapterType: "cursor",
supported: true,
mode: "persistent", mode: "persistent",
desiredSkills: ["paperclipai/paperclip/paperclip"], desiredSkills: ["paperclipai/paperclip/paperclip"],
entries: [], entries: [],

View file

@ -35,6 +35,7 @@ describe("instance settings routes", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({ mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false, censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt", feedbackDataSharingPreference: "prompt",
}); });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ mockInstanceSettingsService.getExperimental.mockResolvedValue({
@ -45,6 +46,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1", id: "instance-settings-1",
general: { general: {
censorUsernameInLogs: true, censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed", feedbackDataSharingPreference: "allowed",
}, },
}); });
@ -114,6 +116,7 @@ describe("instance settings routes", () => {
expect(getRes.status).toBe(200); expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ expect(getRes.body).toEqual({
censorUsernameInLogs: false, censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt", feedbackDataSharingPreference: "prompt",
}); });
@ -121,18 +124,20 @@ describe("instance settings routes", () => {
.patch("/api/instance/settings/general") .patch("/api/instance/settings/general")
.send({ .send({
censorUsernameInLogs: true, censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed", feedbackDataSharingPreference: "allowed",
}); });
expect(patchRes.status).toBe(200); expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true, censorUsernameInLogs: true,
keyboardShortcuts: true,
feedbackDataSharingPreference: "allowed", feedbackDataSharingPreference: "allowed",
}); });
expect(mockLogActivity).toHaveBeenCalledTimes(2); expect(mockLogActivity).toHaveBeenCalledTimes(2);
}); });
it("rejects non-admin board users", async () => { it("allows non-admin board users to read general settings", async () => {
const app = createApp({ const app = createApp({
type: "board", type: "board",
userId: "user-1", userId: "user-1",
@ -143,8 +148,25 @@ describe("instance settings routes", () => {
const res = await request(app).get("/api/instance/settings/general"); const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled();
});
it("rejects non-admin board users from updating general settings", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
});
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true, keyboardShortcuts: true });
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
}); });
it("rejects agent callers", async () => { it("rejects agent callers", async () => {

View file

@ -2,7 +2,7 @@ import { Router, type Request } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto"; import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path"; import path from "node:path";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db"; import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { import {
agentSkillSyncSchema, agentSkillSyncSchema,
@ -220,6 +220,73 @@ export function agentRoutes(db: Db) {
return allowedByGrant || canCreateAgents(actorAgent); return allowedByGrant || canCreateAgents(actorAgent);
} }
async function buildSkippedWakeupResponse(
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
payload: Record<string, unknown> | null | undefined,
) {
const issueId = typeof payload?.issueId === "string" && payload.issueId.trim() ? payload.issueId : null;
if (!issueId) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId: null,
executionRunId: null,
executionAgentId: null,
executionAgentName: null,
};
}
const issue = await db
.select({
id: issuesTable.id,
executionRunId: issuesTable.executionRunId,
})
.from(issuesTable)
.where(and(eq(issuesTable.id, issueId), eq(issuesTable.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null);
if (!issue?.executionRunId) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId,
executionRunId: null,
executionAgentId: null,
executionAgentName: null,
};
}
const executionRun = await heartbeat.getRun(issue.executionRunId);
if (!executionRun || (executionRun.status !== "queued" && executionRun.status !== "running")) {
return {
status: "skipped" as const,
reason: "wakeup_skipped",
message: "Wakeup was skipped.",
issueId,
executionRunId: issue.executionRunId,
executionAgentId: null,
executionAgentName: null,
};
}
const executionAgent = await svc.getById(executionRun.agentId);
const executionAgentName = executionAgent?.name ?? null;
return {
status: "skipped" as const,
reason: "issue_execution_deferred",
message: executionAgentName
? `Wakeup was deferred because this issue is already being executed by ${executionAgentName}.`
: "Wakeup was deferred because this issue already has an active execution run.",
issueId,
executionRunId: executionRun.id,
executionAgentId: executionRun.agentId,
executionAgentName,
};
}
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId); assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return; if (req.actor.type === "board") return;
@ -532,8 +599,15 @@ export function agentRoutes(db: Db) {
}; };
} }
const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([
"cursor",
"gemini_local",
"opencode_local",
"pi_local",
]);
function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) {
return adapterType !== "claude_local"; return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType);
} }
async function buildRuntimeSkillConfig( async function buildRuntimeSkillConfig(
@ -1994,7 +2068,7 @@ export function agentRoutes(db: Db) {
}); });
if (!run) { if (!run) {
res.status(202).json({ status: "skipped" }); res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
return; return;
} }

View file

@ -21,7 +21,11 @@ export function instanceSettingsRoutes(db: Db) {
const svc = instanceSettingsService(db); const svc = instanceSettingsService(db);
router.get("/instance/settings/general", async (req, res) => { router.get("/instance/settings/general", async (req, res) => {
assertCanManageInstanceSettings(req); // General settings (e.g. keyboardShortcuts) are readable by any
// authenticated board user. Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
res.json(await svc.getGeneral()); res.json(await svc.getGeneral());
}); });
@ -56,7 +60,11 @@ export function instanceSettingsRoutes(db: Db) {
); );
router.get("/instance/settings/experimental", async (req, res) => { router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req); // Experimental settings are readable by any authenticated board user.
// Only PATCH requires instance-admin.
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
res.json(await svc.getExperimental()); res.json(await svc.getExperimental());
}); });

View file

@ -19,12 +19,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
if (parsed.success) { if (parsed.success) {
return { return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
feedbackDataSharingPreference: feedbackDataSharingPreference:
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
}; };
} }
return { return {
censorUsernameInLogs: false, censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
}; };
} }

View file

@ -8,6 +8,7 @@ import type {
AgentKeyCreated, AgentKeyCreated,
AgentRuntimeState, AgentRuntimeState,
AgentTaskSession, AgentTaskSession,
AgentWakeupResponse,
HeartbeatRun, HeartbeatRun,
Approval, Approval,
AgentConfigRevision, AgentConfigRevision,
@ -189,7 +190,7 @@ export const agentsApi = {
idempotencyKey?: string | null; idempotencyKey?: string | null;
}, },
companyId?: string, companyId?: string,
) => api.post<HeartbeatRun | { status: "skipped" }>(agentPath(id, companyId, "/wakeup"), data), ) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) => loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}), api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
availableSkills: () => availableSkills: () =>

View file

@ -113,4 +113,27 @@ describe("IssueRow", () => {
root.unmount(); root.unmount();
}); });
}); });
it("preserves the issue detail breadcrumb source and href in the link target", () => {
const root = createRoot(container);
const issue = createIssue();
const state = {
issueDetailBreadcrumb: { label: "Inbox", href: "/PAP/inbox/mine" },
issueDetailSource: "inbox",
};
act(() => {
root.render(<IssueRow issue={issue} issueLinkState={state} />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.getAttribute("to") ?? link?.getAttribute("href")).toContain(
"/issues/PAP-1?from=inbox&fromHref=%2FPAP%2Finbox%2Fmine",
);
act(() => {
root.unmount();
});
});
}); });

View file

@ -17,6 +17,7 @@ import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner"; import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner"; import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
@ -24,6 +25,7 @@ import { useTheme } from "../context/ThemeContext";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
import { healthApi } from "../api/health"; import { healthApi } from "../api/health";
import { instanceSettingsApi } from "../api/instanceSettings";
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
import { import {
DEFAULT_INSTANCE_SETTINGS_PATH, DEFAULT_INSTANCE_SETTINGS_PATH,
@ -85,6 +87,10 @@ export function Layout() {
}, },
refetchIntervalInBackground: true, refetchIntervalInBackground: true,
}); });
const keyboardShortcutsEnabled = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.keyboardShortcuts === true;
useEffect(() => { useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return; if (companiesLoading || onboardingTriggered.current) return;
@ -141,6 +147,7 @@ export function Layout() {
useCompanyPageMemory(); useCompanyPageMemory();
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: keyboardShortcutsEnabled,
onNewIssue: () => openNewIssue(), onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar, onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel, onTogglePanel: togglePanel,
@ -259,12 +266,13 @@ export function Layout() {
}, [location.hash, location.pathname, location.search]); }, [location.hash, location.pathname, location.search]);
return ( return (
<div <GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div
className={cn( className={cn(
"bg-background text-foreground pt-[env(safe-area-inset-top)]", "bg-background text-foreground pt-[env(safe-area-inset-top)]",
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden", isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
)} )}
> >
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@ -436,6 +444,7 @@ export function Layout() {
<NewGoalDialog /> <NewGoalDialog />
<NewAgentDialog /> <NewAgentDialog />
<ToastViewport /> <ToastViewport />
</div> </div>
</GeneralSettingsProvider>
); );
} }

View file

@ -0,0 +1,28 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
export interface GeneralSettingsContextValue {
keyboardShortcutsEnabled: boolean;
}
const GeneralSettingsContext = createContext<GeneralSettingsContextValue>({
keyboardShortcutsEnabled: false,
});
export function GeneralSettingsProvider({
value,
children,
}: {
value: GeneralSettingsContextValue;
children: ReactNode;
}) {
return (
<GeneralSettingsContext.Provider value={value}>
{children}
</GeneralSettingsContext.Provider>
);
}
export function useGeneralSettings() {
return useContext(GeneralSettingsContext);
}

View file

@ -1,17 +1,25 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
interface ShortcutHandlers { interface ShortcutHandlers {
enabled?: boolean;
onNewIssue?: () => void; onNewIssue?: () => void;
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
onTogglePanel?: () => void; onTogglePanel?: () => void;
} }
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) { export function useKeyboardShortcuts({
enabled = true,
onNewIssue,
onToggleSidebar,
onTogglePanel,
}: ShortcutHandlers) {
useEffect(() => { useEffect(() => {
if (!enabled) return;
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs // Don't fire shortcuts when typing in inputs
const target = e.target as HTMLElement; if (isKeyboardShortcutTextInputTarget(e.target)) {
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return; return;
} }
@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [onNewIssue, onToggleSidebar, onTogglePanel]); }, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
} }

View file

@ -193,13 +193,24 @@
.scrollbar-auto-hide::-webkit-scrollbar-thumb { .scrollbar-auto-hide::-webkit-scrollbar-thumb {
background: transparent !important; background: transparent !important;
} }
/* Light mode scrollbar on hover */
.scrollbar-auto-hide:hover::-webkit-scrollbar-track { .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important; background: oklch(0.92 0 0) !important;
} }
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb { .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
background: oklch(0.4 0 0) !important; background: oklch(0.7 0 0) !important;
} }
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover { .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0) !important;
}
/* Dark mode scrollbar on hover */
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important;
}
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
background: oklch(0.4 0 0) !important;
}
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0) !important; background: oklch(0.5 0 0) !important;
} }

View file

@ -1,18 +1,32 @@
// @vitest-environment node // @vitest-environment node
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type {
Approval,
DashboardSummary,
ExecutionWorkspace,
HeartbeatRun,
Issue,
JoinRequest,
ProjectWorkspace,
} from "@paperclipai/shared";
import { import {
DEFAULT_INBOX_ISSUE_COLUMNS,
computeInboxBadgeData, computeInboxBadgeData,
getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns,
loadLastInboxTab, loadLastInboxTab,
normalizeInboxIssueColumns,
RECENT_ISSUES_LIMIT, RECENT_ISSUES_LIMIT,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex, resolveInboxSelectionIndex,
saveInboxIssueColumns,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
} from "./inbox"; } from "./inbox";
@ -170,6 +184,63 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
}; };
} }
function makeProjectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
return {
id: "project-workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Primary workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: null,
repoRef: null,
defaultRef: null,
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
isPrimary: true,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
...overrides,
};
}
function makeExecutionWorkspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
return {
id: "execution-workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 branch",
status: "active",
cwd: "/tmp/project/worktree",
repoUrl: null,
baseRef: null,
branchName: "pap-1",
providerType: "git_worktree",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-03-11T00:00:00.000Z"),
openedAt: new Date("2026-03-11T00:00:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
...overrides,
};
}
const dashboard: DashboardSummary = { const dashboard: DashboardSummary = {
companyId: "company-1", companyId: "company-1",
agents: { agents: {
@ -314,6 +385,16 @@ describe("inbox helpers", () => {
]); ]);
}); });
it("sorts touched issues by latest external comment timestamp", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z");
const olderIssue = makeIssue("2", true);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
});
it("mixes join requests into the inbox feed by most recent activity", () => { it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true); const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
@ -419,6 +500,116 @@ describe("inbox helpers", () => {
expect(loadLastInboxTab()).toBe("all"); expect(loadLastInboxTab()).toBe("all");
}); });
it("defaults issue columns to the current inbox layout", () => {
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
});
it("normalizes saved issue columns to valid values in canonical order", () => {
saveInboxIssueColumns(["labels", "updated", "status", "workspace", "labels", "assignee"]);
expect(loadInboxIssueColumns()).toEqual(["status", "assignee", "workspace", "labels", "updated"]);
expect(normalizeInboxIssueColumns(["project", "workspace", "wat", "id"])).toEqual(["id", "project", "workspace"]);
});
it("hides the workspace column option unless isolated workspaces are enabled", () => {
expect(getAvailableInboxIssueColumns(false)).toEqual(["status", "id", "assignee", "project", "labels", "updated"]);
expect(getAvailableInboxIssueColumns(true)).toEqual([
"status",
"id",
"assignee",
"project",
"workspace",
"labels",
"updated",
]);
});
it("allows hiding every optional issue column down to the title-only view", () => {
saveInboxIssueColumns([]);
expect(loadInboxIssueColumns()).toEqual([]);
});
it("shows explicit workspace names but leaves the default workspace blank", () => {
const issue = makeIssue("1", true);
issue.projectId = "project-1";
issue.projectWorkspaceId = "project-workspace-1";
issue.executionWorkspaceId = "execution-workspace-1";
const executionWorkspace = makeExecutionWorkspace();
const defaultWorkspace = makeProjectWorkspace();
const secondaryWorkspace = makeProjectWorkspace({
id: "project-workspace-2",
name: "Secondary workspace",
isPrimary: false,
});
expect(
resolveIssueWorkspaceName(issue, {
executionWorkspaceById: new Map([[executionWorkspace.id, executionWorkspace]]),
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBe("PAP-1 branch");
issue.executionWorkspaceId = null;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
issue.projectWorkspaceId = secondaryWorkspace.id;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBe("Secondary workspace");
issue.projectWorkspaceId = null;
expect(
resolveIssueWorkspaceName(issue, {
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
issue.executionWorkspaceId = "execution-workspace-shared-default";
issue.projectWorkspaceId = defaultWorkspace.id;
expect(
resolveIssueWorkspaceName(issue, {
executionWorkspaceById: new Map([[
issue.executionWorkspaceId,
makeExecutionWorkspace({
id: issue.executionWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
projectWorkspaceId: defaultWorkspace.id,
name: "PAP-1067",
}),
]]),
projectWorkspaceById: new Map([
[defaultWorkspace.id, defaultWorkspace],
[secondaryWorkspace.id, secondaryWorkspace],
]),
defaultProjectWorkspaceIdByProjectId: new Map([[issue.projectId!, defaultWorkspace.id]]),
}),
).toBeNull();
});
it("maps legacy new-tab storage to mine", () => { it("maps legacy new-tab storage to mine", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new"); localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("mine"); expect(loadLastInboxTab()).toBe("mine");

View file

@ -1,10 +1,4 @@
import type { import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
Approval,
DashboardSummary,
HeartbeatRun,
Issue,
JoinRequest,
} from "@paperclipai/shared";
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"]);
@ -12,8 +6,12 @@ export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_reques
export const DISMISSED_KEY = "paperclip:inbox:dismissed"; export const DISMISSED_KEY = "paperclip:inbox:dismissed";
export const READ_ITEMS_KEY = "paperclip:inbox:read-items"; export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export type InboxTab = "mine" | "recent" | "unread" | "all"; export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "labels", "updated"] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
export type InboxWorkItem = export type InboxWorkItem =
| { | {
kind: "issue"; kind: "issue";
@ -79,6 +77,80 @@ export function saveReadInboxItems(ids: Set<string>) {
} }
} }
export function normalizeInboxIssueColumns(columns: Iterable<string | InboxIssueColumn>): InboxIssueColumn[] {
const selected = new Set(columns);
return inboxIssueColumns.filter((column) => selected.has(column));
}
export function getAvailableInboxIssueColumns(enableWorkspaceColumn: boolean): InboxIssueColumn[] {
if (enableWorkspaceColumn) return [...inboxIssueColumns];
return inboxIssueColumns.filter((column) => column !== "workspace");
}
export function loadInboxIssueColumns(): InboxIssueColumn[] {
try {
const raw = localStorage.getItem(INBOX_ISSUE_COLUMNS_KEY);
if (raw === null) return DEFAULT_INBOX_ISSUE_COLUMNS;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return DEFAULT_INBOX_ISSUE_COLUMNS;
return normalizeInboxIssueColumns(parsed);
} catch {
return DEFAULT_INBOX_ISSUE_COLUMNS;
}
}
export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
try {
localStorage.setItem(
INBOX_ISSUE_COLUMNS_KEY,
JSON.stringify(normalizeInboxIssueColumns(columns)),
);
} catch {
// Ignore localStorage failures.
}
}
export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}: {
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>;
},
): string | null {
const defaultProjectWorkspaceId = issue.projectId
? defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
: null;
if (issue.executionWorkspaceId) {
const executionWorkspace = executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace" && linkedProjectWorkspaceId === defaultProjectWorkspaceId;
if (isDefaultSharedExecutionWorkspace) return null;
const workspaceName = executionWorkspace?.name;
if (workspaceName) return workspaceName;
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
const workspaceName = projectWorkspaceById?.get(issue.projectWorkspaceId)?.name;
if (workspaceName) return workspaceName;
}
return null;
}
export function loadLastInboxTab(): InboxTab { export function loadLastInboxTab(): InboxTab {
try { try {
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState, createIssueDetailLocationState,
createIssueDetailPath, createIssueDetailPath,
readIssueDetailBreadcrumb, readIssueDetailBreadcrumb,
shouldArmIssueDetailInboxQuickArchive,
} from "./issueDetailBreadcrumb"; } from "./issueDetailBreadcrumb";
describe("issueDetailBreadcrumb", () => { describe("issueDetailBreadcrumb", () => {
@ -25,10 +27,30 @@ describe("issueDetailBreadcrumb", () => {
it("adds the source query param when building an issue detail path", () => { it("adds the source query param when building an issue detail path", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox"); expect(createIssueDetailPath("PAP-465", state)).toBe(
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
);
}); });
it("reuses the current source query param when state has been dropped", () => { it("reuses the current source query param when state has been dropped", () => {
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues"); expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
);
});
it("restores the exact breadcrumb href from the query fallback", () => {
expect(
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
).toEqual({
label: "Inbox",
href: "/PAP/inbox/unread",
});
});
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(shouldArmIssueDetailInboxQuickArchive(state)).toBe(false);
expect(shouldArmIssueDetailInboxQuickArchive(armIssueDetailInboxQuickArchive(state))).toBe(true);
}); });
}); });

View file

@ -8,9 +8,11 @@ type IssueDetailBreadcrumb = {
type IssueDetailLocationState = { type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb; issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource; issueDetailSource?: IssueDetailSource;
issueDetailInboxQuickArchiveArmed?: boolean;
}; };
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false; if (typeof value !== "object" || value === null) return false;
@ -35,6 +37,13 @@ function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | n
return isIssueDetailSource(source) ? source : null; return isIssueDetailSource(source) ? source : null;
} }
function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null {
if (!search) return null;
const params = new URLSearchParams(search);
const href = params.get(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
return href && href.startsWith("/") ? href : null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb { function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" }; if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" }; return { label: "Issues", href: "/issues" };
@ -51,11 +60,30 @@ export function createIssueDetailLocationState(
}; };
} }
export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLocationState {
if (typeof state !== "object" || state === null) {
return { issueDetailInboxQuickArchiveArmed: true };
}
return {
...(state as IssueDetailLocationState),
issueDetailInboxQuickArchiveArmed: true,
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string { export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search); const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
const breadcrumb =
(typeof state === "object" && state !== null
? (state as IssueDetailLocationState).issueDetailBreadcrumb
: null);
const breadcrumbHref =
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
readIssueDetailBreadcrumbHrefFromSearch(search);
if (!source) return `/issues/${issuePathId}`; if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
return `/issues/${issuePathId}?${params.toString()}`; return `/issues/${issuePathId}?${params.toString()}`;
} }
@ -66,5 +94,14 @@ export function readIssueDetailBreadcrumb(state: unknown, search?: string): Issu
} }
const source = readIssueDetailSourceFromSearch(search); const source = readIssueDetailSourceFromSearch(search);
return source ? breadcrumbForSource(source) : null; if (!source) return null;
const fallback = breadcrumbForSource(source);
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
return href ? { ...fallback, href } : fallback;
}
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
if (typeof state !== "object" || state === null) return false;
return (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true;
} }

View file

@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget,
resolveInboxQuickArchiveKeyAction,
} from "./keyboardShortcuts";
describe("keyboardShortcuts helpers", () => {
it("detects editable shortcut targets", () => {
const wrapper = document.createElement("div");
wrapper.innerHTML = `
<div contenteditable="true"><span id="contenteditable-child">Editable</span></div>
<div role="textbox"><span id="textbox-child">Textbox</span></div>
<button id="button">Action</button>
`;
const editableChild = wrapper.querySelector("#contenteditable-child");
const textboxChild = wrapper.querySelector("#textbox-child");
const button = wrapper.querySelector("#button");
expect(isKeyboardShortcutTextInputTarget(editableChild)).toBe(true);
expect(isKeyboardShortcutTextInputTarget(textboxChild)).toBe(true);
expect(isKeyboardShortcutTextInputTarget(button)).toBe(false);
});
it("reports when a modal dialog is open", () => {
const root = document.createElement("div");
root.innerHTML = `<div role="dialog" aria-modal="true"></div>`;
expect(hasBlockingShortcutDialog(root)).toBe(true);
expect(hasBlockingShortcutDialog(document.createElement("div"))).toBe(false);
});
it("archives only the first clean y press", () => {
const button = document.createElement("button");
expect(resolveInboxQuickArchiveKeyAction({
armed: true,
defaultPrevented: false,
key: "y",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("archive");
});
it("disarms on the first non-y keypress", () => {
const button = document.createElement("button");
expect(resolveInboxQuickArchiveKeyAction({
armed: true,
defaultPrevented: false,
key: "n",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("disarm");
});
it("stays inert for modifier combos before a real keypress", () => {
const button = document.createElement("button");
expect(resolveInboxQuickArchiveKeyAction({
armed: true,
defaultPrevented: false,
key: "Meta",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("ignore");
expect(resolveInboxQuickArchiveKeyAction({
armed: true,
defaultPrevented: false,
key: "y",
metaKey: true,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("ignore");
});
it("disarms instead of archiving when typing into an editor", () => {
const input = document.createElement("input");
expect(resolveInboxQuickArchiveKeyAction({
armed: true,
defaultPrevented: false,
key: "y",
metaKey: false,
ctrlKey: false,
altKey: false,
target: input,
hasOpenDialog: false,
})).toBe("disarm");
});
});

View file

@ -0,0 +1,54 @@
export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
"input",
"textarea",
"select",
"[contenteditable='true']",
"[contenteditable='plaintext-only']",
"[role='textbox']",
"[role='combobox']",
].join(", ");
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return !!target.closest(KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR);
}
export function hasBlockingShortcutDialog(root: ParentNode = document): boolean {
return !!root.querySelector("[role='dialog'], [aria-modal='true']");
}
export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEYS.has(key);
}
export function resolveInboxQuickArchiveKeyAction({
armed,
defaultPrevented,
key,
metaKey,
ctrlKey,
altKey,
target,
hasOpenDialog,
}: {
armed: boolean;
defaultPrevented: boolean;
key: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
target: EventTarget | null;
hasOpenDialog: boolean;
}): InboxQuickArchiveKeyAction {
if (!armed) return "ignore";
if (defaultPrevented) return "disarm";
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
if (key === "y") return "archive";
return "disarm";
}

View file

@ -2937,7 +2937,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
payload: resumePayload, payload: resumePayload,
}, run.companyId); }, run.companyId);
if (!("id" in result)) { if (!("id" in result)) {
throw new Error("Resume request was skipped because the agent is not currently invokable."); throw new Error(result.message ?? "Resume request was skipped.");
} }
return result; return result;
}, },
@ -2969,7 +2969,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
payload: retryPayload, payload: retryPayload,
}, run.companyId); }, run.companyId);
if (!("id" in result)) { if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable."); throw new Error(result.message ?? "Retry was skipped.");
} }
return result; return result;
}, },

View file

@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
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 { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox"; import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => ( Link: ({ children, className, ...props }: ComponentProps<"a">) => (
@ -148,31 +148,91 @@ describe("InboxIssueMetaLeading", () => {
container.remove(); container.remove();
}); });
it("neutralizes selected status and live accents", () => { it("keeps status and live accents visible", () => {
const root = createRoot(container); const root = createRoot(container);
act(() => { act(() => {
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />); root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
}); });
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); const statusIcon = container.querySelector('span[class*="border-blue-600"]');
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]'); const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
(node) => node.textContent === "Live" && node.className.includes("text-"), (node) => node.textContent === "Live" && node.className.includes("text-"),
); );
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]'); const liveDot = container.querySelector('span[class*="bg-blue-500"]');
const pulseRing = container.querySelector('span[class*="animate-pulse"]'); const pulseRing = container.querySelector('span[class*="animate-pulse"]');
expect(statusIcon).not.toBeNull(); expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).toContain("!border-muted-foreground"); expect(statusIcon?.className).not.toContain("!border-muted-foreground");
expect(statusIcon?.className).toContain("!text-muted-foreground"); expect(statusIcon?.className).not.toContain("!text-muted-foreground");
expect(liveBadge).not.toBeNull(); expect(liveBadge).not.toBeNull();
expect(liveBadge?.className).toContain("bg-muted"); expect(liveBadge?.className).toContain("bg-blue-500/10");
expect(liveBadgeLabel).not.toBeNull(); expect(liveBadgeLabel).not.toBeNull();
expect(liveBadgeLabel?.className).toContain("text-muted-foreground"); expect(liveBadgeLabel?.className).toContain("text-blue-600");
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
expect(liveDot).not.toBeNull(); expect(liveDot).not.toBeNull();
expect(pulseRing).toBeNull(); expect(pulseRing).not.toBeNull();
act(() => {
root.unmount();
});
});
});
describe("InboxIssueTrailingColumns", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders an empty tags cell when an issue has no labels", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue({ labels: [], labelIds: [] })}
columns={["labels"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => {
root.unmount();
});
});
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue()}
columns={["workspace"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => { act(() => {
root.unmount(); root.unmount();

View file

@ -4,15 +4,25 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access"; import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { dashboardApi } from "../api/dashboard"; import { dashboardApi } from "../api/dashboard";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
createIssueDetailPath,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow"; import { IssueRow } from "../components/IssueRow";
@ -21,11 +31,31 @@ import { SwipeToArchive } from "../components/SwipeToArchive";
import { StatusIcon } from "../components/StatusIcon"; import { StatusIcon } from "../components/StatusIcon";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -40,19 +70,29 @@ import {
X, X,
RotateCcw, RotateCcw,
UserPlus, UserPlus,
Columns3,
Search,
} from "lucide-react"; } from "lucide-react";
import { Input } from "@/components/ui/input";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import { import {
ACTIONABLE_APPROVAL_STATUSES, ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns,
normalizeInboxIssueColumns,
resolveIssueWorkspaceName,
resolveInboxSelectionIndex, resolveInboxSelectionIndex,
saveInboxIssueColumns,
InboxApprovalFilter, InboxApprovalFilter,
type InboxIssueColumn,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
type InboxTab, type InboxTab,
@ -100,58 +140,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground"; const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"];
const inboxIssueColumnLabels: Record<InboxIssueColumn, string> = {
function getSelectedUnreadButtonClass(selected: boolean): string { status: "Status",
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20"; id: "ID",
} assignee: "Assignee",
project: "Project",
function getSelectedUnreadDotClass(selected: boolean): string { workspace: "Workspace",
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400"; labels: "Tags",
} updated: "Last updated",
};
const inboxIssueColumnDescriptions: Record<InboxIssueColumn, string> = {
status: "Issue state chip on the left edge.",
id: "Ticket identifier like PAP-1009.",
assignee: "Assigned agent or board user.",
project: "Linked project pill with its color.",
workspace: "Execution or project workspace used for the issue.",
labels: "Issue labels and tags.",
updated: "Latest visible activity time.",
};
export function InboxIssueMetaLeading({ export function InboxIssueMetaLeading({
issue, issue,
selected,
isLive, isLive,
showStatus = true,
showIdentifier = true,
}: { }: {
issue: Issue; issue: Issue;
selected: boolean;
isLive: boolean; isLive: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
}) { }) {
return ( return (
<> <>
<span className="hidden shrink-0 sm:inline-flex"> {showStatus ? (
<StatusIcon <span className="hidden shrink-0 sm:inline-flex">
status={issue.status} <StatusIcon status={issue.status} />
className={selected ? selectedInboxAccentClass : undefined} </span>
/> ) : null}
</span> {showIdentifier ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)} {issue.identifier ?? issue.id.slice(0, 8)}
</span> </span>
) : null}
{isLive && ( {isLive && (
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2", "inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
selected ? "bg-muted" : "bg-blue-500/10", "bg-blue-500/10",
)} )}
> >
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
{!selected ? ( <span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
) : null}
<span <span
className={cn( className={cn(
"relative inline-flex h-2 w-2 rounded-full", "relative inline-flex h-2 w-2 rounded-full",
selected ? "bg-muted-foreground/70" : "bg-blue-500", "bg-blue-500",
)} )}
/> />
</span> </span>
<span <span
className={cn( className={cn(
"hidden text-[11px] font-medium sm:inline", "hidden text-[11px] font-medium sm:inline",
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400", "text-blue-600 dark:text-blue-400",
)} )}
> >
Live Live
@ -162,6 +213,150 @@ export function InboxIssueMetaLeading({
); );
} }
function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
return columns
.map((column) => {
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
if (column === "project") return "minmax(6.5rem, 8.5rem)";
if (column === "workspace") return "minmax(9rem, 12rem)";
if (column === "labels") return "minmax(8rem, 10rem)";
return "minmax(6rem, 7rem)";
})
.join(" ");
}
export function InboxIssueTrailingColumns({
issue,
columns,
projectName,
projectColor,
workspaceName,
assigneeName,
currentUserId,
}: {
issue: Issue;
columns: InboxIssueColumn[];
projectName: string | null;
projectColor: string | null;
workspaceName: string | null;
assigneeName: string | null;
currentUserId: string | null;
}) {
const activityText = timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (
<span
className="grid items-center gap-2"
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
>
{columns.map((column) => {
if (column === "assignee") {
if (issue.assigneeAgentId) {
return (
<span key={column} className="min-w-0 text-xs text-foreground">
<Identity
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
size="sm"
className="min-w-0"
/>
</span>
);
}
if (issue.assigneeUserId) {
return (
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{userLabel}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
Unassigned
</span>
);
}
if (column === "project") {
if (projectName) {
const accentColor = projectColor ?? "#64748b";
return (
<span
key={column}
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accentColor }}
/>
<span className="truncate">{projectName}</span>
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
No project
</span>
);
}
if (column === "labels") {
if ((issue.labels ?? []).length > 0) {
return (
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
{(issue.labels ?? []).slice(0, 2).map((label) => (
<span
key={label.id}
className="inline-flex min-w-0 max-w-full items-center font-medium"
style={{
color: pickTextColorForPillBg(label.color, 0.12),
}}
>
<span className="truncate">{label.name}</span>
</span>
))}
{(issue.labels ?? []).length > 2 ? (
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
+{(issue.labels ?? []).length - 2}
</span>
) : null}
</span>
);
}
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
if (column === "workspace") {
if (!workspaceName) {
return <span key={column} className="min-w-0" aria-hidden="true" />;
}
return (
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
{workspaceName}
</span>
);
}
return (
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
{activityText}
</span>
);
})}
</span>
);
}
export function FailedRunInboxRow({ export function FailedRunInboxRow({
run, run,
issueById, issueById,
@ -211,13 +406,13 @@ export function FailedRunInboxRow({
onClick={onMarkRead} onClick={onMarkRead}
className={cn( className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected), "hover:bg-blue-500/20",
)} )}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected), "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -367,13 +562,13 @@ function ApprovalInboxRow({
onClick={onMarkRead} onClick={onMarkRead}
className={cn( className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected), "hover:bg-blue-500/20",
)} )}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected), "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -506,13 +701,13 @@ function JoinRequestInboxRow({
onClick={onMarkRead} onClick={onMarkRead}
className={cn( className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected), "hover:bg-blue-500/20",
)} )}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full transition-opacity duration-300", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected), "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -597,8 +792,16 @@ export function Inbox() {
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const { keyboardShortcutsEnabled } = useGeneralSettings();
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const [searchQuery, setSearchQuery] = useState("");
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed, dismiss } = useDismissedInboxItems(); const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems(); const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
@ -618,12 +821,31 @@ export function Inbox() {
[location.pathname, location.search, location.hash], [location.pathname, location.search, location.hash],
); );
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.list(selectedCompanyId)
: ["execution-workspaces", "__disabled__"],
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
});
useEffect(() => { useEffect(() => {
setBreadcrumbs([{ label: "Inbox" }]); setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
@ -631,6 +853,7 @@ export function Inbox() {
useEffect(() => { useEffect(() => {
saveLastInboxTab(tab); saveLastInboxTab(tab);
setSelectedIndex(-1); setSelectedIndex(-1);
setSearchQuery("");
}, [tab]); }, [tab]);
const { const {
@ -731,6 +954,59 @@ export function Inbox() {
for (const issue of issues ?? []) map.set(issue.id, issue); for (const issue of issues ?? []) map.set(issue.id, issue);
return map; return map;
}, [issues]); }, [issues]);
const projectById = useMemo(() => {
const map = new Map<string, { name: string; color: string | null }>();
for (const project of projects ?? []) {
map.set(project.id, { name: project.name, color: project.color });
}
return map;
}, [projects]);
const projectWorkspaceById = useMemo(() => {
const map = new Map<string, { name: string }>();
for (const project of projects ?? []) {
for (const workspace of project.workspaces ?? []) {
map.set(workspace.id, { name: workspace.name });
}
}
return map;
}, [projects]);
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
const map = new Map<string, string>();
for (const project of projects ?? []) {
const defaultWorkspaceId =
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
?? project.primaryWorkspace?.id
?? null;
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
}
return map;
}, [projects]);
const executionWorkspaceById = useMemo(() => {
const map = new Map<string, {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
}>();
for (const workspace of executionWorkspaces) {
map.set(workspace.id, {
name: workspace.name,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
});
}
return map;
}, [executionWorkspaces]);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
[isolatedWorkspacesEnabled],
);
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
const visibleTrailingIssueColumns = useMemo(
() => trailingIssueColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet],
);
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const failedRuns = useMemo( const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)), () => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
@ -784,10 +1060,81 @@ export function Inbox() {
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab], [approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab, joinRequestsForTab],
); );
const filteredWorkItems = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return workItemsToRender;
return workItemsToRender.filter((item) => {
if (item.kind === "issue") {
const issue = item.issue;
if (issue.title.toLowerCase().includes(q)) return true;
if (issue.identifier?.toLowerCase().includes(q)) return true;
if (issue.description?.toLowerCase().includes(q)) return true;
if (isolatedWorkspacesEnabled) {
const workspaceName = resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
});
if (workspaceName?.toLowerCase().includes(q)) return true;
}
return false;
}
if (item.kind === "approval") {
const a = item.approval;
const label = approvalLabel(a.type, a.payload as Record<string, unknown> | null);
if (label.toLowerCase().includes(q)) return true;
if (a.type.toLowerCase().includes(q)) return true;
return false;
}
if (item.kind === "failed_run") {
const run = item.run;
const name = agentById.get(run.agentId);
if (name?.toLowerCase().includes(q)) return true;
const msg = runFailureMessage(run);
if (msg.toLowerCase().includes(q)) return true;
const issueId = readIssueIdFromRun(run);
if (issueId) {
const issue = issueById.get(issueId);
if (issue?.title.toLowerCase().includes(q)) return true;
if (issue?.identifier?.toLowerCase().includes(q)) return true;
}
return false;
}
if (item.kind === "join_request") {
const jr = item.joinRequest;
if (jr.agentName?.toLowerCase().includes(q)) return true;
if (jr.capabilities?.toLowerCase().includes(q)) return true;
return false;
}
return false;
});
}, [
workItemsToRender,
searchQuery,
agentById,
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
issueById,
isolatedWorkspacesEnabled,
projectWorkspaceById,
]);
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
if (!id) return null; if (!id) return null;
return agentById.get(id) ?? null; return agentById.get(id) ?? null;
}; };
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
const normalized = normalizeInboxIssueColumns(next);
setVisibleIssueColumns(normalized);
saveInboxIssueColumns(normalized);
}, []);
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
if (enabled) {
setIssueColumns([...visibleIssueColumns, column]);
return;
}
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]);
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: (id: string) => approvalsApi.approve(id), mutationFn: (id: string) => approvalsApi.approve(id),
@ -858,7 +1205,7 @@ export function Inbox() {
payload, payload,
}); });
if (!("id" in result)) { if (!("id" in result)) {
throw new Error("Retry was skipped because the agent is not currently invokable."); throw new Error(result.message ?? "Retry was skipped.");
} }
return { newRun: result, originalRun: run }; return { newRun: result, originalRun: run };
}, },
@ -881,6 +1228,7 @@ export function Inbox() {
}); });
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set()); const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const [showMarkAllReadConfirm, setShowMarkAllReadConfirm] = useState(false);
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set()); const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set()); const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
@ -1017,12 +1365,12 @@ export function Inbox() {
// Keep selection valid when the list shape changes, but do not auto-select on initial load. // Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => { useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length)); setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, filteredWorkItems.length));
}, [workItemsToRender.length]); }, [filteredWorkItems.length]);
// Use refs for keyboard handler to avoid stale closures // Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({ const kbStateRef = useRef({
workItems: workItemsToRender, workItems: filteredWorkItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivingIssueIds, archivingIssueIds,
@ -1031,7 +1379,7 @@ export function Inbox() {
readItems, readItems,
}); });
kbStateRef.current = { kbStateRef.current = {
workItems: workItemsToRender, workItems: filteredWorkItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
archivingIssueIds, archivingIssueIds,
@ -1061,6 +1409,8 @@ export function Inbox() {
// Keyboard shortcuts (mail-client style) — single stable listener using refs // Keyboard shortcuts (mail-client style) — single stable listener using refs
useEffect(() => { useEffect(() => {
if (!keyboardShortcutsEnabled) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
@ -1068,9 +1418,8 @@ export function Inbox() {
const target = e.target; const target = e.target;
if ( if (
!(target instanceof HTMLElement) || !(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") || isKeyboardShortcutTextInputTarget(target) ||
target.isContentEditable || hasBlockingShortcutDialog(document) ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey || e.metaKey ||
e.ctrlKey || e.ctrlKey ||
e.altKey e.altKey
@ -1148,7 +1497,8 @@ export function Inbox() {
const item = st.workItems[st.selectedIndex]; const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") { if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id; const pathId = item.issue.identifier ?? item.issue.id;
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState }); const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
act.navigate(createIssueDetailPath(pathId, detailState), { state: detailState });
} else if (item.kind === "approval") { } else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`); act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") { } else if (item.kind === "failed_run") {
@ -1162,7 +1512,7 @@ export function Inbox() {
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [getWorkItemKey, issueLinkState]); }, [getWorkItemKey, issueLinkState, keyboardShortcutsEnabled]);
// Scroll selected item into view // Scroll selected item into view
useEffect(() => { useEffect(() => {
@ -1184,7 +1534,7 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 && dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget"); !dismissed.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = workItemsToRender.length > 0; const showWorkItemsSection = filteredWorkItems.length > 0;
const showAlertsSection = shouldShowInboxSection({ const showAlertsSection = shouldShowInboxSection({
tab, tab,
hasItems: hasAlerts, hasItems: hasAlerts,
@ -1214,7 +1564,6 @@ export function Inbox() {
const unreadIssueIds = markAllReadIssues const unreadIssueIds = markAllReadIssues
.map((issue) => issue.id); .map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0; const canMarkAllRead = unreadIssueIds.length > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -1236,17 +1585,104 @@ export function Inbox() {
</Tabs> </Tabs>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-[180px] pl-8 text-xs sm:w-[220px]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Columns3 className="mr-1 h-3.5 w-3.5" />
Show / hide columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
<div className="space-y-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Desktop issue rows
</div>
<div className="text-sm font-medium text-foreground">
Choose which inbox columns stay visible
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableIssueColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column}
checked={visibleIssueColumnSet.has(column)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => toggleIssueColumn(column, checked === true)}
className="items-start rounded-lg px-3 py-2.5 pl-8"
>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
{inboxIssueColumnLabels[column]}
</span>
<span className="text-xs leading-relaxed text-muted-foreground">
{inboxIssueColumnDescriptions[column]}
</span>
</span>
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
className="rounded-lg px-3 py-2 text-sm"
>
Reset defaults
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canMarkAllRead && ( {canMarkAllRead && (
<Button <>
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
className="h-8 shrink-0" size="sm"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)} className="h-8 shrink-0"
disabled={markAllReadMutation.isPending} onClick={() => setShowMarkAllReadConfirm(true)}
> disabled={markAllReadMutation.isPending}
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"} >
</Button> {markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
<Dialog open={showMarkAllReadConfirm} onOpenChange={setShowMarkAllReadConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mark all as read?</DialogTitle>
<DialogDescription>
This will mark {unreadIssueIds.length} unread {unreadIssueIds.length === 1 ? "item" : "items"} as read.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowMarkAllReadConfirm(false)}>
Cancel
</Button>
<Button
onClick={() => {
setShowMarkAllReadConfirm(false);
markAllReadMutation.mutate(unreadIssueIds);
}}
>
Mark all as read
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)} )}
</div> </div>
</div> </div>
@ -1297,9 +1733,11 @@ export function Inbox() {
{allLoaded && visibleSections.length === 0 && ( {allLoaded && visibleSections.length === 0 && (
<EmptyState <EmptyState
icon={InboxIcon} icon={searchQuery.trim() ? Search : InboxIcon}
message={ message={
tab === "mine" searchQuery.trim()
? "No inbox items match your search."
: tab === "mine"
? "Inbox zero." ? "Inbox zero."
: tab === "unread" : tab === "unread"
? "No new inbox items." ? "No new inbox items."
@ -1315,7 +1753,7 @@ export function Inbox() {
{showSeparatorBefore("work_items") && <Separator />} {showSeparatorBefore("work_items") && <Separator />}
<div> <div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card"> <div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.flatMap((item, index) => { {filteredWorkItems.flatMap((item, index) => {
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div <div
key={`sel-${key}`} key={`sel-${key}`}
@ -1331,13 +1769,13 @@ export function Inbox() {
index > 0 && index > 0 &&
item.timestamp > 0 && item.timestamp > 0 &&
item.timestamp < todayCutoff && item.timestamp < todayCutoff &&
workItemsToRender[index - 1].timestamp >= todayCutoff; filteredWorkItems[index - 1].timestamp >= todayCutoff;
const elements: ReactNode[] = []; const elements: ReactNode[] = [];
if (showTodayDivider) { if (showTodayDivider) {
elements.push( elements.push(
<div key="today-divider" className="flex items-center gap-3 px-4 my-2"> <div key="today-divider" className="flex items-center gap-3 px-4 my-2">
<div className="flex-1 border-t border-border" /> <div className="flex-1 border-t border-zinc-600" />
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"> <span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Earlier Earlier
</span> </span>
</div>, </div>,
@ -1458,6 +1896,7 @@ export function Inbox() {
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);
const isArchiving = archivingIssueIds.has(issue.id); const isArchiving = archivingIssueIds.has(issue.id);
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const row = ( const row = (
<IssueRow <IssueRow
key={`issue:${issue.id}`} key={`issue:${issue.id}`}
@ -1472,15 +1911,12 @@ export function Inbox() {
desktopMetaLeading={ desktopMetaLeading={
<InboxIssueMetaLeading <InboxIssueMetaLeading
issue={issue} issue={issue}
selected={isSelected}
isLive={liveIssueIds.has(issue.id)} isLive={liveIssueIds.has(issue.id)}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
/> />
} }
mobileMeta={ mobileMeta={issueActivityText(issue).toLowerCase()}
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
unreadState={ unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden" isUnread ? "visible" : isFading ? "fading" : "hidden"
} }
@ -1491,10 +1927,22 @@ export function Inbox() {
: undefined : undefined
} }
archiveDisabled={isArchiving || archiveIssueMutation.isPending} archiveDisabled={isArchiving || archiveIssueMutation.isPending}
trailingMeta={ desktopTrailing={
issue.lastExternalCommentAt visibleTrailingIssueColumns.length > 0 ? (
? `commented ${timeAgo(issue.lastExternalCommentAt)}` <InboxIssueTrailingColumns
: `updated ${timeAgo(issue.updatedAt)}` issue={issue}
columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null}
workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId}
/>
) : undefined
} }
/> />
); );

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings"; import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
@ -51,6 +52,7 @@ export function InstanceGeneralSettings() {
} }
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
return ( return (
@ -106,6 +108,36 @@ export function InstanceGeneralSettings() {
</div> </div>
</section> </section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Keyboard shortcuts</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating issues or
toggling panels. This is off by default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle keyboard shortcuts"
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
keyboardShortcuts ? "bg-green-600" : "bg-muted",
)}
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5"> <section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">