mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
[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:
parent
fee514efcb
commit
057fee4836
19 changed files with 596 additions and 275 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
14
ui/src/lib/recent-projects.ts
Normal file
14
ui/src/lib/recent-projects.ts
Normal 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);
|
||||
}
|
||||
79
ui/src/lib/recent-selections.test.ts
Normal file
79
ui/src/lib/recent-selections.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
50
ui/src/lib/recent-selections.ts
Normal file
50
ui/src/lib/recent-selections.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue