import { useCallback, useEffect, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, Paperclip, Radio } from "lucide-react"; import type { ChatPublicationState, ChatFileTransferPhase, IssueAttachment, } from "@paperclipai/shared"; import { chatEndpointsApi, type ChatProvider, type ChatPublicationSummary, type ExternalChannelBindingSummary, } from "@/api/chatEndpoints"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/context/ToastContext"; import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/queryKeys"; import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled"; import { issuesApi } from "@/api/issues"; import { boardSendDraftKey, clearBoardSendDraft, canDismissBoardSendBatch, readBoardSendDraft, readBoardSendRejection, writeBoardSendDraft, type BoardSendRejection, type RetainedBoardSend, } from "./board-send-draft"; const providerNames: Record = { speko: "Speko", slack: "Slack", github: "GitHub", discord: "Discord", "microsoft-teams": "Microsoft Teams", telegram: "Telegram", agentmail: "AgentMail", "imessage-photon": "iMessage Photon", }; type PublicationFeedback = { title: string; body: string; tone: "info" | "success" | "warn" | "error"; }; const publicationFeedback: Record = { awaiting_consent: { title: "Waiting for file consent", body: "The recipient must accept the file card in Microsoft Teams. The file is not delivered yet; this send identity is kept while Paperclip waits.", tone: "info", }, published: { title: "Sent to channel", body: "The board update was published to the connected conversation.", tone: "success", }, pending: { title: "Queued for channel", body: "Delivery is still pending. Your draft is kept until Paperclip confirms publication.", tone: "info", }, streaming: { title: "Publishing to channel", body: "Delivery is still in progress. Your draft is kept until Paperclip confirms publication.", tone: "info", }, retry: { title: "Delivery retry scheduled", body: "Paperclip will retry this publication. Your draft and retry identity are kept.", tone: "warn", }, delivery_unknown: { title: "Delivery not confirmed", body: "The provider may have accepted this update. Resolve it in Activity before trying again to avoid a duplicate.", tone: "warn", }, failed: { title: "Channel delivery failed", body: "Your draft is kept. Open Activity to retry this same publication safely.", tone: "error", }, cancelled: { title: "Channel delivery cancelled", body: "Your draft is kept. Some parts may already have been published; check Activity before starting a new send.", tone: "info", }, }; const filePhaseLabels: Record = { consent_pending: "Consent card queued", consent_sending: "Sending consent card", consent_unknown: "Consent card delivery not confirmed", awaiting_consent: "Awaiting consent", upload_pending: "Upload queued", uploading: "Uploading file", upload_unknown: "File upload not confirmed", file_info_pending: "File notification queued", file_info_sending: "Sending file notification", file_info_unknown: "File notification not confirmed", delivered: "Delivered", declined: "Declined", expired: "Consent expired", cancelled: "Cancelled; remote bytes may remain", conflict: "File delivery needs review", }; export function useIssueChatBinding(companyId: string, issueId: string) { const { enabled, githubEnabled } = useChatConnectorsEnabled(); const queryEnabled = (enabled || githubEnabled) && Boolean(companyId && issueId) && !issueId.startsWith("chat:"); const query = useQuery({ queryKey: ["issue-chat-binding", companyId, issueId], queryFn: () => chatEndpointsApi.getIssueBinding(issueId), enabled: queryEnabled, }); return { binding: queryEnabled && chatProviderVisible(query.data?.provider, enabled, githubEnabled) ? (query.data ?? null) : null, isLoading: queryEnabled && query.isLoading, }; } type ConnectedTaskProps = { className?: string; attachments?: IssueAttachment[]; companyId: string; issueId: string; issueCacheRefs?: string[]; }; export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) { const { binding } = useIssueChatBinding(props.companyId, props.issueId); if (!binding || binding.provider === "agentmail") return null; return ( ); } function ConnectedTaskComposer({ className, attachments = [], companyId, issueId, issueCacheRefs, binding, }: ConnectedTaskProps & { binding: ExternalChannelBindingSummary }) { const { pushToast } = useToast(); const queryClient = useQueryClient(); const [composing, setComposing] = useState(false); const [body, setBody] = useState(""); const [selectedAttachmentIds, setSelectedAttachmentIds] = useState( [], ); const [publication, setPublication] = useState( null, ); const idempotencyKey = useRef(null); const retainedSend = useRef(null); const retainedScopeKey = useRef(null); const [unconfirmedRequest, setUnconfirmedRequest] = useState(false); const [rejection, setRejection] = useState(null); const [excludedAttachmentIds, setExcludedAttachmentIds] = useState( [], ); const [selectionNotice, setSelectionNotice] = useState(false); const [storageError, setStorageError] = useState(null); const fileInput = useRef(null); const uploadInFlight = useRef(false); const mounted = useRef(true); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadedAttachments, setUploadedAttachments] = useState< IssueAttachment[] >([]); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const storageKey = binding ? boardSendDraftKey( companyId, issueId, binding.endpointId, binding.conversationId, ) : null; const loadedStorageKey = useRef(null); useEffect(() => { if (!storageKey || loadedStorageKey.current === storageKey) return; loadedStorageKey.current = storageKey; try { const saved = readBoardSendDraft(storageKey); retainedScopeKey.current = storageKey; retainedSend.current = saved; idempotencyKey.current = saved?.idempotencyKey ?? null; setBody(saved?.body ?? ""); setSelectedAttachmentIds(saved?.attachmentIds ?? []); setPublication(saved?.publication ?? null); setUnconfirmedRequest( Boolean(saved && !saved.publication && !saved.rejection), ); setRejection(saved?.rejection ?? null); setComposing(Boolean(saved)); setStorageError(null); } catch { setStorageError( "Saved delivery identity could not be read. Check Activity and restore browser storage before starting another send.", ); setComposing(true); } }, [storageKey]); const deliveryScopeReady = Boolean( storageKey && loadedStorageKey.current === storageKey && retainedScopeKey.current === storageKey, ); const invalidateTask = useCallback(() => { for (const ref of new Set([issueId, ...(issueCacheRefs ?? [])])) { for (const queryKey of [ queryKeys.issues.comments(ref), queryKeys.issues.attachments(ref), queryKeys.issues.detail(ref), queryKeys.issues.activity(ref), ]) { void queryClient.invalidateQueries({ queryKey }); } } }, [issueId, issueCacheRefs, queryClient]); const finishPublication = useCallback(() => { if (storageKey) { try { clearBoardSendDraft(storageKey); } catch { /* The retained anchor remains safe to recheck after reload. */ } } retainedSend.current = null; setUnconfirmedRequest(false); setRejection(null); setSelectionNotice(false); setPublication(null); idempotencyKey.current = null; setBody(""); setSelectedAttachmentIds([]); setUploadedAttachments([]); setUploadError(null); setComposing(false); invalidateTask(); pushToast(publicationFeedback.published); }, [invalidateTask, pushToast, storageKey]); // Keep the first returned ID as the anchor. A batch's blocking row may // change as text and files finish; no read is allowed to submit another send. const publicationStatus = useQuery({ queryKey: [ "chat-publication-batch", companyId, binding?.endpointId, binding?.conversationId, publication?.id, ], queryFn: () => chatEndpointsApi.getPublicationBatchStatus( binding!.endpointId, binding!.conversationId, publication!.id, ), enabled: deliveryScopeReady && Boolean(publication), staleTime: 0, refetchInterval: 2_000, refetchIntervalInBackground: false, retry: false, }); useEffect(() => { const batch = publicationStatus.data; if ( publication && batch && batch.total > 0 && batch.published === batch.total && batch.publication.state === "published" ) { finishPublication(); } }, [publication, publicationStatus.data, finishPublication]); const publish = useMutation({ mutationFn: (input: { attachmentIds: string[]; body: string; idempotencyKey: string; endpointId: string; conversationId: string; }) => chatEndpointsApi.publishBoardMessage( input.endpointId, input.conversationId, input.body, input.idempotencyKey, input.attachmentIds, ), onSuccess: (result) => { invalidateTask(); const feedback = publicationFeedback[result.state]; setPublication(result.state === "published" ? null : result); if (result.state === "published") { finishPublication(); return; } setUnconfirmedRequest(false); if (storageKey && retainedSend.current) { retainedSend.current = { ...retainedSend.current, publication: { id: result.id, state: result.state, attempts: result.attempts, }, }; try { writeBoardSendDraft(storageKey, retainedSend.current); } catch { // The pre-POST payload/key is already persisted. It remains a safe, // explicit same-request retry when the publication ID cannot be saved. } } pushToast({ ...feedback, action: { label: "View activity", href: `/apps/chat/${binding!.endpointId}/activity`, }, }); }, onError: (error, request) => { const rejected = readBoardSendRejection(error, request); if (rejected && retainedSend.current && storageKey) { const saved = { ...retainedSend.current, rejection: rejected }; try { // Keep the negative receipt through reload before offering a new key. writeBoardSendDraft(storageKey, saved); } catch { setStorageError( "The rejected send could not be saved. Restore browser storage, then retry this same request to recover its receipt.", ); return; } retainedSend.current = saved; setRejection(rejected); setUnconfirmedRequest(false); invalidateTask(); pushToast({ title: "Update was not sent", body: "A selected file already belongs to another comment. Edit the rejected send to correct the selection.", tone: "error", }); return; } pushToast({ title: "Couldn't confirm channel delivery", body: error instanceof Error ? `${error.message} Your draft is kept; retrying here reuses the same request identity.` : "Your draft is kept; retrying here reuses the same request identity.", tone: "error", }); }, }); const uploadDisabled = Boolean( retainedSend.current || publication || publish.isPending || publish.isError || unconfirmedRequest || storageError || !deliveryScopeReady || uploading, ); async function uploadFile(file: File) { if (uploadDisabled || uploadInFlight.current || retainedSend.current) return; uploadInFlight.current = true; setUploading(true); setUploadError(null); try { const attachment = await issuesApi.uploadAttachment( companyId, issueId, file, ); if (!mounted.current) return; setUploadedAttachments((current) => [...current, attachment]); setSelectedAttachmentIds((current) => [...current, attachment.id]); idempotencyKey.current = null; } catch (error) { if (mounted.current) { setUploadError( `${error instanceof Error ? error.message : "Upload could not be confirmed."} No channel message was sent. Check task files before retrying the upload.`, ); } } finally { uploadInFlight.current = false; if (mounted.current) setUploading(false); // An interrupted response may still have stored the file on this task. invalidateTask(); } } // Keep newly uploaded files usable before the task refetch completes. Once // present, server metadata wins (especially a file bound to a sent comment). const taskAttachments = [ ...new Map( [...uploadedAttachments, ...attachments].map((attachment) => [ attachment.id, attachment, ]), ).values(), ]; useEffect(() => { // Metadata may arrive after this file was selected but before Send. Never // silently keep a now-hidden selection, and never rewrite a retained send. if (retainedSend.current) return; const newlyBound = attachments .filter((file) => file.issueCommentId !== null) .map((file) => file.id); if (!selectedAttachmentIds.some((id) => newlyBound.includes(id))) return; setSelectedAttachmentIds((current) => current.filter((id) => !newlyBound.includes(id)), ); setSelectionNotice(true); idempotencyKey.current = null; }, [attachments, selectedAttachmentIds]); const showingRetainedFiles = Boolean(retainedSend.current); // Comment binding removes files from new-send eligibility, not from the // immutable receipt for the current send. Saved names survive reload while // task metadata is loading (or a selected attachment has since been removed). const visibleAttachments = retainedSend.current ? retainedSend.current.attachmentIds.map((id) => ({ id, originalFilename: retainedSend.current?.attachmentNames?.find((file) => file.id === id) ?.name ?? taskAttachments.find((attachment) => attachment.id === id) ?.originalFilename ?? "Selected task file (details unavailable)", })) : taskAttachments.filter( (attachment) => attachment.issueCommentId === null && !excludedAttachmentIds.includes(attachment.id), ); const currentPublication = publicationStatus.data?.publication ?? publication; const batch = publicationStatus.data; const dismissible = !publicationStatus.isError && !publicationStatus.isFetching && canDismissBoardSendBatch(batch); const mixedTerminal = canDismissBoardSendBatch(batch) && batch!.published < batch!.total; const currentFeedback = mixedTerminal ? { title: "Delivery settled with mixed outcomes", body: "Not every part was confirmed delivered. Review the outcomes below; dismissing this receipt does not resend anything.", tone: "info" as const, } : currentPublication?.state === "cancelled" && (batch?.awaitingConsent ?? 0) > 0 ? { title: "Waiting for remaining file consent", body: "Some parts have settled. The remaining file cards still need the recipient's response; this send stays locked until the whole batch is resolved.", tone: "info" as const, } : currentPublication ? publicationFeedback[currentPublication.state] : null; const activityPath = `/apps/chat/${binding.endpointId}/activity`; return (

Connected to {providerNames[binding.provider]}

{binding.externalLabel} · {binding.provider === "slack" ? "Messages you send here and agent replies are also posted to Slack." : "Agent assignment is fixed for this external task."}

{binding.externalUrl && ( )}
{composing && (