Refine issue workflow surfaces and live updates

This commit is contained in:
dotta 2026-04-09 10:26:17 -05:00
parent b4a58ba8a6
commit 03dff1a29a
48 changed files with 2800 additions and 1163 deletions

View file

@ -14,12 +14,14 @@ import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
filterInboxIssues,
getAvailableInboxIssueColumns,
getApprovalsForTab,
getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
@ -32,6 +34,7 @@ import {
saveInboxIssueColumns,
saveLastInboxTab,
shouldShowInboxSection,
type InboxWorkItem,
} from "./inbox";
const storage = new Map<string, string>();
@ -336,7 +339,6 @@ describe("inbox helpers", () => {
});
expect(result.mineIssues).toBe(1);
// inbox = mineIssues(1) + agent-error alert(1) + budget alert(1)
expect(result.inbox).toBe(3);
});
@ -493,7 +495,7 @@ describe("inbox helpers", () => {
approvals: [],
});
expect(items.map((i) => (i.kind === "issue" ? i.issue.id : ""))).toEqual([
expect(items.map((item) => (item.kind === "issue" ? item.issue.id : ""))).toEqual([
"recent",
"older",
]);
@ -701,4 +703,30 @@ describe("inbox helpers", () => {
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
});
it("hides routine execution issues until the toggle is enabled", () => {
const manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const };
const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const };
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue]);
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue, routineIssue]);
});
it("groups mixed inbox items by type while preserving item order within each group", () => {
const items: InboxWorkItem[] = [
{ kind: "approval", timestamp: 4, approval: makeApproval("pending") },
{ kind: "issue", timestamp: 3, issue: makeIssue("1", true) },
{ kind: "issue", timestamp: 2, issue: makeIssue("2", false) },
{ kind: "failed_run", timestamp: 1, run: makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z") },
{ kind: "join_request", timestamp: 0, joinRequest: makeJoinRequest("join-1") },
];
expect(groupInboxWorkItems(items, "none")).toEqual([{ key: "__all", label: null, items }]);
expect(groupInboxWorkItems(items, "type")).toEqual([
{ key: "issue", label: "Issues", items: [items[1], items[2]] },
{ key: "approval", label: "Approvals", items: [items[0]] },
{ key: "failed_run", label: "Failed runs", items: [items[3]] },
{ key: "join_request", label: "Join requests", items: [items[4]] },
]);
});
});

View file

