mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Refine issue workflow surfaces and live updates
This commit is contained in:
parent
b4a58ba8a6
commit
03dff1a29a
48 changed files with 2800 additions and 1163 deletions
|
|
@ -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]] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
89
ui/src/lib/issue-filters.ts
Normal file
89
ui/src/lib/issue-filters.ts
Normal 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;
|
||||
}
|
||||
30
ui/src/lib/issueChatTranscriptRuns.test.ts
Normal file
30
ui/src/lib/issueChatTranscriptRuns.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
42
ui/src/lib/issueChatTranscriptRuns.ts
Normal file
42
ui/src/lib/issueChatTranscriptRuns.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
86
ui/src/lib/navigation-scroll.test.ts
Normal file
86
ui/src/lib/navigation-scroll.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
45
ui/src/lib/navigation-scroll.ts
Normal file
45
ui/src/lib/navigation-scroll.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue