Merge pull request #7553 from paperclipai/codex/pap-10343-operator-qol-pr
[codex] Group operator QoL fixes
This commit is contained in:
@@ -63,6 +63,14 @@ describe("issuesApi.list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes plan document filters through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", { hasPlanDocument: false, limit: 25 });
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/issues?hasPlanDocument=false&limit=25",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
|
||||
@@ -58,6 +58,7 @@ export const issuesApi = {
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
hasPlanDocument?: boolean;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -86,6 +87,9 @@ export const issuesApi = {
|
||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true");
|
||||
if (filters?.hasPlanDocument !== undefined) {
|
||||
params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false");
|
||||
}
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
className="paperclip-doc-annotation-sheet flex max-h-[88vh] flex-col rounded-none border-t border-border bg-background p-0"
|
||||
className="paperclip-doc-annotation-sheet z-[60] flex max-h-[88vh] flex-col rounded-none border-t border-border bg-popover p-0 text-popover-foreground shadow-2xl"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
Comments on {props.documentKey} revision {props.documentRevisionNumber}
|
||||
@@ -95,7 +95,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
|
||||
aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`}
|
||||
data-testid="document-annotation-panel"
|
||||
className={cn(
|
||||
"flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-card shadow-md",
|
||||
"isolate flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-popover text-popover-foreground shadow-xl",
|
||||
props.className,
|
||||
)}
|
||||
style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined}
|
||||
@@ -205,7 +205,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
<>
|
||||
<header
|
||||
data-testid={bodyTestId}
|
||||
className="flex items-start justify-between gap-2 border-b border-border px-3 py-2.5"
|
||||
className="flex items-start justify-between gap-2 border-b border-border bg-popover px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<p className="text-sm font-medium">Comments</p>
|
||||
@@ -227,7 +227,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1 border-b border-border bg-popover px-3 py-2">
|
||||
{FILTERS.map((entry) => {
|
||||
const count = counts[entry.id];
|
||||
const isActive = filter === entry.id;
|
||||
@@ -255,12 +255,12 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
{props.newCommentDisabled && props.newCommentDisabledReason ? (
|
||||
<p
|
||||
data-testid="document-annotation-disabled-reason"
|
||||
className="border-b border-border bg-muted/40 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
className="border-b border-border bg-muted px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{props.newCommentDisabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-popover px-3 py-2">
|
||||
{filteredThreads.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
{filter === "open" ? "No open comments yet. Select text to add one." : `No ${filter} comments.`}
|
||||
@@ -302,8 +302,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
{props.pendingAnchor ? (
|
||||
<div className="border-t border-border bg-muted/20 px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-background px-2 py-1 text-xs italic text-muted-foreground">
|
||||
<div className="border-t border-border bg-popover px-3 py-2">
|
||||
<blockquote className="mb-2 line-clamp-3 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground">
|
||||
{truncate(props.pendingAnchor.selectedText, 160)}
|
||||
</blockquote>
|
||||
<Textarea
|
||||
@@ -389,9 +389,9 @@ function ThreadCard(props: {
|
||||
data-focused={props.expanded || undefined}
|
||||
aria-labelledby={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"rounded-none border border-border bg-card transition-colors",
|
||||
"rounded-none border border-border bg-background transition-colors",
|
||||
props.expanded && "ring-1 ring-ring/70",
|
||||
thread.status === "resolved" && "bg-muted/30",
|
||||
thread.status === "resolved" && "bg-muted",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onClick={props.onFocus}
|
||||
@@ -405,8 +405,8 @@ function ThreadCard(props: {
|
||||
<blockquote
|
||||
id={`thread-quote-${thread.id}`}
|
||||
className={cn(
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted/40 px-2 py-1 text-xs italic text-muted-foreground",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted/30",
|
||||
"mx-3 mt-1 line-clamp-2 overflow-hidden rounded-none bg-muted px-2 py-1 text-xs italic text-muted-foreground",
|
||||
(thread.anchorState === "stale" || thread.status === "resolved") && "bg-muted",
|
||||
)}
|
||||
>
|
||||
{truncate(thread.selectedText, 120)}
|
||||
|
||||
@@ -60,8 +60,6 @@ function makeAttachment(overrides: Partial<IssueAttachment> = {}): IssueAttachme
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
contentPath: "/api/attachments/attachment-1/content",
|
||||
openPath: "/api/attachments/attachment-1/content",
|
||||
downloadPath: "/api/attachments/attachment-1/content?download=1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -271,8 +269,6 @@ describe("IssueAttachmentsSection", () => {
|
||||
originalFilename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
contentPath: "/api/attachments/pdf-attachment/content",
|
||||
openPath: "/api/attachments/pdf-attachment/content",
|
||||
downloadPath: "/api/attachments/pdf-attachment/content?download=1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1627,6 +1627,70 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("submits Other text for pending question interactions", async () => {
|
||||
const root = createRoot(container);
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
interactions={[createQuestionInteraction()]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const otherButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Other"),
|
||||
);
|
||||
expect(otherButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
otherButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(textarea, "Phase 1 plus docs");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Submit answers"),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "interaction-question-1",
|
||||
kind: "ask_user_questions",
|
||||
}),
|
||||
[{ questionId: "scope", optionIds: [], otherText: "Phase 1 plus docs" }],
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes the cancel callback for pending question interactions", async () => {
|
||||
const root = createRoot(container);
|
||||
const onCancelInteraction = vi.fn(async () => undefined);
|
||||
|
||||
@@ -282,6 +282,7 @@ describe("IssueDocumentAnnotations", () => {
|
||||
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor?.className).toContain("fixed");
|
||||
expect(anchor?.className).toContain("z-[60]");
|
||||
});
|
||||
|
||||
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
|
||||
@@ -712,6 +713,8 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet?.getAttribute("data-side")).toBe("bottom");
|
||||
expect(sheet?.className).toContain("paperclip-doc-annotation-sheet");
|
||||
expect(sheet?.className).toContain("z-[60]");
|
||||
expect(sheet?.className).toContain("bg-popover");
|
||||
} finally {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
|
||||
@@ -310,7 +310,7 @@ export function IssueDocumentAnnotations({
|
||||
{panelOpen && !isMobile && desktopPanelFrame ? (
|
||||
<div
|
||||
data-testid="document-annotation-panel-anchor"
|
||||
className="pointer-events-auto fixed hidden lg:block"
|
||||
className="pointer-events-auto fixed z-[60] hidden lg:block"
|
||||
style={{
|
||||
left: desktopPanelFrame.left,
|
||||
maxHeight: desktopPanelFrame.maxHeight,
|
||||
|
||||
@@ -89,6 +89,70 @@ describe("IssueThreadInteractionCard", () => {
|
||||
"interaction-questions-default-post-submit-summary-prompt",
|
||||
);
|
||||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
|
||||
|
||||
const otherLink = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent === "Other",
|
||||
);
|
||||
expect(otherLink?.getAttribute("role")).toBeNull();
|
||||
expect(otherLink?.className).toContain("underline");
|
||||
});
|
||||
|
||||
it("submits written Other answers for pending questions", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
});
|
||||
|
||||
const otherButtons = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Other"),
|
||||
);
|
||||
expect(otherButtons.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
otherButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(textarea, "Keep only the root item open");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const summaryCheckbox = Array.from(host.querySelectorAll('[role="checkbox"]')).find((button) =>
|
||||
button.textContent?.includes("Inline answer pills"),
|
||||
);
|
||||
await act(async () => {
|
||||
summaryCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send answers"),
|
||||
);
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "ask_user_questions" }),
|
||||
[
|
||||
{
|
||||
questionId: "collapse-depth",
|
||||
optionIds: [],
|
||||
otherText: "Keep only the root item open",
|
||||
},
|
||||
{
|
||||
questionId: "post-submit-summary",
|
||||
optionIds: ["answers-inline"],
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("only shows question cancellation when a cancel handler is wired", () => {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
|
||||
interface IssueThreadInteractionCardProps {
|
||||
interaction: IssueThreadInteraction;
|
||||
agentMap?: Map<string, Agent>;
|
||||
@@ -662,6 +664,20 @@ function AskUserQuestionsCard({
|
||||
]),
|
||||
),
|
||||
);
|
||||
const [draftOtherAnswers, setDraftOtherAnswers] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, answer.otherText ?? ""]),
|
||||
),
|
||||
);
|
||||
const [otherActiveQuestions, setOtherActiveQuestions] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, true]),
|
||||
),
|
||||
);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
|
||||
@@ -674,15 +690,45 @@ function AskUserQuestionsCard({
|
||||
]),
|
||||
),
|
||||
);
|
||||
setDraftOtherAnswers(
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, answer.otherText ?? ""]),
|
||||
),
|
||||
);
|
||||
setOtherActiveQuestions(
|
||||
Object.fromEntries(
|
||||
(interaction.result?.answers ?? [])
|
||||
.filter((answer) => answer.otherText)
|
||||
.map((answer) => [answer.questionId, true]),
|
||||
),
|
||||
);
|
||||
}, [interaction.result?.answers]);
|
||||
|
||||
const questions = interaction.payload.questions;
|
||||
const requiredQuestions = questions.filter((question) => question.required);
|
||||
const canSubmit = requiredQuestions.every(
|
||||
(question) => (draftAnswers[question.id] ?? []).length > 0,
|
||||
(question) =>
|
||||
(draftAnswers[question.id] ?? []).length > 0
|
||||
|| (
|
||||
otherActiveQuestions[question.id] === true
|
||||
&& (draftOtherAnswers[question.id]?.trim().length ?? 0) > 0
|
||||
),
|
||||
);
|
||||
|
||||
function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
|
||||
if (optionId === OTHER_ANSWER_ID) {
|
||||
setOtherActiveQuestions((current) => ({
|
||||
...current,
|
||||
[questionId]: !current[questionId],
|
||||
}));
|
||||
if (selectionMode === "single") {
|
||||
setDraftAnswers((current) => ({ ...current, [questionId]: [] }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftAnswers((current) => {
|
||||
const existing = current[questionId] ?? [];
|
||||
if (selectionMode === "single") {
|
||||
@@ -693,6 +739,9 @@ function AskUserQuestionsCard({
|
||||
: [...existing, optionId];
|
||||
return { ...current, [questionId]: next };
|
||||
});
|
||||
if (selectionMode === "single") {
|
||||
setOtherActiveQuestions((current) => ({ ...current, [questionId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -701,10 +750,16 @@ function AskUserQuestionsCard({
|
||||
try {
|
||||
await onSubmitInteractionAnswers(
|
||||
interaction,
|
||||
questions.map((question) => ({
|
||||
questionId: question.id,
|
||||
optionIds: draftAnswers[question.id] ?? [],
|
||||
})),
|
||||
questions.map((question) => {
|
||||
const otherText = otherActiveQuestions[question.id] === true
|
||||
? draftOtherAnswers[question.id]?.trim() ?? ""
|
||||
: "";
|
||||
return {
|
||||
questionId: question.id,
|
||||
optionIds: draftAnswers[question.id] ?? [],
|
||||
...(otherText ? { otherText } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -766,23 +821,53 @@ function AskUserQuestionsCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 grid gap-3"
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<QuestionOptionButton
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode)}
|
||||
<div className="mt-3 space-y-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<QuestionOptionButton
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={`${interaction.id}-${question.id}-other`}
|
||||
aria-expanded={otherActiveQuestions[question.id] === true}
|
||||
className={cn(
|
||||
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
otherActiveQuestions[question.id]
|
||||
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
{otherActiveQuestions[question.id] ? (
|
||||
<Textarea
|
||||
aria-label={`Other answer for ${question.prompt}`}
|
||||
value={draftOtherAnswers[question.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftOtherAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: event.target.value,
|
||||
}))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-24 bg-background text-sm"
|
||||
/>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,12 @@ const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"application/x-binary",
|
||||
]);
|
||||
|
||||
type AttachmentPathLike = {
|
||||
contentPath: string;
|
||||
openPath?: string;
|
||||
downloadPath?: string;
|
||||
};
|
||||
|
||||
function normalizedContentType(attachment: Pick<IssueAttachment, "contentType">) {
|
||||
return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
}
|
||||
@@ -15,15 +21,11 @@ export function attachmentFilename(attachment: Pick<IssueAttachment, "id" | "ori
|
||||
return attachment.originalFilename ?? attachment.id;
|
||||
}
|
||||
|
||||
export function attachmentOpenPath(
|
||||
attachment: Pick<IssueAttachment, "contentPath" | "openPath">,
|
||||
) {
|
||||
export function attachmentOpenPath(attachment: AttachmentPathLike) {
|
||||
return attachment.openPath ?? attachment.contentPath;
|
||||
}
|
||||
|
||||
export function attachmentDownloadPath(
|
||||
attachment: Pick<IssueAttachment, "contentPath" | "downloadPath">,
|
||||
) {
|
||||
export function attachmentDownloadPath(attachment: AttachmentPathLike) {
|
||||
return attachment.downloadPath ?? `${attachment.contentPath}?download=1`;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,11 @@ describe("issue thread interaction helpers", () => {
|
||||
{
|
||||
questionId: "question-1",
|
||||
optionIds: ["option-2", "option-1"],
|
||||
otherText: "A written answer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(labels).toEqual(["Option 2", "Option 1"]);
|
||||
expect(labels).toEqual(["Option 2", "Option 1", "Other: A written answer"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,12 +132,15 @@ export function getQuestionAnswerLabels(args: {
|
||||
answers: readonly AskUserQuestionsAnswer[];
|
||||
}) {
|
||||
const { question, answers } = args;
|
||||
const selectedIds =
|
||||
answers.find((answer) => answer.questionId === question.id)?.optionIds ?? [];
|
||||
const answer = answers.find((candidate) => candidate.questionId === question.id);
|
||||
const selectedIds = answer?.optionIds ?? [];
|
||||
const optionLabelById = new Map(
|
||||
question.options.map((option) => [option.id, option.label] as const),
|
||||
);
|
||||
return selectedIds
|
||||
const labels = selectedIds
|
||||
.map((optionId) => optionLabelById.get(optionId))
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
const otherText = answer?.otherText?.trim();
|
||||
if (otherText) labels.push(`Other: ${otherText}`);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@@ -295,6 +295,38 @@ describe("Inbox toolbar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hides workspace grouping when isolated workspaces are disabled", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
||||
});
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Inbox />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
|
||||
expect(groupButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent);
|
||||
expect(groupOptions).not.toContain("Workspace");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("syncs hover with j/k selection on inbox rows", async () => {
|
||||
routerMock.location.pathname = "/inbox/mine";
|
||||
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
|
||||
|
||||
@@ -456,6 +456,86 @@ describe("Routines page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the routines list to project groups sorted by title", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-3", title: "Agent review", projectId: "project-2" }),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides archived routines from the routines list", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
||||
createRoutine({ id: "routine-2", title: "Archived cleanup", status: "archived" }),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("1 routine");
|
||||
expect(text).toContain("Morning sync");
|
||||
expect(text).not.toContain("Archived cleanup");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an outlined row-level run now button on the routines table", async () => {
|
||||
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
@@ -584,6 +664,12 @@ describe("Routines page", () => {
|
||||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && issuesListMock.mock.calls.length === 0; attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -83,9 +83,9 @@ type RoutineGroup = {
|
||||
};
|
||||
|
||||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
groupBy: "project",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
@@ -417,9 +417,13 @@ export function Routines() {
|
||||
[projects],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||
const visibleRoutines = useMemo(
|
||||
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
|
||||
[routines],
|
||||
);
|
||||
const sortedRoutines = useMemo(
|
||||
() => sortRoutines(routines ?? [], routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, routines],
|
||||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
);
|
||||
const routineGroups = useMemo(
|
||||
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
@@ -516,7 +520,7 @@ export function Routines() {
|
||||
<TabsContent value="routines" className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
|
||||
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover>
|
||||
@@ -873,11 +877,11 @@ export function Routines() {
|
||||
|
||||
{activeTab === "routines" ? (
|
||||
<div>
|
||||
{(routines ?? []).length === 0 ? (
|
||||
{visibleRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||
message="No active routines. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user