Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions app/components/copilot/ChatSharePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen((open) => !open)}
className="inline-flex h-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-3 text-[11px] font-semibold uppercase tracking-wide text-slate-600 transition hover:border-slate-300 hover:text-slate-800"
>
Share
</button>
{isOpen ? (
<div className="absolute right-0 top-11 z-20 flex w-72 flex-col gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-lg">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">Share chat</p>
<p className="truncate text-[11px] text-slate-500">{shareTitle}</p>
</div>
<button
type="button"
onClick={() => {
setIsOpen(false);
setShowProviderForm(false);
}}
className="text-xs text-slate-400 transition hover:text-slate-700"
>
Close
</button>
</div>

<div className="flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
onClick={shareChatLink}
className="text-slate-600 transition hover:text-slate-900"
>
Share link
</button>
<span className="text-slate-300">/</span>
<button
type="button"
onClick={copyShareLink}
className="text-slate-600 transition hover:text-slate-900"
>
Copy link
</button>
<span className="text-slate-300">/</span>
<button
type="button"
onClick={() => setShowProviderForm((open) => !open)}
className="text-slate-600 transition hover:text-slate-900"
>
Send message
</button>
</div>

{showProviderForm ? (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2">
<input
type="text"
value={shareChannel}
onChange={(e) => 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"
/>
<button
type="button"
onClick={sendSharedChat}
disabled={shareState.loading || !shareChannel.trim()}
className="rounded-md bg-[#55cfd0] px-3 py-1.5 text-[11px] font-semibold text-[#0b1517] transition hover:bg-[#3fb8b8] disabled:cursor-not-allowed disabled:bg-[#b7eded]"
>
{shareState.loading ? "Sending..." : "Send"}
</button>
</div>
) : null}

<div className="flex flex-wrap gap-2">
{linkStatus ? <Pill label={linkStatus} /> : null}
{shareNotice ? <Pill label={shareNotice} tone="success" /> : null}
{shareState.error ? <Pill label={shareState.error} tone="error" /> : null}
</div>
</div>
) : null}
</div>
);
}
12 changes: 12 additions & 0 deletions app/components/copilot/CopilotPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,6 +85,15 @@ export function CopilotPanel({ initialChatId }: { initialChatId?: string }) {
[turns.length],
);
const showSessionStamp = Boolean(chatId);
const shareableTurns = useMemo<ShareableChatTurn[]>(
() =>
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();
Expand Down Expand Up @@ -191,6 +202,7 @@ export function CopilotPanel({ initialChatId }: { initialChatId?: string }) {
title="Copilot"
action={
<div className="flex items-center gap-3 text-xs">
{canShare && chatId ? <ChatSharePanel chatId={chatId} turns={shareableTurns} /> : null}
<button
type="button"
onClick={() => {
Expand Down
130 changes: 130 additions & 0 deletions app/lib/chatShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
export type ShareableChatTurn = {
role: "user" | "copilot" | "error";
text: string;
};

type ProviderShareOptions = {
chatId: string;
chatUrl: string;
turns: ShareableChatTurn[];
};

type MessageBlock = {
type: "header" | "section" | "divider";
text?: string;
fields?: Record<string, string>;
};

export type ProviderSharePayload = {
body: string;
metadata: Record<string, string | number>;
blocks: MessageBlock[];
};

const DEFAULT_SHARE_TITLE = "OpsOrch Copilot chat";
const MAX_TURN_LENGTH = 280;

function normalizeTitleText(text: string) {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= MAX_TURN_LENGTH) return collapsed;
return `${collapsed.slice(0, MAX_TURN_LENGTH - 1)}…`;
}

function formatTurnText(text: string) {
const normalized = text
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.replace(/[ \t]+$/g, ""))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();

if (normalized.length <= MAX_TURN_LENGTH) return normalized;
return `${normalized.slice(0, MAX_TURN_LENGTH - 1).trimEnd()}…`;
}

function getRoleLabel(role: ShareableChatTurn["role"]) {
if (role === "user") return "You";
if (role === "copilot") return "Copilot";
return "Error";
}

function selectShareTurns(turns: ShareableChatTurn[]) {
const firstUserTurn = turns.find((turn) => turn.role === "user" && turn.text.trim().length > 0);
const lastResponseTurn =
[...turns].reverse().find((turn) => turn.role === "copilot" && turn.text.trim().length > 0) ||
[...turns].reverse().find((turn) => turn.role !== "user" && turn.text.trim().length > 0);

return [firstUserTurn, lastResponseTurn].filter(
(turn, index, list): turn is ShareableChatTurn => Boolean(turn) && list.indexOf(turn) === index,
);
}

export function buildChatSharePath(chatId: string) {
return `/chats/${encodeURIComponent(chatId)}`;
}

export function buildChatShareTitle(turns: ShareableChatTurn[]) {
const firstUserTurn = turns.find((turn) => turn.role === "user" && turn.text.trim().length > 0);
if (!firstUserTurn) return DEFAULT_SHARE_TITLE;
const normalized = normalizeTitleText(firstUserTurn.text);
return normalized.length <= 72 ? normalized : `${normalized.slice(0, 71)}…`;
}

export function buildChatShareTranscript(turns: ShareableChatTurn[]) {
return selectShareTurns(turns)
.map((turn) => `### ${getRoleLabel(turn.role)}\n${formatTurnText(turn.text) || "_No content_"}`)
.join("\n\n");
}

export function buildProviderSharePayload({
chatId,
chatUrl,
turns,
}: ProviderShareOptions): ProviderSharePayload {
const title = buildChatShareTitle(turns);
const transcript = buildChatShareTranscript(turns);
const selectedTurns = selectShareTurns(turns);
const firstQuestion = selectedTurns.find((turn) => turn.role === "user");
const lastConclusion = selectedTurns.find((turn) => turn.role !== "user");
const questionText = firstQuestion ? formatTurnText(firstQuestion.text) : "";
const conclusionText = lastConclusion ? formatTurnText(lastConclusion.text) : "";
const body = [
`OpsOrch Copilot: ${title}`,
"",
questionText ? `Question:\n${questionText}` : "",
"",
conclusionText ? `Latest conclusion:\n${conclusionText}` : "",
"",
`Open chat: [${title}](${chatUrl})`,
]
.filter(Boolean)
.join("\n");

return {
body,
metadata: {
chatId,
chatUrl,
shareTitle: title,
turnCount: turns.length,
},
blocks: [
{ type: "header", text: "OpsOrch Copilot Chat" },
{ type: "section", text: `*${title}*` },
{
type: "section",
fields: {
"Chat ID": chatId,
Turns: String(turns.length),
},
},
{ type: "divider" },
...(firstQuestion ? [{ type: "section" as const, text: `*Initial question*\n${formatTurnText(firstQuestion.text) || "_No content_"}` }] : []),
...(lastConclusion ? [{ type: "section" as const, text: `*Latest conclusion*\n${formatTurnText(lastConclusion.text) || "_No content_"}` }] : []),
{ type: "divider" },
{ type: "section", text: `[Open full chat](${chatUrl})` },
...(transcript ? [{ type: "section" as const, text: transcript }] : []),
],
};
}
Loading
Loading