diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 16d62a0d..6bbcf649 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -1,6 +1,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { cn } from "../lib/utils"; export interface InlineEntityOption { @@ -21,6 +22,7 @@ interface InlineEntitySelectorProps { className?: string; renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode; renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; + recentOptionIds?: string[]; /** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */ disablePortal?: boolean; /** Open the popover when the trigger receives keyboard/programmatic focus. */ @@ -41,6 +43,7 @@ export const InlineEntitySelector = forwardRef( - () => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options], - [noneLabel, options], - ); + const allOptions = useMemo(() => { + const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options]; + return orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); + }, [noneLabel, options, recentOptionIds, value]); const filteredOptions = useMemo(() => { const term = query.trim().toLowerCase(); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 13f237c6..44d56f91 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -20,10 +20,6 @@ const { appendMock } = vi.hoisted(() => ({ appendMock: vi.fn(async () => undefined), })); -const { threadMessagesMock } = vi.hoisted(() => ({ - threadMessagesMock: vi.fn(() =>
), -})); - const { captureComposerViewportSnapshotMock, restoreComposerViewportSnapshotMock, @@ -36,31 +32,7 @@ const { vi.mock("@assistant-ui/react", () => ({ AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, - ThreadPrimitive: { - Root: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), - Viewport: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
- ), - Empty: ({ children }: { children: ReactNode }) =>
{children}
, - Messages: () => threadMessagesMock(), - }, - MessagePrimitive: { - Root: ({ children }: { children: ReactNode }) =>
{children}
, - Content: () => null, - Parts: () => null, - }, useAui: () => ({ thread: () => ({ append: appendMock }) }), - useAuiState: () => false, - useMessage: () => ({ - id: "message", - role: "assistant", - createdAt: new Date("2026-04-06T12:00:00.000Z"), - content: [], - metadata: { custom: {} }, - status: { type: "complete" }, - }), })); vi.mock("./transcript/useLiveRunTranscripts", () => ({ @@ -127,6 +99,12 @@ vi.mock("./OutputFeedbackButtons", () => ({ OutputFeedbackButtons: () => null, })); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + vi.mock("./AgentIconPicker", () => ({ AgentIcon: () => null, })); @@ -149,7 +127,6 @@ describe("IssueChatThread", () => { container = document.createElement("div"); document.body.appendChild(container); localStorage.clear(); - threadMessagesMock.mockImplementation(() =>
); }); afterEach(() => { @@ -157,7 +134,6 @@ describe("IssueChatThread", () => { vi.useRealTimers(); appendMock.mockReset(); markdownEditorFocusMock.mockReset(); - threadMessagesMock.mockReset(); captureComposerViewportSnapshotMock.mockClear(); restoreComposerViewportSnapshotMock.mockClear(); shouldPreserveComposerViewportMock.mockClear(); @@ -228,12 +204,8 @@ describe("IssueChatThread", () => { }); }); - it("falls back to a safe transcript warning when assistant-ui throws during message rendering", () => { + it("renders the transcript directly from stable Paperclip messages", () => { const root = createRoot(container); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - threadMessagesMock.mockImplementation(() => { - throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)"); - }); act(() => { root.render( @@ -260,11 +232,9 @@ describe("IssueChatThread", () => { ); }); - expect(container.textContent).toContain("Chat renderer hit an internal state error."); expect(container.textContent).toContain("Agent summary"); - expect(consoleErrorSpy).toHaveBeenCalled(); + expect(container.textContent).not.toContain("Chat renderer hit an internal state error."); - consoleErrorSpy.mockRestore(); act(() => { root.unmount(); }); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 096ef8f2..13e494e6 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,13 +1,13 @@ import { AssistantRuntimeProvider, - ActionBarPrimitive, - MessagePrimitive, - ThreadPrimitive, useAui, - useAuiState, - useMessage, } from "@assistant-ui/react"; -import type { ToolCallMessagePart } from "@assistant-ui/react"; +import type { + ReasoningMessagePart, + TextMessagePart, + ThreadMessage, + ToolCallMessagePart, +} from "@assistant-ui/react"; import { createContext, Component, @@ -257,7 +257,7 @@ interface IssueChatThreadProps { type IssueChatErrorBoundaryProps = { resetKey: string; - messages: readonly import("@assistant-ui/react").ThreadMessage[]; + messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; children: ReactNode; @@ -301,7 +301,7 @@ class IssueChatErrorBoundary extends Component | undefined; if (typeof custom?.["authorName"] === "string") return custom["authorName"]; if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"]; @@ -310,7 +310,7 @@ function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessag return "System"; } -function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) { +function fallbackTextParts(message: ThreadMessage) { const contentLines: string[] = []; for (const part of message.content) { if (part.type === "text" || part.type === "reasoning") { @@ -337,7 +337,7 @@ function IssueChatFallbackThread({ emptyMessage, variant, }: { - messages: readonly import("@assistant-ui/react").ThreadMessage[]; + messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; }) { @@ -574,9 +574,16 @@ function cleanToolDisplayText(tool: ToolCallMessagePart): string { return summary ? `${name} ${summary}` : name; } -function IssueChatChainOfThought() { +type IssueChatCoTPart = ReasoningMessagePart | ToolCallMessagePart; + +function IssueChatChainOfThought({ + message, + cotParts, +}: { + message: ThreadMessage; + cotParts: readonly IssueChatCoTPart[]; +}) { const { agentMap } = useContext(IssueChatCtx); - const message = useMessage(); const custom = message.metadata.custom as Record; const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; @@ -584,8 +591,6 @@ function IssueChatChainOfThought() { const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; const isMessageRunning = message.role === "assistant" && message.status?.type === "running"; - const cotParts = useAuiState((s) => s.chainOfThought?.parts ?? []) as ReadonlyArray<{ type: string; text?: string; toolName?: string; toolCallId?: string; args?: unknown; argsText?: string; result?: unknown; isError?: boolean }>; - const myIndex = useMemo( () => findCoTSegmentIndex(message.content, cotParts), [message.content, cotParts], @@ -931,7 +936,103 @@ function IssueChatToolPart({ ); } -function IssueChatUserMessage() { +function getThreadMessageCopyText(message: ThreadMessage) { + return message.content + .filter((part): part is TextMessagePart => part.type === "text") + .map((part) => part.text) + .join("\n\n"); +} + +function IssueChatTextParts({ + message, + recessed = false, +}: { + message: ThreadMessage; + recessed?: boolean; +}) { + return ( + <> + {message.content + .filter((part): part is TextMessagePart => part.type === "text") + .map((part, index) => ( + + ))} + + ); +} + +function groupAssistantParts( + content: readonly ThreadMessage["content"][number][], +): Array< + | { type: "text"; part: TextMessagePart; index: number } + | { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number } +> { + const groups: Array< + | { type: "text"; part: TextMessagePart; index: number } + | { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number } + > = []; + let pendingCoT: IssueChatCoTPart[] = []; + let pendingStartIndex = -1; + + const flushCoT = () => { + if (pendingCoT.length === 0) return; + groups.push({ type: "cot", parts: pendingCoT, startIndex: pendingStartIndex }); + pendingCoT = []; + pendingStartIndex = -1; + }; + + content.forEach((part, index) => { + if (part.type === "reasoning" || part.type === "tool-call") { + if (pendingCoT.length === 0) pendingStartIndex = index; + pendingCoT.push(part); + return; + } + flushCoT(); + if (part.type === "text") { + groups.push({ type: "text", part, index }); + } + }); + flushCoT(); + + return groups; +} + +function IssueChatAssistantParts({ + message, + hasCoT, +}: { + message: ThreadMessage; + hasCoT: boolean; +}) { + return ( + <> + {groupAssistantParts(message.content).map((group) => { + if (group.type === "text") { + return ( + + ); + } + return ( + + ); + })} + + ); +} + +function IssueChatUserMessage({ message }: { message: ThreadMessage }) { const { onInterruptQueued, onCancelQueued, @@ -939,7 +1040,6 @@ function IssueChatUserMessage() { currentUserId, userProfileMap, } = useContext(IssueChatCtx); - const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id; @@ -1008,11 +1108,7 @@ function IssueChatUserMessage() {
) : null}
- , - }} - /> +
@@ -1064,7 +1160,7 @@ function IssueChatUserMessage() { ); return ( - +
{isCurrentUser ? ( <> @@ -1078,11 +1174,11 @@ function IssueChatUserMessage() { )}
- +
); } -function IssueChatAssistantMessage() { +function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { const { feedbackVoteByTargetId, feedbackDataSharingPreference, @@ -1093,7 +1189,6 @@ function IssueChatAssistantMessage() { onStopRun, stoppingRunId, } = useContext(IssueChatCtx); - const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const authorName = typeof custom.authorName === "string" @@ -1120,6 +1215,8 @@ function IssueChatAssistantMessage() { const isFoldable = !isRunning && !!chainOfThoughtLabel; const [folded, setFolded] = useState(isFoldable); const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); + const [copied, setCopied] = useState(false); + const copyText = getThreadMessageCopyText(message); // Derive fold state synchronously during render (not in useEffect) so the // browser never paints the un-folded intermediate state — prevents the @@ -1149,7 +1246,7 @@ function IssueChatAssistantMessage() { const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; return ( - +
{agentIcon ? ( @@ -1192,12 +1289,7 @@ function IssueChatAssistantMessage() { {!folded ? ( <>
- , - ChainOfThought: IssueChatChainOfThought, - }} - /> + {message.content.length === 0 && waitingText ? (
@@ -1225,15 +1317,20 @@ function IssueChatAssistantMessage() {
- { + void navigator.clipboard.writeText(copyText).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }} > - - - + {copied ? : } + {commentId && onVote ? ( { - const text = message.content - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("\n\n"); - void navigator.clipboard.writeText(text); + void navigator.clipboard.writeText(copyText); }} > @@ -1307,7 +1400,7 @@ function IssueChatAssistantMessage() { ) : null}
- +
); } @@ -1531,9 +1624,8 @@ function IssueChatFeedbackButtons({ ); } -function IssueChatSystemMessage() { +function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx); - const message = useMessage(); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; const runId = typeof custom.runId === "string" ? custom.runId : null; @@ -1601,16 +1693,16 @@ function IssueChatSystemMessage() { if (isCurrentUser) { return ( - +
{eventContent}
- +
); } return ( - +
{agentIcon ? ( @@ -1623,7 +1715,7 @@ function IssueChatSystemMessage() { {eventContent}
-
+
); } @@ -1631,7 +1723,7 @@ function IssueChatSystemMessage() { const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined; if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) { return ( - +
{runAgentIcon ? ( @@ -1665,7 +1757,7 @@ function IssueChatSystemMessage() {
-
+ ); } @@ -2036,7 +2128,7 @@ export function IssueChatThread({ userLabelMap, ], ); - const stableMessagesRef = useRef([]); + const stableMessagesRef = useRef([]); const stableMessageCacheRef = useRef>(new Map()); const messages = useMemo(() => { const stabilized = stabilizeThreadMessages( @@ -2131,15 +2223,6 @@ export function IssueChatThread({ ], ); - const components = useMemo( - () => ({ - UserMessage: IssueChatUserMessage, - AssistantMessage: IssueChatAssistantMessage, - SystemMessage: IssueChatSystemMessage, - }), - [], - ); - const resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full"; const resolvedEmptyMessage = emptyMessage ?? (variant === "embedded" @@ -2172,9 +2255,12 @@ export function IssueChatThread({ emptyMessage={resolvedEmptyMessage} variant={variant} > - - - +
+
+ {messages.length === 0 ? (
{resolvedEmptyMessage}
- - + ) : ( + // Keep transcript rendering independent from assistant-ui's + // index-scoped message providers; live transcripts can shrink + // or regroup while the runtime still holds stale indices. + messages.map((message) => { + if (message.role === "user") { + return ; + } + if (message.role === "assistant") { + return ; + } + return ; + }) + )}
- - +
+
{showComposer ? ( diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index b03ed822..63d921b6 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -62,8 +62,10 @@ vi.mock("../hooks/useProjectOrder", () => ({ vi.mock("../lib/recent-assignees", () => ({ getRecentAssigneeIds: () => [], + getRecentAssigneeSelectionIds: () => [], sortAgentsByRecency: (agents: unknown[]) => agents, trackRecentAssignee: vi.fn(), + trackRecentAssigneeUser: vi.fn(), })); vi.mock("../lib/assignees", () => ({ diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 518d510d..5512acc9 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -12,7 +12,15 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members"; import { useProjectOrder } from "../hooks/useProjectOrder"; -import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { + getRecentAssigneeIds, + getRecentAssigneeSelectionIds, + sortAgentsByRecency, + trackRecentAssignee, + trackRecentAssigneeUser, +} from "../lib/recent-assignees"; +import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; +import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy"; import { StatusIcon } from "./StatusIcon"; @@ -294,10 +302,16 @@ export function IssueProperties({ }; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); + const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), [agents, recentAssigneeIds], ); + const recentAssigneeValues = useMemo( + () => recentAssigneeSelectionIds, + [recentAssigneeSelectionIds], + ); + const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], @@ -315,6 +329,11 @@ export function IssueProperties({ const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); + const selectedAssigneeValue = issue.assigneeAgentId + ? `agent:${issue.assigneeAgentId}` + : issue.assigneeUserId + ? `user:${issue.assigneeUserId}` + : ""; const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { onUpdate({ executionPolicy: buildExecutionPolicy({ @@ -499,6 +518,46 @@ export function IssueProperties({ ); + const assigneePickerOptions = orderItemsBySelectedAndRecent( + [ + { id: "", kind: "none" as const, label: "No assignee", searchText: "" }, + ...(currentUserId + ? [{ + id: `user:${currentUserId}`, + kind: "user" as const, + userId: currentUserId, + label: "Assign to me", + searchText: userLabel(currentUserId) ?? "", + }] + : []), + ...(issue.createdByUserId && issue.createdByUserId !== currentUserId + ? [{ + id: `user:${issue.createdByUserId}`, + kind: "user" as const, + userId: issue.createdByUserId, + label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", + searchText: creatorUserLabel ?? "requester", + }] + : []), + ...otherUserOptions.map((option) => ({ + id: option.id, + kind: "user" as const, + userId: option.id.slice("user:".length), + label: option.label, + searchText: option.searchText ?? "", + })), + ...sortedAgents.map((agent) => ({ + id: `agent:${agent.id}`, + kind: "agent" as const, + agent, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })), + ], + selectedAssigneeValue, + recentAssigneeValues, + ); + const assigneeContent = ( <>
- - {currentUserId && ( - - )} - {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( - - )} - {otherUserOptions + {assigneePickerOptions .filter((option) => { if (!assigneeSearch.trim()) return true; const q = assigneeSearch.toLowerCase(); - return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q); + return `${option.label} ${option.searchText}`.toLowerCase().includes(q); }) - .map((option) => { - const userId = option.id.slice("user:".length); - return ( - - ); - })} - {sortedAgents - .filter((a) => { - if (!assigneeSearch.trim()) return true; - const q = assigneeSearch.toLowerCase(); - return a.name.toLowerCase().includes(q); - }) - .map((a) => ( - - ))} + ) : null} + {option.label} + + ))}
); @@ -702,6 +712,20 @@ export function IssueProperties({ No project ); + const projectPickerOptions = orderItemsBySelectedAndRecent( + [ + { id: "", kind: "none" as const, name: "No project", color: null as string | null }, + ...orderedProjects.map((project) => ({ + id: project.id, + kind: "project" as const, + project, + name: project.name, + color: project.color ?? null, + })), + ], + issue.projectId ?? "", + recentProjectIds, + ); const projectContent = ( <> @@ -713,58 +737,53 @@ export function IssueProperties({ autoFocus={!inline} />
- - {orderedProjects - .filter((p) => { + {projectPickerOptions + .filter((option) => { if (!projectSearch.trim()) return true; const q = projectSearch.toLowerCase(); - return p.name.toLowerCase().includes(q); + return option.name.toLowerCase().includes(q); }) - .map((p) => ( - - ))} + .map((option) => ( + + ))}
); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 70d96ff6..fa91a9c1 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -15,6 +15,7 @@ import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../l import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"; import { buildExecutionPolicy } from "../lib/issue-execution-policy"; import { useToastActions } from "../context/ToastContext"; import { @@ -854,6 +855,11 @@ export function NewIssueDialog() { ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); + const recentAssigneeOptionIds = useMemo( + () => recentAssigneeIds.map((id) => assigneeValueFromSelection({ assigneeAgentId: id })), + [recentAssigneeIds], + ); + const recentProjectIds = useMemo(() => getRecentProjectIds(), [newIssueOpen]); const assigneeOptions = useMemo( () => [ ...currentUserAssigneeOption(currentUserId), @@ -887,6 +893,7 @@ export function NewIssueDialog() { const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment"); const handleProjectChange = useCallback((nextProjectId: string) => { + if (nextProjectId) trackRecentProject(nextProjectId); setProjectId(nextProjectId); const nextProject = orderedProjects.find((project) => project.id === nextProjectId); executionWorkspaceDefaultProjectId.current = nextProjectId || null; @@ -1096,6 +1103,7 @@ export function NewIssueDialog() { ref={assigneeSelectorRef} value={assigneeValue} options={assigneeOptions} + recentOptionIds={recentAssigneeOptionIds} placeholder="Assignee" disablePortal noneLabel="No assignee" @@ -1147,6 +1155,7 @@ export function NewIssueDialog() { ref={projectSelectorRef} value={projectId} options={projectOptions} + recentOptionIds={recentProjectIds} placeholder="Project" disablePortal noneLabel="No project" @@ -1236,6 +1245,7 @@ export function NewIssueDialog() { getRecentAssigneeIds(), [open]); + const recentProjectIds = useMemo(() => getRecentProjectIds(), [open]); const assigneeOptions = useMemo( () => sortAgentsByRecency( @@ -271,6 +273,7 @@ export function RoutineRunVariablesDialog({ { const project = projects.find((entry) => entry.id === projectId) ?? null; + if (projectId) trackRecentProject(projectId); setSelection((current) => ({ ...current, projectId })); setWorkspaceConfig(buildInitialWorkspaceConfig(project)); setWorkspaceConfigValid(true); diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx index bd2ee877..d5623d19 100644 --- a/ui/src/components/ui/command.tsx +++ b/ui/src/components/ui/command.tsx @@ -83,7 +83,7 @@ function CommandInput({ { expect(issues).toHaveLength(2); }); - it("shows only my approvals on mine, while recent and unread stay company-wide", () => { + it("shows actionable approvals on mine, while recent and unread stay company-wide", () => { const approvals = [ { ...makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"), @@ -425,6 +425,7 @@ describe("inbox helpers", () => { expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([ "approval-revision", "approval-approved", + "approval-pending", ]); expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([ "approval-revision", @@ -440,13 +441,21 @@ describe("inbox helpers", () => { ]); }); - it("keeps unrelated approvals out of a new user's badge and mine tab", () => { + it("surfaces agent-requested actionable approvals in mine and the badge", () => { const approvals = [ - { ...makeApproval("pending"), requestedByUserId: "user-2" }, - { ...makeApproval("revision_requested"), decidedByUserId: "user-3" }, + { + ...makeApprovalWithTimestamps("approval-agent-requested", "pending", "2026-03-11T02:00:00.000Z"), + requestedByUserId: null, + }, + { + ...makeApprovalWithTimestamps("approval-unrelated-resolved", "approved", "2026-03-11T03:00:00.000Z"), + requestedByUserId: "user-2", + }, ]; - expect(getApprovalsForTab(approvals, "mine", "all", "user-1")).toEqual([]); + expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([ + "approval-agent-requested", + ]); const result = computeInboxBadgeData({ approvals, @@ -459,7 +468,7 @@ describe("inbox helpers", () => { currentUserId: "user-1", }); - expect(result.approvals).toBe(0); + expect(result.approvals).toBe(1); }); it("does not count company-wide alerts in the personal inbox badge", () => { diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 86d34e1b..a282f5dc 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -706,11 +706,7 @@ export function getApprovalsForTab( ); if (tab === "mine") { - if (!currentUserId) return []; - return sortedApprovals.filter( - (approval) => - approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId, - ); + return sortedApprovals.filter((approval) => isApprovalVisibleInMine(approval, currentUserId)); } if (tab === "recent") return sortedApprovals; if (tab === "unread") { @@ -724,6 +720,15 @@ export function getApprovalsForTab( }); } +export function isApprovalVisibleInMine( + approval: Approval, + currentUserId?: string | null, +): boolean { + if (ACTIONABLE_APPROVAL_STATUSES.has(approval.status)) return true; + if (!currentUserId) return false; + return approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId; +} + export function approvalActivityTimestamp(approval: Approval): number { const updatedAt = normalizeTimestamp(approval.updatedAt); if (updatedAt > 0) return updatedAt; @@ -1030,8 +1035,7 @@ export function computeInboxBadgeData({ }): InboxBadgeData { const actionableApprovals = approvals.filter( (approval) => - !!currentUserId && - (approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId) && + isApprovalVisibleInMine(approval, currentUserId) && ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && !isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt), ).length; diff --git a/ui/src/lib/recent-assignees.ts b/ui/src/lib/recent-assignees.ts index 7c3e9c91..9b4937a3 100644 --- a/ui/src/lib/recent-assignees.ts +++ b/ui/src/lib/recent-assignees.ts @@ -1,30 +1,51 @@ +import { + RECENT_SELECTION_DISPLAY_LIMIT, + readRecentSelectionIds, + trackRecentSelectionId, +} from "./recent-selections"; + const STORAGE_KEY = "paperclip:recent-assignees"; -const MAX_RECENT = 10; + +function agentSelectionId(agentId: string): string { + return `agent:${agentId}`; +} + +function userSelectionId(userId: string): string { + return `user:${userId}`; +} + +function agentIdFromSelectionId(id: string): string | null { + if (id.startsWith("agent:")) return id.slice("agent:".length); + if (!id.includes(":")) return id; + return null; +} export function getRecentAssigneeIds(): string[] { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } + return readRecentSelectionIds(STORAGE_KEY) + .map(agentIdFromSelectionId) + .filter((id): id is string => Boolean(id)); +} + +export function getRecentAssigneeSelectionIds(): string[] { + return readRecentSelectionIds(STORAGE_KEY).map((id) => { + if (id.includes(":")) return id; + return agentSelectionId(id); + }); } export function trackRecentAssignee(agentId: string): void { - if (!agentId) return; - const recent = getRecentAssigneeIds().filter((id) => id !== agentId); - recent.unshift(agentId); - if (recent.length > MAX_RECENT) recent.length = MAX_RECENT; - localStorage.setItem(STORAGE_KEY, JSON.stringify(recent)); + trackRecentSelectionId(STORAGE_KEY, agentSelectionId(agentId)); +} + +export function trackRecentAssigneeUser(userId: string): void { + trackRecentSelectionId(STORAGE_KEY, userSelectionId(userId)); } export function sortAgentsByRecency( agents: T[], recentIds: string[], ): T[] { - const recentIndex = new Map(recentIds.map((id, i) => [id, i])); + const recentIndex = new Map(recentIds.slice(0, RECENT_SELECTION_DISPLAY_LIMIT).map((id, i) => [id, i])); return [...agents].sort((a, b) => { const aRecent = recentIndex.get(a.id); const bRecent = recentIndex.get(b.id); diff --git a/ui/src/lib/recent-projects.ts b/ui/src/lib/recent-projects.ts new file mode 100644 index 00000000..d9da2621 --- /dev/null +++ b/ui/src/lib/recent-projects.ts @@ -0,0 +1,14 @@ +import { + readRecentSelectionIds, + trackRecentSelectionId, +} from "./recent-selections"; + +const STORAGE_KEY = "paperclip:recent-projects"; + +export function getRecentProjectIds(): string[] { + return readRecentSelectionIds(STORAGE_KEY); +} + +export function trackRecentProject(projectId: string): void { + trackRecentSelectionId(STORAGE_KEY, projectId); +} diff --git a/ui/src/lib/recent-selections.test.ts b/ui/src/lib/recent-selections.test.ts new file mode 100644 index 00000000..6d376a2c --- /dev/null +++ b/ui/src/lib/recent-selections.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from "vitest"; +import { + getRecentAssigneeIds, + getRecentAssigneeSelectionIds, + sortAgentsByRecency, + trackRecentAssignee, + trackRecentAssigneeUser, +} from "./recent-assignees"; +import { getRecentProjectIds, trackRecentProject } from "./recent-projects"; +import { orderItemsBySelectedAndRecent } from "./recent-selections"; + +describe("recent selection ordering", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("keeps the selected option first, then three recent options, then default order", () => { + const ordered = orderItemsBySelectedAndRecent( + [ + { id: "", label: "No project" }, + { id: "alpha", label: "Alpha" }, + { id: "bravo", label: "Bravo" }, + { id: "charlie", label: "Charlie" }, + { id: "delta", label: "Delta" }, + { id: "echo", label: "Echo" }, + ], + "charlie", + ["echo", "bravo", "delta", "alpha"], + ); + + expect(ordered.map((item) => item.id)).toEqual(["charlie", "echo", "bravo", "delta", "", "alpha"]); + }); + + it("keeps the no-value option first when it is selected", () => { + const ordered = orderItemsBySelectedAndRecent( + [ + { id: "", label: "No assignee" }, + { id: "agent-1", label: "Agent 1" }, + { id: "agent-2", label: "Agent 2" }, + ], + "", + ["agent-2"], + ); + + expect(ordered.map((item) => item.id)).toEqual(["", "agent-2", "agent-1"]); + }); + + it("only promotes the latest three assignees before default alphabetical order", () => { + const agents = [ + { id: "alpha", name: "Alpha" }, + { id: "bravo", name: "Bravo" }, + { id: "charlie", name: "Charlie" }, + { id: "delta", name: "Delta" }, + { id: "echo", name: "Echo" }, + ]; + + const sorted = sortAgentsByRecency(agents, ["delta", "bravo", "echo", "charlie"]); + + expect(sorted.map((agent) => agent.id)).toEqual(["delta", "bravo", "echo", "alpha", "charlie"]); + }); + + it("tracks recent project ids newest first without duplicates", () => { + trackRecentProject("project-1"); + trackRecentProject("project-2"); + trackRecentProject("project-1"); + + expect(getRecentProjectIds()).toEqual(["project-1", "project-2"]); + }); + + it("tracks recent user and agent assignee selections with prefixed ids", () => { + trackRecentAssignee("agent-1"); + trackRecentAssigneeUser("user-1"); + + expect(getRecentAssigneeSelectionIds()).toEqual(["user:user-1", "agent:agent-1"]); + expect(getRecentAssigneeIds()).toEqual(["agent-1"]); + }); +}); diff --git a/ui/src/lib/recent-selections.ts b/ui/src/lib/recent-selections.ts new file mode 100644 index 00000000..3ca845ad --- /dev/null +++ b/ui/src/lib/recent-selections.ts @@ -0,0 +1,50 @@ +export const RECENT_SELECTION_DISPLAY_LIMIT = 3; +const MAX_STORED_RECENT_SELECTIONS = 10; + +export function readRecentSelectionIds(storageKey: string): string[] { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : []; + } catch { + return []; + } +} + +export function trackRecentSelectionId(storageKey: string, id: string): void { + if (!id) return; + const recent = readRecentSelectionIds(storageKey).filter((candidate) => candidate !== id); + recent.unshift(id); + if (recent.length > MAX_STORED_RECENT_SELECTIONS) recent.length = MAX_STORED_RECENT_SELECTIONS; + localStorage.setItem(storageKey, JSON.stringify(recent)); +} + +export function orderItemsBySelectedAndRecent( + items: T[], + selectedId: string | null | undefined, + recentIds: string[], + recentLimit = RECENT_SELECTION_DISPLAY_LIMIT, +): T[] { + const itemById = new Map(items.map((item) => [item.id, item])); + const ordered: T[] = []; + const seen = new Set(); + + const push = (id: string | null | undefined) => { + if (id === null || id === undefined || seen.has(id)) return; + const item = itemById.get(id); + if (!item) return; + ordered.push(item); + seen.add(id); + }; + + push(selectedId); + for (const recentId of recentIds.slice(0, recentLimit)) { + push(recentId); + } + for (const item of items) { + push(item.id); + } + + return ordered; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index dd00e43f..dddfde4d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -328,7 +328,7 @@ function IssueDetailLoadingState({ const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; return ( -
+
@@ -2091,6 +2091,7 @@ export function IssueDetail() { const showInboxToolbar = isMobile && isFromInbox; const archivePending = archiveFromInbox.isPending; const issueHidden = !!issue?.hiddenAt; + const canArchiveFromInbox = isFromInbox && !!issue?.id && !issueHidden; useEffect(() => { if (!showInboxToolbar) { @@ -2213,7 +2214,7 @@ export function IssueDetail() { ); return ( -
+
{/* Parent chain breadcrumb */} {ancestors.length > 0 && (