@ -15,8 +15,10 @@ 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 const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export type InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
export type InboxWorkItemGroupBy = "none" | "type";
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
@ -51,6 +53,12 @@ export interface InboxBadgeData {
alerts: number;
}
export interface InboxWorkItemGroup {
key: string;
label: string | null;
items: InboxWorkItem[];
}
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
@ -137,6 +145,35 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
}
}
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
try {
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
return raw === "type" ? raw : "none";
} catch {
return "none";
}
}
export function saveInboxWorkItemGroupBy(groupBy: InboxWorkItemGroupBy) {
try {
localStorage.setItem(INBOX_GROUP_BY_KEY, groupBy);
} catch {
// Ignore localStorage failures.
}
}
export function shouldIncludeRoutineExecutionIssue(
issue: Pick<Issue, "originKind">,
showRoutineExecutions: boolean,
): boolean {
return showRoutineExecutions || issue.originKind !== "routine_execution";
}
export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolean): Issue[] {
if (showRoutineExecutions) return issues;
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
}
export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
{
@ -362,6 +399,48 @@ export function getInboxWorkItems({
});
}
const inboxWorkItemKindOrder: InboxWorkItem["kind"][] = [
"issue",
"approval",
"failed_run",
"join_request",
];
const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
issue: "Issues",
approval: "Approvals",
failed_run: "Failed runs",
join_request: "Join requests",
};
export function groupInboxWorkItems(
items: InboxWorkItem[],
groupBy: InboxWorkItemGroupBy,
): InboxWorkItemGroup[] {
if (groupBy === "none") {
return [{ key: "__all", label: null, items }];
}
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
for (const item of items) {
const existing = groups.get(item.kind) ?? [];
existing.push(item);
groups.set(item.kind, existing);
}
const orderedGroups: InboxWorkItemGroup[] = [];
for (const kind of inboxWorkItemKindOrder) {
const groupItems = groups.get(kind) ?? [];
if (groupItems.length === 0) continue;
orderedGroups.push({
key: kind,
label: inboxWorkItemKindLabels[kind],
items: groupItems,
});
}
return orderedGroups;
}
/**
* Groups parent-child issues in a flat InboxWorkItem list.
*

View file

@ -370,6 +370,70 @@ describe("buildIssueChatMessages", () => {
]);
});
it("compacts long run transcripts in issue chat while preserving matching tool context", () => {
const isoAt = (baseMs: number, offsetSeconds: number) =>
new Date(baseMs + offsetSeconds * 1000).toISOString();
const baseMs = Date.parse("2026-04-06T12:00:00.000Z");
const transcript = [
...Array.from({ length: 9 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, index),
text: `Older update ${index + 1}`,
})),
{
kind: "tool_call" as const,
ts: isoAt(baseMs, 9),
name: "search",
toolUseId: "tool-keep",
input: { query: "issue chat virtualization" },
},
...Array.from({ length: 79 }, (_, index) => ({
kind: "assistant" as const,
ts: isoAt(baseMs, 10 + index),
text: `Recent update ${index + 1}`,
})),
{
kind: "tool_result" as const,
ts: isoAt(baseMs, 89),
toolUseId: "tool-keep",
content: "search completed",
isError: false,
},
];
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-3",
status: "succeeded",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
startedAt: new Date("2026-04-06T12:00:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([["run-history-3", transcript]]),
hasOutputForRun: (runId) => runId === "run-history-3",
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
const textParts = messages[0]?.content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text) ?? [];
expect(textParts.join("\n")).not.toContain("Older update 1");
expect(messages[0]?.content).toContainEqual(expect.objectContaining({
type: "tool-call",
toolCallId: "tool-keep",
toolName: "search",
result: "search completed",
}));
});
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
const liveMessages = buildIssueChatMessages({
comments: [],

View file

@ -32,10 +32,12 @@ export interface IssueChatLinkedRun {
runId: string;
status: string;
agentId: string;
adapterType?: string;
agentName?: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
hasStoredOutput?: boolean;
}
export interface IssueChatTranscriptEntry {
@ -71,6 +73,8 @@ export interface IssueChatTranscriptEntry {
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
}
const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30;
type MessageWithOrder = {
createdAtMs: number;
order: number;
@ -156,6 +160,62 @@ function formatDiffBlock(lines: string[]) {
return `\`\`\`diff\n${lines.join("\n")}\n\`\`\``;
}
function isIssueChatRenderableTranscriptEntry(entry: IssueChatTranscriptEntry) {
return entry.kind !== "init"
&& entry.kind !== "stderr"
&& entry.kind !== "stdout"
&& entry.kind !== "system";
}
function compactIssueChatTranscript(
entries: readonly IssueChatTranscriptEntry[],
maxVisibleEntries = ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES,
): readonly IssueChatTranscriptEntry[] {
const renderable = entries
.map((entry, fullIndex) => ({ entry, fullIndex }))
.filter(({ entry }) => isIssueChatRenderableTranscriptEntry(entry));
if (renderable.length <= maxVisibleEntries) {
return entries;
}
let startPos = Math.max(0, renderable.length - maxVisibleEntries);
while (
startPos > 0
&& renderable[startPos]?.entry.kind === "diff"
&& renderable[startPos - 1]?.entry.kind === "diff"
) {
startPos -= 1;
}
const keptRenderablePositions = new Set<number>();
for (let pos = startPos; pos < renderable.length; pos += 1) {
keptRenderablePositions.add(pos);
}
// Keep the matching tool call when the visible tail starts at a tool result.
for (let pos = startPos; pos < renderable.length; pos += 1) {
const entry = renderable[pos]?.entry;
if (entry?.kind !== "tool_result" || !entry.toolUseId) continue;
for (let scan = pos - 1; scan >= 0; scan -= 1) {
const candidate = renderable[scan]?.entry;
if (candidate?.kind === "tool_call" && candidate.toolUseId === entry.toolUseId) {
keptRenderablePositions.add(scan);
break;
}
}
}
const keptFullIndices = new Set<number>();
for (const pos of keptRenderablePositions) {
const fullIndex = renderable[pos]?.fullIndex;
if (fullIndex !== undefined) keptFullIndices.add(fullIndex);
}
const compactedEntries = entries.filter((_entry, index) => keptFullIndices.has(index));
return compactedEntries;
}
function createAssistantMetadata(custom: Record<string, unknown>) {
return {
unstable_state: null,
@ -401,7 +461,8 @@ function createHistoricalTranscriptMessage(args: {
}) {
const { run, transcript, hasOutput, agentMap } = args;
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const compactedTranscript = compactIssueChatTranscript(transcript);
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
const waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0
? parts
@ -595,7 +656,8 @@ function createLiveRunMessage(args: {
transcript: readonly IssueChatTranscriptEntry[];
}) {
const { run, transcript } = args;
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const compactedTranscript = compactIssueChatTranscript(transcript);
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
const waitingText =
run.status === "queued"
? "Queued..."

View file

@ -0,0 +1,89 @@
import type { Issue } from "@paperclipai/shared";
export type IssueFilterState = {
statuses: string[];
priorities: string[];
assignees: string[];
labels: string[];
projects: string[];
showRoutineExecutions: boolean;
};
export const defaultIssueFilterState: IssueFilterState = {
statuses: [],
priorities: [],
assignees: [],
labels: [],
projects: [],
showRoutineExecutions: false,
};
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
export const issuePriorityOrder = ["critical", "high", "medium", "low"];
export const issueQuickFilterPresets = [
{ label: "All", statuses: [] as string[] },
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
export function issueFilterLabel(value: string): string {
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function issueFilterArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, index) => value === sortedB[index]);
}
export function toggleIssueFilterValue(values: string[], value: string): string[] {
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
}
export function applyIssueFilters(
issues: Issue[],
state: IssueFilterState,
currentUserId?: string | null,
enableRoutineVisibilityFilter = false,
): Issue[] {
let result = issues;
if (enableRoutineVisibilityFilter && !state.showRoutineExecutions) {
result = result.filter((issue) => issue.originKind !== "routine_execution");
}
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
if (state.priorities.length > 0) result = result.filter((issue) => state.priorities.includes(issue.priority));
if (state.assignees.length > 0) {
result = result.filter((issue) => {
for (const assignee of state.assignees) {
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
if (issue.assigneeAgentId === assignee) return true;
}
return false;
});
}
if (state.labels.length > 0) {
result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id)));
}
if (state.projects.length > 0) {
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
}
return result;
}
export function countActiveIssueFilters(
state: IssueFilterState,
enableRoutineVisibilityFilter = false,
): number {
let count = 0;
if (state.statuses.length > 0) count += 1;
if (state.priorities.length > 0) count += 1;
if (state.assignees.length > 0) count += 1;
if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1;
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
return count;
}

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { resolveIssueChatTranscriptRuns } from "./issueChatTranscriptRuns";
describe("resolveIssueChatTranscriptRuns", () => {
it("uses adapterType from linked runs without requiring agent metadata", () => {
const runs = resolveIssueChatTranscriptRuns({
linkedRuns: [
{
runId: "run-1",
status: "succeeded",
agentId: "agent-1",
adapterType: "codex_local",
createdAt: "2026-04-09T12:00:00.000Z",
startedAt: "2026-04-09T12:00:00.000Z",
finishedAt: "2026-04-09T12:01:00.000Z",
hasStoredOutput: true,
},
],
});
expect(runs).toEqual([
{
id: "run-1",
status: "succeeded",
adapterType: "codex_local",
hasStoredOutput: true,
},
]);
});
});

View file

@ -0,0 +1,42 @@
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import type { RunTranscriptSource } from "../components/transcript/useLiveRunTranscripts";
import type { IssueChatLinkedRun } from "./issue-chat-messages";
export function resolveIssueChatTranscriptRuns(args: {
linkedRuns?: readonly IssueChatLinkedRun[];
liveRuns?: readonly LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
}): RunTranscriptSource[] {
const { linkedRuns = [], liveRuns = [], activeRun = null } = args;
const combined = new Map<string, RunTranscriptSource>();
for (const run of liveRuns) {
combined.set(run.id, {
id: run.id,
status: run.status,
adapterType: run.adapterType,
});
}
if (activeRun) {
combined.set(activeRun.id, {
id: activeRun.id,
status: activeRun.status,
adapterType: activeRun.adapterType,
});
}
for (const run of linkedRuns) {
if (combined.has(run.runId)) continue;
const adapterType = run.adapterType;
if (!adapterType) continue;
combined.set(run.runId, {
id: run.runId,
status: run.status,
adapterType,
hasStoredOutput: run.hasStoredOutput,
});
}
return [...combined.values()];
}

View file

@ -4,11 +4,14 @@ import {
createIssueDetailLocationState,
createIssueDetailPath,
hasLegacyIssueDetailQuery,
readIssueDetailHeaderSeed,
readIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive,
withIssueDetailHeaderSeed,
} from "./issueDetailBreadcrumb";
import type { Issue } from "@paperclipai/shared";
const sessionStorageMock = (() => {
const store = new Map<string, string>();
@ -29,6 +32,91 @@ Object.defineProperty(globalThis, "window", {
});
describe("issueDetailBreadcrumb", () => {
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Prefilled issue title",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 42,
identifier: "PAP-42",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionPolicy: null,
executionState: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
project: {
id: "project-1",
companyId: "company-1",
urlKey: "paperclip-app",
goalId: null,
goalIds: [],
goals: [],
name: "Paperclip App",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: null,
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/paperclip-app",
effectiveLocalFolder: "/tmp/paperclip-app",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
goal: null,
currentExecutionWorkspace: null,
workProducts: [],
mentionedProjects: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: null,
isUnreadForMe: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
it("returns clean issue detail paths", () => {
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
});
@ -77,6 +165,48 @@ describe("issueDetailBreadcrumb", () => {
});
});
it("attaches and reads issue header seed data from route state", () => {
const seededState = withIssueDetailHeaderSeed(
createIssueDetailLocationState("Issues", "/issues", "issues"),
createIssue(),
);
expect(readIssueDetailHeaderSeed(seededState)).toEqual({
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-42",
title: "Prefilled issue title",
status: "todo",
priority: "medium",
projectId: "project-1",
projectName: "Paperclip App",
originKind: "manual",
originId: null,
});
});
it("persists issue header seed data when breadcrumb state is remembered", () => {
const seededState = withIssueDetailHeaderSeed(
createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"),
createIssue(),
);
sessionStorageMock.clear();
rememberIssueDetailLocationState("PAP-42", seededState);
const restoredState = readIssueDetailLocationState("PAP-42", null);
expect(readIssueDetailHeaderSeed(restoredState)).toEqual({
id: "11111111-1111-4111-8111-111111111111",
identifier: "PAP-42",
title: "Prefilled issue title",
status: "todo",
priority: "medium",
projectId: "project-1",
projectName: "Paperclip App",
originKind: "manual",
originId: null,
});
});
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");

View file

@ -1,3 +1,5 @@
import type { Issue } from "@paperclipai/shared";
type IssueDetailSource = "issues" | "inbox";
type IssueDetailBreadcrumb = {
@ -5,10 +7,23 @@ type IssueDetailBreadcrumb = {
href: string;
};
export type IssueDetailHeaderSeed = {
id: string;
identifier: string | null;
title: string;
status: Issue["status"];
priority: Issue["priority"];
projectId: string | null;
projectName: string | null;
originKind?: Issue["originKind"];
originId?: string | null;
};
type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource;
issueDetailInboxQuickArchiveArmed?: boolean;
issueDetailHeaderSeed?: IssueDetailHeaderSeed;
};
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
@ -25,6 +40,58 @@ function isIssueDetailSource(value: unknown): value is IssueDetailSource {
return value === "issues" || value === "inbox";
}
function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailHeaderSeed>;
const hasOriginKind =
candidate.originKind === undefined || typeof candidate.originKind === "string";
const hasOriginId =
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
return (
typeof candidate.id === "string"
&& (candidate.identifier === null || typeof candidate.identifier === "string")
&& typeof candidate.title === "string"
&& typeof candidate.status === "string"
&& typeof candidate.priority === "string"
&& (candidate.projectId === null || typeof candidate.projectId === "string")
&& (candidate.projectName === null || typeof candidate.projectName === "string")
&& hasOriginKind
&& hasOriginId
);
}
function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
return {
id: issue.id,
identifier: issue.identifier ?? null,
title: issue.title,
status: issue.status,
priority: issue.priority,
projectId: issue.projectId ?? null,
projectName: issue.project?.name ?? null,
originKind: issue.originKind,
originId: issue.originId ?? null,
};
}
export function withIssueDetailHeaderSeed(state: unknown, issue: Issue): IssueDetailLocationState {
const headerSeed = createIssueDetailHeaderSeed(issue);
if (typeof state !== "object" || state === null) {
return { issueDetailHeaderSeed: headerSeed };
}
return {
...(state as IssueDetailLocationState),
issueDetailHeaderSeed: headerSeed,
};
}
export function readIssueDetailHeaderSeed(state: unknown): IssueDetailHeaderSeed | null {
if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailHeaderSeed;
return isIssueDetailHeaderSeed(candidate) ? candidate : null;
}
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null;
const source = (state as IssueDetailLocationState).issueDetailSource;
@ -96,10 +163,14 @@ function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLoc
: null;
const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null;
const headerSeed = isIssueDetailHeaderSeed(parsed.issueDetailHeaderSeed)
? parsed.issueDetailHeaderSeed
: undefined;
return {
issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
};
} catch {
return null;
@ -115,11 +186,13 @@ function normalizeIssueDetailLocationState(
if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null;
const headerSeed = readIssueDetailHeaderSeed(state) ?? undefined;
return {
issueDetailBreadcrumb: candidate,
issueDetailSource: source,
issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
};
}
}

View file

@ -4,7 +4,7 @@ import {
type SerializedLinkNode,
} from "@lexical/link";
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
const CUSTOM_MENTION_URL_RE = /^(agent|project|skill):\/\//;
export class MentionAwareLinkNode extends LinkNode {
static getType(): string {

View file

@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
resetNavigationScroll,
SIDEBAR_SCROLL_RESET_STATE,
shouldResetScrollOnNavigation,
} from "./navigation-scroll";
describe("navigation-scroll", () => {
it("resets scroll only for flagged sidebar navigation", () => {
expect(
shouldResetScrollOnNavigation({
previousPathname: "/issues",
pathname: "/dashboard",
navigationType: "PUSH",
state: SIDEBAR_SCROLL_RESET_STATE,
}),
).toBe(true);
expect(
shouldResetScrollOnNavigation({
previousPathname: "/issues",
pathname: "/dashboard",
navigationType: "PUSH",
state: null,
}),
).toBe(false);
});
it("preserves scroll restoration for browser history navigation even for sidebar entries", () => {
expect(
shouldResetScrollOnNavigation({
previousPathname: "/issues",
pathname: "/dashboard",
navigationType: "POP",
state: SIDEBAR_SCROLL_RESET_STATE,
}),
).toBe(false);
});
it("does not reset scroll on the initial render or when the pathname is unchanged", () => {
expect(
shouldResetScrollOnNavigation({
previousPathname: null,
pathname: "/dashboard",
navigationType: "PUSH",
state: SIDEBAR_SCROLL_RESET_STATE,
}),
).toBe(false);
expect(
shouldResetScrollOnNavigation({
previousPathname: "/dashboard",
pathname: "/dashboard",
navigationType: "REPLACE",
state: SIDEBAR_SCROLL_RESET_STATE,
}),
).toBe(false);
});
it("resets both the main content container and page scroll state", () => {
const main = document.createElement("main");
main.scrollTop = 180;
main.scrollLeft = 14;
main.scrollTo = vi.fn();
document.body.appendChild(main);
document.documentElement.scrollTop = 240;
document.documentElement.scrollLeft = 9;
document.body.scrollTop = 120;
document.body.scrollLeft = 7;
const windowScrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
resetNavigationScroll(main);
expect(main.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
expect(main.scrollTop).toBe(0);
expect(main.scrollLeft).toBe(0);
expect(document.documentElement.scrollTop).toBe(0);
expect(document.documentElement.scrollLeft).toBe(0);
expect(document.body.scrollTop).toBe(0);
expect(document.body.scrollLeft).toBe(0);
expect(windowScrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
});
});

View file

@ -0,0 +1,45 @@
export type NavigationType = "POP" | "PUSH" | "REPLACE";
export const SIDEBAR_SCROLL_RESET_STATE = {
paperclipSidebarScrollReset: true,
} as const;
export function shouldResetScrollOnNavigation(params: {
previousPathname: string | null;
pathname: string;
navigationType: NavigationType;
state: unknown;
}): boolean {
const { previousPathname, pathname, navigationType, state } = params;
if (previousPathname === null) return false;
if (previousPathname === pathname) return false;
if (navigationType === "POP") return false;
return hasSidebarScrollResetState(state);
}
export function resetNavigationScroll(mainElement: HTMLElement | null): void {
mainElement?.scrollTo?.({ top: 0, left: 0, behavior: "auto" });
if (mainElement) {
mainElement.scrollTop = 0;
mainElement.scrollLeft = 0;
}
const scrollingElement = document.scrollingElement ?? document.documentElement;
if (scrollingElement) {
scrollingElement.scrollTop = 0;
scrollingElement.scrollLeft = 0;
}
if (document.body) {
document.body.scrollTop = 0;
document.body.scrollLeft = 0;
}
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}
function hasSidebarScrollResetState(state: unknown): boolean {
if (!state || typeof state !== "object") return false;
return (state as Record<string, unknown>).paperclipSidebarScrollReset === true;
}

View file

@ -66,6 +66,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:10.000Z",
createdAt: "2026-04-08T21:00:00.000Z",
@ -80,6 +81,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1",
status: "running",
agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-08T21:00:00.000Z",
@ -93,6 +95,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1",
status: "cancelled",
agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:11.000Z",
createdAt: "2026-04-08T21:00:00.000Z",

View file

@ -4,6 +4,7 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
export interface InterruptRunSource {
id: string;
agentId: string;
adapterType: string;
startedAt: Date | string | null;
createdAt: Date | string;
invocationSource: string;
@ -30,6 +31,7 @@ export function upsertInterruptedRun(
runId: run.id,
status: "cancelled",
agentId: run.agentId,
adapterType: run.adapterType,
startedAt: toIsoString(run.startedAt),
finishedAt,
createdAt: toIsoString(run.createdAt) ?? finishedAt,