From 3ab7d52f00ebefaf34d503bc648f554f95be8c0e Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 2 Apr 2026 11:45:15 -0500 Subject: [PATCH 1/3] feat(inbox): add operator search and keyboard controls Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/types/heartbeat.ts | 12 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/instance.ts | 1 + packages/shared/src/types/issue.ts | 1 + packages/shared/src/validators/instance.ts | 1 + .../src/__tests__/agent-skills-routes.test.ts | 22 +- .../instance-settings-routes.test.ts | 26 +- server/src/routes/agents.ts | 81 ++- server/src/routes/instance-settings.ts | 12 +- server/src/services/instance-settings.ts | 2 + ui/src/api/agents.ts | 3 +- ui/src/components/IssueRow.test.tsx | 23 + ui/src/components/Layout.tsx | 6 + ui/src/hooks/useKeyboardShortcuts.ts | 16 +- ui/src/index.css | 15 +- ui/src/lib/inbox.test.ts | 204 +++++- ui/src/lib/inbox.ts | 89 ++- ui/src/lib/issueDetailBreadcrumb.test.ts | 26 +- ui/src/lib/issueDetailBreadcrumb.ts | 39 +- ui/src/lib/keyboardShortcuts.test.ts | 106 ++++ ui/src/lib/keyboardShortcuts.ts | 54 ++ ui/src/pages/Inbox.test.tsx | 85 ++- ui/src/pages/Inbox.tsx | 594 +++++++++++++++--- ui/src/pages/InstanceGeneralSettings.tsx | 32 + 25 files changed, 1340 insertions(+), 114 deletions(-) create mode 100644 ui/src/lib/keyboardShortcuts.test.ts create mode 100644 ui/src/lib/keyboardShortcuts.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 325ccbdb..a69da428 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -255,6 +255,8 @@ export type { FinanceSummary, FinanceByBiller, FinanceByKind, + AgentWakeupResponse, + AgentWakeupSkipped, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index c399b6da..5e8dd217 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -42,6 +42,18 @@ export interface HeartbeatRun { updatedAt: Date; } +export interface AgentWakeupSkipped { + status: "skipped"; + reason: string; + message: string | null; + issueId: string | null; + executionRunId: string | null; + executionAgentId: string | null; + executionAgentName: string | null; +} + +export type AgentWakeupResponse = HeartbeatRun | AgentWakeupSkipped; + export interface HeartbeatRunEvent { id: number; companyId: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d76b37a8..f11ce54d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -141,6 +141,8 @@ export type { export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js"; export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js"; export type { + AgentWakeupResponse, + AgentWakeupSkipped, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index ec156d89..70599868 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -2,6 +2,7 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; + keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; } diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index d55f8e15..9ef7f2d4 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -143,6 +143,7 @@ export interface Issue { mentionedProjects?: Project[]; myLastTouchAt?: Date | null; lastExternalCommentAt?: Date | null; + lastActivityAt?: Date | null; isUnreadForMe?: boolean; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 4afad283..5634c8f6 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -4,6 +4,7 @@ import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), + keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 801e0991..8590d988 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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")); mockAdapter.listSkills.mockResolvedValue({ adapterType: "codex_local", 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", desiredSkills: ["paperclipai/paperclip/paperclip"], entries: [], diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 4d8e12b1..08abfd19 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -35,6 +35,7 @@ describe("instance settings routes", () => { vi.clearAllMocks(); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, + keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ @@ -45,6 +46,7 @@ describe("instance settings routes", () => { id: "instance-settings-1", general: { censorUsernameInLogs: true, + keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }, }); @@ -114,6 +116,7 @@ describe("instance settings routes", () => { expect(getRes.status).toBe(200); expect(getRes.body).toEqual({ censorUsernameInLogs: false, + keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); @@ -121,18 +124,20 @@ describe("instance settings routes", () => { .patch("/api/instance/settings/general") .send({ censorUsernameInLogs: true, + keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }); expect(patchRes.status).toBe(200); expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ censorUsernameInLogs: true, + keyboardShortcuts: true, feedbackDataSharingPreference: "allowed", }); 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({ type: "board", userId: "user-1", @@ -143,8 +148,25 @@ describe("instance settings routes", () => { 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(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); + expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled(); }); it("rejects agent callers", async () => { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c2cacfe4..c2ed8ee2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2,7 +2,7 @@ import { Router, type Request } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; 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 { agentSkillSyncSchema, @@ -220,6 +220,73 @@ export function agentRoutes(db: Db) { return allowedByGrant || canCreateAgents(actorAgent); } + async function buildSkippedWakeupResponse( + agent: NonNullable>>, + payload: Record | 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 }) { assertCompanyAccess(req, targetAgent.companyId); 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) { - return adapterType !== "claude_local"; + return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); } async function buildRuntimeSkillConfig( @@ -1001,6 +1075,7 @@ export function agentRoutes(db: Db) { projectId: issue.projectId, goalId: issue.goalId, parentId: issue.parentId, + lastActivityAt: (issue as typeof issue & { lastActivityAt?: Date | null }).lastActivityAt ?? issue.updatedAt, updatedAt: issue.updatedAt, activeRun: issue.activeRun, })), @@ -1994,7 +2069,7 @@ export function agentRoutes(db: Db) { }); if (!run) { - res.status(202).json({ status: "skipped" }); + res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null)); return; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 1c9493ca..7bd24e36 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -21,7 +21,11 @@ export function instanceSettingsRoutes(db: Db) { const svc = instanceSettingsService(db); 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()); }); @@ -56,7 +60,11 @@ export function instanceSettingsRoutes(db: Db) { ); 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()); }); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index f1d2afcd..7856591d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -19,12 +19,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { if (parsed.success) { return { censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false, + keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, }; } return { censorUsernameInLogs: false, + keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, }; } diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ec090b43..bda8bf7a 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -8,6 +8,7 @@ import type { AgentKeyCreated, AgentRuntimeState, AgentTaskSession, + AgentWakeupResponse, HeartbeatRun, Approval, AgentConfigRevision, @@ -189,7 +190,7 @@ export const agentsApi = { idempotencyKey?: string | null; }, companyId?: string, - ) => api.post(agentPath(id, companyId, "/wakeup"), data), + ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), availableSkills: () => diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 3273d96c..04deb423 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -113,4 +113,27 @@ describe("IssueRow", () => { 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(); + }); + + 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(); + }); + }); }); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8761b71c..69457356 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -24,6 +24,7 @@ import { useTheme } from "../context/ThemeContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { DEFAULT_INSTANCE_SETTINGS_PATH, @@ -85,6 +86,10 @@ export function Layout() { }, refetchIntervalInBackground: true, }); + const keyboardShortcutsEnabled = useQuery({ + queryKey: queryKeys.instance.generalSettings, + queryFn: () => instanceSettingsApi.getGeneral(), + }).data?.keyboardShortcuts === true; useEffect(() => { if (companiesLoading || onboardingTriggered.current) return; @@ -141,6 +146,7 @@ export function Layout() { useCompanyPageMemory(); useKeyboardShortcuts({ + enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index 6120da80..6f5b33c4 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,17 +1,25 @@ import { useEffect } from "react"; +import { isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; interface ShortcutHandlers { + enabled?: boolean; onNewIssue?: () => void; onToggleSidebar?: () => void; onTogglePanel?: () => void; } -export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) { +export function useKeyboardShortcuts({ + enabled = true, + onNewIssue, + onToggleSidebar, + onTogglePanel, +}: ShortcutHandlers) { useEffect(() => { + if (!enabled) return; + function handleKeyDown(e: KeyboardEvent) { // Don't fire shortcuts when typing in inputs - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { + if (isKeyboardShortcutTextInputTarget(e.target)) { return; } @@ -36,5 +44,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onNewIssue, onToggleSidebar, onTogglePanel]); + }, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]); } diff --git a/ui/src/index.css b/ui/src/index.css index c220e8cd..12b1c966 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -193,13 +193,24 @@ .scrollbar-auto-hide::-webkit-scrollbar-thumb { background: transparent !important; } +/* Light mode scrollbar on hover */ .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 { - background: oklch(0.4 0 0) !important; + background: oklch(0.7 0 0) !important; } .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; } diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 9befeaa0..784e38cb 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1,18 +1,32 @@ // @vitest-environment node 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 { + DEFAULT_INBOX_ISSUE_COLUMNS, computeInboxBadgeData, + getAvailableInboxIssueColumns, getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, getRecentTouchedIssues, getUnreadTouchedIssues, isMineInboxTab, + loadInboxIssueColumns, loadLastInboxTab, + normalizeInboxIssueColumns, RECENT_ISSUES_LIMIT, + resolveIssueWorkspaceName, resolveInboxSelectionIndex, + saveInboxIssueColumns, saveLastInboxTab, shouldShowInboxSection, } from "./inbox"; @@ -166,10 +180,68 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue { labelIds: [], myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"), lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"), + lastActivityAt: new Date("2026-03-11T01:00:00.000Z"), isUnreadForMe, }; } +function makeProjectWorkspace(overrides: Partial = {}): 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 { + 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 = { companyId: "company-1", agents: { @@ -286,10 +358,10 @@ describe("inbox helpers", () => { it("mixes approvals into the inbox feed by most recent activity", () => { const newerIssue = makeIssue("1", true); - newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); + newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); const olderIssue = makeIssue("2", false); - olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z"); + olderIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z"); const approval = makeApprovalWithTimestamps( "approval-between", @@ -314,9 +386,21 @@ describe("inbox helpers", () => { ]); }); + it("prefers canonical lastActivityAt over comment-only timestamps", () => { + const activityIssue = makeIssue("1", true); + activityIssue.lastExternalCommentAt = new Date("2026-03-11T01:00:00.000Z"); + activityIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z"); + + const commentIssue = makeIssue("2", true); + commentIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); + commentIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); + + expect(getRecentTouchedIssues([commentIssue, activityIssue]).map((issue) => issue.id)).toEqual(["1", "2"]); + }); + it("mixes join requests into the inbox feed by most recent activity", () => { const issue = makeIssue("1", true); - issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z"); + issue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); const joinRequest = makeJoinRequest("join-1"); joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z"); @@ -401,7 +485,7 @@ describe("inbox helpers", () => { it("limits recent touched issues before unread badge counting", () => { const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => { const issue = makeIssue(String(index + 1), index < 3); - issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000); + issue.lastActivityAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000); return issue; }); @@ -419,6 +503,116 @@ describe("inbox helpers", () => { 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", () => { localStorage.setItem("paperclip:inbox:last-tab", "new"); expect(loadLastInboxTab()).toBe("mine"); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index e42152a2..9f26e998 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -1,10 +1,4 @@ -import type { - Approval, - DashboardSummary, - HeartbeatRun, - Issue, - JoinRequest, -} from "@paperclipai/shared"; +import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; export const RECENT_ISSUES_LIMIT = 100; 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 READ_ITEMS_KEY = "paperclip:inbox:read-items"; 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 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 = | { kind: "issue"; @@ -79,6 +77,80 @@ export function saveReadInboxItems(ids: Set) { } } +export function normalizeInboxIssueColumns(columns: Iterable): 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, + { + executionWorkspaceById, + projectWorkspaceById, + defaultProjectWorkspaceIdByProjectId, + }: { + executionWorkspaceById?: ReadonlyMap; + projectWorkspaceById?: ReadonlyMap; + defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; + }, +): 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 { try { const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); @@ -145,6 +217,9 @@ export function normalizeTimestamp(value: string | Date | null | undefined): num } export function issueLastActivityTimestamp(issue: Issue): number { + const lastActivityAt = normalizeTimestamp(issue.lastActivityAt); + if (lastActivityAt > 0) return lastActivityAt; + const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt); if (lastExternalCommentAt > 0) return lastExternalCommentAt; diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts index dcb18479..cebf65bf 100644 --- a/ui/src/lib/issueDetailBreadcrumb.test.ts +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + armIssueDetailInboxQuickArchive, createIssueDetailLocationState, createIssueDetailPath, readIssueDetailBreadcrumb, + shouldArmIssueDetailInboxQuickArchive, } from "./issueDetailBreadcrumb"; describe("issueDetailBreadcrumb", () => { @@ -25,10 +27,30 @@ describe("issueDetailBreadcrumb", () => { it("adds the source query param when building an issue detail path", () => { 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", () => { - 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); }); }); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index 1f940ef8..a53864e4 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -8,9 +8,11 @@ type IssueDetailBreadcrumb = { type IssueDetailLocationState = { issueDetailBreadcrumb?: IssueDetailBreadcrumb; issueDetailSource?: IssueDetailSource; + issueDetailInboxQuickArchiveArmed?: boolean; }; const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; +const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref"; function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { if (typeof value !== "object" || value === null) return false; @@ -35,6 +37,13 @@ function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | n 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 { if (source === "inbox") return { label: "Inbox", href: "/inbox" }; 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 { 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}`; const params = new URLSearchParams(); params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); + if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref); return `/issues/${issuePathId}?${params.toString()}`; } @@ -66,5 +94,14 @@ export function readIssueDetailBreadcrumb(state: unknown, search?: string): Issu } 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; } diff --git a/ui/src/lib/keyboardShortcuts.test.ts b/ui/src/lib/keyboardShortcuts.test.ts new file mode 100644 index 00000000..90d4281a --- /dev/null +++ b/ui/src/lib/keyboardShortcuts.test.ts @@ -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 = ` +
Editable
+
Textbox
+ + `; + + 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 = `
`; + + 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"); + }); +}); diff --git a/ui/src/lib/keyboardShortcuts.ts b/ui/src/lib/keyboardShortcuts.ts new file mode 100644 index 00000000..0983e218 --- /dev/null +++ b/ui/src/lib/keyboardShortcuts.ts @@ -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"; +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index c103a523..adb161c2 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -5,7 +5,7 @@ import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox"; +import { FailedRunInboxRow, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox"; vi.mock("@/lib/router", () => ({ Link: ({ children, className, ...props }: ComponentProps<"a">) => ( @@ -56,6 +56,7 @@ function createIssue(overrides: Partial = {}): Issue { labelIds: [], myLastTouchAt: null, lastExternalCommentAt: null, + lastActivityAt: new Date("2026-03-11T00:00:00.000Z"), isUnreadForMe: false, ...overrides, }; @@ -148,31 +149,91 @@ describe("InboxIssueMetaLeading", () => { container.remove(); }); - it("neutralizes selected status and live accents", () => { + it("keeps status and live accents visible", () => { const root = createRoot(container); act(() => { - root.render(); + root.render(); }); - const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); - const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]'); + const statusIcon = container.querySelector('span[class*="border-blue-600"]'); + const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]'); const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( (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"]'); expect(statusIcon).not.toBeNull(); - expect(statusIcon?.className).toContain("!border-muted-foreground"); - expect(statusIcon?.className).toContain("!text-muted-foreground"); + expect(statusIcon?.className).not.toContain("!border-muted-foreground"); + expect(statusIcon?.className).not.toContain("!text-muted-foreground"); expect(liveBadge).not.toBeNull(); - expect(liveBadge?.className).toContain("bg-muted"); + expect(liveBadge?.className).toContain("bg-blue-500/10"); expect(liveBadgeLabel).not.toBeNull(); - expect(liveBadgeLabel?.className).toContain("text-muted-foreground"); - expect(liveBadgeLabel?.className).not.toContain("text-blue-600"); + expect(liveBadgeLabel?.className).toContain("text-blue-600"); 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( + , + ); + }); + + 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( + , + ); + }); + + expect(container.textContent).toBe(""); act(() => { root.unmount(); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 9c86b04b..1a356580 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -4,15 +4,24 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; +import { authApi } from "../api/auth"; import { ApiError } from "../api/client"; import { dashboardApi } from "../api/dashboard"; +import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; +import { instanceSettingsApi } from "../api/instanceSettings"; +import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; 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 { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; @@ -21,11 +30,31 @@ import { SwipeToArchive } from "../components/SwipeToArchive"; import { StatusIcon } from "../components/StatusIcon"; import { cn } from "../lib/utils"; import { StatusBadge } from "../components/StatusBadge"; +import { Identity } from "../components/Identity"; import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload"; +import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { timeAgo } from "../lib/timeAgo"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Tabs } from "@/components/ui/tabs"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Select, SelectContent, @@ -40,19 +69,29 @@ import { X, RotateCcw, UserPlus, + Columns3, + Search, } from "lucide-react"; +import { Input } from "@/components/ui/input"; import { PageTabBar } from "../components/PageTabBar"; import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared"; import { ACTIONABLE_APPROVAL_STATUSES, + DEFAULT_INBOX_ISSUE_COLUMNS, + getAvailableInboxIssueColumns, getApprovalsForTab, getInboxWorkItems, getInboxKeyboardSelectionIndex, getLatestFailedRunsByAgent, getRecentTouchedIssues, isMineInboxTab, + loadInboxIssueColumns, + normalizeInboxIssueColumns, + resolveIssueWorkspaceName, resolveInboxSelectionIndex, + saveInboxIssueColumns, InboxApprovalFilter, + type InboxIssueColumn, saveLastInboxTab, shouldShowInboxSection, type InboxTab, @@ -100,58 +139,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; -const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground"; - -function getSelectedUnreadButtonClass(selected: boolean): string { - return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20"; -} - -function getSelectedUnreadDotClass(selected: boolean): string { - return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400"; -} +const trailingIssueColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "labels", "updated"]; +const inboxIssueColumnLabels: Record = { + status: "Status", + id: "ID", + assignee: "Assignee", + project: "Project", + workspace: "Workspace", + labels: "Tags", + updated: "Last updated", +}; +const inboxIssueColumnDescriptions: Record = { + 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({ issue, - selected, isLive, + showStatus = true, + showIdentifier = true, }: { issue: Issue; - selected: boolean; isLive: boolean; + showStatus?: boolean; + showIdentifier?: boolean; }) { return ( <> - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - + {showStatus ? ( + + + + ) : null} + {showIdentifier ? ( + + {issue.identifier ?? issue.id.slice(0, 8)} + + ) : null} {isLive && ( - {!selected ? ( - - ) : null} + Live @@ -162,6 +212,150 @@ export function InboxIssueMetaLeading({ ); } +function issueActivityText(issue: Issue): string { + return `Updated ${timeAgo(issue.lastActivityAt ?? 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.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); + const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; + + return ( + + {columns.map((column) => { + if (column === "assignee") { + if (issue.assigneeAgentId) { + return ( + + + + ); + } + + if (issue.assigneeUserId) { + return ( + + {userLabel} + + ); + } + + return ( + + Unassigned + + ); + } + + if (column === "project") { + if (projectName) { + const accentColor = projectColor ?? "#64748b"; + return ( + + + {projectName} + + ); + } + + return ( + + No project + + ); + } + + if (column === "labels") { + if ((issue.labels ?? []).length > 0) { + return ( + + {(issue.labels ?? []).slice(0, 2).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 2 ? ( + + +{(issue.labels ?? []).length - 2} + + ) : null} + + ); + } + + return