mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip lets people manage work performed by AI agents. > - Task chat saves follow-up messages while an agent runs. > - A native runner can accept Steer only after an active provider turn exists. > - During preparation, or with an unsupported provider, the queue showed a disabled Steer button and no delivery action. > - The server also rejected Interrupt for ordinary native queues. > - This change offers an explicit Interrupt fallback and uses the existing cancellation and admission path. > - People can send their saved messages while Stop confirmation, cleanup, and execution gates remain enforced. ## Linked Issues or Issue Description Related: #15865 covers mixed legacy queues. #15030 covers steering acknowledgements. This change covers native queues without available steering. **What happened?** A queued message had a disabled Steer button while the native runner was still preparing. Clicking it did nothing. The server returned HTTP 409 for an explicit Interrupt request against the same native queue. **Expected behavior** Offer Steer when the provider can accept same-turn input. Otherwise offer Interrupt, labeled with its actual effect. Preserve saved messages until the previous execution stops and normal admission accepts the successor. **Steps to reproduce** 1. Start a task with the Paperclip Runner adapter. 2. Submit a follow-up while the run is preparing, before its provider turn opens. 3. Observe the disabled Steer button above the composer. 4. With this change, click Interrupt. The existing Stop path cancels the previous execution and submits the saved queue through normal admission. **Paperclip version or commit** Reproduced locally on master at523818ea9b. The branch is rebased on2f9d3d43c3. ## What Changed - Offer Interrupt for unavailable or unsupported native steering when the parent supplies an interrupt callback. - Keep Steer for an available provider turn. - Accept explicit Interrupt for a deferred native queue and record its run target. - Bind native continuation to the exact Interrupt queue, run, actor, and cancellation receipt. This lets one click send the messages after Stop completes. - Add component, task-thread, real PostgreSQL route, and continuation authorization regressions. - Close the route fixture database pool before disposing its PostgreSQL instance. - Document the fallback and its Stop and admission requirements. ## Verification - Red: two component cases failed because Interrupt was absent. Two route cases returned HTTP 409, `This queue does not support interruption`, before the repair. - Red: stronger delivery assertions also caught `send_predates_stop` after the first repair. The admission fix covers both the freshness exception and later eligibility check. - Green: both route regressions now wait for preparation settlement, then deliver both saved messages exactly once without a second click. The complete route suite passes 119 tests. The complete continuation suite passes 376 tests, including wrong actor, queue, target, and missing-receipt cases. - UI: task-thread and queued-message suites pass 394 tests, with one existing skip. The issue-page suite also passes (the earlier three-suite run passed 644 tests before the final three UI cases). - Token gates and `git diff --check` pass. - Full workspace build and low-memory typecheck pass. The initial typecheck caught a missing `membershipRole` in an unrelated fixture; the rebase picked up the correction already on master, and the complete typecheck then passed. - The full local `pnpm test:run` passed all six chat shards (1,931 tests). It was stopped during general-server validation after the final commit passed all PR CI gates. The complete local suite is not claimed as a pass. - Earlier route-suite teardown timed out with an open database pool. Closing that pool fixed teardown; the complete route suite then passed. - All final-head CI gates pass: 52 successful checks and two intentional skips. Greptile is 5/5 on the same head with no unresolved review threads. ## Risks Interrupt stops the previous execution and starts a successor; it is a different action from same-turn steering. The UI labels that action explicitly. Cleanup or execution gates can keep the messages queued. Existing company access, board permissions, queue revision, run target, cancellation proof, and admission checks remain in place. No schema migration is needed. ## Model Used OpenAI Codex (GPT-6), with reasoning, tool use, and code execution. The exact model ID and context window are not exposed by this client. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
DndContext,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
closestCenter,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
SortableContext,
|
||
arrayMove,
|
||
sortableKeyboardCoordinates,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import {
|
||
CornerDownRight,
|
||
GripVertical,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import type {
|
||
IssueQueuedCommentEntry,
|
||
IssueQueuedCommentQueue,
|
||
} from "@paperclipai/shared";
|
||
import { cn } from "@/lib/utils";
|
||
import { TaskChatComposerBar } from "./TaskChatComposerBar";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
|
||
type QueueAction = "steer" | "interrupt" | "discard" | null;
|
||
|
||
export function reorderQueuedMessageEntries(
|
||
entries: IssueQueuedCommentEntry[],
|
||
activeId: string,
|
||
overId: string,
|
||
) {
|
||
if (entries.some((entry) => entry.source?.kind === "interaction")) return null;
|
||
const from = entries.findIndex((entry) => entry.comment.id === activeId);
|
||
const to = entries.findIndex((entry) => entry.comment.id === overId);
|
||
if (from < 0 || to < 0 || from === to) return null;
|
||
return arrayMove(entries, from, to).map((entry, position) => ({
|
||
...entry,
|
||
position,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* People see one readable line. A saved card response also carries a typed
|
||
* JSON receipt for the agent; it never reaches a label or a hover.
|
||
*/
|
||
export function queuedMessageLabel(entry: IssueQueuedCommentEntry): string {
|
||
if (entry.source?.kind !== "interaction") return entry.comment.body;
|
||
const summary = entry.source.summary?.trim();
|
||
return summary || entry.comment.body.split("\n")[0] || "Saved response";
|
||
}
|
||
|
||
function queueActionErrorCode(error: unknown): string | null {
|
||
if (typeof error !== "object" || error === null) return null;
|
||
const body = (error as { body?: unknown }).body;
|
||
if (typeof body !== "object" || body === null) return null;
|
||
const directCode = (body as { code?: unknown }).code;
|
||
if (typeof directCode === "string") return directCode;
|
||
const details = (body as { details?: unknown }).details;
|
||
if (typeof details !== "object" || details === null) return null;
|
||
const detailCode = (details as { code?: unknown }).code;
|
||
return typeof detailCode === "string" ? detailCode : null;
|
||
}
|
||
|
||
export interface TaskChatQueuedMessagesProps {
|
||
queue: IssueQueuedCommentQueue;
|
||
onEdit: (commentId: string) => void;
|
||
onReorder: (orderedCommentIds: string[], revision: string) => Promise<void>;
|
||
onSteer: (commentId: string, revision: string) => Promise<void>;
|
||
onInterrupt?: () => Promise<IssueQueuedCommentQueue | void>;
|
||
onDiscard: (commentId: string, revision: string) => Promise<void>;
|
||
}
|
||
|
||
function SortableQueuedMessage({
|
||
entry,
|
||
queue,
|
||
busy,
|
||
queueMutationDisabled,
|
||
action,
|
||
onEdit,
|
||
onSteer,
|
||
onInterrupt,
|
||
onDiscard,
|
||
}: {
|
||
entry: IssueQueuedCommentEntry;
|
||
queue: IssueQueuedCommentQueue;
|
||
busy: boolean;
|
||
queueMutationDisabled: boolean;
|
||
action: QueueAction;
|
||
onEdit: () => void;
|
||
onSteer: () => void;
|
||
onInterrupt?: () => void;
|
||
onDiscard: () => void;
|
||
}) {
|
||
const immutableResponse = entry.source?.kind === "interaction";
|
||
const label = queuedMessageLabel(entry);
|
||
const sortable = useSortable({
|
||
id: entry.comment.id,
|
||
disabled: queueMutationDisabled || immutableResponse,
|
||
});
|
||
const style = {
|
||
transform: CSS.Transform.toString(sortable.transform),
|
||
transition: sortable.transition,
|
||
};
|
||
const steerDisabled =
|
||
queueMutationDisabled || queue.steeringDisposition !== "available";
|
||
const steerTitle =
|
||
queue.steeringDisposition === "unsupported"
|
||
? "This runner does not support steering"
|
||
: queue.steeringDisposition === "temporarily_unavailable"
|
||
? "Steering is temporarily unavailable"
|
||
: "Steer this message into the active turn";
|
||
|
||
return (
|
||
<div
|
||
ref={sortable.setNodeRef}
|
||
style={style}
|
||
className={cn(
|
||
"group flex h-11 min-w-0 items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 text-sm last:border-b-0",
|
||
sortable.isDragging &&
|
||
"relative z-20 rounded-lg border border-border shadow-lg",
|
||
)}
|
||
data-testid={`task-chat-queued-message-${entry.comment.id}`}
|
||
>
|
||
<button
|
||
type="button"
|
||
ref={sortable.setActivatorNodeRef}
|
||
{...sortable.attributes}
|
||
{...sortable.listeners}
|
||
disabled={queueMutationDisabled || immutableResponse}
|
||
aria-label={`Reorder queued message: ${label}`}
|
||
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40"
|
||
>
|
||
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
||
</button>
|
||
|
||
<CornerDownRight
|
||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
<span className="min-w-0 flex-1 truncate px-1" title={label}>
|
||
{label}
|
||
</span>
|
||
|
||
{(queue.targetRunId || queue.state === "deferred") && (!queue.targetRunId || queue.protocol === "legacy" || entry.source?.requiresFreshSession ||
|
||
(queue.steeringDisposition !== "available" && onInterrupt) ? (
|
||
<button
|
||
type="button"
|
||
onClick={onInterrupt}
|
||
aria-label={!queue.targetRunId ? "Send queued messages" : undefined}
|
||
disabled={busy || !queue.queueId || !onInterrupt}
|
||
title={queue.targetRunId ? "Interrupt the active turn and send queued messages" : "Send queued messages now"}
|
||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||
data-testid={`task-chat-queued-interrupt-${entry.comment.id}`}
|
||
>
|
||
{action === "interrupt" ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
{queue.targetRunId ? "Interrupt" : "Send"}
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={onSteer}
|
||
disabled={steerDisabled}
|
||
title={steerTitle}
|
||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||
data-testid={`task-chat-queued-steer-${entry.comment.id}`}
|
||
>
|
||
{action === "steer" ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
Steer
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onDiscard}
|
||
disabled={
|
||
busy ||
|
||
(!queue.queueId && !entry.comment.id.startsWith("optimistic-")) ||
|
||
!entry.canDiscard
|
||
}
|
||
title="Discard queued message"
|
||
aria-label={`Discard queued message: ${label}`}
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||
data-testid={`task-chat-queued-discard-${entry.comment.id}`}
|
||
>
|
||
{action === "discard" ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||
) : (
|
||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||
)}
|
||
</button>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={queueMutationDisabled || immutableResponse}
|
||
title="Queued message actions"
|
||
aria-label={`Queued message actions: ${label}`}
|
||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-44">
|
||
<DropdownMenuItem disabled={!entry.canEdit} onSelect={onEdit}>
|
||
<Pencil className="h-4 w-4" aria-hidden />
|
||
Edit message
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Compact production queue shown immediately above the default task composer. */
|
||
export function TaskChatQueuedMessages({
|
||
queue,
|
||
onEdit,
|
||
onReorder,
|
||
onSteer,
|
||
onInterrupt,
|
||
onDiscard,
|
||
}: TaskChatQueuedMessagesProps) {
|
||
const [entries, setEntries] = useState(queue.entries);
|
||
const latestQueue = useRef(queue);
|
||
latestQueue.current = queue;
|
||
const optimisticDeliveryIds = useRef(new Set<string>());
|
||
const [pending, setPending] = useState<{
|
||
commentId: string;
|
||
action: Exclude<QueueAction, null>;
|
||
} | null>(null);
|
||
const [reordering, setReordering] = useState(false);
|
||
const [announcement, setAnnouncement] = useState("");
|
||
const [visibleError, setVisibleError] = useState<string | null>(null);
|
||
const [deferredStatus, setDeferredStatus] = useState<{ queueId: string | null; message: string } | null>(null);
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
}),
|
||
);
|
||
|
||
useEffect(() => {
|
||
// Polling can return the old queue while delivery is still in flight.
|
||
setEntries(queue.entries.filter(
|
||
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
|
||
));
|
||
}, [queue.entries, queue.revision]);
|
||
|
||
useEffect(() => {
|
||
setDeferredStatus(current => {
|
||
if (!current) return null;
|
||
if (queue.state !== "deferred" || queue.queueId !== current.queueId || !queue.entries.length) return null;
|
||
const message = queue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready.";
|
||
return current.message === message ? current : { ...current, message };
|
||
});
|
||
}, [queue.state, queue.queueId, queue.entries.length, queue.executionWait?.message]);
|
||
|
||
const ids = useMemo(
|
||
() => entries.map((entry) => entry.comment.id),
|
||
[entries],
|
||
);
|
||
|
||
async function handleDragEnd(event: DragEndEvent) {
|
||
const { active, over } = event;
|
||
if (
|
||
!queue.queueId ||
|
||
!over ||
|
||
active.id === over.id ||
|
||
reordering ||
|
||
pending
|
||
)
|
||
return;
|
||
const previous = entries;
|
||
const next = reorderQueuedMessageEntries(
|
||
entries,
|
||
String(active.id),
|
||
String(over.id),
|
||
);
|
||
if (!next) return;
|
||
const orderedIds = next.map((entry) => entry.comment.id);
|
||
const activeCommentId = String(active.id);
|
||
const to = next.findIndex((entry) => entry.comment.id === activeCommentId);
|
||
setEntries(next);
|
||
setReordering(true);
|
||
setVisibleError(null);
|
||
setAnnouncement(
|
||
`Moved queued message to position ${to + 1} of ${next.length}.`,
|
||
);
|
||
try {
|
||
await onReorder(orderedIds, queue.revision);
|
||
} catch (error) {
|
||
setEntries(previous);
|
||
setAnnouncement("");
|
||
setVisibleError(
|
||
queueActionErrorCode(error) === "queued_comment_revision_conflict"
|
||
? "The queue changed in another session. Its latest order has been restored."
|
||
: "Couldn’t reorder. Previous order restored.",
|
||
);
|
||
} finally {
|
||
setReordering(false);
|
||
}
|
||
}
|
||
|
||
async function runRowAction(
|
||
commentId: string,
|
||
action: Exclude<QueueAction, null>,
|
||
) {
|
||
const locallyDiscardable =
|
||
action === "discard" && commentId.startsWith("optimistic-");
|
||
if (
|
||
pending ||
|
||
reordering ||
|
||
(!queue.queueId && action !== "interrupt" && !locallyDiscardable)
|
||
) {
|
||
return;
|
||
}
|
||
const deliveredIds = action === "interrupt"
|
||
? entries.map((entry) => entry.comment.id)
|
||
: action === "steer" ? [commentId] : [];
|
||
for (const id of deliveredIds) optimisticDeliveryIds.current.add(id);
|
||
if (deliveredIds.length) {
|
||
setEntries((current) => current.filter(
|
||
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
|
||
));
|
||
}
|
||
setPending({ commentId, action });
|
||
setVisibleError(null); setDeferredStatus(null);
|
||
setAnnouncement(
|
||
action === "steer"
|
||
? "Steering queued message."
|
||
: action === "interrupt"
|
||
? "Sending queued messages."
|
||
: "Discarding queued message.",
|
||
);
|
||
try {
|
||
if (action === "steer") await onSteer(commentId, queue.revision);
|
||
else if (action === "interrupt") {
|
||
const updatedQueue = await onInterrupt?.();
|
||
if (updatedQueue?.state === "deferred") {
|
||
for (const entry of updatedQueue.entries) optimisticDeliveryIds.current.delete(entry.comment.id);
|
||
setEntries(updatedQueue.entries.filter(entry => !optimisticDeliveryIds.current.has(entry.comment.id)));
|
||
setDeferredStatus({ queueId: updatedQueue.queueId, message: updatedQueue.executionWait?.message ?? "Messages are still queued. They will send when the task is ready." });
|
||
setAnnouncement("Messages are still queued.");
|
||
return;
|
||
}
|
||
}
|
||
else await onDiscard(commentId, queue.revision);
|
||
if (action === "discard") {
|
||
setEntries((current) =>
|
||
current.filter((entry) => entry.comment.id !== commentId),
|
||
);
|
||
}
|
||
setAnnouncement(
|
||
action === "steer"
|
||
? "Message steered into the active turn."
|
||
: action === "interrupt"
|
||
? queue.targetRunId
|
||
? "Queued messages will be sent when the previous run has stopped."
|
||
: "Queued messages submitted. Waiting for the agent."
|
||
: "Queued message discarded.",
|
||
);
|
||
} catch (error) {
|
||
for (const id of deliveredIds) optimisticDeliveryIds.current.delete(id);
|
||
if (deliveredIds.length) {
|
||
setEntries(latestQueue.current.entries.filter(
|
||
(entry) => !optimisticDeliveryIds.current.has(entry.comment.id),
|
||
));
|
||
}
|
||
setAnnouncement("");
|
||
const code = queueActionErrorCode(error);
|
||
setVisibleError(
|
||
code === "queued_comment_already_dispatching"
|
||
? "Too late to discard: this message is already being sent."
|
||
: action === "steer"
|
||
? "Couldn’t steer. Message is still queued."
|
||
: action === "interrupt"
|
||
? queue.targetRunId ? "Couldn’t interrupt. Message is still queued." : "Couldn’t send. Message is still queued."
|
||
: code === "queued_comment_revision_conflict"
|
||
? "The queue changed in another session. Review it and try again."
|
||
: "Couldn’t discard. Message is still queued.",
|
||
);
|
||
} finally {
|
||
setPending(null);
|
||
}
|
||
}
|
||
|
||
if (entries.length === 0 && !visibleError && !deferredStatus) return null;
|
||
|
||
return (
|
||
<TaskChatComposerBar
|
||
data-testid="task-chat-queued-messages"
|
||
aria-label="Queued messages"
|
||
>
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={(event) => void handleDragEnd(event)}
|
||
>
|
||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||
{/* Bounded so a long queue scrolls here instead of covering the conversation. */}
|
||
<div
|
||
className="max-h-(--tc-queued-list-max-h) overflow-y-auto overscroll-contain"
|
||
data-testid="task-chat-queued-messages-list"
|
||
>
|
||
{entries.map((entry) => {
|
||
const action =
|
||
pending?.commentId === entry.comment.id ? pending.action : null;
|
||
const busy = Boolean(pending || reordering);
|
||
const queueMutationDisabled = Boolean(
|
||
!queue.queueId || pending || reordering,
|
||
);
|
||
return (
|
||
<SortableQueuedMessage
|
||
key={entry.comment.id}
|
||
entry={entry}
|
||
queue={queue}
|
||
busy={busy}
|
||
queueMutationDisabled={queueMutationDisabled}
|
||
action={action}
|
||
onEdit={() => onEdit(entry.comment.id)}
|
||
onSteer={() => void runRowAction(entry.comment.id, "steer")}
|
||
onInterrupt={
|
||
onInterrupt
|
||
? () => void runRowAction(entry.comment.id, "interrupt")
|
||
: undefined
|
||
}
|
||
onDiscard={() => void runRowAction(entry.comment.id, "discard")}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
{deferredStatus ? <p role="status" className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">{deferredStatus.message}</p> : null}
|
||
{visibleError ? (
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
className="border-t border-destructive/20 bg-destructive/5 px-3 py-1.5 text-xs text-destructive"
|
||
>
|
||
{visibleError}
|
||
</div>
|
||
) : null}
|
||
<div className="sr-only" aria-live="polite" aria-atomic="true">
|
||
{announcement}
|
||
</div>
|
||
</TaskChatComposerBar>
|
||
);
|
||
}
|