mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
Runtime #15727 is merged. This PR adds the UI. Qualification follows in
#15729.
## Thinking Path
> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds the Speko setup and call controls to the existing Apps
and task UI.
> - This PR depends on #15727. The qualification suite follows
separately. Speko stays experimental.
## Linked Issues or Issue Description
**Problem or motivation**
Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.
**Proposed solution**
Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.
**Roadmap alignment**
This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.
Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.
## What Changed
- Add the Speko Apps catalog entry, provider artwork, and browser SDK.
- Reuse existing agents in setup. Keep the optional Company Phone Agent
in the catalog, with connection instructions available to any assigned
agent.
- Add task voice controls, transcripts, phone callbacks, inbound-line
settings, and call Activity entries.
- Surface retryable connection and incoming-task inventory failures,
preserve active call controls and cached tasks, restore keyboard focus
after recovery, and bound notification polling so a stuck request cannot
hide later results.
- Put Save & exit and Connect Speko in the credential step’s shared
footer.
- Add deterministic production-component Storybook fixtures for setup,
lifecycle, failures, permissions, and phone surfaces.
- Include voice components in the design guide. Stories never request a
real microphone or place calls.
## Verification
- All 27 focused UI/controller regressions, UI typecheck, token gates,
and 28 map/catalog structural tests pass. The shared connection cache
test covers pause/resume invalidation during the 30-second fresh period.
- Storybook builds 157 Speko production-component stories. The complete
prior 156-story set passed all 624 checks on UI source 249cbc616a. After
the final cache repair, all 152 affected page/launcher checks passed
across both themes, 390/1200 widths and reduced motion. Real microphone
access and external provider requests are blocked.
- Source annotations follow the current generated-catalog contract. No
generated catalog or lockfile is included. Three inherited
GitHub-heading tests passed after master reconciliation.
- Latest-head CI and Greptile review must pass before merge. Local
fixture success does not establish provider or spoken-playback
acceptance.
- The separate integrated browser run passed two journeys and failed
browser answer retrieval. Real browser/telephone playback and timing
acceptance remain outstanding. See the dated qualification report and
E01–E16 runbook. Speko stays experimental.
## Risks
- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.
## Model Used
OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.
## 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
- [ ] 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>
896 lines
33 KiB
TypeScript
896 lines
33 KiB
TypeScript
import { TaskVoiceLauncher } from "@/components/voice/TaskVoiceLauncher";
|
|
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<ChatProvider, string> = {
|
|
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<ChatPublicationState, PublicationFeedback> = {
|
|
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<ChatFileTransferPhase, string> = {
|
|
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[];
|
|
assigneeAgentId?: string | null;
|
|
};
|
|
|
|
export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
|
|
const { binding } = useIssueChatBinding(props.companyId, props.issueId);
|
|
return <>
|
|
{binding && binding.provider !== "speko" && binding.provider !== "agentmail" && <ConnectedTaskComposer
|
|
key={boardSendDraftKey(props.companyId, props.issueId, binding.endpointId, binding.conversationId)}
|
|
{...props}
|
|
binding={binding}
|
|
/>}
|
|
<TaskVoiceLauncher companyId={props.companyId} issueId={props.issueId} agentId={props.assigneeAgentId} boundEndpointId={binding?.provider === "speko" ? binding.endpointId : undefined} />
|
|
</>;
|
|
}
|
|
|
|
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<string[]>(
|
|
[],
|
|
);
|
|
const [publication, setPublication] = useState<ChatPublicationSummary | null>(
|
|
null,
|
|
);
|
|
const idempotencyKey = useRef<string | null>(null);
|
|
const retainedSend = useRef<RetainedBoardSend | null>(null);
|
|
const retainedScopeKey = useRef<string | null>(null);
|
|
const [unconfirmedRequest, setUnconfirmedRequest] = useState(false);
|
|
const [rejection, setRejection] = useState<BoardSendRejection | null>(null);
|
|
const [excludedAttachmentIds, setExcludedAttachmentIds] = useState<string[]>(
|
|
[],
|
|
);
|
|
const [selectionNotice, setSelectionNotice] = useState(false);
|
|
const [storageError, setStorageError] = useState<string | null>(null);
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const uploadInFlight = useRef(false);
|
|
const mounted = useRef(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadError, setUploadError] = useState<string | null>(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<string | null>(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 (
|
|
<section
|
|
aria-label="External conversation"
|
|
className={cn("task-context-notice space-y-3", className)}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex min-w-0 flex-1 basis-64 items-center gap-3">
|
|
<Radio className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">
|
|
Connected to {providerNames[binding.provider]}
|
|
</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{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."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
{binding.externalUrl && (
|
|
<Button asChild size="sm" variant="outline">
|
|
<a href={binding.externalUrl} target="_blank" rel="noreferrer">
|
|
Open {providerNames[binding.provider]} <ExternalLink />
|
|
</a>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setComposing((value) => !value)}
|
|
>
|
|
Send to channel
|
|
</Button>
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link to={`/apps/chat/${binding.endpointId}/conversations`}>
|
|
Connection
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{composing && (
|
|
<div className="space-y-2 border-t border-border pt-3">
|
|
<label
|
|
className="text-xs font-medium"
|
|
htmlFor="external-board-update"
|
|
>
|
|
Board update
|
|
</label>
|
|
<Textarea
|
|
id="external-board-update"
|
|
value={body}
|
|
disabled={
|
|
Boolean(publication) ||
|
|
Boolean(rejection) ||
|
|
publish.isError ||
|
|
unconfirmedRequest ||
|
|
Boolean(storageError) ||
|
|
!deliveryScopeReady
|
|
}
|
|
onChange={(event) => {
|
|
setBody(event.target.value);
|
|
idempotencyKey.current = null;
|
|
publish.reset();
|
|
}}
|
|
placeholder="Write only what should be visible in the provider conversation."
|
|
/>
|
|
{selectedAttachmentIds.length > 0 && !body.trim() && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Add a message to send with your files.
|
|
</p>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
className="hidden"
|
|
aria-label="Attach file to channel update"
|
|
disabled={uploadDisabled}
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
if (file) void uploadFile(file);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={uploadDisabled}
|
|
onClick={() => fileInput.current?.click()}
|
|
>
|
|
<Paperclip />
|
|
{uploading ? "Uploading…" : "Attach file"}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
Files stay on this task until you send them to the channel.
|
|
</p>
|
|
</div>
|
|
{uploadError && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{uploadError}
|
|
</p>
|
|
)}
|
|
{selectionNotice && (
|
|
<p role="status" className="text-xs text-muted-foreground">
|
|
A file already attached to another comment was removed from this
|
|
selection. Attach a new copy or share the task link; your message
|
|
is unchanged.
|
|
</p>
|
|
)}
|
|
{visibleAttachments.length > 0 && (
|
|
<fieldset
|
|
className="space-y-2 rounded-md border border-border bg-background p-3"
|
|
disabled={
|
|
showingRetainedFiles ||
|
|
Boolean(publication) ||
|
|
publish.isError ||
|
|
unconfirmedRequest ||
|
|
Boolean(storageError) ||
|
|
!deliveryScopeReady
|
|
}
|
|
>
|
|
<legend className="px-1 text-xs font-medium">
|
|
{showingRetainedFiles
|
|
? "Files in this send"
|
|
: "Include task files"}
|
|
</legend>
|
|
<p className="text-xs text-muted-foreground">
|
|
{binding.provider === "github"
|
|
? "GitHub Apps cannot upload file bytes in comments. Checked files stay on the Paperclip task; GitHub receives an authenticated task link when this Board has a public URL, or a private-task notice otherwise."
|
|
: binding.provider === "microsoft-teams" &&
|
|
!showingRetainedFiles
|
|
? "In personal Teams chats, recipients accept each file before upload. Channels and group chats receive supported images directly; other files stay on the task, with a task link or private-task notice."
|
|
: showingRetainedFiles
|
|
? "These are the files selected for this send. Selection is locked until delivery is resolved."
|
|
: "Only checked files will be published to the external conversation."}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{visibleAttachments.map((attachment) => {
|
|
const label =
|
|
attachment.originalFilename ?? "Unnamed attachment";
|
|
return (
|
|
<label
|
|
className="flex items-center gap-2 text-xs"
|
|
key={attachment.id}
|
|
>
|
|
<Checkbox
|
|
disabled={showingRetainedFiles}
|
|
checked={selectedAttachmentIds.includes(attachment.id)}
|
|
onCheckedChange={(checked) => {
|
|
setSelectedAttachmentIds((current) =>
|
|
checked === true
|
|
? [...current, attachment.id]
|
|
: current.filter((id) => id !== attachment.id),
|
|
);
|
|
idempotencyKey.current = null;
|
|
publish.reset();
|
|
}}
|
|
/>
|
|
<Paperclip className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="truncate">{label}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
)}
|
|
{storageError && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{storageError}
|
|
</p>
|
|
)}
|
|
{rejection && (
|
|
<div
|
|
role="alert"
|
|
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
|
|
>
|
|
<p className="font-medium">Update was not sent</p>
|
|
<p className="text-muted-foreground">
|
|
A selected file already belongs to another comment. This request
|
|
was rejected before any channel message was queued. Your exact
|
|
draft is kept.
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={Boolean(storageError)}
|
|
onClick={() => {
|
|
if (!storageKey || !retainedSend.current?.rejection) return;
|
|
try {
|
|
clearBoardSendDraft(storageKey);
|
|
} catch {
|
|
setStorageError(
|
|
"Saved rejection could not be cleared. Restore browser storage before editing this send.",
|
|
);
|
|
return;
|
|
}
|
|
const invalidIds =
|
|
retainedSend.current.rejection.attachmentIds;
|
|
setExcludedAttachmentIds((current) => [
|
|
...new Set([...current, ...invalidIds]),
|
|
]);
|
|
setSelectedAttachmentIds((current) =>
|
|
current.filter((id) => !invalidIds.includes(id)),
|
|
);
|
|
setUploadedAttachments((current) =>
|
|
current.filter((file) => !invalidIds.includes(file.id)),
|
|
);
|
|
retainedSend.current = null;
|
|
idempotencyKey.current = null;
|
|
setRejection(null);
|
|
setUnconfirmedRequest(false);
|
|
setSelectionNotice(true);
|
|
publish.reset();
|
|
}}
|
|
>
|
|
Edit rejected send
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{!rejection &&
|
|
(publish.isError || unconfirmedRequest) &&
|
|
!publish.isPending &&
|
|
!publication && (
|
|
<div
|
|
role="alert"
|
|
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
|
|
>
|
|
<p className="font-medium">Delivery result not confirmed</p>
|
|
<p className="text-muted-foreground">
|
|
Your exact draft and request identity are kept. Retry safely
|
|
to learn the authoritative publication state without creating
|
|
a duplicate.
|
|
</p>
|
|
<Link
|
|
className="inline-block font-medium underline underline-offset-4"
|
|
to={activityPath}
|
|
>
|
|
Open Activity
|
|
</Link>
|
|
</div>
|
|
)}
|
|
{publication && currentPublication && currentFeedback && (
|
|
<div
|
|
role={
|
|
currentPublication.state === "failed" ||
|
|
currentPublication.state === "delivery_unknown"
|
|
? "alert"
|
|
: "status"
|
|
}
|
|
className="space-y-1 rounded-md border border-border bg-background p-3 text-xs"
|
|
>
|
|
<p className="font-medium">{currentFeedback.title}</p>
|
|
<p className="text-muted-foreground">{currentFeedback.body}</p>
|
|
{batch && (
|
|
<p className="text-muted-foreground">
|
|
{batch.declined !== undefined &&
|
|
batch.expired !== undefined &&
|
|
batch.cancelled !== undefined &&
|
|
batch.awaitingConsent !== undefined
|
|
? [
|
|
`${batch.published} published`,
|
|
...(batch.awaitingConsent
|
|
? [`${batch.awaitingConsent} awaiting consent`]
|
|
: []),
|
|
...(batch.declined
|
|
? [`${batch.declined} declined`]
|
|
: []),
|
|
...(batch.expired ? [`${batch.expired} expired`] : []),
|
|
...(batch.cancelled
|
|
? [`${batch.cancelled} cancelled`]
|
|
: []),
|
|
].join(" · ")
|
|
: `${batch.published} of ${batch.total} parts published.`}
|
|
</p>
|
|
)}
|
|
{batch?.parts?.some((part) => part.fileTransfer) && (
|
|
<ul
|
|
className="space-y-1 text-muted-foreground"
|
|
aria-label="File delivery outcomes"
|
|
>
|
|
{batch.parts
|
|
.filter((part) => part.fileTransfer)
|
|
.map((part) => (
|
|
<li key={part.id}>
|
|
{part.fileTransfer!.filename} —{" "}
|
|
{filePhaseLabels[part.fileTransfer!.phase]}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{publicationStatus.isError && (
|
|
<p role="alert" className="text-muted-foreground">
|
|
Delivery status could not be refreshed. Your draft is kept;
|
|
Paperclip will check again without sending another update.
|
|
</p>
|
|
)}
|
|
{currentPublication.redactedError && (
|
|
<p className="text-muted-foreground">
|
|
Provider detail: {currentPublication.redactedError}
|
|
</p>
|
|
)}
|
|
<Link
|
|
className="inline-block font-medium underline underline-offset-4"
|
|
to={activityPath}
|
|
>
|
|
Open Activity
|
|
</Link>
|
|
{dismissible && (
|
|
<Button
|
|
className="ml-3"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (storageKey) {
|
|
try {
|
|
clearBoardSendDraft(storageKey);
|
|
} catch {
|
|
setStorageError(
|
|
"Saved delivery identity could not be cleared. Restore browser storage before starting another send.",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
setStorageError(null);
|
|
retainedSend.current = null;
|
|
setUnconfirmedRequest(false);
|
|
setPublication(null);
|
|
setBody("");
|
|
setSelectedAttachmentIds([]);
|
|
setUploadedAttachments([]);
|
|
setUploadError(null);
|
|
idempotencyKey.current = null;
|
|
publish.reset();
|
|
}}
|
|
>
|
|
Dismiss delivery receipt
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
{binding.provider === "slack"
|
|
? "Your message is posted to Slack with your name and starts the agent."
|
|
: "Ordinary board comments remain Paperclip-only."}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
disabled={
|
|
!body.trim() ||
|
|
publish.isPending ||
|
|
uploading ||
|
|
Boolean(publication) ||
|
|
Boolean(rejection) ||
|
|
Boolean(storageError) ||
|
|
!deliveryScopeReady
|
|
}
|
|
onClick={() => {
|
|
if (
|
|
uploadInFlight.current ||
|
|
!storageKey ||
|
|
loadedStorageKey.current !== storageKey ||
|
|
retainedScopeKey.current !== storageKey
|
|
)
|
|
return;
|
|
idempotencyKey.current ??= crypto.randomUUID();
|
|
const input = retainedSend.current ?? {
|
|
attachmentIds: selectedAttachmentIds,
|
|
attachmentNames: selectedAttachmentIds.map((id) => ({
|
|
id,
|
|
name:
|
|
taskAttachments.find((attachment) => attachment.id === id)
|
|
?.originalFilename ?? "Unnamed attachment",
|
|
})),
|
|
body: body.trim(),
|
|
idempotencyKey: idempotencyKey.current,
|
|
publication: null,
|
|
};
|
|
try {
|
|
if (!storageKey) throw new Error("Missing delivery scope");
|
|
writeBoardSendDraft(storageKey, input);
|
|
} catch {
|
|
setStorageError(
|
|
"Browser storage could not preserve this delivery identity. No update was sent. Restore browser storage, then reload to try again.",
|
|
);
|
|
return;
|
|
}
|
|
retainedSend.current = input;
|
|
setUnconfirmedRequest(true);
|
|
publish.mutate({
|
|
...input,
|
|
endpointId: binding.endpointId,
|
|
conversationId: binding.conversationId,
|
|
});
|
|
}}
|
|
>
|
|
{publish.isPending
|
|
? "Sending…"
|
|
: !rejection && (publish.isError || unconfirmedRequest)
|
|
? "Retry safely"
|
|
: "Send to channel"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|