mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
fix(ui): default human task replies to latest assigning agent
This commit is contained in:
1 parent
0ac194450a
commit
285d20499c
12 files changed
+219
-21
No files matched your search
@@ -67,6 +67,11 @@ Tasks have parentage. Every task exists in service of a parent task, all the way
|
||||
|
||||
The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks.
|
||||
|
||||
When a task is assigned to the current human, the message composer defaults to
|
||||
the latest agent that assigned it to them. If no assignment is available, it uses
|
||||
the latest other commenter, or leaves the recipient empty. A manual recipient
|
||||
choice stays selected while task data refreshes.
|
||||
|
||||
### Company Skills and Policy
|
||||
|
||||
Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction.
|
||||
|
||||
@@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
@@ -78,6 +85,34 @@ describe("CommentThread", () => {
|
||||
let writeTextMock: ReturnType<typeof vi.fn<(text: string) => Promise<void>>>;
|
||||
let execCommandMock: ReturnType<typeof vi.fn<typeof document.execCommand>>;
|
||||
|
||||
it("updates automatic suggestions but preserves manual choices until the task changes", () => {
|
||||
const root = createRoot(container);
|
||||
const render = (suggestion: string, draftKey = "task-one") => act(() => root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement;
|
||||
render("agent:first");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe("agent:latest");
|
||||
for (const manual of ["user:me", ""]) {
|
||||
act(() => {
|
||||
selector().value = manual;
|
||||
selector().dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
render("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe(manual);
|
||||
}
|
||||
render("agent:first", "task-two");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -752,6 +752,12 @@ export function CommentThread({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const manualAssigneeRef = useRef(false);
|
||||
const assigneeDraftKeyRef = useRef(draftKey);
|
||||
const selectAssignee = (value: string) => {
|
||||
manualAssigneeRef.current = true;
|
||||
setReassignTarget(value);
|
||||
};
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
|
||||
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
|
||||
@@ -852,8 +858,12 @@ export function CommentThread({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
if (assigneeDraftKeyRef.current !== draftKey) {
|
||||
assigneeDraftKeyRef.current = draftKey;
|
||||
manualAssigneeRef.current = false;
|
||||
}
|
||||
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [draftKey, effectiveSuggestedAssigneeValue]);
|
||||
|
||||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
@@ -898,6 +908,7 @@ export function CommentThread({
|
||||
try {
|
||||
await onAdd(submittedBody, reopen, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
manualAssigneeRef.current = false;
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
@@ -1051,7 +1062,7 @@ export function CommentThread({
|
||||
noneLabel="No responsible"
|
||||
searchPlaceholder="Search responsible..."
|
||||
emptyMessage="No responsible found."
|
||||
onChange={setReassignTarget}
|
||||
onChange={selectAssignee}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Responsible</span>;
|
||||
|
||||
@@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
@@ -375,6 +382,34 @@ function createFileDragEvent(type: string, files: File[]) {
|
||||
describe("IssueChatThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
it("updates automatic suggestions but preserves manual choices until the task changes", () => {
|
||||
const root = createRoot(container);
|
||||
const render = (suggestion: string, draftKey = "task-one") => act(() => root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement;
|
||||
render("agent:first");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe("agent:latest");
|
||||
for (const manual of ["user:me", ""]) {
|
||||
act(() => {
|
||||
selector().value = manual;
|
||||
selector().dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
render("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe(manual);
|
||||
}
|
||||
render("agent:first", "task-two");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -4779,6 +4779,12 @@ const IssueChatComposer = forwardRef<
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue =
|
||||
suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const manualAssigneeRef = useRef(false);
|
||||
const assigneeDraftKeyRef = useRef(draftKey);
|
||||
const selectAssignee = (value: string) => {
|
||||
manualAssigneeRef.current = true;
|
||||
setReassignTarget(value);
|
||||
};
|
||||
const [reassignTarget, setReassignTarget] = useState(
|
||||
effectiveSuggestedAssigneeValue,
|
||||
);
|
||||
@@ -4935,8 +4941,12 @@ const IssueChatComposer = forwardRef<
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
if (assigneeDraftKeyRef.current !== draftKey) {
|
||||
assigneeDraftKeyRef.current = draftKey;
|
||||
manualAssigneeRef.current = false;
|
||||
}
|
||||
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [draftKey, effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingWorkMode(resolvedIssueWorkMode);
|
||||
@@ -5081,6 +5091,7 @@ const IssueChatComposer = forwardRef<
|
||||
setComposerAttachments((current) =>
|
||||
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
|
||||
);
|
||||
manualAssigneeRef.current = false;
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
setRunSettings(null);
|
||||
} catch (error) {
|
||||
@@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef<
|
||||
overrides={assigneeAdapterOverrides}
|
||||
settings={runSettings}
|
||||
onSettingsChange={setRunSettings}
|
||||
onAssigneeChange={setReassignTarget}
|
||||
onAssigneeChange={selectAssignee}
|
||||
triggerRef={reassignTriggerRef}
|
||||
renderAssigneeIdentity={(value) => {
|
||||
const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null;
|
||||
@@ -5638,7 +5649,7 @@ const IssueChatComposer = forwardRef<
|
||||
noneLabel="No responsible"
|
||||
searchPlaceholder="Search responsible..."
|
||||
emptyMessage="No responsible found."
|
||||
onChange={setReassignTarget}
|
||||
onChange={selectAssignee}
|
||||
className="h-8 text-xs"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option)
|
||||
|
||||
@@ -524,6 +524,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
conversationMode,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
suggestedAssigneeValue,
|
||||
assigneeAdapterOverrides,
|
||||
issueStatus,
|
||||
issueAssigneeAgentId = null,
|
||||
@@ -601,12 +602,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const effectiveGoalAgentId =
|
||||
pendingComposerAssignee === null
|
||||
? issueAssigneeAgentId
|
||||
: pendingComposerAssignee.startsWith("agent:")
|
||||
? pendingComposerAssignee.slice("agent:".length) || null
|
||||
: null;
|
||||
const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}`
|
||||
? suggestedAssigneeValue
|
||||
: undefined;
|
||||
const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee;
|
||||
const effectiveGoalAgentId = effectiveComposerAssignee === undefined
|
||||
? issueAssigneeAgentId
|
||||
: effectiveComposerAssignee.startsWith("agent:")
|
||||
? effectiveComposerAssignee.slice("agent:".length) || null
|
||||
: null;
|
||||
const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3203,6 +3207,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
modelAgents={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={composerSuggestedAssignee}
|
||||
companyId={companyId}
|
||||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||||
onPendingAssigneeChange={setPendingComposerAssignee}
|
||||
|
||||
@@ -1701,6 +1701,25 @@ describe("TaskChatComposer", () => {
|
||||
).toBe("agent:a2");
|
||||
});
|
||||
|
||||
it("uses assignment suggestions and retains a manual human choice after refresh", async () => {
|
||||
const view = (suggestion: string) => <TaskChatComposer onAdd={vi.fn()} workMode="standard"
|
||||
enableReassign currentAssigneeValue="user:u1" suggestedAssigneeValue={suggestion}
|
||||
reassignOptions={[{ id: "agent:a1", label: "Alpha" }, { id: "agent:a2", label: "Beta" }, { id: "user:u1", label: "Me" }]}
|
||||
agentMap={new Map([["a1", { icon: "bot" }], ["a2", { icon: "bot" }]])} />;
|
||||
render(view("agent:a1"));
|
||||
const trigger = () => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]')!;
|
||||
expect(trigger().textContent).toContain("Alpha");
|
||||
flushSync(() => root!.render(view("agent:a2")));
|
||||
expect(trigger().textContent).toContain("Beta");
|
||||
flushSync(() => trigger().click());
|
||||
await flushAsync();
|
||||
const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!;
|
||||
flushSync(() => me.click());
|
||||
await flushAsync();
|
||||
flushSync(() => root!.render(view("agent:a1")));
|
||||
expect(trigger().textContent).toContain("Me");
|
||||
});
|
||||
|
||||
it("shows human avatars in assignee options and after selection", async () => {
|
||||
render(
|
||||
<TaskChatComposer
|
||||
|
||||
@@ -142,6 +142,7 @@ interface TaskChatComposerProps {
|
||||
{ label: string; image: string | null }
|
||||
> | null;
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
onPendingAssigneeChange?: (value: string | null) => void;
|
||||
issueStatus?: string;
|
||||
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
|
||||
@@ -397,6 +398,7 @@ export function TaskChatComposer({
|
||||
assigneeAdapterOverrides,
|
||||
userProfileMap,
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
onPendingAssigneeChange,
|
||||
issueStatus,
|
||||
mobile = false,
|
||||
@@ -638,7 +640,7 @@ export function TaskChatComposer({
|
||||
const showAssignee = Boolean(
|
||||
enableReassign && reassignOptions && reassignOptions.length > 0,
|
||||
);
|
||||
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue;
|
||||
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const assigneeLabel =
|
||||
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
|
||||
const assigneeName =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
@@ -72,14 +73,14 @@ describe("assignee selection helpers", () => {
|
||||
).toBe("agent:agent-123");
|
||||
});
|
||||
|
||||
it("falls back to the actual assignee when there is no better commenter hint", () => {
|
||||
it("leaves the recipient empty instead of defaulting to the current human", () => {
|
||||
expect(
|
||||
suggestedCommentAssigneeValue(
|
||||
{ assigneeUserId: "board-user" },
|
||||
[{ authorUserId: "board-user" }],
|
||||
"board-user",
|
||||
),
|
||||
).toBe("user:board-user");
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("skips the current agent when choosing a suggested commenter assignee", () => {
|
||||
@@ -97,3 +98,45 @@ describe("assignee selection helpers", () => {
|
||||
).toBe("agent:agent-123");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("assignment history suggestions", () => {
|
||||
const assignment = (actorId: string, date: string, details: Record<string, unknown> = {}): ActivityEvent => ({
|
||||
companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null,
|
||||
id: actorId, actorType: "agent", actorId, action: "issue.updated",
|
||||
createdAt: new Date(date), details: { assigneeUserId: "me", _previous: { assigneeUserId: null }, ...details },
|
||||
});
|
||||
const suggest = (activity: ActivityEvent[]) => suggestedCommentAssigneeValue(
|
||||
{ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, activity,
|
||||
);
|
||||
|
||||
it("uses the latest assigning actor in either history order, ahead of commenters", () => {
|
||||
const older = assignment("first", "2026-01-01");
|
||||
const newer = assignment("last", "2026-02-01");
|
||||
expect(suggest([older, newer])).toBe("agent:last");
|
||||
expect(suggest([newer, older])).toBe("agent:last");
|
||||
});
|
||||
|
||||
it("ignores other recipients, human actors, non-assignment events and unchanged fields", () => {
|
||||
const valid = assignment("assigner", "2026-01-01");
|
||||
const invalid = assignment("other", "2026-02-01");
|
||||
expect(suggest([
|
||||
valid,
|
||||
{ ...invalid, actorType: "user" },
|
||||
{ ...invalid, action: "issue.comment_added" },
|
||||
assignment("other-user", "2026-02-01", { assigneeUserId: "someone-else" }),
|
||||
assignment("no-op", "2026-02-01", { _previous: { assigneeUserId: "me" } }),
|
||||
assignment("resent", "2026-02-01", { _previous: { status: "todo" } }),
|
||||
])).toBe("agent:assigner");
|
||||
});
|
||||
|
||||
it("includes the agent that created a task assigned to the human", () => {
|
||||
expect(suggest([{ ...assignment("creator", "2026-01-01"), action: "issue.created" }])).toBe("agent:creator");
|
||||
});
|
||||
|
||||
it("keeps the existing commenter fallback and other task defaults", () => {
|
||||
expect(suggest([])).toBe("agent:commenter");
|
||||
expect(suggestedCommentAssigneeValue({ assigneeAgentId: "owner" }, [], "me")).toBe("agent:owner");
|
||||
expect(suggestedCommentAssigneeValue({ assigneeUserId: "someone-else" }, [], "me")).toBe("user:someone-else");
|
||||
});
|
||||
});
|
||||
+23
-1
@@ -1,3 +1,5 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
|
||||
export interface AssigneeSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -30,7 +32,27 @@ export function suggestedCommentAssigneeValue(
|
||||
comments: CommentAssigneeSuggestionComment[] | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
currentAgentId?: string | null | undefined,
|
||||
activity: readonly ActivityEvent[] = [],
|
||||
): string {
|
||||
const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId);
|
||||
if (assignedToCurrentUser) {
|
||||
const assignment = activity
|
||||
.filter((event) => {
|
||||
if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false;
|
||||
const details = event.details;
|
||||
if (!details || details.assigneeUserId !== currentUserId) return false;
|
||||
if (event.action === "issue.created") return true;
|
||||
if (event.action !== "issue.updated") return false;
|
||||
const previous = details._previous;
|
||||
// Updates can resend unchanged fields. Only a real assignment counts.
|
||||
return previous !== null && typeof previous === "object"
|
||||
&& Object.hasOwn(previous, "assigneeUserId")
|
||||
&& (previous as Record<string, unknown>).assigneeUserId !== currentUserId;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
if (assignment) return `agent:${assignment.actorId}`;
|
||||
}
|
||||
|
||||
if (comments && comments.length > 0 && (currentUserId || currentAgentId)) {
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const comment = comments[i];
|
||||
@@ -43,7 +65,7 @@ export function suggestedCommentAssigneeValue(
|
||||
}
|
||||
}
|
||||
|
||||
return assigneeValueFromSelection(issue);
|
||||
return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue);
|
||||
}
|
||||
|
||||
export function parseAssigneeValue(value: string): AssigneeSelection {
|
||||
|
||||
@@ -3841,14 +3841,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
[issue],
|
||||
);
|
||||
|
||||
const { data: assignmentActivity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId!),
|
||||
queryFn: () => activityApi.forIssue(issueId!),
|
||||
enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId),
|
||||
});
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() =>
|
||||
suggestedCommentAssigneeValue(
|
||||
issue ?? {},
|
||||
mergeIssueComments(comments ?? [], optimisticComments),
|
||||
currentUserId,
|
||||
undefined,
|
||||
assignmentActivity,
|
||||
),
|
||||
[issue, comments, optimisticComments, currentUserId],
|
||||
[issue, comments, optimisticComments, currentUserId, assignmentActivity],
|
||||
);
|
||||
|
||||
const threadComments = useMemo(
|
||||
|
||||
@@ -2177,8 +2177,10 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str
|
||||
starterAssigneeValue ? parseAssigneeValue(starterAssigneeValue) : activeConversationIssue ?? {},
|
||||
conversationComments,
|
||||
currentUserId,
|
||||
undefined,
|
||||
conversationActivity,
|
||||
),
|
||||
[activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue],
|
||||
[activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue, conversationActivity],
|
||||
);
|
||||
const conversationRunningRun = useMemo(
|
||||
() => resolveRunningPipelineConversationRun(resolvedConversationActiveRun, resolvedConversationLiveRuns),
|
||||
|
||||
Reference in new issue
Block a user