[codex] Polish issue and operator workflow UI (#4090)

## Thinking Path

> - Paperclip operators spend much of their time in issues, inboxes,
selectors, and rich comment threads.
> - Small interaction problems in those surfaces slow down supervision
of AI-agent work.
> - The branch included related operator quality-of-life fixes for issue
layout, inbox actions, recent selectors, mobile inputs, and chat
rendering stability.
> - These changes are UI-focused and can land independently from
workspace navigation and access-profile work.
> - This pull request groups the operator QoL fixes into one standalone
branch.
> - The benefit is a more stable and efficient board workflow for issue
triage and task editing.

## What Changed

- Widened issue detail content and added a desktop inbox archive action.
- Fixed mobile text-field zoom by keeping touch input font sizes at
16px.
- Prioritized recent picker selections for assignees/projects in issue
and routine flows.
- Showed actionable approvals in the Mine inbox model.
- Fixed issue chat renderer state crashes and hardened tests.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/lib/inbox.test.ts ui/src/lib/recent-selections.test.ts`
- Split integration check: merged last after the other
[PAP-1614](/PAP/issues/PAP-1614) branches with no merge conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.

## Risks

- Low to medium risk: mostly UI state, layout, and selection-priority
behavior.
- Visual layout and mobile zoom behavior may need browser/device QA
beyond component tests.
- No database migrations are included.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-20 06:16:41 -05:00 committed by GitHub
parent fee514efcb
commit 057fee4836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 596 additions and 275 deletions

View file

@ -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<HTMLButtonElement, InlineEntitySe
className,
renderTriggerValue,
renderOption,
recentOptionIds = [],
disablePortal,
openOnFocus = true,
},
@ -53,10 +56,10 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
const shouldPreventCloseAutoFocusRef = useRef(false);
const isPointerDownRef = useRef(false);
const allOptions = useMemo<InlineEntityOption[]>(
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
[noneLabel, options],
);
const allOptions = useMemo<InlineEntityOption[]>(() => {
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();

View file

@ -20,10 +20,6 @@ const { appendMock } = vi.hoisted(() => ({
appendMock: vi.fn(async () => undefined),
}));
const { threadMessagesMock } = vi.hoisted(() => ({
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
}));
const {
captureComposerViewportSnapshotMock,
restoreComposerViewportSnapshotMock,
@ -36,31 +32,7 @@ const {
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ThreadPrimitive: {
Root: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="thread-root" className={className}>{children}</div>
),
Viewport: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="thread-viewport" className={className}>{children}</div>
),
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Messages: () => threadMessagesMock(),
},
MessagePrimitive: {
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
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 }) => <div>{children}</div>,
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(() => <div data-testid="thread-messages" />);
});
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();
});

View file

@ -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<IssueChatErrorBoundaryProps, Issu
}
}
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
function fallbackAuthorLabel(message: ThreadMessage) {
const custom = message.metadata?.custom as Record<string, unknown> | 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<string, unknown>;
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) => (
<IssueChatTextPart
key={`${message.id}:text:${index}`}
text={part.text}
recessed={recessed}
/>
))}
</>
);
}
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 (
<IssueChatTextPart
key={`${message.id}:text:${group.index}`}
text={group.part.text}
recessed={hasCoT}
/>
);
}
return (
<IssueChatChainOfThought
key={`${message.id}:cot:${group.startIndex}`}
message={message}
cotParts={group.parts}
/>
);
})}
</>
);
}
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<string, unknown>;
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() {
</div>
) : null}
<div className="min-w-0 max-w-full space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
}}
/>
<IssueChatTextParts message={message} />
</div>
</div>
@ -1064,7 +1160,7 @@ function IssueChatUserMessage() {
);
return (
<MessagePrimitive.Root id={anchorId}>
<div id={anchorId}>
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
{isCurrentUser ? (
<>
@ -1078,11 +1174,11 @@ function IssueChatUserMessage() {
</>
)}
</div>
</MessagePrimitive.Root>
</div>
);
}
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<string, unknown>;
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 (
<MessagePrimitive.Root id={anchorId}>
<div id={anchorId}>
<div className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5 shrink-0">
{agentIcon ? (
@ -1192,12 +1289,7 @@ function IssueChatAssistantMessage() {
{!folded ? (
<>
<div className="space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} recessed={hasCoT} />,
ChainOfThought: IssueChatChainOfThought,
}}
/>
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
{message.content.length === 0 && waitingText ? (
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
@ -1225,15 +1317,20 @@ function IssueChatAssistantMessage() {
</div>
<div className="mt-2 flex items-center gap-1">
<ActionBarPrimitive.Copy
copiedDuration={2000}
className="group inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[copied=true]:text-foreground"
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard.writeText(copyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
<Copy className="h-3.5 w-3.5 group-data-[copied=true]:hidden" />
<Check className="hidden h-3.5 w-3.5 group-data-[copied=true]:block" />
</ActionBarPrimitive.Copy>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{commentId && onVote ? (
<IssueChatFeedbackButtons
activeVote={activeVote}
@ -1270,11 +1367,7 @@ function IssueChatAssistantMessage() {
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
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);
}}
>
<Copy className="mr-2 h-3.5 w-3.5" />
@ -1307,7 +1400,7 @@ function IssueChatAssistantMessage() {
) : null}
</div>
</div>
</MessagePrimitive.Root>
</div>
);
}
@ -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<string, unknown>;
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 (
<MessagePrimitive.Root id={anchorId}>
<div id={anchorId}>
<div className="flex items-start justify-end gap-2 py-1">
{eventContent}
</div>
</MessagePrimitive.Root>
</div>
);
}
return (
<MessagePrimitive.Root id={anchorId}>
<div id={anchorId}>
<div className="flex items-start gap-2.5 py-1">
<Avatar size="sm" className="mt-0.5">
{agentIcon ? (
@ -1623,7 +1715,7 @@ function IssueChatSystemMessage() {
{eventContent}
</div>
</div>
</MessagePrimitive.Root>
</div>
);
}
@ -1631,7 +1723,7 @@ function IssueChatSystemMessage() {
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
return (
<MessagePrimitive.Root id={anchorId}>
<div id={anchorId}>
<div className="flex items-center gap-2.5 py-1">
<Avatar size="sm">
{runAgentIcon ? (
@ -1665,7 +1757,7 @@ function IssueChatSystemMessage() {
</div>
</div>
</div>
</MessagePrimitive.Root>
</div>
);
}
@ -2036,7 +2128,7 @@ export function IssueChatThread({
userLabelMap,
],
);
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(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}
>
<ThreadPrimitive.Root className="">
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
<ThreadPrimitive.Empty>
<div data-testid="thread-root">
<div
data-testid="thread-viewport"
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
>
{messages.length === 0 ? (
<div className={cn(
"text-center text-sm text-muted-foreground",
variant === "embedded"
@ -2183,11 +2269,23 @@ export function IssueChatThread({
)}>
{resolvedEmptyMessage}
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={components} />
) : (
// 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 <IssueChatUserMessage key={message.id} message={message} />;
}
if (message.role === "assistant") {
return <IssueChatAssistantMessage key={message.id} message={message} />;
}
return <IssueChatSystemMessage key={message.id} message={message} />;
})
)}
<div ref={bottomAnchorRef} />
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</div>
</div>
</IssueChatErrorBoundary>
{showComposer ? (

View file

@ -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", () => ({

View file

@ -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 = (
<>
<input
@ -509,89 +568,40 @@ export function IssueProperties({
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{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 (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === userId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
setAssigneeOpen(false);
}}
>
.map((option) => (
<button
key={option.id || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
option.id === selectedAssigneeValue && "bg-accent",
)}
onClick={() => {
if (option.kind === "agent") {
trackRecentAssignee(option.agent.id);
onUpdate({ assigneeAgentId: option.agent.id, assigneeUserId: null });
} else if (option.kind === "user") {
trackRecentAssigneeUser(option.userId);
onUpdate({ assigneeAgentId: null, assigneeUserId: option.userId });
} else {
onUpdate({ assigneeAgentId: null, assigneeUserId: null });
}
setAssigneeOpen(false);
}}
>
{option.kind === "agent" ? (
<AgentIcon icon={option.agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
) : option.kind === "user" ? (
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
);
})}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
) : null}
{option.label}
</button>
))}
</div>
</>
);
@ -702,6 +712,20 @@ export function IssueProperties({
<span className="text-sm text-muted-foreground">No project</span>
</>
);
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}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
No project
</button>
{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) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
{p.name}
</button>
))}
.map((option) => (
<button
key={option.id || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
option.id === (issue.projectId ?? "") && "bg-accent",
)}
onClick={() => {
if (option.kind === "project") {
const defaultMode = defaultExecutionWorkspaceModeForProject(option.project);
trackRecentProject(option.project.id);
onUpdate({
projectId: option.project.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(option.project),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: option.project.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
} else {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
}
setProjectOpen(false);
}}
>
{option.kind === "project" ? (
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: option.color ?? "#6366f1" }}
/>
) : null}
{option.name}
</button>
))}
</div>
</>
);

View file

@ -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<InlineEntityOption[]>(
() => [
...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() {
<InlineEntitySelector
value={reviewerValue}
options={assigneeOptions}
recentOptionIds={recentAssigneeOptionIds}
placeholder="Reviewer"
disablePortal
noneLabel="No reviewer"
@ -1280,6 +1290,7 @@ export function NewIssueDialog() {
<InlineEntitySelector
value={approverValue}
options={assigneeOptions}
recentOptionIds={recentAssigneeOptionIds}
placeholder="Approver"
disablePortal
noneLabel="No approver"

View file

@ -7,6 +7,7 @@ import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -162,6 +163,7 @@ export function RoutineRunVariablesDialog({
[projects, selection.projectId],
);
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [open]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
@ -271,6 +273,7 @@ export function RoutineRunVariablesDialog({
<InlineEntitySelector
value={selection.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Agent"
noneLabel="Select an agent"
searchPlaceholder="Search agents..."
@ -312,6 +315,7 @@ export function RoutineRunVariablesDialog({
<InlineEntitySelector
value={selection.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
@ -320,6 +324,7 @@ export function RoutineRunVariablesDialog({
openOnFocus={false}
onChange={(projectId) => {
const project = projects.find((entry) => entry.id === projectId) ?? null;
if (projectId) trackRecentProject(projectId);
setSelection((current) => ({ ...current, projectId }));
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
setWorkspaceConfigValid(true);

View file

@ -83,7 +83,7 @@ function CommandInput({
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-base md:text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View file

@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base md:text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View file

@ -355,11 +355,18 @@
--accentBgActive: color-mix(in oklab, var(--accent) 72%, var(--background));
--accentText: var(--accent-foreground);
font-family: inherit;
font-size: 0.875rem;
font-size: 1rem;
line-height: 1.5;
color: var(--foreground);
}
@media (min-width: 768px) {
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {
font-size: 0.875rem;
}
}
.paperclip-mdxeditor-scope [class*="_iconButton_"],
.paperclip-mdxeditor [class*="_iconButton_"] {
color: var(--baseText);
@ -385,7 +392,7 @@
}
.paperclip-mdxeditor [class*="_placeholder_"] {
font-size: 0.875rem;
font-size: inherit;
line-height: 1.5;
color: var(--muted-foreground);
}

View file

@ -406,7 +406,7 @@ describe("inbox helpers", () => {
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", () => {

View file

@ -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;

View file

@ -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<T extends { id: string; name: string }>(
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);

View file

@ -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);
}

View file

@ -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"]);
});
});

View file

@ -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<T extends { id: string }>(
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<string>();
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;
}

View file

@ -328,7 +328,7 @@ function IssueDetailLoadingState({
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
return (
<div className="max-w-2xl space-y-6">
<div className="max-w-3xl space-y-6">
<div className="space-y-3">
<Skeleton className="h-3 w-40" />
@ -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 (
<div className="max-w-2xl space-y-6">
<div className="max-w-3xl space-y-6">
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
@ -2338,6 +2339,20 @@ export function IssueDetail() {
)}
<div className="hidden md:flex items-center md:ml-auto shrink-0">
{canArchiveFromInbox && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
if (!archivePending && issue?.id) archiveFromInbox.mutate(issue.id);
}}
disabled={archivePending}
title="Archive from inbox"
aria-label="Archive from inbox"
>
<Archive className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"

View file

@ -40,6 +40,7 @@ import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/Rout
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
import { RunButton } from "../components/AgentActionButtons";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
@ -626,6 +627,7 @@ export function RoutineDetail() {
[projects],
);
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [routine?.id]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
@ -786,6 +788,7 @@ export function RoutineDetail() {
ref={assigneeSelectorRef}
value={editDraft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
@ -831,11 +834,15 @@ export function RoutineDetail() {
ref={projectSelectorRef}
value={editDraft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
onChange={(projectId) => {
if (projectId) trackRecentProject(projectId);
setEditDraft((current) => ({ ...current, projectId }));
}}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (

View file

@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
@ -461,6 +462,7 @@ export function Routines() {
});
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [composerOpen]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
@ -716,6 +718,7 @@ export function Routines() {
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
recentOptionIds={recentAssigneeIds}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
@ -761,11 +764,15 @@ export function Routines() {
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
recentOptionIds={recentProjectIds}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onChange={(projectId) => {
if (projectId) trackRecentProject(projectId);
setDraft((current) => ({ ...current, projectId }));
}}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (