+ Adds a “Replay chat” button that plays a finished chat
+ back as it happened. Saved only in this browser.
+
+
+
+
+
+ );
+}
+
function TelemetrySection() {
const enabled = useChatTelemetrySetting();
if (!CHAT_TELEMETRY_AVAILABLE) return null;
@@ -426,6 +458,7 @@ export function ChatSettingsPanel({
)}
{budgets && }
+
diff --git a/signalpilot/web/components/chat/chat-ui-context.ts b/signalpilot/web/components/chat/chat-ui-context.ts
index d4026b22b..9f6c87011 100644
--- a/signalpilot/web/components/chat/chat-ui-context.ts
+++ b/signalpilot/web/components/chat/chat-ui-context.ts
@@ -41,6 +41,12 @@ export type ChatUiContextValue = {
* conversation route.
*/
downloadFile?: (fileId: string, filename: string) => Promise;
+ /**
+ * Override for fetching a text file's content. The shared read-only page
+ * injects it so the artifacts panel viewer reads through the share-token
+ * route; owner pages omit it and the viewer uses the conversation route.
+ */
+ getFileText?: (fileId: string) => Promise;
/**
* Override for paging the full rows of a governed query result. The
* fixture harness injects a deterministic generator; live pages omit it
@@ -60,6 +66,13 @@ export type ChatUiContextValue = {
* it and the cards tick on the real clock.
*/
nowMs?: number;
+ /** True on read-only surfaces (the shared page): no Stop/Retry actions. */
+ readOnly?: boolean;
+ /**
+ * Replay frame that was paused or scrubbed to: every text block renders
+ * complete, with no smoothing and no caret. Set only by the replay view.
+ */
+ textInstant?: boolean;
/** Opens the right-side Chat settings panel (connectors, budgets). */
openChatSettings?: () => void;
onStop: (runId: string) => Promise;
diff --git a/signalpilot/web/components/chat/download-ui-file.ts b/signalpilot/web/components/chat/download-ui-file.ts
new file mode 100644
index 000000000..1f6e7d192
--- /dev/null
+++ b/signalpilot/web/components/chat/download-ui-file.ts
@@ -0,0 +1,16 @@
+import { downloadConversationFile, type ConversationFileInfo } from "~/lib/api";
+import type { ChatUiContextValue } from "~/components/chat/chat-ui-context";
+
+/**
+ * Download a file's bytes through the context override when one is set
+ * (the shared page), else through the owner conversation route. Resolves
+ * without doing anything when neither is available.
+ */
+export function downloadUiFile(
+ ui: Pick,
+ file: Pick,
+): Promise {
+ if (ui.downloadFile) return ui.downloadFile(file.id, file.filename);
+ if (!ui.conversationId) return Promise.resolve();
+ return downloadConversationFile(ui.conversationId, file.id, file.filename);
+}
diff --git a/signalpilot/web/components/chat/markdown/file-chip.tsx b/signalpilot/web/components/chat/markdown/file-chip.tsx
index e06209c77..9bd94ca12 100644
--- a/signalpilot/web/components/chat/markdown/file-chip.tsx
+++ b/signalpilot/web/components/chat/markdown/file-chip.tsx
@@ -1,26 +1,17 @@
"use client";
import { AlertTriangle, ArrowDownToLine } from "lucide-react";
-import { downloadConversationFile, type ConversationFileInfo } from "~/lib/api";
+import type { ConversationFileInfo } from "~/lib/api";
import { middleTruncate } from "~/lib/chat-artifact-cards";
import { formatByteSize } from "~/lib/chat-artifacts";
import { kindIcon } from "~/components/chat/artifacts-panel";
import type { ChatUiContextValue } from "~/components/chat/chat-ui-context";
+import { downloadUiFile } from "~/components/chat/download-ui-file";
import { useToast } from "~/components/ui/toast";
-/**
- * Download a file's bytes through the context override when one is set
- * (the shared page), else through the owner conversation route. Resolves
- * without doing anything when neither is available.
- */
-export function downloadUiFile(
- ui: Pick,
- file: Pick,
-): Promise {
- if (ui.downloadFile) return ui.downloadFile(file.id, file.filename);
- if (!ui.conversationId) return Promise.resolve();
- return downloadConversationFile(ui.conversationId, file.id, file.filename);
-}
+// Re-exported so existing importers keep working; the implementation lives
+// in its own module to avoid an import cycle with the file viewer.
+export { downloadUiFile } from "~/components/chat/download-ui-file";
/** Primary verb by kind. Data previews, documents open, the rest download. */
export function chipActionLabel(kind: string): "Preview" | "Open" | "Download" {
diff --git a/signalpilot/web/components/chat/replay-controls.tsx b/signalpilot/web/components/chat/replay-controls.tsx
index 9d3ad4eea..0daacd42f 100644
--- a/signalpilot/web/components/chat/replay-controls.tsx
+++ b/signalpilot/web/components/chat/replay-controls.tsx
@@ -1,15 +1,19 @@
"use client";
import { Pause, Play, RotateCcw, X } from "lucide-react";
+import { REPLAY_SPEEDS, type ReplaySpeed } from "~/lib/chat-replay";
/**
- * Control bar shown above a message while its run is being replayed.
- * Timing is "smart" compressed: 4x speed with tool waits capped at 10s.
+ * Control bar shown above the transcript while the conversation is being
+ * replayed. Timing is "smart" compressed: the chosen speed, with any single
+ * wait capped at 10s.
*/
export function ReplayControls({
elapsed,
totalMs,
playing,
+ speed,
+ onSpeedChange,
onTogglePlay,
onRestart,
onScrub,
@@ -18,6 +22,8 @@ export function ReplayControls({
elapsed: number;
totalMs: number;
playing: boolean;
+ speed: ReplaySpeed;
+ onSpeedChange: (speed: ReplaySpeed) => void;
onTogglePlay: () => void;
onRestart: () => void;
onScrub: (ms: number) => void;
@@ -26,7 +32,7 @@ export function ReplayControls({
return (
Replay
@@ -47,6 +53,30 @@ export function ReplayControls({
>
+
+ {REPLAY_SPEEDS.map((value) => (
+
+ ))}
+
) : block.kind === "thinking" ? (
diff --git a/signalpilot/web/components/chat/share-link-dialog.tsx b/signalpilot/web/components/chat/share-link-dialog.tsx
new file mode 100644
index 000000000..8821f2e2d
--- /dev/null
+++ b/signalpilot/web/components/chat/share-link-dialog.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+// Centered dialog shown right after a chat is shared. It puts the link in
+// front of the user with a copy action and states plainly who can open it:
+// signed-in members of this organization, nobody else, ever.
+
+import { CheckCircle2, Copy, Loader2, LockKeyhole, X } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useFocusTrap } from "~/components/ui/use-focus-trap";
+
+export function ShareLinkDialog({
+ url: link,
+ onClose,
+}: {
+ /** The share URL; "pending" shows the dialog with a loader while the
+ * gateway mints the link; null hides it. */
+ url: string | "pending" | null;
+ onClose: () => void;
+}) {
+ const open = link !== null;
+ const pending = link === "pending";
+ const url = pending ? null : link;
+ const panelRef = useRef(null);
+ const inputRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ useFocusTrap(panelRef, open);
+
+ const copy = useCallback(async () => {
+ if (!url) return;
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ } catch {
+ // Clipboard blocked: leave the link selected so Ctrl+C works.
+ inputRef.current?.select();
+ }
+ }, [url]);
+
+ // Copy on open so the common case is one click; the button re-copies.
+ useEffect(() => {
+ if (!open) {
+ setCopied(false);
+ return;
+ }
+ if (!url) return;
+ void copy();
+ inputRef.current?.select();
+ }, [open, url, copy]);
+
+ if (!open) return null;
+
+ return (
+
+ Only people signed in to your organization can open this link.
+ It is not public and never becomes public: anyone else, signed
+ in or not, sees nothing. Sharing again replaces this link, and
+ you can revoke it at any time from the chat menu.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/signalpilot/web/components/chat/shared-chat-files.tsx b/signalpilot/web/components/chat/shared-chat-files.tsx
deleted file mode 100644
index 5ce05f803..000000000
--- a/signalpilot/web/components/chat/shared-chat-files.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-"use client";
-
-// Read-only file surfaces for the shared conversation page: the files list
-// under the transcript and the lightbox the inline figures and chips open.
-// The page has no artifacts panel, so this is the whole viewer.
-
-import { Download, Loader2 } from "lucide-react";
-import type { ConversationFileInfo } from "~/lib/api";
-import { formatByteSize } from "~/lib/chat-artifacts";
-import { middleTruncate } from "~/lib/chat-artifact-cards";
-import { kindIcon } from "~/components/chat/artifacts-panel";
-import { ArtifactLightbox } from "~/components/chat/artifact-lightbox";
-import { useFileObjectUrl } from "~/components/chat/use-file-object-url";
-
-export type SharedFileActions = {
- /** Opens an image in the lightbox; downloads anything else. */
- open: (file: ConversationFileInfo) => void;
- download: (file: ConversationFileInfo) => void;
-};
-
-export function SharedFilesSection({
- files,
- actions,
-}: {
- files: ConversationFileInfo[];
- actions: SharedFileActions;
-}) {
- if (files.length === 0) return null;
- return (
-
-
-
- );
-}
-
-/**
- * Fullscreen image viewer for one shared file. Must render inside the
- * page's ChatUiContext provider so the object URL comes through the
- * shared content route override.
- */
-export function SharedFileLightbox({
- file,
- onClose,
-}: {
- file: ConversationFileInfo | null;
- onClose: () => void;
-}) {
- const { url, error } = useFileObjectUrl(file, null);
- return (
-
- {error ? (
-
- This file is no longer available.
-
- ) : url ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
-
- );
-}
diff --git a/signalpilot/web/components/chat/shared-standalone-data-chat.tsx b/signalpilot/web/components/chat/shared-standalone-data-chat.tsx
index 6a474c685..1fb9df4f8 100644
--- a/signalpilot/web/components/chat/shared-standalone-data-chat.tsx
+++ b/signalpilot/web/components/chat/shared-standalone-data-chat.tsx
@@ -1,147 +1,96 @@
"use client";
-import {
- AlertCircle,
- ArrowLeft,
- Bot,
- GitFork,
- Loader2,
- LockKeyhole,
- Sparkles,
-} from "lucide-react";
+// The shared chat page IS the chat page, read only: the same message tree,
+// inline artifact cards, and artifacts panel as the owner sees, fed by the
+// share-token routes. One button forks the whole chat into the viewer's own.
+
+import { AlertCircle, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
-import { useCallback, useMemo, useState } from "react";
-import { ChatMarkdown } from "~/components/chat/chat-markdown";
-import useSWR from "swr";
-import {
- ChatUiContext,
- type ChatUiContextValue,
-} from "~/components/chat/chat-ui-context";
-import {
- SharedFileLightbox,
- SharedFilesSection,
-} from "~/components/chat/shared-chat-files";
-import { useToast } from "~/components/ui/toast";
+import { useCallback, useRef, useState } from "react";
+import { ArtifactsPanel } from "~/components/chat/artifacts-panel";
+import { ChatMessage } from "~/components/chat/chat-message";
+import { ChatReplayView } from "~/components/chat/chat-replay-view";
+import { ChatUiContext } from "~/components/chat/chat-ui-context";
+import { SharedChatHeader } from "~/components/chat/shared/shared-chat-header";
import {
- downloadSharedConversationFile,
- forkSharedStandaloneConversation,
- getSharedConversationFileObjectUrl,
- getSharedConversationFiles,
- getSharedStandaloneForkPreview,
- getSharedStandaloneConversation,
- type ConversationFileInfo,
- type StandaloneForkPreview,
-} from "~/lib/api";
+ useForkSharedChat,
+ useSharedChat,
+ useSharedChatUi,
+} from "~/components/chat/shared/use-shared-chat";
+import { chatShellClassName } from "~/components/chat/standalone-chat-derivations";
+import { ChatPanelToggles } from "~/components/chat/standalone-chat-panels";
+import { useOpenArtifact } from "~/components/chat/use-open-artifact";
+import { hasArtifactsContent } from "~/lib/chat-artifacts";
+import { canReplayConversation } from "~/lib/chat-replay";
+import { useChatReplaySetting } from "~/components/chat/use-chat-replay-setting";
+import { standaloneMessageKey } from "~/lib/standalone-chat-state";
-const noop = async () => undefined;
-
-export function SharedStandaloneDataChat({ token }: { token: string }) {
- const router = useRouter();
- const { toast } = useToast();
- const [forking, setForking] = useState(false);
- const [forkPreview, setForkPreview] = useState(
- null,
- );
- const [perQueryBudget, setPerQueryBudget] = useState(0.25);
- const [chatBudget, setChatBudget] = useState(1);
- const { data, error, isLoading } = useSWR(
- `shared-standalone-chat:${token}`,
- () => getSharedStandaloneConversation(token),
- { revalidateOnFocus: false },
- );
- // Files from the conversation's finished runs. A failure here only hides
- // the file surfaces; the transcript still renders.
- const { data: fileData } = useSWR(
- data ? `shared-standalone-chat-files:${token}` : null,
- () => getSharedConversationFiles(token),
- { revalidateOnFocus: false, shouldRetryOnError: false },
- );
- const files = useMemo(() => fileData?.files ?? [], [fileData]);
- const [lightboxFile, setLightboxFile] = useState(
- null,
- );
- const downloadFile = useCallback(
- (fileId: string, filename: string) =>
- downloadSharedConversationFile(token, fileId, filename).catch(() => {
- toast("This file is no longer available.", "error");
- }),
- [toast, token],
- );
- // The shared page has no artifacts panel: opening a file shows an image
- // in the lightbox and downloads anything else.
- const openFile = useCallback(
- (file: ConversationFileInfo) => {
- if (file.kind === "image") setLightboxFile(file);
- else void downloadFile(file.id, file.filename);
- },
- [downloadFile],
- );
- const openArtifact = useCallback(
- (fileId: string) => {
- const file = files.find((entry) => entry.id === fileId);
- if (file) openFile(file);
- },
- [files, openFile],
- );
- const getFileObjectUrl = useCallback(
- (fileId: string) => getSharedConversationFileObjectUrl(token, fileId),
- [token],
- );
- // Read-only: no run events reach this page, so no inline cards derive;
- // the markdown overrides (figure, chip) resolve against `files`.
- const ui = useMemo(
- () => ({
- events: [],
- conversationId: null,
- files,
- openArtifact,
- getFileObjectUrl,
- downloadFile,
- onStop: noop,
- onRetry: noop,
- onOpenDashboardPreview: () => undefined,
- }),
- [downloadFile, files, getFileObjectUrl, openArtifact],
+function SharedChatUnavailable({ onBack }: { onBack: () => void }) {
+ return (
+
+
+
+
+ Shared conversation unavailable
+
+
+ This link is invalid, revoked, archived, or belongs to another
+ organization.
+
+
+
+
);
+}
- const prepareFork = async () => {
- setForking(true);
- try {
- const preview = await getSharedStandaloneForkPreview(token);
- setForkPreview(preview);
- setPerQueryBudget(preview.per_query_budget_usd);
- setChatBudget(preview.chat_budget_usd);
- } catch (forkError) {
- toast(
- forkError instanceof Error
- ? forkError.message
- : "Could not fork this conversation",
- "error",
- );
- } finally {
- setForking(false);
- }
- };
+/**
+ * Blocks the page while the gateway copies the chat. Forking a long chat
+ * with many files takes several seconds, and the redirect to the new chat
+ * only happens when it is done, so the wait must be visible.
+ */
+function ForkingOverlay() {
+ return (
+
+
+
+
+
+ Copying this chat into your workspace
+
+
+ Messages, work timeline, and files. You will land on your copy
+ when it is ready.
+