mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
feat(inbox): add operator search and keyboard controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
3ab7d52f00
25 changed files with 1340 additions and 114 deletions
|
|
@ -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> = {}): 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 = {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
106
ui/src/lib/keyboardShortcuts.test.ts
Normal file
106
ui/src/lib/keyboardShortcuts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
ui/src/lib/keyboardShortcuts.ts
Normal file
54
ui/src/lib/keyboardShortcuts.ts
Normal 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue