diff --git a/app/components/copilot/ChatSharePanel.tsx b/app/components/copilot/ChatSharePanel.tsx new file mode 100644 index 0000000..6a04f19 --- /dev/null +++ b/app/components/copilot/ChatSharePanel.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { requestJSON } from "@/app/lib/api"; +import { useAsyncState } from "@/app/lib/hooks"; +import { Pill } from "@/app/lib/ui"; +import { + buildChatSharePath, + buildChatShareTitle, + buildProviderSharePayload, + type ShareableChatTurn, +} from "@/app/lib/chatShare"; + +type ChatSharePanelProps = { + chatId: string; + turns: ShareableChatTurn[]; +}; + +export function ChatSharePanel({ chatId, turns }: ChatSharePanelProps) { + const [shareChannel, setShareChannel] = useState("#ops"); + const [linkStatus, setLinkStatus] = useState(""); + const [shareNotice, setShareNotice] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [showProviderForm, setShowProviderForm] = useState(false); + const shareState = useAsyncState(); + + const shareTitle = useMemo(() => buildChatShareTitle(turns), [turns]); + + const getChatUrl = () => { + if (typeof window === "undefined") return ""; + return `${window.location.origin}${buildChatSharePath(chatId)}`; + }; + + const copyShareLink = async () => { + try { + if (!navigator.clipboard) { + throw new Error("Clipboard is not available in this browser"); + } + await navigator.clipboard.writeText(getChatUrl()); + setLinkStatus("Link copied"); + } catch (err) { + setLinkStatus(err instanceof Error ? err.message : "Failed to copy link"); + } + }; + + const shareChatLink = async () => { + const chatUrl = getChatUrl(); + if (!chatUrl) return; + + if (typeof navigator !== "undefined" && typeof navigator.share === "function") { + try { + await navigator.share({ + title: shareTitle, + text: "Open this OpsOrch Copilot chat", + url: chatUrl, + }); + setLinkStatus("Share sheet opened"); + return; + } catch (err) { + const errorName = err instanceof Error ? err.name : ""; + if (errorName === "AbortError") { + setLinkStatus(""); + return; + } + } + } + + await copyShareLink(); + }; + + const sendSharedChat = async () => { + if (!shareChannel.trim()) return; + + shareState.start(); + try { + const payload = buildProviderSharePayload({ + chatId, + chatUrl: getChatUrl(), + turns, + }); + await requestJSON("/messages/send", { + method: "POST", + body: JSON.stringify({ + channel: shareChannel.trim(), + ...payload, + }), + }); + shareState.succeed(); + setShareNotice(`Sent to ${shareChannel.trim()}`); + setShowProviderForm(false); + } catch (err) { + shareState.fail(err); + setShareNotice(""); + } + }; + + return ( +
+ + {isOpen ? ( +
+
+
+

Share chat

+

{shareTitle}

+
+ +
+ +
+ + / + + / + +
+ + {showProviderForm ? ( +
+ setShareChannel(e.target.value)} + placeholder="#ops" + className="h-8 min-w-[8rem] flex-1 rounded-md border border-slate-300 bg-white px-2 text-xs text-slate-900 focus:border-[#55cfd0] focus:outline-none focus:ring-2 focus:ring-[#55cfd0]/20" + /> + +
+ ) : null} + +
+ {linkStatus ? : null} + {shareNotice ? : null} + {shareState.error ? : null} +
+
+ ) : null} +
+ ); +} diff --git a/app/components/copilot/CopilotPanel.tsx b/app/components/copilot/CopilotPanel.tsx index e9a0f5c..231a531 100644 --- a/app/components/copilot/CopilotPanel.tsx +++ b/app/components/copilot/CopilotPanel.tsx @@ -4,10 +4,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { CopilotAnswer, CopilotReferences, TurnExecutionTrace } from "@/app/lib/types"; import { useAsyncState } from "@/app/lib/hooks"; import { Field, Pill, Section, TextArea } from "@/app/lib/ui"; +import { ChatSharePanel } from "@/app/components/copilot/ChatSharePanel"; import { ConfidenceBar } from "@/app/components/copilot/ConfidenceBar"; import { CopilotConclusion } from "@/app/components/copilot/CopilotConclusion"; import { ResponseDetailsContent } from "@/app/components/copilot/ResponseDetails"; import { parseJsonString, stringifyData } from "@/app/lib/utils"; +import { type ShareableChatTurn } from "@/app/lib/chatShare"; type CopilotTurn = { id: string; @@ -83,6 +85,15 @@ export function CopilotPanel({ initialChatId }: { initialChatId?: string }) { [turns.length], ); const showSessionStamp = Boolean(chatId); + const shareableTurns = useMemo( + () => + turns.map((turn) => ({ + role: turn.role, + text: turn.role === "copilot" ? turn.answer?.conclusion || turn.text : turn.text, + })), + [turns], + ); + const canShare = Boolean(chatId) && shareableTurns.length > 0; const ask = async () => { const trimmed = question.trim(); @@ -191,6 +202,7 @@ export function CopilotPanel({ initialChatId }: { initialChatId?: string }) { title="Copilot" action={
+ {canShare && chatId ? : null}