mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50: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 { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
export interface InlineEntityOption {
|
export interface InlineEntityOption {
|
||||||
|
|
@ -21,6 +22,7 @@ interface InlineEntitySelectorProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
|
renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode;
|
||||||
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
|
||||||
|
recentOptionIds?: string[];
|
||||||
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
|
||||||
disablePortal?: boolean;
|
disablePortal?: boolean;
|
||||||
/** Open the popover when the trigger receives keyboard/programmatic focus. */
|
/** Open the popover when the trigger receives keyboard/programmatic focus. */
|
||||||
|
|
@ -41,6 +43,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
className,
|
className,
|
||||||
renderTriggerValue,
|
renderTriggerValue,
|
||||||
renderOption,
|
renderOption,
|
||||||
|
recentOptionIds = [],
|
||||||
disablePortal,
|
disablePortal,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
},
|
},
|
||||||
|
|
@ -53,10 +56,10 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||||
const shouldPreventCloseAutoFocusRef = useRef(false);
|
const shouldPreventCloseAutoFocusRef = useRef(false);
|
||||||
const isPointerDownRef = useRef(false);
|
const isPointerDownRef = useRef(false);
|
||||||
|
|
||||||
const allOptions = useMemo<InlineEntityOption[]>(
|
const allOptions = useMemo<InlineEntityOption[]>(() => {
|
||||||
() => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options],
|
const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options];
|
||||||
[noneLabel, options],
|
return orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds);
|
||||||
);
|
}, [noneLabel, options, recentOptionIds, value]);
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const term = query.trim().toLowerCase();
|
const term = query.trim().toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,6 @@ const { appendMock } = vi.hoisted(() => ({
|
||||||
appendMock: vi.fn(async () => undefined),
|
appendMock: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { threadMessagesMock } = vi.hoisted(() => ({
|
|
||||||
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
captureComposerViewportSnapshotMock,
|
captureComposerViewportSnapshotMock,
|
||||||
restoreComposerViewportSnapshotMock,
|
restoreComposerViewportSnapshotMock,
|
||||||
|
|
@ -36,31 +32,7 @@ const {
|
||||||
|
|
||||||
vi.mock("@assistant-ui/react", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
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 }) }),
|
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", () => ({
|
vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||||
|
|
@ -127,6 +99,12 @@ vi.mock("./OutputFeedbackButtons", () => ({
|
||||||
OutputFeedbackButtons: () => null,
|
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", () => ({
|
vi.mock("./AgentIconPicker", () => ({
|
||||||
AgentIcon: () => null,
|
AgentIcon: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -149,7 +127,6 @@ describe("IssueChatThread", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -157,7 +134,6 @@ describe("IssueChatThread", () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
appendMock.mockReset();
|
appendMock.mockReset();
|
||||||
markdownEditorFocusMock.mockReset();
|
markdownEditorFocusMock.mockReset();
|
||||||
threadMessagesMock.mockReset();
|
|
||||||
captureComposerViewportSnapshotMock.mockClear();
|
captureComposerViewportSnapshotMock.mockClear();
|
||||||
restoreComposerViewportSnapshotMock.mockClear();
|
restoreComposerViewportSnapshotMock.mockClear();
|
||||||
shouldPreserveComposerViewportMock.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 root = createRoot(container);
|
||||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
threadMessagesMock.mockImplementation(() => {
|
|
||||||
throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)");
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
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(container.textContent).toContain("Agent summary");
|
||||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
expect(container.textContent).not.toContain("Chat renderer hit an internal state error.");
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import {
|
import {
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
ActionBarPrimitive,
|
|
||||||
MessagePrimitive,
|
|
||||||
ThreadPrimitive,
|
|
||||||
useAui,
|
useAui,
|
||||||
useAuiState,
|
|
||||||
useMessage,
|
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
import type {
|
||||||
|
ReasoningMessagePart,
|
||||||
|
TextMessagePart,
|
||||||
|
ThreadMessage,
|
||||||
|
ToolCallMessagePart,
|
||||||
|
} from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
Component,
|
Component,
|
||||||
|
|
@ -257,7 +257,7 @@ interface IssueChatThreadProps {
|
||||||
|
|
||||||
type IssueChatErrorBoundaryProps = {
|
type IssueChatErrorBoundaryProps = {
|
||||||
resetKey: string;
|
resetKey: string;
|
||||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
messages: readonly ThreadMessage[];
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
variant: "full" | "embedded";
|
variant: "full" | "embedded";
|
||||||
children: ReactNode;
|
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;
|
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||||
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
||||||
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
|
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
|
||||||
|
|
@ -310,7 +310,7 @@ function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessag
|
||||||
return "System";
|
return "System";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
|
function fallbackTextParts(message: ThreadMessage) {
|
||||||
const contentLines: string[] = [];
|
const contentLines: string[] = [];
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "text" || part.type === "reasoning") {
|
if (part.type === "text" || part.type === "reasoning") {
|
||||||
|
|
@ -337,7 +337,7 @@ function IssueChatFallbackThread({
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
variant,
|
variant,
|
||||||
}: {
|
}: {
|
||||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
messages: readonly ThreadMessage[];
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
variant: "full" | "embedded";
|
variant: "full" | "embedded";
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -574,9 +574,16 @@ function cleanToolDisplayText(tool: ToolCallMessagePart): string {
|
||||||
return summary ? `${name} ${summary}` : name;
|
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 { agentMap } = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||||
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : 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 agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||||
const isMessageRunning = message.role === "assistant" && message.status?.type === "running";
|
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(
|
const myIndex = useMemo(
|
||||||
() => findCoTSegmentIndex(message.content, cotParts),
|
() => findCoTSegmentIndex(message.content, cotParts),
|
||||||
[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 {
|
const {
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
|
|
@ -939,7 +1040,6 @@ function IssueChatUserMessage() {
|
||||||
currentUserId,
|
currentUserId,
|
||||||
userProfileMap,
|
userProfileMap,
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||||
|
|
@ -1008,11 +1108,7 @@ function IssueChatUserMessage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0 max-w-full space-y-3">
|
<div className="min-w-0 max-w-full space-y-3">
|
||||||
<MessagePrimitive.Parts
|
<IssueChatTextParts message={message} />
|
||||||
components={{
|
|
||||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1064,7 +1160,7 @@ function IssueChatUserMessage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
||||||
{isCurrentUser ? (
|
{isCurrentUser ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1078,11 +1174,11 @@ function IssueChatUserMessage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatAssistantMessage() {
|
function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||||
const {
|
const {
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
feedbackDataSharingPreference,
|
feedbackDataSharingPreference,
|
||||||
|
|
@ -1093,7 +1189,6 @@ function IssueChatAssistantMessage() {
|
||||||
onStopRun,
|
onStopRun,
|
||||||
stoppingRunId,
|
stoppingRunId,
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||||
const authorName = typeof custom.authorName === "string"
|
const authorName = typeof custom.authorName === "string"
|
||||||
|
|
@ -1120,6 +1215,8 @@ function IssueChatAssistantMessage() {
|
||||||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||||
const [folded, setFolded] = useState(isFoldable);
|
const [folded, setFolded] = useState(isFoldable);
|
||||||
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, 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
|
// Derive fold state synchronously during render (not in useEffect) so the
|
||||||
// browser never paints the un-folded intermediate state — prevents 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;
|
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className="flex items-start gap-2.5 py-1.5">
|
<div className="flex items-start gap-2.5 py-1.5">
|
||||||
<Avatar size="sm" className="mt-0.5 shrink-0">
|
<Avatar size="sm" className="mt-0.5 shrink-0">
|
||||||
{agentIcon ? (
|
{agentIcon ? (
|
||||||
|
|
@ -1192,12 +1289,7 @@ function IssueChatAssistantMessage() {
|
||||||
{!folded ? (
|
{!folded ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<MessagePrimitive.Parts
|
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
|
||||||
components={{
|
|
||||||
Text: ({ text }) => <IssueChatTextPart text={text} recessed={hasCoT} />,
|
|
||||||
ChainOfThought: IssueChatChainOfThought,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{message.content.length === 0 && waitingText ? (
|
{message.content.length === 0 && waitingText ? (
|
||||||
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
|
<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">
|
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||||
|
|
@ -1225,15 +1317,20 @@ function IssueChatAssistantMessage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-1">
|
<div className="mt-2 flex items-center gap-1">
|
||||||
<ActionBarPrimitive.Copy
|
<button
|
||||||
copiedDuration={2000}
|
type="button"
|
||||||
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"
|
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"
|
title="Copy message"
|
||||||
aria-label="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" />
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
<Check className="hidden h-3.5 w-3.5 group-data-[copied=true]:block" />
|
</button>
|
||||||
</ActionBarPrimitive.Copy>
|
|
||||||
{commentId && onVote ? (
|
{commentId && onVote ? (
|
||||||
<IssueChatFeedbackButtons
|
<IssueChatFeedbackButtons
|
||||||
activeVote={activeVote}
|
activeVote={activeVote}
|
||||||
|
|
@ -1270,11 +1367,7 @@ function IssueChatAssistantMessage() {
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = message.content
|
void navigator.clipboard.writeText(copyText);
|
||||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n\n");
|
|
||||||
void navigator.clipboard.writeText(text);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||||
|
|
@ -1307,7 +1400,7 @@ function IssueChatAssistantMessage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||||
|
|
@ -1601,16 +1693,16 @@ function IssueChatSystemMessage() {
|
||||||
|
|
||||||
if (isCurrentUser) {
|
if (isCurrentUser) {
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className="flex items-start justify-end gap-2 py-1">
|
<div className="flex items-start justify-end gap-2 py-1">
|
||||||
{eventContent}
|
{eventContent}
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className="flex items-start gap-2.5 py-1">
|
<div className="flex items-start gap-2.5 py-1">
|
||||||
<Avatar size="sm" className="mt-0.5">
|
<Avatar size="sm" className="mt-0.5">
|
||||||
{agentIcon ? (
|
{agentIcon ? (
|
||||||
|
|
@ -1623,7 +1715,7 @@ function IssueChatSystemMessage() {
|
||||||
{eventContent}
|
{eventContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1631,7 +1723,7 @@ function IssueChatSystemMessage() {
|
||||||
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
|
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
|
||||||
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
|
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root id={anchorId}>
|
<div id={anchorId}>
|
||||||
<div className="flex items-center gap-2.5 py-1">
|
<div className="flex items-center gap-2.5 py-1">
|
||||||
<Avatar size="sm">
|
<Avatar size="sm">
|
||||||
{runAgentIcon ? (
|
{runAgentIcon ? (
|
||||||
|
|
@ -1665,7 +1757,7 @@ function IssueChatSystemMessage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2036,7 +2128,7 @@ export function IssueChatThread({
|
||||||
userLabelMap,
|
userLabelMap,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
|
||||||
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
const stabilized = stabilizeThreadMessages(
|
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 resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full";
|
||||||
const resolvedEmptyMessage = emptyMessage
|
const resolvedEmptyMessage = emptyMessage
|
||||||
?? (variant === "embedded"
|
?? (variant === "embedded"
|
||||||
|
|
@ -2172,9 +2255,12 @@ export function IssueChatThread({
|
||||||
emptyMessage={resolvedEmptyMessage}
|
emptyMessage={resolvedEmptyMessage}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Root className="">
|
<div data-testid="thread-root">
|
||||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
<div
|
||||||
<ThreadPrimitive.Empty>
|
data-testid="thread-viewport"
|
||||||
|
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
|
||||||
|
>
|
||||||
|
{messages.length === 0 ? (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-center text-sm text-muted-foreground",
|
"text-center text-sm text-muted-foreground",
|
||||||
variant === "embedded"
|
variant === "embedded"
|
||||||
|
|
@ -2183,11 +2269,23 @@ export function IssueChatThread({
|
||||||
)}>
|
)}>
|
||||||
{resolvedEmptyMessage}
|
{resolvedEmptyMessage}
|
||||||
</div>
|
</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} />
|
<div ref={bottomAnchorRef} />
|
||||||
</ThreadPrimitive.Viewport>
|
</div>
|
||||||
</ThreadPrimitive.Root>
|
</div>
|
||||||
</IssueChatErrorBoundary>
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,10 @@ vi.mock("../hooks/useProjectOrder", () => ({
|
||||||
|
|
||||||
vi.mock("../lib/recent-assignees", () => ({
|
vi.mock("../lib/recent-assignees", () => ({
|
||||||
getRecentAssigneeIds: () => [],
|
getRecentAssigneeIds: () => [],
|
||||||
|
getRecentAssigneeSelectionIds: () => [],
|
||||||
sortAgentsByRecency: (agents: unknown[]) => agents,
|
sortAgentsByRecency: (agents: unknown[]) => agents,
|
||||||
trackRecentAssignee: vi.fn(),
|
trackRecentAssignee: vi.fn(),
|
||||||
|
trackRecentAssigneeUser: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../lib/assignees", () => ({
|
vi.mock("../lib/assignees", () => ({
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,15 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
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 { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
@ -294,10 +302,16 @@ export function IssueProperties({
|
||||||
};
|
};
|
||||||
|
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
||||||
|
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]);
|
||||||
const sortedAgents = useMemo(
|
const sortedAgents = useMemo(
|
||||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||||
[agents, recentAssigneeIds],
|
[agents, recentAssigneeIds],
|
||||||
);
|
);
|
||||||
|
const recentAssigneeValues = useMemo(
|
||||||
|
() => recentAssigneeSelectionIds,
|
||||||
|
[recentAssigneeSelectionIds],
|
||||||
|
);
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]);
|
||||||
const userLabelMap = useMemo(
|
const userLabelMap = useMemo(
|
||||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||||
[companyMembers?.users],
|
[companyMembers?.users],
|
||||||
|
|
@ -315,6 +329,11 @@ export function IssueProperties({
|
||||||
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
|
||||||
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
const assigneeUserLabel = userLabel(issue.assigneeUserId);
|
||||||
const creatorUserLabel = userLabel(issue.createdByUserId);
|
const creatorUserLabel = userLabel(issue.createdByUserId);
|
||||||
|
const selectedAssigneeValue = issue.assigneeAgentId
|
||||||
|
? `agent:${issue.assigneeAgentId}`
|
||||||
|
: issue.assigneeUserId
|
||||||
|
? `user:${issue.assigneeUserId}`
|
||||||
|
: "";
|
||||||
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
executionPolicy: buildExecutionPolicy({
|
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 = (
|
const assigneeContent = (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
|
@ -509,89 +568,40 @@ export function IssueProperties({
|
||||||
autoFocus={!inline}
|
autoFocus={!inline}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
{assigneePickerOptions
|
||||||
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
|
|
||||||
.filter((option) => {
|
.filter((option) => {
|
||||||
if (!assigneeSearch.trim()) return true;
|
if (!assigneeSearch.trim()) return true;
|
||||||
const q = assigneeSearch.toLowerCase();
|
const q = assigneeSearch.toLowerCase();
|
||||||
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
|
return `${option.label} ${option.searchText}`.toLowerCase().includes(q);
|
||||||
})
|
})
|
||||||
.map((option) => {
|
.map((option) => (
|
||||||
const userId = option.id.slice("user:".length);
|
<button
|
||||||
return (
|
key={option.id || "__none__"}
|
||||||
<button
|
className={cn(
|
||||||
key={option.id}
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
className={cn(
|
option.id === selectedAssigneeValue && "bg-accent",
|
||||||
"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={() => {
|
||||||
)}
|
if (option.kind === "agent") {
|
||||||
onClick={() => {
|
trackRecentAssignee(option.agent.id);
|
||||||
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
|
onUpdate({ assigneeAgentId: option.agent.id, assigneeUserId: null });
|
||||||
setAssigneeOpen(false);
|
} 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" />
|
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
{option.label}
|
) : null}
|
||||||
</button>
|
{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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -702,6 +712,20 @@ export function IssueProperties({
|
||||||
<span className="text-sm text-muted-foreground">No project</span>
|
<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 = (
|
const projectContent = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -713,58 +737,53 @@ export function IssueProperties({
|
||||||
autoFocus={!inline}
|
autoFocus={!inline}
|
||||||
/>
|
/>
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
{projectPickerOptions
|
||||||
className={cn(
|
.filter((option) => {
|
||||||
"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) => {
|
|
||||||
if (!projectSearch.trim()) return true;
|
if (!projectSearch.trim()) return true;
|
||||||
const q = projectSearch.toLowerCase();
|
const q = projectSearch.toLowerCase();
|
||||||
return p.name.toLowerCase().includes(q);
|
return option.name.toLowerCase().includes(q);
|
||||||
})
|
})
|
||||||
.map((p) => (
|
.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={option.id || "__none__"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
|
"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"
|
option.id === (issue.projectId ?? "") && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
|
if (option.kind === "project") {
|
||||||
onUpdate({
|
const defaultMode = defaultExecutionWorkspaceModeForProject(option.project);
|
||||||
projectId: p.id,
|
trackRecentProject(option.project.id);
|
||||||
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
|
onUpdate({
|
||||||
executionWorkspaceId: null,
|
projectId: option.project.id,
|
||||||
executionWorkspacePreference: defaultMode,
|
projectWorkspaceId: defaultProjectWorkspaceIdForProject(option.project),
|
||||||
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
|
executionWorkspaceId: null,
|
||||||
? { mode: defaultMode }
|
executionWorkspacePreference: defaultMode,
|
||||||
: null,
|
executionWorkspaceSettings: option.project.executionWorkspacePolicy?.enabled
|
||||||
});
|
? { mode: defaultMode }
|
||||||
setProjectOpen(false);
|
: null,
|
||||||
}}
|
});
|
||||||
>
|
} else {
|
||||||
<span
|
onUpdate({
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
projectId: null,
|
||||||
style={{ backgroundColor: p.color ?? "#6366f1" }}
|
projectWorkspaceId: null,
|
||||||
/>
|
executionWorkspaceId: null,
|
||||||
{p.name}
|
executionWorkspacePreference: null,
|
||||||
</button>
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../l
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
|
import { buildExecutionPolicy } from "../lib/issue-execution-policy";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
|
|
@ -854,6 +855,11 @@ export function NewIssueDialog() {
|
||||||
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
||||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
||||||
|
const recentAssigneeOptionIds = useMemo(
|
||||||
|
() => recentAssigneeIds.map((id) => assigneeValueFromSelection({ assigneeAgentId: id })),
|
||||||
|
[recentAssigneeIds],
|
||||||
|
);
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [newIssueOpen]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() => [
|
() => [
|
||||||
...currentUserAssigneeOption(currentUserId),
|
...currentUserAssigneeOption(currentUserId),
|
||||||
|
|
@ -887,6 +893,7 @@ export function NewIssueDialog() {
|
||||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||||
|
|
||||||
const handleProjectChange = useCallback((nextProjectId: string) => {
|
const handleProjectChange = useCallback((nextProjectId: string) => {
|
||||||
|
if (nextProjectId) trackRecentProject(nextProjectId);
|
||||||
setProjectId(nextProjectId);
|
setProjectId(nextProjectId);
|
||||||
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
|
||||||
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
|
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
|
||||||
|
|
@ -1096,6 +1103,7 @@ export function NewIssueDialog() {
|
||||||
ref={assigneeSelectorRef}
|
ref={assigneeSelectorRef}
|
||||||
value={assigneeValue}
|
value={assigneeValue}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeOptionIds}
|
||||||
placeholder="Assignee"
|
placeholder="Assignee"
|
||||||
disablePortal
|
disablePortal
|
||||||
noneLabel="No assignee"
|
noneLabel="No assignee"
|
||||||
|
|
@ -1147,6 +1155,7 @@ export function NewIssueDialog() {
|
||||||
ref={projectSelectorRef}
|
ref={projectSelectorRef}
|
||||||
value={projectId}
|
value={projectId}
|
||||||
options={projectOptions}
|
options={projectOptions}
|
||||||
|
recentOptionIds={recentProjectIds}
|
||||||
placeholder="Project"
|
placeholder="Project"
|
||||||
disablePortal
|
disablePortal
|
||||||
noneLabel="No project"
|
noneLabel="No project"
|
||||||
|
|
@ -1236,6 +1245,7 @@ export function NewIssueDialog() {
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={reviewerValue}
|
value={reviewerValue}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeOptionIds}
|
||||||
placeholder="Reviewer"
|
placeholder="Reviewer"
|
||||||
disablePortal
|
disablePortal
|
||||||
noneLabel="No reviewer"
|
noneLabel="No reviewer"
|
||||||
|
|
@ -1280,6 +1290,7 @@ export function NewIssueDialog() {
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={approverValue}
|
value={approverValue}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeOptionIds}
|
||||||
placeholder="Approver"
|
placeholder="Approver"
|
||||||
disablePortal
|
disablePortal
|
||||||
noneLabel="No approver"
|
noneLabel="No approver"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -162,6 +163,7 @@ export function RoutineRunVariablesDialog({
|
||||||
[projects, selection.projectId],
|
[projects, selection.projectId],
|
||||||
);
|
);
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [open]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
sortAgentsByRecency(
|
sortAgentsByRecency(
|
||||||
|
|
@ -271,6 +273,7 @@ export function RoutineRunVariablesDialog({
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={selection.assigneeAgentId}
|
value={selection.assigneeAgentId}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeIds}
|
||||||
placeholder="Agent"
|
placeholder="Agent"
|
||||||
noneLabel="Select an agent"
|
noneLabel="Select an agent"
|
||||||
searchPlaceholder="Search agents..."
|
searchPlaceholder="Search agents..."
|
||||||
|
|
@ -312,6 +315,7 @@ export function RoutineRunVariablesDialog({
|
||||||
<InlineEntitySelector
|
<InlineEntitySelector
|
||||||
value={selection.projectId}
|
value={selection.projectId}
|
||||||
options={projectOptions}
|
options={projectOptions}
|
||||||
|
recentOptionIds={recentProjectIds}
|
||||||
placeholder="Project"
|
placeholder="Project"
|
||||||
noneLabel="No project"
|
noneLabel="No project"
|
||||||
searchPlaceholder="Search projects..."
|
searchPlaceholder="Search projects..."
|
||||||
|
|
@ -320,6 +324,7 @@ export function RoutineRunVariablesDialog({
|
||||||
openOnFocus={false}
|
openOnFocus={false}
|
||||||
onChange={(projectId) => {
|
onChange={(projectId) => {
|
||||||
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
const project = projects.find((entry) => entry.id === projectId) ?? null;
|
||||||
|
if (projectId) trackRecentProject(projectId);
|
||||||
setSelection((current) => ({ ...current, projectId }));
|
setSelection((current) => ({ ...current, projectId }));
|
||||||
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
|
||||||
setWorkspaceConfigValid(true);
|
setWorkspaceConfigValid(true);
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ function CommandInput({
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -355,11 +355,18 @@
|
||||||
--accentBgActive: color-mix(in oklab, var(--accent) 72%, var(--background));
|
--accentBgActive: color-mix(in oklab, var(--accent) 72%, var(--background));
|
||||||
--accentText: var(--accent-foreground);
|
--accentText: var(--accent-foreground);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.paperclip-mdxeditor-scope,
|
||||||
|
.paperclip-mdxeditor {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor-scope [class*="_iconButton_"],
|
.paperclip-mdxeditor-scope [class*="_iconButton_"],
|
||||||
.paperclip-mdxeditor [class*="_iconButton_"] {
|
.paperclip-mdxeditor [class*="_iconButton_"] {
|
||||||
color: var(--baseText);
|
color: var(--baseText);
|
||||||
|
|
@ -385,7 +392,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperclip-mdxeditor [class*="_placeholder_"] {
|
.paperclip-mdxeditor [class*="_placeholder_"] {
|
||||||
font-size: 0.875rem;
|
font-size: inherit;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ describe("inbox helpers", () => {
|
||||||
expect(issues).toHaveLength(2);
|
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 = [
|
const approvals = [
|
||||||
{
|
{
|
||||||
...makeApprovalWithTimestamps("approval-approved", "approved", "2026-03-11T02:00:00.000Z"),
|
...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([
|
expect(getApprovalsForTab(approvals, "mine", "all", "user-1").map((approval) => approval.id)).toEqual([
|
||||||
"approval-revision",
|
"approval-revision",
|
||||||
"approval-approved",
|
"approval-approved",
|
||||||
|
"approval-pending",
|
||||||
]);
|
]);
|
||||||
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
||||||
"approval-revision",
|
"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 = [
|
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({
|
const result = computeInboxBadgeData({
|
||||||
approvals,
|
approvals,
|
||||||
|
|
@ -459,7 +468,7 @@ describe("inbox helpers", () => {
|
||||||
currentUserId: "user-1",
|
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", () => {
|
it("does not count company-wide alerts in the personal inbox badge", () => {
|
||||||
|
|
|
||||||
|
|
@ -706,11 +706,7 @@ export function getApprovalsForTab(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tab === "mine") {
|
if (tab === "mine") {
|
||||||
if (!currentUserId) return [];
|
return sortedApprovals.filter((approval) => isApprovalVisibleInMine(approval, currentUserId));
|
||||||
return sortedApprovals.filter(
|
|
||||||
(approval) =>
|
|
||||||
approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (tab === "recent") return sortedApprovals;
|
if (tab === "recent") return sortedApprovals;
|
||||||
if (tab === "unread") {
|
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 {
|
export function approvalActivityTimestamp(approval: Approval): number {
|
||||||
const updatedAt = normalizeTimestamp(approval.updatedAt);
|
const updatedAt = normalizeTimestamp(approval.updatedAt);
|
||||||
if (updatedAt > 0) return updatedAt;
|
if (updatedAt > 0) return updatedAt;
|
||||||
|
|
@ -1030,8 +1035,7 @@ export function computeInboxBadgeData({
|
||||||
}): InboxBadgeData {
|
}): InboxBadgeData {
|
||||||
const actionableApprovals = approvals.filter(
|
const actionableApprovals = approvals.filter(
|
||||||
(approval) =>
|
(approval) =>
|
||||||
!!currentUserId &&
|
isApprovalVisibleInMine(approval, currentUserId) &&
|
||||||
(approval.requestedByUserId === currentUserId || approval.decidedByUserId === currentUserId) &&
|
|
||||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||||
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
||||||
).length;
|
).length;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,51 @@
|
||||||
|
import {
|
||||||
|
RECENT_SELECTION_DISPLAY_LIMIT,
|
||||||
|
readRecentSelectionIds,
|
||||||
|
trackRecentSelectionId,
|
||||||
|
} from "./recent-selections";
|
||||||
|
|
||||||
const STORAGE_KEY = "paperclip:recent-assignees";
|
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[] {
|
export function getRecentAssigneeIds(): string[] {
|
||||||
try {
|
return readRecentSelectionIds(STORAGE_KEY)
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
.map(agentIdFromSelectionId)
|
||||||
if (!raw) return [];
|
.filter((id): id is string => Boolean(id));
|
||||||
const parsed = JSON.parse(raw);
|
}
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
export function getRecentAssigneeSelectionIds(): string[] {
|
||||||
return [];
|
return readRecentSelectionIds(STORAGE_KEY).map((id) => {
|
||||||
}
|
if (id.includes(":")) return id;
|
||||||
|
return agentSelectionId(id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackRecentAssignee(agentId: string): void {
|
export function trackRecentAssignee(agentId: string): void {
|
||||||
if (!agentId) return;
|
trackRecentSelectionId(STORAGE_KEY, agentSelectionId(agentId));
|
||||||
const recent = getRecentAssigneeIds().filter((id) => id !== agentId);
|
}
|
||||||
recent.unshift(agentId);
|
|
||||||
if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
|
export function trackRecentAssigneeUser(userId: string): void {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(recent));
|
trackRecentSelectionId(STORAGE_KEY, userSelectionId(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortAgentsByRecency<T extends { id: string; name: string }>(
|
export function sortAgentsByRecency<T extends { id: string; name: string }>(
|
||||||
agents: T[],
|
agents: T[],
|
||||||
recentIds: string[],
|
recentIds: string[],
|
||||||
): T[] {
|
): 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) => {
|
return [...agents].sort((a, b) => {
|
||||||
const aRecent = recentIndex.get(a.id);
|
const aRecent = recentIndex.get(a.id);
|
||||||
const bRecent = recentIndex.get(b.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;
|
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-3xl space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-3 w-40" />
|
<Skeleton className="h-3 w-40" />
|
||||||
|
|
||||||
|
|
@ -2091,6 +2091,7 @@ export function IssueDetail() {
|
||||||
const showInboxToolbar = isMobile && isFromInbox;
|
const showInboxToolbar = isMobile && isFromInbox;
|
||||||
const archivePending = archiveFromInbox.isPending;
|
const archivePending = archiveFromInbox.isPending;
|
||||||
const issueHidden = !!issue?.hiddenAt;
|
const issueHidden = !!issue?.hiddenAt;
|
||||||
|
const canArchiveFromInbox = isFromInbox && !!issue?.id && !issueHidden;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showInboxToolbar) {
|
if (!showInboxToolbar) {
|
||||||
|
|
@ -2213,7 +2214,7 @@ export function IssueDetail() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-3xl space-y-6">
|
||||||
{/* Parent chain breadcrumb */}
|
{/* Parent chain breadcrumb */}
|
||||||
{ancestors.length > 0 && (
|
{ancestors.length > 0 && (
|
||||||
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
|
<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">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/Rout
|
||||||
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
||||||
import { RunButton } from "../components/AgentActionButtons";
|
import { RunButton } from "../components/AgentActionButtons";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -626,6 +627,7 @@ export function RoutineDetail() {
|
||||||
[projects],
|
[projects],
|
||||||
);
|
);
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [routine?.id]);
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [routine?.id]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
sortAgentsByRecency(
|
sortAgentsByRecency(
|
||||||
|
|
@ -786,6 +788,7 @@ export function RoutineDetail() {
|
||||||
ref={assigneeSelectorRef}
|
ref={assigneeSelectorRef}
|
||||||
value={editDraft.assigneeAgentId}
|
value={editDraft.assigneeAgentId}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeIds}
|
||||||
placeholder="Assignee"
|
placeholder="Assignee"
|
||||||
noneLabel="No assignee"
|
noneLabel="No assignee"
|
||||||
searchPlaceholder="Search assignees..."
|
searchPlaceholder="Search assignees..."
|
||||||
|
|
@ -831,11 +834,15 @@ export function RoutineDetail() {
|
||||||
ref={projectSelectorRef}
|
ref={projectSelectorRef}
|
||||||
value={editDraft.projectId}
|
value={editDraft.projectId}
|
||||||
options={projectOptions}
|
options={projectOptions}
|
||||||
|
recentOptionIds={recentProjectIds}
|
||||||
placeholder="Project"
|
placeholder="Project"
|
||||||
noneLabel="No project"
|
noneLabel="No project"
|
||||||
searchPlaceholder="Search projects..."
|
searchPlaceholder="Search projects..."
|
||||||
emptyMessage="No projects found."
|
emptyMessage="No projects found."
|
||||||
onChange={(projectId) => setEditDraft((current) => ({ ...current, projectId }))}
|
onChange={(projectId) => {
|
||||||
|
if (projectId) trackRecentProject(projectId);
|
||||||
|
setEditDraft((current) => ({ ...current, projectId }));
|
||||||
|
}}
|
||||||
onConfirm={() => descriptionEditorRef.current?.focus()}
|
onConfirm={() => descriptionEditorRef.current?.focus()}
|
||||||
renderTriggerValue={(option) =>
|
renderTriggerValue={(option) =>
|
||||||
option && currentProject ? (
|
option && currentProject ? (
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
|
|
@ -461,6 +462,7 @@ export function Routines() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [composerOpen]);
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), [composerOpen]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
sortAgentsByRecency(
|
sortAgentsByRecency(
|
||||||
|
|
@ -716,6 +718,7 @@ export function Routines() {
|
||||||
ref={assigneeSelectorRef}
|
ref={assigneeSelectorRef}
|
||||||
value={draft.assigneeAgentId}
|
value={draft.assigneeAgentId}
|
||||||
options={assigneeOptions}
|
options={assigneeOptions}
|
||||||
|
recentOptionIds={recentAssigneeIds}
|
||||||
placeholder="Assignee"
|
placeholder="Assignee"
|
||||||
noneLabel="No assignee"
|
noneLabel="No assignee"
|
||||||
searchPlaceholder="Search assignees..."
|
searchPlaceholder="Search assignees..."
|
||||||
|
|
@ -761,11 +764,15 @@ export function Routines() {
|
||||||
ref={projectSelectorRef}
|
ref={projectSelectorRef}
|
||||||
value={draft.projectId}
|
value={draft.projectId}
|
||||||
options={projectOptions}
|
options={projectOptions}
|
||||||
|
recentOptionIds={recentProjectIds}
|
||||||
placeholder="Project"
|
placeholder="Project"
|
||||||
noneLabel="No project"
|
noneLabel="No project"
|
||||||
searchPlaceholder="Search projects..."
|
searchPlaceholder="Search projects..."
|
||||||
emptyMessage="No projects found."
|
emptyMessage="No projects found."
|
||||||
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
|
onChange={(projectId) => {
|
||||||
|
if (projectId) trackRecentProject(projectId);
|
||||||
|
setDraft((current) => ({ ...current, projectId }));
|
||||||
|
}}
|
||||||
onConfirm={() => descriptionEditorRef.current?.focus()}
|
onConfirm={() => descriptionEditorRef.current?.focus()}
|
||||||
renderTriggerValue={(option) =>
|
renderTriggerValue={(option) =>
|
||||||
option && currentProject ? (
|
option && currentProject ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue