From d9bf0c54e56ab61dc527594bf445f18fcc761502 Mon Sep 17 00:00:00 2001 From: kiwi0401 Date: Tue, 8 Sep 2026 09:58:42 -0700 Subject: [PATCH 1/2] feat(chat): whole-conversation replay with speed control, off by default Replace the per-message Replay action with one conversation-level replay: every turn plays on a single compressed clock, user messages appear at their moment, each run re-streams its steps and inline artifact cards, and the artifacts panel pops open on the first file that becomes ready while playing. Speed is selectable (2x/5x/10x, default 5x) and switching keeps the viewer at the same point in the original run. Fixes two replay bugs: artifact cards for a run used to appear at t=0 as trailing cards because the card derivation saw the full file manifest, and text kept streaming after a scrub to the end because the smoothing hook drained deltas on its own clock. Cards now follow the frame and text renders instantly whenever the replay is paused or scrubbed. The "Replay chat" button is gated behind a browser-local "Enable replay" switch in Chat settings, off by default, since replay is mainly a demo feature. The message module is split into assistant-message, chat-message-timing and chat-replay-view to stay under the file limit. Co-Authored-By: Claude Fable 5.1 --- .../web/components/chat/assistant-message.tsx | 275 ++++++++++ .../components/chat/chat-message-timing.tsx | 69 +++ .../web/components/chat/chat-message.tsx | 471 +---------------- .../web/components/chat/chat-replay-view.tsx | 181 +++++++ .../components/chat/chat-settings-panel.tsx | 33 ++ .../web/components/chat/chat-ui-context.ts | 13 + .../web/components/chat/replay-controls.tsx | 36 +- .../chat/run-timeline/run-activity-blocks.tsx | 10 +- .../chat/standalone-chat-test-harness.tsx | 51 +- .../chat/use-chat-replay-setting.test.tsx | 55 ++ .../chat/use-chat-replay-setting.ts | 48 ++ .../web/e2e/chat-replay-artifacts.spec.ts | 238 +++++++++ signalpilot/web/lib/chat-artifact-cards.ts | 19 +- signalpilot/web/lib/chat-replay.test.ts | 478 ++++++++++++++++-- signalpilot/web/lib/chat-replay.ts | 451 ++++++++++++++--- 15 files changed, 1845 insertions(+), 583 deletions(-) create mode 100644 signalpilot/web/components/chat/assistant-message.tsx create mode 100644 signalpilot/web/components/chat/chat-message-timing.tsx create mode 100644 signalpilot/web/components/chat/chat-replay-view.tsx create mode 100644 signalpilot/web/components/chat/use-chat-replay-setting.test.tsx create mode 100644 signalpilot/web/components/chat/use-chat-replay-setting.ts create mode 100644 signalpilot/web/e2e/chat-replay-artifacts.spec.ts diff --git a/signalpilot/web/components/chat/assistant-message.tsx b/signalpilot/web/components/chat/assistant-message.tsx new file mode 100644 index 000000000..da5a52994 --- /dev/null +++ b/signalpilot/web/components/chat/assistant-message.tsx @@ -0,0 +1,275 @@ +"use client"; + +// The assistant turn of the standalone data chat transcript: activity +// blocks, inline artifact cards, the answer and the action row. Rendered +// live by chat-message.tsx and again, on the compressed replay clock, by +// chat-replay-view.tsx. + +import { + AlertCircle, + ChevronRight, + CircleStop, + Copy, + Loader2, + Play, + Sparkles, + Wrench, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { ChatMarkdown } from "~/components/chat/chat-markdown"; +import { + openStandaloneNotebookArchive, + type StandaloneChatRunStatus, +} from "~/lib/api"; +import { + RunActivityBlocks, + RunTimeline, + StepArtifactCardsContext, + collectStepSequences, +} from "~/components/chat/run-timeline"; +import { MessageRunContext } from "~/components/chat/message-run-context"; +import { ConnectorSignInCards } from "~/components/chat/connector-signin-card"; +import { RuntimeBootCard } from "~/components/chat/runtime-boot-card"; +import { + deriveLiveStateFromBlocks, + extractRuntimeBoot, + foldRunBlocks, + foldRunSteps, + shouldShowRuntimeBoot, +} from "~/lib/chat-run-steps"; +import { LivePill } from "~/components/chat/live-pill"; +import { useToast } from "~/components/ui/toast"; +import { useChatUi, type UiMessage } from "~/components/chat/chat-ui-context"; +import { + DashboardPreviewCard, + messageDashboardPreview, +} from "~/components/chat/chat-dashboard-preview-card"; +import { + deriveArtifactCards, + groupCardsByAnchor, +} from "~/lib/chat-artifact-cards"; +import { MessageTiming } from "~/components/chat/chat-message-timing"; + +function WorkTimeline({ runId }: { runId: string }) { + const { events } = useChatUi(); + const steps = useMemo(() => foldRunSteps(events, runId), [events, runId]); + return ; +} + +const ACTION_BUTTON = + "inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[11px] text-[var(--color-text-dim)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; + +export function AssistantMessage({ + message, + previousMessageAt, + replayMode = false, +}: { + message: UiMessage; + previousMessageAt?: number; + /** Rendered by the conversation replay: no action row. */ + replayMode?: boolean; +}) { + const runId = + message.runId ?? + (typeof message.metadata.run_id === "string" + ? message.metadata.run_id + : ""); + const runStatus = + message.runStatus ?? + (typeof message.metadata.status === "string" + ? (message.metadata.status as StandaloneChatRunStatus) + : "completed"); + const [showWork, setShowWork] = useState(false); + const ui = useChatUi(); + const { events, files, onRetry, onStop } = ui; + // A read-only surface (the shared page) keeps Copy and Replay but has no + // run to stop or retry. Read defensively: the flag is optional. + const readOnly = ui.readOnly === true; + // Replay paused or scrubbed: text renders complete, with no caret. + const textInstant = ui.textInstant === true; + const { toast } = useToast(); + const blocks = useMemo( + () => (runId ? foldRunBlocks(events, runId) : []), + [events, runId], + ); + // Present only on cold sandbox starts — warm follow-ups emit no boot events. + const runtimeBoot = useMemo( + () => (runId ? extractRuntimeBoot(events, runId) : null), + [events, runId], + ); + const steps = useMemo( + () => + blocks.flatMap((block) => (block.kind === "steps" ? block.steps : [])), + [blocks], + ); + const blocksHaveText = blocks.some((block) => block.kind === "text"); + const runError = steps.find((step) => step.category === "error")?.detail; + const messageRepeatsRunError = + runStatus === "failed" && + Boolean(runError) && + message.content.trim() === runError?.trim(); + const successful = runStatus === "completed"; + const running = runStatus === "queued" || runStatus === "running"; + // What the agent is doing right now: drives the caret, the inline + // indicator and the footer pill. Idle whenever the run is not active. + const live = useMemo( + () => deriveLiveStateFromBlocks(blocks, runtimeBoot, runStatus), + [blocks, runtimeBoot, runStatus], + ); + // Artifact cards: every captured file gets one, placed in the timeline + // right after the step that produced it (joined on the tool_started + // sequence). Files no step claims trail the timeline. Derived from the + // persisted events and manifest, so rehydration on refresh is free. + const fileCards = useMemo( + () => (runId ? deriveArtifactCards(events, files, runId, running) : []), + [events, files, runId, running], + ); + const anchoredCards = useMemo( + () => groupCardsByAnchor(fileCards, collectStepSequences(steps)), + [fileCards, steps], + ); + const messageRun = useMemo( + () => ({ runId: runId || null, running }), + [runId, running], + ); + const runtimeArchiveAvailable = + message.metadata.runtime_archive_available === true; + const dashboardPreview = successful + ? messageDashboardPreview(message.metadata) + : null; + return ( +
+
+
+ {running ? ( + + ) : runStatus === "failed" ? ( + + ) : ( + + )} +
+ + +
+ {shouldShowRuntimeBoot(runtimeBoot, running) && runtimeBoot && ( + + )} + {(running || + blocks.length > 0 || + anchoredCards.trailing.length > 0) && ( +
+ +
+ )} + {runId && } + {!blocksHaveText && message.content && !messageRepeatsRunError && ( + + )} + {dashboardPreview && ( + + )} + {runStatus === "cancelled" && ( +

+ This run was stopped. Completed work remains available below. +

+ )} + {!replayMode && ( +
+ {successful && ( + + )} + {runId && (runtimeArchiveAvailable || steps.length === 0) && ( + + )} + {running && } + + {running && runId && !readOnly && ( + + + + + )} + {runStatus === "failed" && runId && !readOnly && ( + + )} +
+ )} + {showWork && runId && !runtimeArchiveAvailable && ( +
+ +
+ )} +
+
+
+
+
+ ); +} diff --git a/signalpilot/web/components/chat/chat-message-timing.tsx b/signalpilot/web/components/chat/chat-message-timing.tsx new file mode 100644 index 000000000..4967540cd --- /dev/null +++ b/signalpilot/web/components/chat/chat-message-timing.tsx @@ -0,0 +1,69 @@ +"use client"; + +// Telemetry footer for one transcript message: wall-clock, response +// duration and token counts. Renders nothing unless telemetry is enabled. + +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { + formatTelemetryClock, + formatTelemetryDuration, + formatTokenCount, + estimateMessageTokens, + parseChatTokenUsage, + totalChatTokens, +} from "~/lib/chat-telemetry"; +import { useChatTelemetryContext } from "~/components/chat/chat-telemetry-context"; + +export function MessageTiming({ + message, + previousMessageAt, + running, +}: { + message: UiMessage; + previousMessageAt?: number; + running?: boolean; +}) { + const telemetry = useChatTelemetryContext(); + if (!telemetry.enabled) return null; + const recordedAt = message.created_at * 1_000; + const endAt = running ? telemetry.nowMs : recordedAt; + const duration = + message.role === "assistant" && previousMessageAt != null + ? Math.max(0, endAt - previousMessageAt * 1_000) + : null; + const exact = new Date(recordedAt).toLocaleString(); + const estimatedTextTokens = estimateMessageTokens(message.content); + const usage = parseChatTokenUsage(message.metadata.token_usage); + const runTokens = totalChatTokens(usage); + const usageTitle = usage + ? [ + `Exact SDK run usage: ${runTokens.toLocaleString("en-US")} tokens`, + `${(usage.input_tokens ?? 0).toLocaleString("en-US")} input`, + `${(usage.output_tokens ?? 0).toLocaleString("en-US")} output`, + `${(usage.cache_creation_input_tokens ?? 0).toLocaleString("en-US")} cache write`, + `${(usage.cache_read_input_tokens ?? 0).toLocaleString("en-US")} cache read`, + ].join(" · ") + : "Estimated visible-text tokens; exact usage is only available at run completion"; + return ( + + + {formatTelemetryClock(recordedAt)} + {duration != null ? ` · ${formatTelemetryDuration(duration)}` : ""} + + · + + ~{formatTokenCount(estimatedTextTokens)} text + {usage ? ` · ${formatTokenCount(runTokens)} run tokens` : " tokens"} + + + ); +} diff --git a/signalpilot/web/components/chat/chat-message.tsx b/signalpilot/web/components/chat/chat-message.tsx index d27c38f89..294068f9b 100644 --- a/signalpilot/web/components/chat/chat-message.tsx +++ b/signalpilot/web/components/chat/chat-message.tsx @@ -1,347 +1,15 @@ "use client"; -// Message components for the standalone data chat transcript. +// Message components for the standalone data chat transcript. The +// assistant turn lives in assistant-message.tsx; conversation replay in +// chat-replay-view.tsx. -import { - AlertCircle, - ChevronRight, - CircleStop, - Copy, - Loader2, - Play, - Sparkles, - Wrench, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { ChatMarkdown } from "~/components/chat/chat-markdown"; -import { - openStandaloneNotebookArchive, - type StandaloneChatRunStatus, -} from "~/lib/api"; -import { - RunActivityBlocks, - RunTimeline, - StepArtifactCardsContext, - collectStepSequences, -} from "~/components/chat/run-timeline"; -import { MessageRunContext } from "~/components/chat/message-run-context"; -import { ConnectorSignInCards } from "~/components/chat/connector-signin-card"; -import { RuntimeBootCard } from "~/components/chat/runtime-boot-card"; -import { ReplayControls } from "~/components/chat/replay-controls"; -import { - deriveLiveStateFromBlocks, - extractRuntimeBoot, - foldRunBlocks, - foldRunSteps, - shouldShowRuntimeBoot, -} from "~/lib/chat-run-steps"; -import { LivePill } from "~/components/chat/live-pill"; -import { useChatReplay } from "~/lib/chat-replay"; -import { useToast } from "~/components/ui/toast"; -import { - ChatUiContext, - useChatUi, - type UiMessage, -} from "~/components/chat/chat-ui-context"; -import { - DashboardPreviewCard, - messageDashboardPreview, -} from "~/components/chat/chat-dashboard-preview-card"; -import { - deriveArtifactCards, - groupCardsByAnchor, -} from "~/lib/chat-artifact-cards"; -import { - formatTelemetryClock, - formatTelemetryDuration, - formatTokenCount, - estimateMessageTokens, - parseChatTokenUsage, - totalChatTokens, -} from "~/lib/chat-telemetry"; -import { useChatTelemetryContext } from "~/components/chat/chat-telemetry-context"; +import { Loader2 } from "lucide-react"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { AssistantMessage } from "~/components/chat/assistant-message"; +import { MessageTiming } from "~/components/chat/chat-message-timing"; -function MessageTiming({ - message, - previousMessageAt, - running, -}: { - message: UiMessage; - previousMessageAt?: number; - running?: boolean; -}) { - const telemetry = useChatTelemetryContext(); - if (!telemetry.enabled) return null; - const recordedAt = message.created_at * 1_000; - const endAt = running ? telemetry.nowMs : recordedAt; - const duration = - message.role === "assistant" && previousMessageAt != null - ? Math.max(0, endAt - previousMessageAt * 1_000) - : null; - const exact = new Date(recordedAt).toLocaleString(); - const estimatedTextTokens = estimateMessageTokens(message.content); - const usage = parseChatTokenUsage(message.metadata.token_usage); - const runTokens = totalChatTokens(usage); - const usageTitle = usage - ? [ - `Exact SDK run usage: ${runTokens.toLocaleString("en-US")} tokens`, - `${(usage.input_tokens ?? 0).toLocaleString("en-US")} input`, - `${(usage.output_tokens ?? 0).toLocaleString("en-US")} output`, - `${(usage.cache_creation_input_tokens ?? 0).toLocaleString("en-US")} cache write`, - `${(usage.cache_read_input_tokens ?? 0).toLocaleString("en-US")} cache read`, - ].join(" · ") - : "Estimated visible-text tokens; exact usage is only available at run completion"; - return ( - - - {formatTelemetryClock(recordedAt)} - {duration != null ? ` · ${formatTelemetryDuration(duration)}` : ""} - - · - - ~{formatTokenCount(estimatedTextTokens)} text - {usage ? ` · ${formatTokenCount(runTokens)} run tokens` : " tokens"} - - - ); -} - -function WorkTimeline({ runId }: { runId: string }) { - const { events } = useChatUi(); - const steps = useMemo(() => foldRunSteps(events, runId), [events, runId]); - return ; -} - -function AssistantMessage({ - message, - previousMessageAt, - onReplay, - replayMode = false, -}: { - message: UiMessage; - previousMessageAt?: number; - onReplay?: () => void; - replayMode?: boolean; -}) { - const runId = - message.runId ?? - (typeof message.metadata.run_id === "string" - ? message.metadata.run_id - : ""); - const runStatus = - message.runStatus ?? - (typeof message.metadata.status === "string" - ? (message.metadata.status as StandaloneChatRunStatus) - : "completed"); - const [showWork, setShowWork] = useState(false); - const { events, files, onRetry, onStop } = useChatUi(); - const { toast } = useToast(); - const blocks = useMemo( - () => (runId ? foldRunBlocks(events, runId) : []), - [events, runId], - ); - // Present only on cold sandbox starts — warm follow-ups emit no boot events. - const runtimeBoot = useMemo( - () => (runId ? extractRuntimeBoot(events, runId) : null), - [events, runId], - ); - const steps = useMemo( - () => - blocks.flatMap((block) => (block.kind === "steps" ? block.steps : [])), - [blocks], - ); - const blocksHaveText = blocks.some((block) => block.kind === "text"); - const runError = steps.find((step) => step.category === "error")?.detail; - const messageRepeatsRunError = - runStatus === "failed" && - Boolean(runError) && - message.content.trim() === runError?.trim(); - const successful = runStatus === "completed"; - const running = runStatus === "queued" || runStatus === "running"; - // What the agent is doing right now: drives the caret, the inline - // indicator and the footer pill. Idle whenever the run is not active. - const live = useMemo( - () => deriveLiveStateFromBlocks(blocks, runtimeBoot, runStatus), - [blocks, runtimeBoot, runStatus], - ); - // Artifact cards: every captured file gets one, placed in the timeline - // right after the step that produced it (joined on the tool_started - // sequence). Files no step claims trail the timeline. Derived from the - // persisted events and manifest, so rehydration on refresh is free. - const fileCards = useMemo( - () => (runId ? deriveArtifactCards(events, files, runId, running) : []), - [events, files, runId, running], - ); - const anchoredCards = useMemo( - () => groupCardsByAnchor(fileCards, collectStepSequences(steps)), - [fileCards, steps], - ); - const messageRun = useMemo( - () => ({ runId: runId || null, running }), - [runId, running], - ); - const runtimeArchiveAvailable = - message.metadata.runtime_archive_available === true; - const dashboardPreview = successful - ? messageDashboardPreview(message.metadata) - : null; - return ( -
-
-
- {running ? ( - - ) : runStatus === "failed" ? ( - - ) : ( - - )} -
- - -
- {shouldShowRuntimeBoot(runtimeBoot, running) && runtimeBoot && ( - - )} - {(running || - blocks.length > 0 || - anchoredCards.trailing.length > 0) && ( -
- -
- )} - {runId && } - {!blocksHaveText && message.content && !messageRepeatsRunError && ( - - )} - {dashboardPreview && ( - - )} - {runStatus === "cancelled" && ( -

- This run was stopped. Completed work remains available below. -

- )} - {!replayMode && ( -
- {successful && ( - - )} - {onReplay && successful && ( - - )} - {runId && (runtimeArchiveAvailable || steps.length === 0) && ( - - )} - {running && } - - {running && runId && ( - - - - - )} - {runStatus === "failed" && runId && ( - - )} -
- )} - {showWork && runId && !runtimeArchiveAvailable && ( -
- -
- )} -
-
-
-
-
- ); -} - -function UserMessage({ message }: { message: UiMessage }) { +export function UserMessage({ message }: { message: UiMessage }) { const steeringStatus = message.metadata.steering_status; return (
void; -}) { - const { - events, - conversationId, - files, - openArtifact, - getFileObjectUrl, - downloadFile, - nowMs, - onStop, - onRetry, - } = useChatUi(); - const replay = useChatReplay(events, runId); - // Runs that streamed text carry text_delta events, and the blocks rebuild - // the message from the replayed deltas. Runs that only produced a final - // message have no deltas — for those, reveal the persisted content at the - // moment it actually appeared: when the run completed. - const runStreamedText = useMemo( - () => - events.some( - (event) => event.run_id === runId && event.type === "text_delta", - ), - [events, runId], - ); - const replayMessage = useMemo( - () => ({ - ...message, - content: runStreamedText ? "" : replay.finished ? message.content : "", - runId, - runStatus: replay.finished - ? (message.runStatus ?? "completed") - : "running", - }), - [message, replay.finished, runId, runStreamedText], - ); - return ( -
-
- -
- undefined, - }} - > - - -
- ); -} - -function ReplayableAssistantMessage({ - message, - previousMessageAt, -}: { - message: UiMessage; - previousMessageAt?: number; -}) { - const { events } = useChatUi(); - const [replaying, setReplaying] = useState(false); - const runId = messageRunId(message); - const canReplay = - Boolean(runId) && - events.some((event) => event.run_id === runId && event.type !== "status"); - if (replaying && runId) { - return ( - setReplaying(false)} - /> - ); - } - return ( - setReplaying(true) : undefined} - /> - ); -} - export function ChatMessage({ message, previousMessageAt, @@ -502,9 +52,6 @@ export function ChatMessage({ return message.role === "user" ? ( ) : ( - + ); } diff --git a/signalpilot/web/components/chat/chat-replay-view.tsx b/signalpilot/web/components/chat/chat-replay-view.tsx new file mode 100644 index 000000000..0100bbe2c --- /dev/null +++ b/signalpilot/web/components/chat/chat-replay-view.tsx @@ -0,0 +1,181 @@ +"use client"; + +// Replay mode of the chat transcript: the whole conversation re-plays on +// one compressed clock. A sticky control bar sits above the transcript; +// below it, only the messages whose moment has been reached render, each +// assistant turn through the same AssistantMessage as live, fed by a +// nested chat UI context whose events, file manifest and clock follow the +// replay frame. The first time a file becomes ready while playing, the +// artifacts panel opens on it; while paused or scrubbed every visible +// text block renders complete, with no caret. + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { ReplayControls } from "~/components/chat/replay-controls"; +import { + replayVisibleFiles, + useConversationReplay, + useReplayArtifactAutoOpen, + type ConversationReplayState, + canReplayConversation, +} from "~/lib/chat-replay"; +import type { StandaloneChatEvent } from "~/lib/api"; +import { + ChatUiContext, + useChatUi, + type ChatUiContextValue, + type UiMessage, +} from "~/components/chat/chat-ui-context"; +import { AssistantMessage } from "~/components/chat/assistant-message"; +import { useChatReplaySetting } from "~/components/chat/use-chat-replay-setting"; +import { UserMessage } from "~/components/chat/chat-message"; + +/** Replay mode of one conversation; leaving the conversation leaves it. + * `enabled` is the browser-local "Enable replay" chat setting: the pages + * only offer the button when it is on. */ +export function useReplayMode( + conversationId: string | undefined, + events: StandaloneChatEvent[], + streaming: boolean, +) { + const enabled = useChatReplaySetting(); + const [forConversation, setForConversation] = useState(null); + const replaying = + Boolean(conversationId) && forConversation === conversationId; + const canReplay = + enabled && + Boolean(conversationId) && + !streaming && + !replaying && + canReplayConversation(events); + const enterReplay = useCallback( + () => setForConversation(conversationId ?? null), + [conversationId], + ); + const exitReplay = useCallback(() => setForConversation(null), []); + return { canReplay, replaying, enterReplay, exitReplay }; +} + +/** Distance from the bottom under which the viewport counts as "at the + * bottom" — the live page's stick-to-bottom rule. */ +const STICK_THRESHOLD_PX = 96; + +/** + * Follow the newest content while playing, as a live chat does: scroll the + * viewport to the bottom on every frame until the user scrolls up, and + * resume once they return to the bottom. A restart follows again. + */ +function useReplayFollow( + viewportRef: RefObject | undefined, + replay: Pick, +) { + const stickRef = useRef(true); + useEffect(() => { + stickRef.current = true; + }, [replay.session]); + useEffect(() => { + const viewport = viewportRef?.current; + if (!viewport) return; + const onScroll = () => { + stickRef.current = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < + STICK_THRESHOLD_PX; + }; + viewport.addEventListener("scroll", onScroll); + return () => viewport.removeEventListener("scroll", onScroll); + }, [viewportRef]); + useLayoutEffect(() => { + const viewport = viewportRef?.current; + if (!viewport || !replay.playing || !stickRef.current) return; + viewport.scrollTo({ top: viewport.scrollHeight }); + }, [viewportRef, replay.playing, replay.elapsed]); +} + +export function ChatReplayView({ + messages, + onExit, + viewportRef, +}: { + messages: UiMessage[]; + onExit: () => void; + /** The scrolling transcript container, for the follow-the-stream rule. */ + viewportRef?: RefObject; +}) { + const ui = useChatUi(); + const { events, files, openArtifact, nowMs } = ui; + const source = useMemo(() => ({ messages, events }), [messages, events]); + const replay = useConversationReplay(source); + const { frame } = replay; + const visibleFiles = useMemo( + () => + replayVisibleFiles(files, { + events, + visibleEvents: frame.visibleEvents, + }), + [files, events, frame.visibleEvents], + ); + useReplayArtifactAutoOpen({ + files: visibleFiles, + runIds: frame.runIds, + playing: replay.playing, + session: replay.session, + openArtifact, + }); + useReplayFollow(viewportRef, replay); + const replayUi = useMemo( + () => ({ + ...ui, + events: frame.visibleEvents, + files: visibleFiles, + // The replay clock: relative timestamps measure from the instant the + // frame shows, not from today. Falls back to the page's clock only + // when nothing carries a parsable timestamp. + nowMs: replay.nowMs ?? nowMs, + textInstant: replay.textInstant, + onOpenDashboardPreview: () => undefined, + }), + [ui, frame.visibleEvents, visibleFiles, replay.nowMs, replay.textInstant, nowMs], + ); + return ( +
+
+
+ +
+
+ +
+ {frame.messages.map((message, index) => + message.role === "user" ? ( + + ) : ( + + ), + )} +
+
+
+ ); +} diff --git a/signalpilot/web/components/chat/chat-settings-panel.tsx b/signalpilot/web/components/chat/chat-settings-panel.tsx index 07b42cce2..8fe7fe01c 100644 --- a/signalpilot/web/components/chat/chat-settings-panel.tsx +++ b/signalpilot/web/components/chat/chat-settings-panel.tsx @@ -30,6 +30,10 @@ import { setChatTelemetryEnabled, useChatTelemetrySetting, } from "~/components/chat/use-chat-telemetry-setting"; +import { + setChatReplayEnabled, + useChatReplaySetting, +} from "~/components/chat/use-chat-replay-setting"; export type ChatBudgetSettings = { perQueryBudgetUsd: number; @@ -311,6 +315,34 @@ function ModelSection({ model }: { model: ChatModelSettings }) { ); } +function ReplaySection() { + const enabled = useChatReplaySetting(); + return ( +
+ + Replay + +
+
+

+ Enable replay +

+

+ 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/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/standalone-chat-test-harness.tsx b/signalpilot/web/components/chat/standalone-chat-test-harness.tsx index ce593553c..b9ac34412 100644 --- a/signalpilot/web/components/chat/standalone-chat-test-harness.tsx +++ b/signalpilot/web/components/chat/standalone-chat-test-harness.tsx @@ -3,6 +3,7 @@ import { FastForward, FlaskConical, + History, Pause, Play, RotateCcw, @@ -16,6 +17,7 @@ import { type UiMessage, } from "~/components/chat/standalone-data-chat"; import { ArtifactsPanel } from "~/components/chat/artifacts-panel"; +import { ChatReplayView } from "~/components/chat/chat-replay-view"; import { StandaloneChatComposer } from "~/components/chat/standalone-chat-composer"; import { useDockScrollCompensation } from "~/components/chat/use-dock-scroll-compensation"; import { selectComposerPlan } from "~/lib/chat-composer-plan"; @@ -85,6 +87,9 @@ export function StandaloneChatTestHarness() { // a click that lands before hydration is silently lost. const [hydrated, setHydrated] = useState(false); useEffect(() => setHydrated(true), []); + // Conversation replay mode, as on the chat page (the transcript swaps + // for the replay view; the harness clock keeps its own position). + const [replaying, setReplaying] = useState(false); const [speed, setSpeed] = useState<(typeof SPEEDS)[number]>(1); const [selectedModel, setSelectedModel] = useState("claude-opus-5"); @@ -192,7 +197,9 @@ export function StandaloneChatTestHarness() { role: "user", content: FIXTURE_USER_PROMPT, sequence: 1, - created_at: 0, + // Epoch seconds, as the gateway records them: the replay anchors + // user messages on this clock. + created_at: fixtureNowMs(0) / 1_000, metadata: {}, }, { @@ -200,7 +207,7 @@ export function StandaloneChatTestHarness() { role: "assistant", content: fixtureAssembledText(elapsed), sequence: 2, - created_at: 0, + created_at: fixtureNowMs(FIXTURE_TOTAL_MS) / 1_000, metadata: { run_id: FIXTURE_RUN_ID }, runId: FIXTURE_RUN_ID, runStatus: status, @@ -212,7 +219,9 @@ export function StandaloneChatTestHarness() { role: "user", content: FIXTURE_FOLLOW_UP_PROMPT, sequence: 3, - created_at: 0, + // A minute after the first run ended: the replay collapses + // the pause to the gap cap. + created_at: fixtureNowMs(FIXTURE_TOTAL_MS + 60_000) / 1_000, metadata: {}, }, { @@ -220,7 +229,7 @@ export function StandaloneChatTestHarness() { role: "assistant", content: "", sequence: 4, - created_at: 0, + created_at: fixtureNowMs(FIXTURE_TOTAL_MS + 60_000) / 1_000, metadata: { run_id: FIXTURE_FOLLOW_UP_RUN_ID }, runId: FIXTURE_FOLLOW_UP_RUN_ID, runStatus: "running", @@ -336,6 +345,20 @@ export function StandaloneChatTestHarness() { {(elapsed / 1000).toFixed(1)}s · {progress}% +
{hasArtifactsContent( conversationNotebooks, diff --git a/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx b/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx new file mode 100644 index 000000000..77bdebf44 --- /dev/null +++ b/signalpilot/web/components/chat/use-chat-replay-setting.test.tsx @@ -0,0 +1,55 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + CHAT_REPLAY_STORAGE_KEY, + setChatReplayEnabled, + useChatReplaySetting, +} from "~/components/chat/use-chat-replay-setting"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function Probe() { + const enabled = useChatReplaySetting(); + return ( + + ); +} + +describe("chat replay local setting", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + container.remove(); + }); + + it("defaults off and persists an opt-in only in local storage", async () => { + await act(async () => root.render()); + const toggle = container.querySelector("button")!; + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBeNull(); + + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBe("true"); + + await act(async () => toggle.click()); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/signalpilot/web/components/chat/use-chat-replay-setting.ts b/signalpilot/web/components/chat/use-chat-replay-setting.ts new file mode 100644 index 000000000..272e7e3f1 --- /dev/null +++ b/signalpilot/web/components/chat/use-chat-replay-setting.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +export const CHAT_REPLAY_STORAGE_KEY = "sp:chat-replay-enabled"; + +const CHANGE_EVENT = "sp:chat-replay-setting-change"; + +function getSnapshot(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(CHAT_REPLAY_STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function subscribe(onStoreChange: () => void): () => void { + const onStorage = (event: StorageEvent) => { + if (event.key === CHAT_REPLAY_STORAGE_KEY) onStoreChange(); + }; + window.addEventListener("storage", onStorage); + window.addEventListener(CHANGE_EVENT, onStoreChange); + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(CHANGE_EVENT, onStoreChange); + }; +} + +export function setChatReplayEnabled(enabled: boolean): void { + try { + if (enabled) { + window.localStorage.setItem(CHAT_REPLAY_STORAGE_KEY, "true"); + } else { + window.localStorage.removeItem(CHAT_REPLAY_STORAGE_KEY); + } + } catch { + // Keep the control usable when browser storage is unavailable. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +/** Browser-only opt-in for the "Replay chat" action. Replay is a demo + * feature most users never need, so it is off until enabled here; missing + * or unavailable storage always means disabled. */ +export function useChatReplaySetting(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, () => false); +} diff --git a/signalpilot/web/e2e/chat-replay-artifacts.spec.ts b/signalpilot/web/e2e/chat-replay-artifacts.spec.ts new file mode 100644 index 000000000..be047ce00 --- /dev/null +++ b/signalpilot/web/e2e/chat-replay-artifacts.spec.ts @@ -0,0 +1,238 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +/** + * Conversation replay ("Replay chat" in the header), exercised through the + * fixture harness at /chats/test. The harness is scrubbed to its end first + * so the conversation is complete, then the replay re-streams the whole + * chat on the compressed clock. At the default 5x every fixture gap is + * under the 10s cap, so replay offset = original offset / 5 (the 24.8s + * fixture replays in ~5.4s). + * + * Inline artifact cards must follow the replay frame the way they follow a + * live run: none before the Write step that produced the file, then the + * card right under that step, and gone again when the scrub moves back. + * Scrubbing renders text instantly; later turns appear at their anchor. + */ + +const BASE = process.env.SP_WEB_BASE_URL ?? "http://localhost:3200"; +const at = (ms: number, extra = "") => + `${BASE}/chats/test?at=${ms}&paused=1${extra}`; +const FIXTURE_END_MS = 24_800; +/** Replay offsets at 5x for original fixture instants. */ +const R = (originalMs: number) => Math.round(originalMs / 5 / 100) * 100; +const REPLAY_END_MS = 5_400; + +async function waitForHydration(page: Page) { + await expect(page.getByTestId("chat-test-harness")).toHaveAttribute( + "data-hydrated", + "1", + ); +} + +/** Drives the replay's range slider the way a user drag does (React listens + * to the native `input` event; the value must go through the native setter + * or React's tracker swallows the change). */ +async function scrubTo(controls: Locator, ms: number) { + await controls + .getByRole("slider", { name: "Replay position" }) + .evaluate((element, value) => { + const input = element as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + setter?.call(input, String(value)); + input.dispatchEvent(new Event("input", { bubbles: true })); + }, ms); +} + +/** Opens the conversation replay and pauses it at once so frames are + * chosen by the scrub, not by the wall clock. */ +async function startPausedReplay(page: Page, extra = "") { + await page.goto(at(FIXTURE_END_MS, extra)); + await waitForHydration(page); + await page.getByTestId("chat-replay-button").click(); + const replay = page.getByTestId("chat-replay"); + await expect(replay).toBeVisible(); + const controls = replay.getByTestId("chat-replay-controls"); + await controls.getByRole("button", { name: "Pause replay" }).click(); + await expect( + controls.getByRole("button", { name: "Play replay" }), + ).toBeVisible(); + return { replay, controls }; +} + +test.describe("conversation replay: artifact cards (fixture harness)", () => { + test("no cards at replay start; cards anchor under their steps as the scrub reaches them", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + // The per-message action row is gone; the header owns the entry point. + await expect(page.getByTestId("standalone-chat-messages")).toHaveCount(0); + // Paused within the first fraction of a second of replay: the user + // message and the queued answer show, nothing has been written yet. + await expect(replay.getByText("Which regions drove Q3")).toBeVisible(); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-row")).toHaveCount(0); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + + // 15.5s of the original run: five files written, each card under its + // own Write step inside the open group. + await scrubTo(controls, R(15_500)); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + const cards = replay.getByTestId("chat-artifact-card"); + await expect(cards).toHaveCount(5); + await expect(cards.nth(0)).toContainText("q3_growth.py"); + await expect(cards.nth(4)).toContainText("q3_summary.md"); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + // Timestamps run on the replay clock, not today's: the frame is seconds + // after the edit, so the card says so. + await expect(cards.nth(0)).toContainText("just now"); + + // 12.5s of the original run: the report's Write has landed but its + // mirror (13.0s) has not — a pending card, under the Write step, + // exactly as live. + await scrubTo(controls, R(12_500)); + const pending = replay.getByTestId("chat-artifact-card-pending"); + await expect(pending).toHaveCount(1); + await expect(pending).toContainText("q3_regional_review.html"); + await expect( + replay + .getByTestId("chat-step-artifact-cards") + .filter({ hasText: "q3_regional_review.html" }), + ).toHaveCount(1); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(1); + + // Scrub back before the first Write (8s original): the files are gone + // again, not stranded as trailing cards. + await scrubTo(controls, R(8_000)); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(0); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + + // End of the replay: the run completes, the group collapses and hoists + // every card into its footer, none trailing — the live end state. + await scrubTo(controls, REPLAY_END_MS); + await expect(replay.getByTestId("chat-group-artifact-cards")).toHaveCount(1); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(3); + await expect(replay.getByTestId("chat-artifact-card-row")).toHaveCount(4); + await expect(replay.getByTestId("chat-trailing-artifact-cards")).toHaveCount(0); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(0); + }); + + test("changing the speed mid-run keeps the same frame and the same cards", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, R(15_500)); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(controls.getByTestId("chat-replay-speed-5")).toHaveAttribute( + "aria-pressed", + "true", + ); + await controls.getByTestId("chat-replay-speed-10").click(); + await expect(controls.getByTestId("chat-replay-speed-10")).toHaveAttribute( + "aria-pressed", + "true", + ); + // Same instant of the original run: the five cards stay, nothing + // pending, and the clock reads half the elapsed. + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card-pending")).toHaveCount(0); + await expect( + controls.getByRole("slider", { name: "Replay position" }), + ).toHaveValue(String(Math.round(15_500 / 10 / 100) * 100)); + await controls.getByTestId("chat-replay-speed-2").click(); + await expect(replay.getByTestId("chat-step-artifact-cards")).toHaveCount(5); + await expect(replay.getByTestId("chat-artifact-card")).toHaveCount(5); + // Still paused: a speed change never starts playback. + await expect( + controls.getByRole("button", { name: "Play replay" }), + ).toBeVisible(); + }); + + test("scrubbing to the end renders the final answer at once, with no caret", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, REPLAY_END_MS); + // The last streamed sentence is present on the very frame, not typed in. + await expect(replay).toContainText("so they are unaffected", { + timeout: 1_000, + }); + await expect(replay.locator('[data-caret="true"]')).toHaveCount(0); + await expect(replay.getByTestId("chat-live-indicator")).toHaveCount(0); + // And stays that way: no smoothing drains text after the fact. + await page.waitForTimeout(600); + await expect(replay.locator('[data-caret="true"]')).toHaveCount(0); + await expect(replay).toContainText("so they are unaffected"); + }); + + test("a later turn's user message appears only once its anchor is reached", async ({ + page, + }) => { + // ?followup=1 adds a second turn a minute after the first run ended; + // the replay collapses that pause to the 10s cap. + const { replay, controls } = await startPausedReplay(page, "&followup=1"); + const followUp = replay.getByText("Project Q4 from these growth rates."); + await scrubTo(controls, REPLAY_END_MS); + await expect(replay).toContainText("so they are unaffected"); + await expect(followUp).toHaveCount(0); + // Past the capped pause: the second turn is on screen. + await scrubTo(controls, REPLAY_END_MS + 10_000); + await expect(followUp).toBeVisible(); + }); + + test("a replayed card still opens the artifacts panel on its file", async ({ + page, + }) => { + const { replay, controls } = await startPausedReplay(page); + await scrubTo(controls, R(15_500)); + await replay + .getByTestId("chat-artifact-card") + .filter({ hasText: "q3_summary.md" }) + .getByTestId("chat-artifact-card-primary") + .click(); + await expect(page.getByTestId("live-notebook-panel")).toBeVisible(); + await expect(page.getByTestId("artifacts-tab-files")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByTestId("chat-file-stub")).toBeVisible(); + }); + + test("while playing, the first ready card pops the artifacts panel open on its file", async ({ + page, + }) => { + await page.goto(at(FIXTURE_END_MS)); + await waitForHydration(page); + // At the end frame the notebook has already ended, so the harness did + // not auto-open the panel; close it if it is open so the pop-up is + // observable. + const close = page.getByTestId("live-notebook-close"); + if (await close.count()) await close.click(); + await expect(page.getByTestId("live-notebook-panel")).toHaveCount(0); + await page.getByTestId("chat-replay-button").click(); + const replay = page.getByTestId("chat-replay"); + await expect(replay).toBeVisible(); + // The first file (q3_growth.py, 9.2s original) is ready ~1.8s in. + await expect(page.getByTestId("live-notebook-panel")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByTestId("artifacts-tab-files")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByTestId("chat-file-stub")).toBeVisible(); + await expect( + replay.getByTestId("chat-artifact-card").first(), + ).toContainText("q3_growth.py"); + // Exit returns the live transcript. + await replay.getByRole("button", { name: "Exit replay" }).click(); + await expect(page.getByTestId("chat-replay")).toHaveCount(0); + await expect(page.getByTestId("standalone-chat-messages")).toBeVisible(); + }); +}); diff --git a/signalpilot/web/lib/chat-artifact-cards.ts b/signalpilot/web/lib/chat-artifact-cards.ts index 00bbde2d8..3889e3429 100644 --- a/signalpilot/web/lib/chat-artifact-cards.ts +++ b/signalpilot/web/lib/chat-artifact-cards.ts @@ -110,7 +110,7 @@ type PathTouch = { }; /** Paths a tool_started event touches: one for Write/Edit tools. */ -function toolTouchPaths(event: StandaloneChatEvent): string[] { +export function toolTouchPaths(event: StandaloneChatEvent): string[] { const tool = text(event.payload.tool); if (!tool || (!WRITE_TOOLS.has(tool) && !EDIT_TOOLS.has(tool))) return []; const input = @@ -139,6 +139,23 @@ function filesChangedTouchPaths(event: StandaloneChatEvent): string[] { return paths; } +/** Every path a `files_changed` event names, in either payload shape: the + * runtime capture (`files: [{path}]`, deleted entries skipped) or the legacy + * content-free mirror (`changed: [path]`). Unlike `filesChangedTouchPaths` + * this carries no anchor semantics; it answers "did the mirror confirm this + * path yet", which is what the replay needs to reveal a manifest row. */ +export function filesChangedNamedPaths(event: StandaloneChatEvent): string[] { + const paths = filesChangedTouchPaths(event); + const changed = event.payload.changed; + if (Array.isArray(changed)) { + for (const entry of changed) { + const path = text(entry); + if (path) paths.push(path); + } + } + return paths; +} + function collectTouches( events: StandaloneChatEvent[], runId: string, diff --git a/signalpilot/web/lib/chat-replay.test.ts b/signalpilot/web/lib/chat-replay.test.ts index 3d158c77c..c409ad265 100644 --- a/signalpilot/web/lib/chat-replay.test.ts +++ b/signalpilot/web/lib/chat-replay.test.ts @@ -1,52 +1,101 @@ -import { describe, expect, it } from "vitest"; -import type { StandaloneChatEvent } from "~/lib/api"; +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConversationFileInfo, StandaloneChatEvent } from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; import { buildReplaySchedule, + canReplayConversation, + DEFAULT_REPLAY_SPEED, + deriveReplayFrame, REPLAY_MAX_GAP_MS, - REPLAY_SPEED, + remapReplayElapsed, + replayInstantFor, replayOffsetFor, + replayVisibleFiles, + useReplayArtifactAutoOpen, } from "~/lib/chat-replay"; const BASE = Date.UTC(2026, 0, 1, 12, 0, 0); +const SPEED = DEFAULT_REPLAY_SPEED; function event( sequence: number, offsetMs: number, type: StandaloneChatEvent["type"] = "progress", runId = "run-1", + payload: StandaloneChatEvent["payload"] = {}, ): StandaloneChatEvent { return { run_id: runId, sequence, type, - payload: {}, + payload, created_at: new Date(BASE + offsetMs).toISOString(), }; } +function user(id: string, offsetMs: number, content = "q"): UiMessage { + return { + id, + role: "user", + content, + sequence: 0, + created_at: (BASE + offsetMs) / 1_000, + metadata: {}, + }; +} + +function assistant( + id: string, + runId: string, + offsetMs: number, + content = "answer", + runStatus: UiMessage["runStatus"] = "completed", +): UiMessage { + return { + id, + role: "assistant", + content, + sequence: 0, + created_at: (BASE + offsetMs) / 1_000, + metadata: { run_id: runId }, + runId, + runStatus, + }; +} + +const eventsOnly = (events: StandaloneChatEvent[]) => ({ messages: [], events }); + describe("buildReplaySchedule", () => { - it("scales gaps by 4x and preserves order", () => { + it("scales gaps by the speed and preserves order", () => { const schedule = buildReplaySchedule( - [event(1, 0), event(2, 2_000), event(3, 10_000)], - "run-1", + eventsOnly([event(1, 0), event(2, 2_000), event(3, 10_000)]), + SPEED, ); expect(schedule.items.map((item) => item.at)).toEqual([ 0, - 2_000 / REPLAY_SPEED, - 2_000 / REPLAY_SPEED + 8_000 / REPLAY_SPEED, + 2_000 / SPEED, + 2_000 / SPEED + 8_000 / SPEED, ]); + expect(schedule.speed).toBe(SPEED); }); - it("caps any single wait at 10 seconds", () => { - // A 15-minute tool call still replays in 10s. - const schedule = buildReplaySchedule( - [event(1, 0, "tool_started"), event(2, 15 * 60_000, "tool_completed")], - "run-1", - ); - expect(schedule.items[1].at).toBe(REPLAY_MAX_GAP_MS); + it("caps any single wait at 10 seconds regardless of speed", () => { + // A 15-minute tool call still replays in 10s, at 2x and at 10x. + for (const speed of [2, 10]) { + const schedule = buildReplaySchedule( + eventsOnly([ + event(1, 0, "tool_started"), + event(2, 15 * 60_000, "tool_completed"), + ]), + speed, + ); + expect(schedule.items[1].at).toBe(REPLAY_MAX_GAP_MS); + } }); - it("ignores events from other runs and unparsable timestamps fall back", () => { + it("unparsable timestamps fall back to a small gap", () => { const broken: StandaloneChatEvent = { run_id: "run-1", sequence: 2, @@ -55,48 +104,395 @@ describe("buildReplaySchedule", () => { created_at: "not-a-date", }; const schedule = buildReplaySchedule( - [event(1, 0), broken, event(3, 1_000), event(4, 0, "progress", "other")], - "run-1", + eventsOnly([event(1, 0), broken, event(3, 1_000)]), + SPEED, ); expect(schedule.items).toHaveLength(3); - // Broken timestamp uses the small fallback gap; the next parsable event - // resumes scaling from the last good anchor. expect(schedule.items[1].at).toBeGreaterThan(0); - expect(schedule.items[2].at).toBeGreaterThanOrEqual( - 1_000 / REPLAY_SPEED, - ); + expect(schedule.items[2].at).toBeGreaterThanOrEqual(1_000 / SPEED); + }); + + it("orders a conversation as it happened: user, run, user, run", () => { + const messages = [ + user("u1", 0), + assistant("a1", "run-1", 5_000), + // Two hours later. + user("u2", 7_200_000), + assistant("a2", "run-2", 7_205_000), + ]; + const events = [ + event(1, 1_000, "progress", "run-1"), + event(2, 4_000, "text_delta", "run-1"), + event(1, 7_201_000, "progress", "run-2"), + event(2, 7_204_000, "text_delta", "run-2"), + ]; + const schedule = buildReplaySchedule({ messages, events }, SPEED); + expect( + schedule.items.map((item) => item.message?.id ?? item.event?.run_id), + ).toEqual(["u1", "run-1", "run-1", "u2", "run-2", "run-2"]); + // The pause between turns collapses to the cap. + expect(schedule.items[3].at - schedule.items[2].at).toBe(REPLAY_MAX_GAP_MS); + expect(schedule.items[1].at).toBe(1_000 / SPEED); + // Assistant messages with events are represented by their run, not + // scheduled themselves. + expect(schedule.items.some((item) => item.message?.id === "a1")).toBe(false); }); - it("a compressed 15-minute run fits in a demo window", () => { - // 30 events spread evenly across 15 minutes: each 31s gap → capped well - // below 10s each, so total is under ~4 minutes. - const events = Array.from({ length: 30 }, (_, index) => - event(index + 1, index * 31_000), + it("schedules an assistant message with no events on its own timestamp", () => { + const messages = [user("u1", 0), assistant("a1", "run-1", 3_000)]; + const schedule = buildReplaySchedule({ messages, events: [] }, SPEED); + expect(schedule.items.map((item) => item.message?.id)).toEqual(["u1", "a1"]); + expect(schedule.items[1].at).toBe(3_000 / SPEED); + }); + + it("keeps events of runs no message claims", () => { + const schedule = buildReplaySchedule( + { messages: [user("u1", 0)], events: [event(1, 500, "progress", "orphan")] }, + SPEED, ); - const schedule = buildReplaySchedule(events, "run-1"); - expect(schedule.totalMs).toBeLessThan(4 * 60_000); - expect(schedule.totalMs).toBeGreaterThan(60_000); + expect(schedule.items).toHaveLength(2); }); }); -describe("replayOffsetFor", () => { +describe("canReplayConversation", () => { + it("needs at least one non-status event", () => { + expect(canReplayConversation([])).toBe(false); + expect(canReplayConversation([event(1, 0, "status")])).toBe(false); + expect(canReplayConversation([event(1, 0, "status"), event(2, 1, "progress")])).toBe(true); + }); +}); + +describe("replayOffsetFor / replayInstantFor", () => { const schedule = buildReplaySchedule( - [event(1, 0), event(2, 60_000), event(3, 62_000)], - "run-1", + eventsOnly([event(1, 0), event(2, 60_000), event(3, 62_000)]), + SPEED, ); it("maps instants between events onto the compressed clock", () => { - // First gap is capped at 10s; an artifact created 30s in maps inside it. expect(replayOffsetFor(schedule, BASE)).toBe(0); expect(replayOffsetFor(schedule, BASE + 61_000)).toBe( - REPLAY_MAX_GAP_MS + 1_000 / REPLAY_SPEED, + REPLAY_MAX_GAP_MS + 1_000 / SPEED, ); + expect(replayOffsetFor(schedule, BASE - 5_000)).toBe(0); + expect(replayOffsetFor(schedule, BASE + 10 * 60_000)).toBe(schedule.totalMs); }); - it("clamps before the run and after its end", () => { - expect(replayOffsetFor(schedule, BASE - 5_000)).toBe(0); - expect(replayOffsetFor(schedule, BASE + 10 * 60_000)).toBe( - schedule.totalMs, + it("returns the original instant at every anchor (inverse of replayOffsetFor)", () => { + for (const anchor of schedule.anchors) { + expect(replayInstantFor(schedule, anchor.at)).toBe(anchor.originalMs); + expect(replayOffsetFor(schedule, anchor.originalMs)).toBe(anchor.at); + } + }); + + it("interpolates at the schedule speed between anchors", () => { + const secondAt = schedule.anchors[1].at; + expect(replayInstantFor(schedule, secondAt + 200)).toBe( + BASE + 60_000 + 200 * SPEED, + ); + expect(replayOffsetFor(schedule, BASE + 61_000)).toBe(secondAt + 200); + }); + + it("never overtakes the next anchor inside a capped wait", () => { + expect(replayInstantFor(schedule, 9_900)).toBe(BASE + 9_900 * SPEED); + expect(replayInstantFor(schedule, REPLAY_MAX_GAP_MS)).toBe(BASE + 60_000); + }); + + it("clamps before the start and runs through the tail after the last event", () => { + expect(replayInstantFor(schedule, -500)).toBe(BASE); + const lastAt = schedule.anchors[2].at; + expect(replayInstantFor(schedule, lastAt + 100)).toBe( + BASE + 62_000 + 100 * SPEED, + ); + expect(replayInstantFor(schedule, schedule.totalMs + 5_000)).toBe( + BASE + 62_000 + (schedule.totalMs - lastAt) * SPEED, + ); + }); + + it("is null when nothing has a parsable timestamp", () => { + const schedule = buildReplaySchedule( + eventsOnly([{ ...event(1, 0), created_at: "nope" }]), + SPEED, ); + expect(replayInstantFor(schedule, 0)).toBeNull(); + }); +}); + +describe("remapReplayElapsed", () => { + const source = eventsOnly([ + event(1, 0), + event(2, 4_000, "tool_started"), + event(3, 8_000, "tool_completed"), + event(4, 12_000, "text_delta"), + ]); + const at5 = buildReplaySchedule(source, 5); + const at10 = buildReplaySchedule(source, 10); + const at2 = buildReplaySchedule(source, 2); + + it("keeps the same original instant when the speed changes", () => { + // 6s into the run at 5x = 1.2s of replay; at 10x the same instant is 0.6s. + expect(replayInstantFor(at5, 1_200)).toBe(BASE + 6_000); + expect(remapReplayElapsed(at5, at10, 1_200)).toBe(600); + expect(replayInstantFor(at10, 600)).toBe(BASE + 6_000); + // And back out to 2x: 3s of replay. + expect(remapReplayElapsed(at10, at2, 600)).toBe(3_000); + expect(replayInstantFor(at2, 3_000)).toBe(BASE + 6_000); + }); + + it("keeps the same visible items across a speed change", () => { + const visibleAt = (schedule: typeof at5, elapsed: number) => + schedule.items.filter((item) => item.at <= elapsed).length; + for (const elapsed of [0, 700, 1_650, 2_400]) { + expect(visibleAt(at10, remapReplayElapsed(at5, at10, elapsed))).toBe( + visibleAt(at5, elapsed), + ); + } + }); + + it("maps the end to the end", () => { + expect(remapReplayElapsed(at5, at10, at5.totalMs)).toBe(at10.totalMs); + expect(remapReplayElapsed(at5, at10, 0)).toBe(0); + }); +}); + +describe("deriveReplayFrame", () => { + const messages = [ + user("u1", 0, "first question"), + assistant("a1", "run-1", 5_000, "persisted answer"), + user("u2", 60_000, "second question"), + assistant("a2", "run-2", 65_000, "final answer"), + assistant("a3", "run-3", 70_000, "no events"), + ]; + const events = [ + event(1, 1_000, "progress", "run-1"), + event(2, 4_000, "progress", "run-1"), + event(1, 61_000, "progress", "run-2"), + event(2, 64_000, "text_delta", "run-2", { delta: "final" }), + ]; + const schedule = buildReplaySchedule({ messages, events }, SPEED); + const ids = (elapsed: number) => + deriveReplayFrame(schedule, elapsed).messages.map((m) => m.id); + const status = (elapsed: number, id: string) => + deriveReplayFrame(schedule, elapsed).messages.find((m) => m.id === id)?.runStatus; + + it("shows a user message at its anchor and its answer as queued right after", () => { + expect(ids(0)).toEqual(["u1", "a1"]); + expect(status(0, "a1")).toBe("queued"); + }); + + it("runs a turn while its events stream and settles to the real status", () => { + const first = schedule.items[1].at; + expect(status(first, "a1")).toBe("running"); + expect(deriveReplayFrame(schedule, first).finishedRunIds.has("run-1")).toBe(false); + const last = schedule.items[2].at; + expect(status(last, "a1")).toBe("completed"); + expect(deriveReplayFrame(schedule, last).finishedRunIds.has("run-1")).toBe(true); + }); + + it("reveals persisted content on completion only for runs that did not stream text", () => { + const first = schedule.items[1].at; + const done1 = schedule.items[2].at; + const content = (elapsed: number, id: string) => + deriveReplayFrame(schedule, elapsed).messages.find((m) => m.id === id)?.content; + expect(content(first, "a1")).toBe(""); + expect(content(done1, "a1")).toBe("persisted answer"); + // run-2 streamed deltas: the blocks rebuild it, the message stays empty. + expect(content(schedule.totalMs, "a2")).toBe(""); + }); + + it("hides a later turn until its anchor, then shows it", () => { + const u2At = schedule.items.find((item) => item.message?.id === "u2")!.at; + expect(ids(u2At - 1)).toEqual(["u1", "a1"]); + expect(ids(u2At)).toEqual(["u1", "a1", "u2", "a2"]); + expect(status(u2At, "a2")).toBe("queued"); + }); + + it("shows an eventless assistant message at its own anchor, as is", () => { + const a3At = schedule.items.find((item) => item.message?.id === "a3")!.at; + expect(ids(a3At - 1)).not.toContain("a3"); + const frame = deriveReplayFrame(schedule, a3At); + expect(frame.messages.find((m) => m.id === "a3")?.content).toBe("no events"); + expect(frame.runIds.has("run-3")).toBe(false); + }); +}); + +const fileRow = (path: string, runId = "run-1"): ConversationFileInfo => ({ + id: `id-${path}`, + path, + filename: path.split("/").pop() ?? path, + kind: "data", + mime_type: "text/csv", + byte_size: 10, + content_hash: "h1", + origin_run_id: runId, + origin: "runtime", + status: "active", + created_at: new Date(BASE).toISOString(), + updated_at: new Date(BASE).toISOString(), +}); + +describe("replayVisibleFiles", () => { + const writeReport = event(1, 0, "tool_started", "run-1", { + tool: "Write", + tool_call_id: "c1", + input: { file_path: "/workspace/exports/report.html", content: "x" }, + }); + const mirrorReport = event(2, 900, "files_changed", "run-1", { + changed: ["exports/report.html"], + deleted: [], + }); + const writeNotes = event(3, 2_000, "tool_started", "run-1", { + tool: "Edit", + tool_call_id: "c2", + input: { file_path: "notes.md", old_string: "a", new_string: "b" }, + }); + const runCells = event(4, 3_000, "tool_started", "run-1", { + tool: "mcp__standalone-chat__run_cells", + tool_call_id: "c3", + input: { cells: [] }, + }); + const captureChart = event(5, 4_000, "files_changed", "run-1", { + changed: 1, + files: [{ path: "artifacts/chart.png", deleted: false }], + tool_call_id: "c3", + }); + const done = event(6, 5_000, "status", "run-1", { status: "completed" }); + const otherRun = event(1, 9_000, "progress", "run-2"); + const events = [ + writeReport, + mirrorReport, + writeNotes, + runCells, + captureChart, + done, + otherRun, + ]; + const files = [ + fileRow("exports/report.html"), + fileRow("notes.md"), + fileRow("artifacts/chart.png"), + fileRow("sweep.csv"), + fileRow("later.csv", "run-2"), + fileRow("earlier.csv", "run-0"), + ]; + const visible = (visibleEvents: StandaloneChatEvent[]) => + replayVisibleFiles(files, { events, visibleEvents }).map((file) => file.path); + + it("shows only files of runs the replay does not cover before anything is touched", () => { + expect(visible([])).toEqual(["earlier.csv"]); + }); + + it("keeps a mirrored file hidden through its pending window, then reveals it", () => { + expect(visible([writeReport])).toEqual(["earlier.csv"]); + expect(visible([writeReport, mirrorReport])).toEqual([ + "exports/report.html", + "earlier.csv", + ]); + }); + + it("reveals a file no capture ever confirms at its Write/Edit step", () => { + expect(visible([writeReport, mirrorReport, writeNotes])).toContain("notes.md"); + }); + + it("reveals a runtime capture at its files_changed, not at the tool call", () => { + expect(visible([writeReport, mirrorReport, writeNotes, runCells])).not.toContain( + "artifacts/chart.png", + ); + expect(visible([writeReport, mirrorReport, writeNotes, runCells, captureChart])).toContain( + "artifacts/chart.png", + ); + }); + + it("holds untouched files (the run-end sweep) until the run's last event", () => { + const beforeDone = [writeReport, mirrorReport, writeNotes, runCells, captureChart]; + expect(visible(beforeDone)).not.toContain("sweep.csv"); + expect(visible([...beforeDone, done])).toContain("sweep.csv"); + // The second run has not started: its file stays hidden. + expect(visible([...beforeDone, done])).not.toContain("later.csv"); + expect(visible(events)).toEqual(files.map((file) => file.path)); + }); + + it("hides a file again when the scrub moves before its touch", () => { + expect(visible(events)).toContain("notes.md"); + expect(visible([writeReport, mirrorReport])).not.toContain("notes.md"); + }); +}); + +describe("useReplayArtifactAutoOpen", () => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const runIds = new Set(["run-1"]); + function Probe(props: { + files: ConversationFileInfo[]; + playing: boolean; + session: number; + openArtifact: (fileId: string) => void; + }) { + useReplayArtifactAutoOpen({ ...props, runIds }); + return null; + } + const render = async ( + files: ConversationFileInfo[], + playing: boolean, + session: number, + openArtifact: (fileId: string) => void, + ) => { + await act(async () => { + root.render(createElement(Probe, { files, playing, session, openArtifact })); + }); + }; + + it("opens each covered file once as it becomes ready while playing", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + const b = fileRow("b.csv"); + const other = fileRow("z.csv", "run-0"); + await render([other], true, 0, open); + expect(open).not.toHaveBeenCalled(); + await render([other, a], true, 0, open); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith("id-a.csv"); + await render([other, a], true, 0, open); + await render([other, a, b], true, 0, open); + expect(open).toHaveBeenCalledTimes(2); + expect(open).toHaveBeenLastCalledWith("id-b.csv"); + }); + + it("absorbs the opening frame and files that appear while paused or scrubbed", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + // Mounted with a file already visible (e.g. a pass-through row): no pop. + await render([a], true, 0, open); + expect(open).not.toHaveBeenCalled(); + const b = fileRow("b.csv"); + await render([a, b], false, 0, open); + await render([a, b], true, 0, open); + expect(open).not.toHaveBeenCalled(); + await render([], false, 0, open); + await render([a, b], true, 0, open); + expect(open).not.toHaveBeenCalled(); + }); + + it("forgets everything on restart so the next session pops up again", async () => { + const open = vi.fn(); + const a = fileRow("a.csv"); + await render([], true, 0, open); + await render([a], true, 0, open); + expect(open).toHaveBeenCalledTimes(1); + await render([], true, 1, open); + await render([a], true, 1, open); + expect(open).toHaveBeenCalledTimes(2); }); }); diff --git a/signalpilot/web/lib/chat-replay.ts b/signalpilot/web/lib/chat-replay.ts index a7bd51dd3..03d616046 100644 --- a/signalpilot/web/lib/chat-replay.ts +++ b/signalpilot/web/lib/chat-replay.ts @@ -1,66 +1,140 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { StandaloneChatEvent } from "~/lib/api"; +import type { + ConversationFileInfo, + StandaloneChatEvent, + StandaloneChatRunStatus, +} from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { + filesChangedNamedPaths, + pathsMatch, + toolTouchPaths, +} from "~/lib/chat-artifact-cards"; /** - * Replay of a recorded run: events are rescheduled onto a compressed clock - * that preserves the original rhythm. Every gap between events plays 4x - * faster, and no single wait (a tool call, a long think) exceeds 10 seconds, - * so a 15-minute run demos in a couple of minutes without losing its shape. + * Replay of a whole conversation: every run's events and the user messages + * are rescheduled onto one compressed clock that preserves the original + * rhythm. Gaps play `speed` times faster and no single wait (a tool call, + * a long think, the hours between two turns) exceeds REPLAY_MAX_GAP_MS of + * replay time regardless of speed — the cap bounds the wait the viewer + * sits through, so a faster speed only shortens the gaps under the cap. */ -export const REPLAY_SPEED = 4; +export const REPLAY_SPEEDS = [2, 5, 10] as const; +export type ReplaySpeed = (typeof REPLAY_SPEEDS)[number]; +export const DEFAULT_REPLAY_SPEED: ReplaySpeed = 5; export const REPLAY_MAX_GAP_MS = 10_000; -/** Gap assumed when an event has an unparsable timestamp (already scaled). */ +/** Gap assumed when an item has an unparsable timestamp (already scaled). */ const FALLBACK_GAP_MS = 150; -/** Breathing room after the last event before the replay reports finished. */ +/** Breathing room after the last item before the replay reports finished. */ const TAIL_MS = 400; -export type ScheduledReplayEvent = { - event: StandaloneChatEvent; - /** Milliseconds from replay start at which this event becomes visible. */ +export type ReplaySource = { + messages: UiMessage[]; + events: StandaloneChatEvent[]; +}; + +export type ScheduledReplayItem = { + /** Milliseconds from replay start at which this item becomes visible. */ at: number; + event?: StandaloneChatEvent; + message?: UiMessage; }; export type ReplaySchedule = { - items: ScheduledReplayEvent[]; + items: ScheduledReplayItem[]; totalMs: number; /** Piecewise map from original epoch-ms to replay offsets. */ anchors: { originalMs: number; at: number }[]; + speed: number; + messages: UiMessage[]; }; +export function replayRunId(message: UiMessage): string { + return ( + message.runId ?? + (typeof message.metadata.run_id === "string" ? message.metadata.run_id : "") + ); +} + +/** A conversation is replayable once some run recorded a real event. */ +export function canReplayConversation(events: StandaloneChatEvent[]): boolean { + return events.some((event) => event.type !== "status"); +} + +function messageOriginalMs(message: UiMessage): number { + return message.created_at > 0 ? message.created_at * 1_000 : NaN; +} + +/** + * The items in the order they happened: each user message, then the + * events of the run that answered it (assistant messages whose run has no + * events stand in for themselves); runs no message claims trail in + * first-seen order so nothing recorded is lost. + */ +function replayItemOrder( + source: ReplaySource, +): { originalMs: number; event?: StandaloneChatEvent; message?: UiMessage }[] { + const byRun = new Map(); + for (const event of source.events) { + const list = byRun.get(event.run_id); + if (list) list.push(event); + else byRun.set(event.run_id, [event]); + } + for (const list of byRun.values()) list.sort((a, b) => a.sequence - b.sequence); + const eventItem = (event: StandaloneChatEvent) => ({ + originalMs: Date.parse(event.created_at), + event, + }); + const items: ReturnType = []; + const consumed = new Set(); + for (const message of source.messages) { + const runEvents = + message.role === "assistant" ? byRun.get(replayRunId(message)) : undefined; + if (runEvents?.length) { + consumed.add(replayRunId(message)); + items.push(...runEvents.map(eventItem)); + } else { + items.push({ originalMs: messageOriginalMs(message), message }); + } + } + for (const [runId, runEvents] of byRun) { + if (!consumed.has(runId)) items.push(...runEvents.map(eventItem)); + } + return items; +} + export function buildReplaySchedule( - events: StandaloneChatEvent[], - runId: string, + source: ReplaySource, + speed: number = DEFAULT_REPLAY_SPEED, ): ReplaySchedule { - const runEvents = events - .filter((event) => event.run_id === runId) - .sort((a, b) => a.sequence - b.sequence); - const items: ScheduledReplayEvent[] = []; + const items: ScheduledReplayItem[] = []; const anchors: ReplaySchedule["anchors"] = []; let at = 0; let previousMs: number | null = null; - for (const event of runEvents) { - const originalMs = Date.parse(event.created_at); + for (const { originalMs, event, message } of replayItemOrder(source)) { if (previousMs != null) { const gap = Number.isFinite(originalMs) ? Math.max(0, originalMs - previousMs) : NaN; at += Number.isFinite(gap) - ? Math.min(gap / REPLAY_SPEED, REPLAY_MAX_GAP_MS) + ? Math.min(gap / speed, REPLAY_MAX_GAP_MS) : FALLBACK_GAP_MS; } if (Number.isFinite(originalMs)) { anchors.push({ originalMs, at }); previousMs = originalMs; } - items.push({ event, at }); + items.push({ at, event, message }); } return { items, totalMs: items.length ? items[items.length - 1].at + TAIL_MS : 0, anchors, + speed, + messages: source.messages, }; } @@ -69,7 +143,7 @@ export function replayOffsetFor( schedule: ReplaySchedule, originalMs: number, ): number { - const { anchors, totalMs } = schedule; + const { anchors, totalMs, speed } = schedule; if (!anchors.length || !Number.isFinite(originalMs)) return totalMs; if (originalMs <= anchors[0].originalMs) return 0; let last = anchors[0]; @@ -79,17 +153,214 @@ export function replayOffsetFor( } return Math.min( last.at + - Math.min((originalMs - last.originalMs) / REPLAY_SPEED, REPLAY_MAX_GAP_MS), + Math.min((originalMs - last.originalMs) / speed, REPLAY_MAX_GAP_MS), totalMs, ); } -export type ChatReplayState = { +/** + * Inverse of `replayOffsetFor`: the original wall-clock instant (epoch ms) + * the replay is showing at `elapsed`. Between anchors the clock advances at + * the schedule's speed from the last anchor and never overtakes the next + * one (a capped gap fast-forwards to it); past the last item it runs on + * through the tail. Null when nothing carries a parsable timestamp. + */ +export function replayInstantFor( + schedule: ReplaySchedule, + elapsed: number, +): number | null { + const { anchors, totalMs, speed } = schedule; + if (!anchors.length) return null; + const clamped = Math.min(Math.max(elapsed, 0), totalMs); + let last = anchors[0]; + let next: ReplaySchedule["anchors"][number] | null = null; + for (const anchor of anchors) { + if (anchor.at <= clamped) { + last = anchor; + } else { + next = anchor; + break; + } + } + const projected = Math.max( + last.originalMs, + last.originalMs + (clamped - last.at) * speed, + ); + return next ? Math.min(projected, next.originalMs) : projected; +} + +/** + * The elapsed offset on `to` that shows the same original instant `from` + * shows at `elapsed` — how a speed change keeps its place in the run. + */ +export function remapReplayElapsed( + from: ReplaySchedule, + to: ReplaySchedule, + elapsed: number, +): number { + if (from.totalMs > 0 && elapsed >= from.totalMs) return to.totalMs; + const instant = replayInstantFor(from, elapsed); + if (instant === null) { + return from.totalMs > 0 ? (elapsed / from.totalMs) * to.totalMs : 0; + } + return replayOffsetFor(to, instant); +} + +export type ReplayFrame = { + visibleEvents: StandaloneChatEvent[]; + /** The transcript at this instant: message status and content follow + * the frame, so each turn renders as it did live. */ + messages: UiMessage[]; + /** Runs the replay covers (those with events in the schedule). */ + runIds: Set; + /** Runs whose every event is visible. */ + finishedRunIds: Set; +}; + +function terminalStatus(message: UiMessage): StandaloneChatRunStatus { + return ( + message.runStatus ?? + (typeof message.metadata.status === "string" + ? (message.metadata.status as StandaloneChatRunStatus) + : "completed") + ); +} + +/** + * What the transcript shows at `elapsed`. A user message appears at its + * anchor. An assistant turn appears as soon as the message before it is + * visible ("queued", as live), runs while its events stream in, and takes + * its real terminal status once the last one is visible. Runs that + * streamed text rebuild the answer from the replayed deltas (content + * empty); runs that only produced a final message reveal it on completion. + */ +export function deriveReplayFrame( + schedule: ReplaySchedule, + elapsed: number, +): ReplayFrame { + const visibleEvents: StandaloneChatEvent[] = []; + const visibleMessageIds = new Set(); + const total = new Map(); + const seen = new Map(); + const streamed = new Set(); + for (const item of schedule.items) { + if (item.event) { + const runId = item.event.run_id; + total.set(runId, (total.get(runId) ?? 0) + 1); + if (item.event.type === "text_delta") streamed.add(runId); + if (item.at <= elapsed) { + visibleEvents.push(item.event); + seen.set(runId, (seen.get(runId) ?? 0) + 1); + } + } else if (item.message && item.at <= elapsed) { + visibleMessageIds.add(item.message.id); + } + } + const runIds = new Set(total.keys()); + const finishedRunIds = new Set(); + for (const [runId, count] of total) { + if ((seen.get(runId) ?? 0) >= count) finishedRunIds.add(runId); + } + const messages: UiMessage[] = []; + schedule.messages.forEach((message, index) => { + const runId = message.role === "assistant" ? replayRunId(message) : ""; + if (!runId || !runIds.has(runId)) { + if (visibleMessageIds.has(message.id)) messages.push(message); + return; + } + const started = (seen.get(runId) ?? 0) > 0; + const previousVisible = + index > 0 && messages[messages.length - 1]?.id === schedule.messages[index - 1].id; + if (!started && !previousVisible) return; + const finished = finishedRunIds.has(runId); + messages.push({ + ...message, + runId, + runStatus: finished ? terminalStatus(message) : started ? "running" : "queued", + content: !streamed.has(runId) && finished ? message.content : "", + }); + }); + return { visibleEvents, messages, runIds, finishedRunIds }; +} + +/** + * The slice of the file manifest a replay frame may show. A file appears + * the way it did live: once a visible `files_changed` of its run names its + * path (the mirror confirmed it) or, for a path no capture event ever + * confirms, once the visible Write/Edit step that produced it lands. Files + * nothing in the run touches (the run-end sweep) appear when the run's + * last event is visible. Files of runs the replay does not cover pass + * through. Derived from `visibleEvents`, so scrubbing back hides files. + */ +export function replayVisibleFiles( + files: ConversationFileInfo[], + opts: { + /** Every event of the conversation, for "was this path ever confirmed". */ + events: StandaloneChatEvent[]; + visibleEvents: StandaloneChatEvent[]; + }, +): ConversationFileInfo[] { + const { events, visibleEvents } = opts; + const total = new Map(); + const confirmedEver = new Map(); + for (const event of events) { + total.set(event.run_id, (total.get(event.run_id) ?? 0) + 1); + if (event.type !== "files_changed") continue; + const list = confirmedEver.get(event.run_id) ?? []; + list.push(...filesChangedNamedPaths(event)); + confirmedEver.set(event.run_id, list); + } + const seen = new Map(); + const confirmedNow = new Map(); + const writtenNow = new Map(); + for (const event of visibleEvents) { + seen.set(event.run_id, (seen.get(event.run_id) ?? 0) + 1); + const target = + event.type === "files_changed" + ? confirmedNow + : event.type === "tool_started" + ? writtenNow + : null; + if (!target) continue; + const paths = + event.type === "files_changed" + ? filesChangedNamedPaths(event) + : toolTouchPaths(event); + if (!paths.length) continue; + const list = target.get(event.run_id) ?? []; + list.push(...paths); + target.set(event.run_id, list); + } + const matches = (paths: string[] | undefined, path: string) => + (paths ?? []).some( + (candidate) => candidate === path || pathsMatch(candidate, path), + ); + return files.filter((file) => { + const runId = file.origin_run_id; + if (!runId || !total.has(runId)) return true; + if ((seen.get(runId) ?? 0) >= (total.get(runId) ?? 0)) return true; + if (matches(confirmedNow.get(runId), file.path)) return true; + if (matches(confirmedEver.get(runId), file.path)) return false; + return matches(writtenNow.get(runId), file.path); + }); +} + +export type ConversationReplayState = { elapsed: number; totalMs: number; playing: boolean; finished: boolean; - visibleEvents: StandaloneChatEvent[]; + speed: ReplaySpeed; + setSpeed: (speed: ReplaySpeed) => void; + frame: ReplayFrame; + /** The original wall-clock instant the frame shows; undefined when + * nothing carries a parsable timestamp (callers fall back to live time). */ + nowMs: number | undefined; + /** Bumps every time the replay starts over from the beginning. */ + session: number; + /** True when the frame was not reached by playback (paused or scrubbed): + * every visible text block renders complete, with no caret. */ + textInstant: boolean; togglePlay: () => void; restart: () => void; scrub: (ms: number) => void; @@ -97,62 +368,114 @@ export type ChatReplayState = { const TICK_MS = 50; -export function useChatReplay( - events: StandaloneChatEvent[], - runId: string, -): ChatReplayState { +export function useConversationReplay( + source: ReplaySource, +): ConversationReplayState { + const { messages, events } = source; + const [speed, setSpeedState] = useState(DEFAULT_REPLAY_SPEED); const schedule = useMemo( - () => buildReplaySchedule(events, runId), - [events, runId], + () => buildReplaySchedule({ messages, events }, speed), + [messages, events, speed], ); const [elapsed, setElapsed] = useState(0); const [playing, setPlaying] = useState(true); - const totalRef = useRef(schedule.totalMs); - totalRef.current = schedule.totalMs; + const [session, setSession] = useState(0); + const totalMs = schedule.totalMs; + const finished = elapsed >= totalMs; + // The clock runs only while playing and not yet at the end; reaching the + // end stops it without a separate state write. + const ticking = playing && !finished; useEffect(() => { - if (!playing) return; + if (!ticking) return; const interval = window.setInterval(() => { - setElapsed((value) => { - const next = value + TICK_MS; - if (next >= totalRef.current) { - window.clearInterval(interval); - return totalRef.current; - } - return next; - }); + setElapsed((value) => Math.min(value + TICK_MS, totalMs)); }, TICK_MS); return () => window.clearInterval(interval); - }, [playing]); - const finished = elapsed >= schedule.totalMs; - useEffect(() => { - if (finished && playing) setPlaying(false); - }, [finished, playing]); - - const visibleEvents = useMemo( - () => - schedule.items - .filter((item) => item.at <= elapsed) - .map((item) => item.event), + }, [ticking, totalMs]); + + const frame = useMemo( + () => deriveReplayFrame(schedule, elapsed), + [schedule, elapsed], + ); + const nowMs = useMemo( + () => replayInstantFor(schedule, elapsed) ?? undefined, [schedule, elapsed], ); + const restart = () => { + setElapsed(0); + setSession((value) => value + 1); + setPlaying(true); + }; return { elapsed, - totalMs: schedule.totalMs, - playing, + totalMs, + playing: ticking, finished, - visibleEvents, - togglePlay: () => { - if (elapsed >= totalRef.current) setElapsed(0); - setPlaying((value) => !value); + speed, + setSpeed: (next) => { + if (next === speed) return; + // Stay at the same point of the original run: map the current + // offset back to its instant and forward onto the new clock. + const nextSchedule = buildReplaySchedule({ messages, events }, next); + setElapsed(remapReplayElapsed(schedule, nextSchedule, elapsed)); + setSpeedState(next); }, - restart: () => { - setElapsed(0); - setPlaying(true); + frame, + nowMs, + session, + textInstant: !ticking, + togglePlay: () => { + // Play from the end starts the run over. + if (finished) restart(); + else setPlaying((value) => !value); }, + restart, scrub: (ms: number) => { setPlaying(false); - setElapsed(Math.min(Math.max(ms, 0), totalRef.current)); + setElapsed(Math.min(Math.max(ms, 0), totalMs)); }, }; } + +/** + * The replay's "pop up": the first time a file of a replayed run becomes + * ready while the replay is playing, open the artifacts panel on it, once + * per file per replay session. Files already visible when a session starts + * or when playback resumes after a pause or a scrub are absorbed silently, + * so scrubbing never opens the panel; a restart clears the memory. + */ +export function useReplayArtifactAutoOpen({ + files, + runIds, + playing, + session, + openArtifact, +}: { + /** The frame's visible manifest (see `replayVisibleFiles`). */ + files: ConversationFileInfo[]; + /** Runs the replay covers; files of other runs never pop. */ + runIds: ReadonlySet; + playing: boolean; + session: number; + openArtifact: (fileId: string) => void; +}): void { + const seenRef = useRef<{ session: number; ids: Set } | null>(null); + // Latest opener without re-running the reveal effect on identity churn. + const openRef = useRef(openArtifact); + useEffect(() => { + openRef.current = openArtifact; + }, [openArtifact]); + useEffect(() => { + // A fresh session absorbs its opening frame silently. + const fresh = seenRef.current === null || seenRef.current.session !== session; + if (fresh) seenRef.current = { session, ids: new Set() }; + const seen = seenRef.current!.ids; + for (const file of files) { + if (!file.origin_run_id || !runIds.has(file.origin_run_id)) continue; + if (file.status !== "active" || seen.has(file.id)) continue; + seen.add(file.id); + if (playing && !fresh) openRef.current(file.id); + } + }, [files, runIds, playing, session]); +} From e5ff5c9deff0cf3dece5821619900fd69e9f5965 Mon Sep 17 00:00:00 2001 From: kiwi0401 Date: Tue, 8 Sep 2026 09:58:54 -0700 Subject: [PATCH 2/2] feat(chat): faithful share view and one-click whole-chat fork The shared chat page is now the real chat page, read only: the same message tree, tool timeline, inline artifact cards and artifacts panel (files and queries), all read through share-token routes. The share endpoint returns the full snapshot (messages with metadata, run events, file manifest) and hides everything that belongs to an in-flight run; new shared routes serve the SQL trace and paged query-result rows. Forking is one click. The preview endpoint, the budget inputs, the cost notice and the confirm step are gone; budgets come from the forking user's saved defaults through the helper bootstrap already uses. The fork copies the whole chat: conversation, runs, messages, run events, files, structured query results and governed executions. One id map plus a recursive JSON walker rewrites every reference inside copied payloads so tool cards and artifact cards resolve in the copy; file origin is remapped to the new run so inline cards derive. Sharing opens a centered dialog with the link, a copy action, a loader while the link is minted, and a plain statement that only signed-in members of the organization can ever open it. Forking shows a full-screen progress overlay until the copy is ready. The sharing and forking feature flags are passed through docker-compose so the local staging stack can exercise both. Co-Authored-By: Claude Fable 5.1 --- docker-compose.yml | 4 + .../gateway/api/chat_routes/conversations.py | 26 +- .../gateway/gateway/api/chat_routes/files.py | 34 +- .../gateway/api/chat_routes/projects.py | 16 +- .../gateway/api/chat_routes/query_results.py | 76 ++- .../gateway/gateway/models/standalone_chat.py | 41 +- .../gateway/store/standalone_chat/__init__.py | 16 +- .../gateway/store/standalone_chat/files.py | 22 +- .../gateway/store/standalone_chat/forking.py | 445 +++++++++++++++++ .../store/standalone_chat/preferences.py | 44 ++ .../gateway/store/standalone_chat/sharing.py | 298 +++++------ signalpilot/gateway/tests/test_chat_files.py | 16 + .../gateway/tests/test_chat_query_results.py | 56 +++ .../gateway/tests/test_standalone_chat.py | 194 ++++++-- .../components/chat/chat-artifact-card.tsx | 20 +- .../web/components/chat/chat-file-viewer.tsx | 33 +- .../web/components/chat/download-ui-file.ts | 16 + .../components/chat/markdown/file-chip.tsx | 19 +- .../web/components/chat/share-link-dialog.tsx | 167 +++++++ .../web/components/chat/shared-chat-files.tsx | 104 ---- .../chat/shared-standalone-data-chat.tsx | 462 ++++++------------ .../chat/shared/shared-chat-header.tsx | 96 ++++ .../components/chat/shared/use-shared-chat.ts | 147 ++++++ .../chat/standalone-chat-panels.tsx | 54 +- .../components/chat/standalone-data-chat.tsx | 45 +- .../chat/use-standalone-chat-actions.ts | 26 +- .../chat/use-standalone-chat-run.ts | 123 +---- signalpilot/web/lib/api/chat-files.ts | 14 + signalpilot/web/lib/api/chat-results.ts | 15 + signalpilot/web/lib/api/standalone-chat.ts | 63 ++- .../lib/standalone-chat-ui-messages.test.ts | 95 ++++ .../web/lib/standalone-chat-ui-messages.ts | 161 ++++++ 32 files changed, 1982 insertions(+), 966 deletions(-) create mode 100644 signalpilot/gateway/gateway/store/standalone_chat/forking.py create mode 100644 signalpilot/gateway/gateway/store/standalone_chat/preferences.py create mode 100644 signalpilot/web/components/chat/download-ui-file.ts create mode 100644 signalpilot/web/components/chat/share-link-dialog.tsx delete mode 100644 signalpilot/web/components/chat/shared-chat-files.tsx create mode 100644 signalpilot/web/components/chat/shared/shared-chat-header.tsx create mode 100644 signalpilot/web/components/chat/shared/use-shared-chat.ts create mode 100644 signalpilot/web/lib/standalone-chat-ui-messages.test.ts create mode 100644 signalpilot/web/lib/standalone-chat-ui-messages.ts diff --git a/docker-compose.yml b/docker-compose.yml index 2335b0957..664f6fa76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,8 @@ services: SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true} SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true} SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false} + SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false} + SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false} # Dedicated billing key for automated improvement runs (from root .env). SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-} SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime} @@ -228,6 +230,8 @@ services: SP_FEATURE_CHAT_RUNTIME_RESULTS: ${SP_FEATURE_CHAT_RUNTIME_RESULTS:-true} SP_FEATURE_CHAT_RUNTIME_ARTIFACTS: ${SP_FEATURE_CHAT_RUNTIME_ARTIFACTS:-true} SP_FEATURE_CHAT_DATASET_REFS: ${SP_FEATURE_CHAT_DATASET_REFS:-false} + SP_FEATURE_CHAT_ORG_SHARING: ${SP_FEATURE_CHAT_ORG_SHARING:-false} + SP_FEATURE_CHAT_FORKING: ${SP_FEATURE_CHAT_FORKING:-false} # Dedicated billing key for automated improvement runs (from root .env). SP_IMPROVEMENT_ANTHROPIC_KEY: ${SP_IMPROVEMENT_ANTHROPIC_KEY:-} SP_CHAT_OBJECTS_BUCKET: ${SP_CHAT_OBJECTS_BUCKET:-sp-chat-runtime} diff --git a/signalpilot/gateway/gateway/api/chat_routes/conversations.py b/signalpilot/gateway/gateway/api/chat_routes/conversations.py index 27a317ee1..e6712d211 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/conversations.py +++ b/signalpilot/gateway/gateway/api/chat_routes/conversations.py @@ -8,9 +8,7 @@ from gateway.git.repos import branch_head_sha from gateway.models.standalone_chat import ( ChatShareGrantInfo, - ForkConfirmation, ForkedConversationInfo, - ForkPreviewInfo, SharedConversationDetail, StandaloneConversationCreate, StandaloneConversationDetail, @@ -334,7 +332,8 @@ async def get_shared_conversation( response_model=ForkedConversationInfo, dependencies=[RequireScope("write")], ) -async def fork_shared_conversation(token: str, body: ForkConfirmation, store: StoreD): +async def fork_shared_conversation(token: str, store: StoreD): + """Copy the whole shared chat into the caller's chats. No body needed.""" _require_enabled() _require_enterprise_feature("forking") try: @@ -343,30 +342,9 @@ async def fork_shared_conversation(token: str, body: ForkConfirmation, store: St org_id=store._require_org_id(), user_id=store.user_id or "local", token=token, - per_query_budget_usd=body.per_query_budget_usd, - chat_budget_usd=body.chat_budget_usd, ) except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc if conversation is None: raise HTTPException(status_code=404, detail="Shared conversation not found") return ForkedConversationInfo(id=conversation.id) - - -@router.get( - "/shared/{token}/fork-preview", - response_model=ForkPreviewInfo, - dependencies=[RequireScope("read")], -) -async def preview_shared_conversation_fork(token: str, store: StoreD): - _require_enabled() - _require_enterprise_feature("forking") - preview = await chat_store.get_fork_preview( - store.session, - org_id=store._require_org_id(), - user_id=store.user_id or "local", - token=token, - ) - if preview is None: - raise HTTPException(status_code=404, detail="Shared conversation not found") - return preview diff --git a/signalpilot/gateway/gateway/api/chat_routes/files.py b/signalpilot/gateway/gateway/api/chat_routes/files.py index 5c03668d6..1055f5c71 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/files.py +++ b/signalpilot/gateway/gateway/api/chat_routes/files.py @@ -15,6 +15,7 @@ from gateway.standalone_chat.object_storage import chat_object_storage from gateway.standalone_chat.sql_trace import list_sql_trace from gateway.store import standalone_chat as chat_store +from gateway.store.standalone_chat.files import file_manifest_entry from ..deps import StoreD from .common import owned_conversation_or_404 as _owned_conversation_or_404 @@ -40,21 +41,9 @@ IfNoneMatchD = Annotated[str | None, Header(alias="If-None-Match")] -def _file_info(row: GatewayChatFile) -> dict: - return { - "id": row.id, - "path": row.path, - "filename": row.filename, - "kind": row.kind, - "mime_type": row.mime_type, - "byte_size": row.byte_size, - "content_hash": row.content_hash, - "origin_run_id": row.origin_run_id, - "origin": row.origin, - "status": row.status, - "created_at": row.created_at, - "updated_at": row.updated_at, - } +# The manifest wire shape lives in the store so the shared snapshot can +# embed it without importing this module. +_file_info = file_manifest_entry def _etag(row: GatewayChatFile) -> str: @@ -219,3 +208,18 @@ async def get_conversation_sql_trace(conversation_id: str, store: StoreD): conversation_id=conversation_id, ) return {"executions": executions} + + +@router.get("/shared/{token}/sql-trace", dependencies=[RequireScope("read")]) +async def get_shared_conversation_sql_trace(token: str, store: StoreD): + """Return the shared chat's governed query executions for finished runs.""" + _require_enabled() + _require_enterprise_feature("organization_sharing") + executions = await chat_store.list_shared_sql_trace( + store.session, + org_id=store._require_org_id(), + token=token, + ) + if executions is None: + raise HTTPException(status_code=404, detail="Shared conversation not found") + return {"executions": executions} diff --git a/signalpilot/gateway/gateway/api/chat_routes/projects.py b/signalpilot/gateway/gateway/api/chat_routes/projects.py index 9617f2548..f497dd411 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/projects.py +++ b/signalpilot/gateway/gateway/api/chat_routes/projects.py @@ -22,6 +22,7 @@ evaluate_project_readiness, resolve_default_project, ) +from gateway.store.standalone_chat.preferences import default_chat_budgets from ..deps import StoreD from .common import is_admin as _is_admin @@ -121,14 +122,9 @@ async def bootstrap_chat(store: StoreD, role: OrgRole): project=selected, readiness=readiness_by_project[selected_id], ) - preference = ( - await store.session.execute( - select(GatewayChatUserPreference).where( - GatewayChatUserPreference.org_id == org_id, - GatewayChatUserPreference.user_id == user_id, - ) - ) - ).scalar_one_or_none() + per_query_budget_usd, chat_budget_usd = await default_chat_budgets( + store.session, org_id=org_id, user_id=user_id + ) return ChatBootstrapResponse( enabled=True, projects=[ @@ -155,8 +151,8 @@ async def bootstrap_chat(store: StoreD, role: OrgRole): selected_project_id=selected_id, is_admin=_is_admin(role), starter_questions=starters, - default_per_query_budget_usd=(preference.default_per_query_budget_usd if preference else 0.25), - default_chat_budget_usd=(preference.default_chat_budget_usd if preference else 1.0), + default_per_query_budget_usd=per_query_budget_usd, + default_chat_budget_usd=chat_budget_usd, available_models=model_options, default_model=selected_model, available_efforts=effort_options, diff --git a/signalpilot/gateway/gateway/api/chat_routes/query_results.py b/signalpilot/gateway/gateway/api/chat_routes/query_results.py index f72aec2de..2e35c7c05 100644 --- a/signalpilot/gateway/gateway/api/chat_routes/query_results.py +++ b/signalpilot/gateway/gateway/api/chat_routes/query_results.py @@ -20,9 +20,10 @@ from gateway.db.models import GatewayStructuredQueryResult from gateway.security.scope_guard import RequireScope from gateway.standalone_chat.query_results import QueryResultUnavailable, load_result_rows +from gateway.store import standalone_chat as chat_store from ..deps import StoreD -from .common import owned_conversation_or_404, require_enabled +from .common import owned_conversation_or_404, require_enabled, require_enterprise_feature router = APIRouter() @@ -37,6 +38,31 @@ def _connection_name(provenance: object) -> str | None: return value if isinstance(value, str) and value else None +def _clamp(offset: int, limit: int) -> tuple[int, int]: + """Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT.""" + return max(0, offset), min(max(1, limit), MAX_LIMIT) + + +async def _result_page(stored: GatewayStructuredQueryResult, *, offset: int, limit: int) -> dict: + try: + rows = await load_result_rows(stored) + except QueryResultUnavailable as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return { + "result_id": stored.id, + "execution_id": stored.execution_id, + "columns": stored.columns_json, + "rows": rows[offset : offset + limit], + "offset": offset, + "limit": limit, + "saved_row_count": stored.saved_row_count, + "query_row_count": stored.query_row_count, + "completeness": stored.result_completeness, + "truncation_reason": stored.truncation_reason, + "connection_name": _connection_name(stored.provenance_json), + } + + @router.get( "/conversations/{conversation_id}/results/{result_id}", dependencies=[RequireScope("read")], @@ -51,9 +77,7 @@ async def get_conversation_query_result( """Return one page of saved rows for a result the owner produced in this conversation.""" require_enabled() await owned_conversation_or_404(store, conversation_id) - # Clamp instead of rejecting: offset >= 0, 1 <= limit <= MAX_LIMIT. - offset = max(0, offset) - limit = min(max(1, limit), MAX_LIMIT) + offset, limit = _clamp(offset, limit) stored = ( await store.session.execute( select(GatewayStructuredQueryResult).where( @@ -66,20 +90,30 @@ async def get_conversation_query_result( ).scalar_one_or_none() if stored is None: raise HTTPException(status_code=404, detail="Query result not found") - try: - rows = await load_result_rows(stored) - except QueryResultUnavailable as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - return { - "result_id": stored.id, - "execution_id": stored.execution_id, - "columns": stored.columns_json, - "rows": rows[offset : offset + limit], - "offset": offset, - "limit": limit, - "saved_row_count": stored.saved_row_count, - "query_row_count": stored.query_row_count, - "completeness": stored.result_completeness, - "truncation_reason": stored.truncation_reason, - "connection_name": _connection_name(stored.provenance_json), - } + return await _result_page(stored, offset=offset, limit=limit) + + +@router.get( + "/shared/{token}/results/{result_id}", + dependencies=[RequireScope("read")], +) +async def get_shared_query_result( + token: str, + result_id: str, + store: StoreD, + offset: int = 0, + limit: int = DEFAULT_LIMIT, +): + """Same page shape for a shared chat, scoped to the grant's owner and conversation.""" + require_enabled() + require_enterprise_feature("organization_sharing") + offset, limit = _clamp(offset, limit) + stored = await chat_store.get_shared_query_result( + store.session, + org_id=store._require_org_id(), + token=token, + result_id=result_id, + ) + if stored is None: + raise HTTPException(status_code=404, detail="Query result not found") + return await _result_page(stored, offset=offset, limit=limit) diff --git a/signalpilot/gateway/gateway/models/standalone_chat.py b/signalpilot/gateway/gateway/models/standalone_chat.py index 67f9450d1..9552ccb89 100644 --- a/signalpilot/gateway/gateway/models/standalone_chat.py +++ b/signalpilot/gateway/gateway/models/standalone_chat.py @@ -263,42 +263,33 @@ class ChatShareGrantInfo(BaseModel): class SharedConversationInfo(BaseModel): + """Share-safe conversation header. No owner ids, no budgets, no spend.""" + title: str project_name: str | None = None + origin: str = "user" + model: str + effort: str = "medium" + commit_sha: str | None = None + branch: str created_at: float updated_at: float -class SharedMessageInfo(BaseModel): - id: str - role: Literal["user", "assistant"] - content: str - sequence: int - created_at: float +class SharedConversationDetail(BaseModel): + """Read-only snapshot of a shared chat: finished runs only. + Messages and events carry the same shapes the owner sees so the shared + page renders through the same components. Files are the share-safe + manifest in the same dict shape as the owner file routes. + """ -class SharedConversationDetail(BaseModel): conversation: SharedConversationInfo - messages: list[SharedMessageInfo] + messages: list[StandaloneMessageInfo] + run_events: list[ChatRunEventInfo] = Field(default_factory=list) + files: list[dict[str, Any]] = Field(default_factory=list) shared_at: datetime class ForkedConversationInfo(BaseModel): id: str - - -class ForkPreviewInfo(BaseModel): - project_id: str - project_name: str - commit_sha: str - per_query_budget_usd: float - chat_budget_usd: float - warehouse_cost_notice: str - - -class ForkConfirmation(BaseModel): - model_config = ConfigDict(extra="forbid") - - confirmed: Literal[True] - per_query_budget_usd: float = Field(ge=0) - chat_budget_usd: float = Field(ge=0) diff --git a/signalpilot/gateway/gateway/store/standalone_chat/__init__.py b/signalpilot/gateway/gateway/store/standalone_chat/__init__.py index 30f98a3b2..0c2bd0182 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/__init__.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/__init__.py @@ -44,7 +44,6 @@ ChatRunInfo, SharedConversationDetail, SharedConversationInfo, - SharedMessageInfo, StandaloneConversationDetail, StandaloneConversationInfo, StandaloneMessageInfo, @@ -87,6 +86,10 @@ mark_conversation_file_deleted, upsert_conversation_file, ) +from gateway.store.standalone_chat.forking import ( + fork_shared_conversation, + remap_ids, +) from gateway.store.standalone_chat.helpers import ( _append_status_message, _event_info, @@ -108,6 +111,7 @@ list_conversation_notebooks, upsert_conversation_notebook, ) +from gateway.store.standalone_chat.preferences import default_chat_budgets from gateway.store.standalone_chat.runs import ( append_event, create_run, @@ -125,11 +129,11 @@ _share_token_hash, _shared_grant_row, create_share_grant, - fork_shared_conversation, - get_fork_preview, get_shared_conversation, get_shared_file, + get_shared_query_result, list_shared_files, + list_shared_sql_trace, revoke_share_grants, ) from gateway.store.standalone_chat.worker import ( @@ -171,7 +175,6 @@ "RunStatus", "SharedConversationDetail", "SharedConversationInfo", - "SharedMessageInfo", "StandaloneConversationDetail", "StandaloneConversationInfo", "StandaloneMessageInfo", @@ -203,6 +206,7 @@ "create_run", "create_share_grant", "datetime", + "default_chat_budgets", "delete", "derive_file_kind", "enterprise_chat_feature_flags", @@ -214,11 +218,11 @@ "get_conversation_detail", "get_conversation_file", "get_conversation_file_by_path", - "get_fork_preview", "get_owned_conversation", "get_shared_conversation", "get_shared_conversation_file", "get_shared_file", + "get_shared_query_result", "get_worker_run", "hashlib", "list_conversation_files", @@ -226,6 +230,7 @@ "list_conversations", "list_shared_conversation_files", "list_shared_files", + "list_shared_sql_trace", "list_run_events", "mark_conversation_file_deleted", "mark_steering_message_picked_up", @@ -238,6 +243,7 @@ "rename_conversation", "update_conversation_effort", "update_conversation_model", + "remap_ids", "renew_lease", "request_cancellation", "retry_run", diff --git a/signalpilot/gateway/gateway/store/standalone_chat/files.py b/signalpilot/gateway/gateway/store/standalone_chat/files.py index 1559359c3..d54e3398c 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/files.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/files.py @@ -48,6 +48,24 @@ } +def file_manifest_entry(row: GatewayChatFile) -> dict: + """The wire shape of one manifest row. Owner and shared routes share it.""" + return { + "id": row.id, + "path": row.path, + "filename": row.filename, + "kind": row.kind, + "mime_type": row.mime_type, + "byte_size": row.byte_size, + "content_hash": row.content_hash, + "origin_run_id": row.origin_run_id, + "origin": row.origin, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + + def derive_file_kind(filename: str, mime_type: str | None) -> str: """Classify a file for the artifacts panel from its extension and MIME type.""" extension = PurePosixPath(filename.lower()).suffix @@ -263,8 +281,8 @@ async def conversation_file_usage( def _shared_file_query(*, org_id: str, owner_user_id: str, conversation_id: str): """Select active files whose origin run is terminal or absent. - A file written by a running run is not share-safe yet. A forked copy has - no origin run and is always safe. + A file written by a running run is not share-safe yet. A forked copy + points at a copied, already-terminal run and is always safe. """ return ( select(GatewayChatFile) diff --git a/signalpilot/gateway/gateway/store/standalone_chat/forking.py b/signalpilot/gateway/gateway/store/standalone_chat/forking.py new file mode 100644 index 000000000..ca8614848 --- /dev/null +++ b/signalpilot/gateway/gateway/store/standalone_chat/forking.py @@ -0,0 +1,445 @@ +"""Fork a shared chat: copy the whole conversation to the caller. + +The fork owner gets a faithful private copy: runs, messages, run events, +files, structured query results, and governed query executions. Every row +gets a new id and one id map rewrites every reference inside the copied +JSON blobs, so tool cards and inline artifact cards resolve in the fork. +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.db.models import ( + GatewayChatConversation, + GatewayChatFile, + GatewayChatMessage, + GatewayChatRun, + GatewayChatRunEvent, + GatewayGovernedQueryExecution, + GatewayStructuredQueryResult, +) +from gateway.standalone_chat.domain import NONTERMINAL_RUN_STATUSES +from gateway.standalone_chat.object_storage import ( + conversation_file_key, + conversation_prefix, + runtime_object_key, +) +from gateway.store.standalone_chat.preferences import default_chat_budgets +from gateway.store.standalone_chat.sharing import _shared_grant_row + + +def _object_storage(): + """Resolve the storage factory through the package namespace at call time. + + Tests patch chat_object_storage on the package module. Read the name late + so the patch takes effect.""" + from gateway.store import standalone_chat as chat_store + + return chat_store.chat_object_storage() + + +def remap_ids(value: Any, id_map: dict[str, str]) -> Any: + """Return a deep copy of ``value`` with every string equal to an old id replaced. + + Only whole string values are rewritten. Keys, numbers, and substrings + stay untouched. Pure: the input is never mutated. + """ + if isinstance(value, str): + return id_map.get(value, value) + if isinstance(value, dict): + return {key: remap_ids(item, id_map) for key, item in value.items()} + if isinstance(value, list): + return [remap_ids(item, id_map) for item in value] + if isinstance(value, tuple): + return tuple(remap_ids(item, id_map) for item in value) + return value + + +def _result_object_category(object_key: str | None) -> str: + """Category segment of a stored result key, e.g. "results" or "derived-results".""" + parts = (object_key or "").split("/") + if len(parts) >= 2 and "runs" in parts: + try: + return parts[parts.index("runs") + 2] + except IndexError: + pass + return "results" + + +async def _rows(db: AsyncSession, query) -> list: + return list((await db.execute(query)).scalars()) + + +async def _load_source(db: AsyncSession, *, org_id: str, source: GatewayChatConversation) -> dict[str, list]: + return { + "runs": await _rows( + db, + select(GatewayChatRun) + .where(GatewayChatRun.conversation_id == source.id) + .order_by(GatewayChatRun.created_at), + ), + "messages": await _rows( + db, + select(GatewayChatMessage) + .where( + GatewayChatMessage.conversation_id == source.id, + GatewayChatMessage.org_id == org_id, + GatewayChatMessage.user_id == source.user_id, + GatewayChatMessage.role.in_(("user", "assistant")), + ) + .order_by(GatewayChatMessage.sequence), + ), + "events": await _rows( + db, + select(GatewayChatRunEvent) + .where( + GatewayChatRunEvent.conversation_id == source.id, + GatewayChatRunEvent.org_id == org_id, + ) + .order_by(GatewayChatRunEvent.created_at, GatewayChatRunEvent.sequence), + ), + "files": await _rows( + db, + select(GatewayChatFile) + .where( + GatewayChatFile.conversation_id == source.id, + GatewayChatFile.org_id == org_id, + GatewayChatFile.user_id == source.user_id, + GatewayChatFile.status == "active", + ) + .order_by(GatewayChatFile.created_at), + ), + "results": await _rows( + db, + select(GatewayStructuredQueryResult) + .where( + GatewayStructuredQueryResult.conversation_id == source.id, + GatewayStructuredQueryResult.org_id == org_id, + ) + .order_by(GatewayStructuredQueryResult.created_at), + ), + "executions": await _rows( + db, + select(GatewayGovernedQueryExecution) + .where( + GatewayGovernedQueryExecution.conversation_id == source.id, + GatewayGovernedQueryExecution.org_id == org_id, + ) + .order_by(GatewayGovernedQueryExecution.created_at), + ), + } + + +def _copy_run( + row: GatewayChatRun, *, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatRun: + return GatewayChatRun( + id=id_map[row.id], + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + project_id=row.project_id, + user_message_id=id_map.get(row.user_message_id, row.user_message_id), + status=row.status, + runtime_env=None, + retry_of_run_id=id_map.get(row.retry_of_run_id or "", row.retry_of_run_id), + execution_session_id=None, + runtime_archive_id=None, + execution_attempt=0, + lease_owner=None, + lease_expires_at=None, + cancellation_requested_at=row.cancellation_requested_at, + public_error_code=row.public_error_code, + public_error_message=row.public_error_message, + cost_usd=row.cost_usd, + usage_json=row.usage_json, + created_at=row.created_at, + started_at=row.started_at, + terminal_at=row.terminal_at, + last_event_sequence=row.last_event_sequence, + ) + + +def _copy_message( + row: GatewayChatMessage, + *, + org_id: str, + user_id: str, + project_id: str | None, + fork_id: str, + id_map: dict[str, str], +) -> GatewayChatMessage: + metadata = dict(remap_ids(row.metadata_json or {}, id_map)) + metadata.pop("internal", None) + metadata.pop("runtime_archive_available", None) + metadata["forked"] = True + return GatewayChatMessage( + id=id_map[row.id], + org_id=org_id, + user_id=user_id, + project_id=project_id, + conversation_id=fork_id, + role=row.role, + content=row.content, + metadata_json=metadata, + idempotency_key=None, + sequence=row.sequence, + created_at=row.created_at, + ) + + +def _copy_event( + row: GatewayChatRunEvent, *, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatRunEvent: + return GatewayChatRunEvent( + id=str(uuid.uuid4()), + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id, row.run_id), + sequence=row.sequence, + event_type=row.event_type, + payload_json=remap_ids(row.payload_json or {}, id_map), + created_at=row.created_at, + ) + + +def _copy_execution( + row: GatewayGovernedQueryExecution, *, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayGovernedQueryExecution: + return GatewayGovernedQueryExecution( + id=id_map[row.id], + org_id=row.org_id, + user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id), + project_id=row.project_id, + commit_sha=row.commit_sha, + connection_name=row.connection_name, + plan_id=row.plan_id, + query_path=row.query_path, + sql_hash=row.sql_hash, + status=row.status, + timeout_seconds=row.timeout_seconds, + warehouse_query_id=row.warehouse_query_id, + estimated_cost_usd=row.estimated_cost_usd, + actual_cost_usd=row.actual_cost_usd, + actual_scan_bytes=row.actual_scan_bytes, + actual_output_bytes=row.actual_output_bytes, + execution_ms=row.execution_ms, + row_count=row.row_count, + completeness=row.completeness, + truncation_reason=row.truncation_reason, + public_error_code=row.public_error_code, + created_at=row.created_at, + started_at=row.started_at, + terminal_at=row.terminal_at, + ) + + +async def _copy_result( + row: GatewayStructuredQueryResult, + *, + storage, + org_id: str, + user_id: str, + fork_id: str, + id_map: dict[str, str], +) -> GatewayStructuredQueryResult: + new_id = id_map[row.id] + object_key = row.object_key + byte_size = row.byte_size + content_hash = row.content_hash + if row.storage_kind == "object" and row.object_key: + object_key = runtime_object_key( + org_id=org_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id) or "fork", + category=_result_object_category(row.object_key), + object_id=new_id, + filename="rows.json", + ) + copied = await storage.copy(source_key=row.object_key, destination_key=object_key) + byte_size = copied.byte_size or row.byte_size + content_hash = copied.content_hash or row.content_hash + return GatewayStructuredQueryResult( + id=new_id, + execution_id=id_map.get(row.execution_id or "", row.execution_id), + org_id=org_id, + owner_user_id=user_id, + conversation_id=fork_id, + run_id=id_map.get(row.run_id or "", row.run_id), + columns_json=row.columns_json, + rows_json=row.rows_json, + preview_rows_json=row.preview_rows_json, + storage_kind=row.storage_kind, + object_key=object_key, + byte_size=byte_size, + content_hash=content_hash, + source_result_ids_json=remap_ids(row.source_result_ids_json or [], id_map), + code_hash=row.code_hash, + result_origin=row.result_origin, + query_row_count=row.query_row_count, + saved_row_count=row.saved_row_count, + source_completeness=row.source_completeness, + result_completeness=row.result_completeness, + display_completeness=row.display_completeness, + truncation_reason=row.truncation_reason, + provenance_json=remap_ids(row.provenance_json or {}, id_map), + freshness_at=row.freshness_at, + created_at=row.created_at, + ) + + +async def _copy_file( + row: GatewayChatFile, *, storage, org_id: str, user_id: str, fork_id: str, id_map: dict[str, str] +) -> GatewayChatFile: + copied_file_id = id_map[row.id] + object_key = conversation_file_key( + org_id=org_id, + conversation_id=fork_id, + file_id=copied_file_id, + filename=row.filename, + ) + copied = await storage.copy(source_key=row.object_key, destination_key=object_key) + return GatewayChatFile( + id=copied_file_id, + org_id=org_id, + user_id=user_id, + conversation_id=fork_id, + path=row.path, + filename=row.filename, + kind=row.kind, + mime_type=row.mime_type, + byte_size=copied.byte_size or row.byte_size, + content_hash=copied.content_hash or row.content_hash, + object_key=object_key, + origin_run_id=id_map.get(row.origin_run_id or "", row.origin_run_id), + origin="fork", + status="active", + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +async def fork_shared_conversation( + db: AsyncSession, + *, + org_id: str, + user_id: str, + token: str, +) -> GatewayChatConversation | None: + """Copy the whole shared chat into a new private conversation for the caller. + + Budgets come from the caller's saved defaults. Raises RuntimeError while + the source has a run in flight. Returns None when the grant is not active + in the caller's organization. + """ + shared = await _shared_grant_row(db, org_id=org_id, token=token, lock=True) + if shared is None: + return None + _, source = shared + active_run = ( + await db.execute( + select(GatewayChatRun.id).where( + GatewayChatRun.conversation_id == source.id, + GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), + ) + ) + ).scalar_one_or_none() + if active_run is not None: + raise RuntimeError("Wait for the current answer to finish before forking this chat") + + per_query_budget_usd, chat_budget_usd = await default_chat_budgets(db, org_id=org_id, user_id=user_id) + rows = await _load_source(db, org_id=org_id, source=source) + now = time.time() + fork = GatewayChatConversation( + id=str(uuid.uuid4()), + org_id=org_id, + user_id=user_id, + project_id=source.project_id, + surface="standalone", + origin=source.origin, + branch=source.branch, + commit_sha=source.commit_sha, + per_query_budget_usd=per_query_budget_usd, + chat_budget_usd=chat_budget_usd, + estimated_spend_usd=0.0, + actual_spend_usd=0.0, + reserved_spend_usd=0.0, + model=source.model, + effort=source.effort, + forked_from_conversation_id=source.id, + status="active", + title=(source.title or "New chat")[:200], + internal_summary=None, + agent_session_id=None, + notebook_session_id=None, + notebook_kernel_session_id=None, + notebook_path=None, + message_count=len(rows["messages"]), + total_tokens=0, + total_cost_usd=0.0, + created_at=now, + updated_at=now, + ) + + # One id map across every copied table. Payloads reference results, + # executions, runs, messages, and files by id; the walker rewrites all. + id_map: dict[str, str] = {source.id: fork.id} + for name in ("runs", "messages", "results", "executions", "files"): + for row in rows[name]: + id_map[row.id] = str(uuid.uuid4()) + + db.add(fork) + for run in rows["runs"]: + db.add(_copy_run(run, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map)) + for message in rows["messages"]: + db.add( + _copy_message( + message, + org_id=org_id, + user_id=user_id, + project_id=source.project_id, + fork_id=fork.id, + id_map=id_map, + ) + ) + for event in rows["events"]: + db.add(_copy_event(event, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map)) + for execution in rows["executions"]: + db.add(_copy_execution(execution, user_id=user_id, fork_id=fork.id, id_map=id_map)) + + storage = _object_storage() if rows["files"] or rows["results"] else None + copied_objects = False + try: + for result in rows["results"]: + if result.storage_kind == "object" and result.object_key: + copied_objects = True + db.add( + await _copy_result( + result, storage=storage, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map + ) + ) + for file in rows["files"]: + copied_objects = True + db.add( + await _copy_file(file, storage=storage, org_id=org_id, user_id=user_id, fork_id=fork.id, id_map=id_map) + ) + await db.commit() + except Exception: + await db.rollback() + if copied_objects and storage is not None: + try: + await storage.delete_prefix(conversation_prefix(org_id, fork.id)) + except Exception: + pass + raise + await db.refresh(fork) + return fork diff --git a/signalpilot/gateway/gateway/store/standalone_chat/preferences.py b/signalpilot/gateway/gateway/store/standalone_chat/preferences.py new file mode 100644 index 000000000..71be141bd --- /dev/null +++ b/signalpilot/gateway/gateway/store/standalone_chat/preferences.py @@ -0,0 +1,44 @@ +"""Per-user chat preference lookups.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from gateway.db.models import GatewayChatUserPreference + +DEFAULT_PER_QUERY_BUDGET_USD = 0.25 +DEFAULT_CHAT_BUDGET_USD = 1.0 + + +async def get_user_chat_preference( + db: AsyncSession, + *, + org_id: str, + user_id: str, +) -> GatewayChatUserPreference | None: + return ( + await db.execute( + select(GatewayChatUserPreference).where( + GatewayChatUserPreference.org_id == org_id, + GatewayChatUserPreference.user_id == user_id, + ) + ) + ).scalar_one_or_none() + + +async def default_chat_budgets( + db: AsyncSession, + *, + org_id: str, + user_id: str, +) -> tuple[float, float]: + """Return (per_query_budget_usd, chat_budget_usd) a new chat starts with. + + The user's saved defaults win; otherwise the product defaults apply. + Bootstrap and forking both read budgets through here. + """ + preference = await get_user_chat_preference(db, org_id=org_id, user_id=user_id) + if preference is None: + return DEFAULT_PER_QUERY_BUDGET_USD, DEFAULT_CHAT_BUDGET_USD + return preference.default_per_query_budget_usd, preference.default_chat_budget_usd diff --git a/signalpilot/gateway/gateway/store/standalone_chat/sharing.py b/signalpilot/gateway/gateway/store/standalone_chat/sharing.py index 0c05fd887..e951ab571 100644 --- a/signalpilot/gateway/gateway/store/standalone_chat/sharing.py +++ b/signalpilot/gateway/gateway/store/standalone_chat/sharing.py @@ -1,12 +1,15 @@ -"""Share grants, shared read views, and conversation forking.""" +"""Share grants and the read-only shared views of one standalone chat. + +Forking lives in ``forking.py``. This module owns the grant lifecycle and +every read that a share token unlocks: the transcript snapshot, files, the +SQL trace, and query result pages. +""" from __future__ import annotations import hashlib import secrets -import time import uuid -from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -16,39 +19,32 @@ GatewayChatFile, GatewayChatMessage, GatewayChatRun, + GatewayChatRunEvent, GatewayChatShareGrant, - GatewayChatUserPreference, + GatewayStructuredQueryResult, GatewayWorkspaceProject, ) from gateway.models.standalone_chat import ( SharedConversationDetail, SharedConversationInfo, - SharedMessageInfo, ) +from gateway.standalone_chat import config as chat_config from gateway.standalone_chat.domain import NONTERMINAL_RUN_STATUSES -from gateway.standalone_chat.object_storage import ( - conversation_file_key, - conversation_prefix, -) +from gateway.standalone_chat.sql_trace import list_sql_trace from gateway.store.standalone_chat.files import ( + file_manifest_entry, get_shared_conversation_file, list_shared_conversation_files, ) from gateway.store.standalone_chat.helpers import ( + _event_info, + _message_info, _now, _owned_conversation_row, + _token_usage, ) -def _object_storage(): - """Resolve the storage factory through the package namespace at call time. - - Tests patch chat_object_storage on the package module. Read the name late - so the patch takes effect.""" - from gateway.store import standalone_chat as chat_store - - return chat_store.chat_object_storage() - def _share_token_hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -159,12 +155,46 @@ async def _shared_grant_row( return (await db.execute(query)).one_or_none() +async def _nonterminal_runs( + db: AsyncSession, + *, + conversation_id: str, +) -> list[GatewayChatRun]: + """Runs still in flight. Their messages, events, and queries stay private.""" + return list( + ( + await db.execute( + select(GatewayChatRun).where( + GatewayChatRun.conversation_id == conversation_id, + GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), + ) + ) + ).scalars() + ) + + +def _belongs_to_run(message: GatewayChatMessage, run_ids: set[str], user_message_ids: set[str]) -> bool: + metadata = message.metadata_json or {} + if message.id in user_message_ids: + return True + for key in ("run_id", "clarification_for_run_id", "steering_for_run_id"): + value = metadata.get(key) + if isinstance(value, str) and value in run_ids: + return True + return False + + async def get_shared_conversation( db: AsyncSession, *, org_id: str, token: str, ) -> SharedConversationDetail | None: + """Full read-only snapshot of the finished part of a shared chat. + + Everything that belongs to an in-flight run is left out: its user + message, its events, and any file it is still writing. + """ shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None @@ -177,6 +207,22 @@ async def get_shared_conversation( ) ) ).scalar_one_or_none() + runs = list( + ( + await db.execute( + select(GatewayChatRun) + .where(GatewayChatRun.conversation_id == conversation.id) + .order_by(GatewayChatRun.created_at) + ) + ).scalars() + ) + hidden_run_ids = {run.id for run in runs if run.status in NONTERMINAL_RUN_STATUSES} + hidden_message_ids = {run.user_message_id for run in runs if run.id in hidden_run_ids} + run_usage = { + run.id: usage + for run in runs + if run.id not in hidden_run_ids and (usage := _token_usage(run.usage_json)) is not None + } messages = list( ( await db.execute( @@ -191,23 +237,43 @@ async def get_shared_conversation( ) ).scalars() ) + events = list( + ( + await db.execute( + select(GatewayChatRunEvent) + .where( + GatewayChatRunEvent.conversation_id == conversation.id, + GatewayChatRunEvent.org_id == org_id, + ) + .order_by(GatewayChatRunEvent.created_at, GatewayChatRunEvent.sequence) + ) + ).scalars() + ) + files = await list_shared_conversation_files( + db, + org_id=org_id, + owner_user_id=conversation.user_id, + conversation_id=conversation.id, + ) return SharedConversationDetail( conversation=SharedConversationInfo( title=conversation.title or "New chat", project_name=(project.display_name or project.name) if project else None, + origin=conversation.origin, + model=conversation.model or chat_config.default_chat_model(), + effort=conversation.effort or chat_config.default_chat_effort(), + commit_sha=conversation.commit_sha, + branch=conversation.branch or "main", created_at=conversation.created_at, updated_at=conversation.updated_at, ), messages=[ - SharedMessageInfo( - id=row.id, - role=row.role, - content=row.content, - sequence=row.sequence, - created_at=row.created_at, - ) + _message_info(row, run_usage=run_usage) for row in messages + if not _belongs_to_run(row, hidden_run_ids, hidden_message_ids) ], + run_events=[_event_info(row) for row in events if row.run_id not in hidden_run_ids], + files=[file_manifest_entry(row) for row in files], shared_at=grant.created_at, ) @@ -251,177 +317,53 @@ async def get_shared_file( ) -async def fork_shared_conversation( +async def list_shared_sql_trace( db: AsyncSession, *, org_id: str, - user_id: str, token: str, - per_query_budget_usd: float, - chat_budget_usd: float, -) -> GatewayChatConversation | None: - """Copy the share-safe snapshot into a new private conversation.""" - shared = await _shared_grant_row(db, org_id=org_id, token=token, lock=True) +) -> list[dict] | None: + """The owner's SQL trace for finished runs. None when the grant is not active.""" + shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None - _, source = shared - active_run = ( - await db.execute( - select(GatewayChatRun.id).where( - GatewayChatRun.conversation_id == source.id, - GatewayChatRun.status.in_(NONTERMINAL_RUN_STATUSES), - ) - ) - ).scalar_one_or_none() - if active_run is not None: - raise RuntimeError("Wait for the current answer to finish before forking this chat") - - messages = list( - ( - await db.execute( - select(GatewayChatMessage) - .where( - GatewayChatMessage.conversation_id == source.id, - GatewayChatMessage.org_id == org_id, - GatewayChatMessage.user_id == source.user_id, - GatewayChatMessage.role.in_(("user", "assistant")), - ) - .order_by(GatewayChatMessage.sequence) - ) - ).scalars() - ) - now = time.time() - fork = GatewayChatConversation( - id=str(uuid.uuid4()), + _, conversation = shared + hidden_run_ids = {run.id for run in await _nonterminal_runs(db, conversation_id=conversation.id)} + executions = await list_sql_trace( + db, org_id=org_id, - user_id=user_id, - project_id=source.project_id, - surface="standalone", - origin=source.origin, - branch=source.branch, - commit_sha=source.commit_sha, - per_query_budget_usd=per_query_budget_usd, - chat_budget_usd=chat_budget_usd, - model=source.model, - effort=source.effort, - forked_from_conversation_id=source.id, - status="active", - title=(source.title or "New chat")[:200], - internal_summary=None, - message_count=len(messages), - total_tokens=0, - total_cost_usd=0.0, - created_at=now, - updated_at=now, - ) - db.add(fork) - - message_ids = {row.id: str(uuid.uuid4()) for row in messages} - for sequence, row in enumerate(messages, start=1): - db.add( - GatewayChatMessage( - id=message_ids[row.id], - org_id=org_id, - user_id=user_id, - project_id=source.project_id, - conversation_id=fork.id, - role=row.role, - content=row.content, - metadata_json={"surface": "standalone", "forked": True}, - sequence=sequence, - created_at=row.created_at, - ) - ) - - files = list( - ( - await db.execute( - select(GatewayChatFile) - .where( - GatewayChatFile.conversation_id == source.id, - GatewayChatFile.org_id == org_id, - GatewayChatFile.user_id == source.user_id, - GatewayChatFile.status == "active", - ) - .order_by(GatewayChatFile.created_at) - ) - ).scalars() + user_id=conversation.user_id, + conversation_id=conversation.id, ) - - storage = _object_storage() - try: - # Copy each conversation file under the fork prefix. The hash stays - # the same. Only the key and the owner change. - for row in files: - copied_file_id = str(uuid.uuid4()) - object_key = conversation_file_key( - org_id=org_id, - conversation_id=fork.id, - file_id=copied_file_id, - filename=row.filename, - ) - copied = await storage.copy(source_key=row.object_key, destination_key=object_key) - db.add( - GatewayChatFile( - id=copied_file_id, - org_id=org_id, - user_id=user_id, - conversation_id=fork.id, - path=row.path, - filename=row.filename, - kind=row.kind, - mime_type=row.mime_type, - byte_size=copied.byte_size or row.byte_size, - content_hash=copied.content_hash or row.content_hash, - object_key=object_key, - origin_run_id=None, - origin="fork", - status="active", - ) - ) - await db.commit() - except Exception: - await db.rollback() - if files: - try: - await storage.delete_prefix(conversation_prefix(org_id, fork.id)) - except Exception: - pass - raise - await db.refresh(fork) - return fork + return [entry for entry in executions if entry["run_id"] not in hidden_run_ids] -async def get_fork_preview( +async def get_shared_query_result( db: AsyncSession, *, org_id: str, - user_id: str, token: str, -) -> dict[str, Any] | None: + result_id: str, +) -> GatewayStructuredQueryResult | None: + """One stored result of the shared chat, scoped to the owner and conversation.""" shared = await _shared_grant_row(db, org_id=org_id, token=token) if shared is None: return None - _, source = shared - project = await db.get(GatewayWorkspaceProject, source.project_id) - if project is None or project.org_id != org_id or not source.commit_sha: - return None - preference = ( + _, conversation = shared + stored = ( await db.execute( - select(GatewayChatUserPreference).where( - GatewayChatUserPreference.org_id == org_id, - GatewayChatUserPreference.user_id == user_id, + select(GatewayStructuredQueryResult).where( + GatewayStructuredQueryResult.id == result_id, + GatewayStructuredQueryResult.org_id == org_id, + GatewayStructuredQueryResult.conversation_id == conversation.id, + GatewayStructuredQueryResult.owner_user_id == conversation.user_id, ) ) ).scalar_one_or_none() - return { - "project_id": project.id, - "project_name": project.display_name or project.name, - "commit_sha": source.commit_sha, - "per_query_budget_usd": preference.default_per_query_budget_usd if preference else 0.25, - "chat_budget_usd": preference.default_chat_budget_usd if preference else 1.0, - "warehouse_cost_notice": ( - "New questions run against live warehouse data and may incur warehouse cost. " - "The dbt project remains frozen at the displayed commit." - ), - } + if stored is None: + return None + if stored.run_id and any( + run.id == stored.run_id for run in await _nonterminal_runs(db, conversation_id=conversation.id) + ): + return None + return stored diff --git a/signalpilot/gateway/tests/test_chat_files.py b/signalpilot/gateway/tests/test_chat_files.py index 066c76086..eb0150b45 100644 --- a/signalpilot/gateway/tests/test_chat_files.py +++ b/signalpilot/gateway/tests/test_chat_files.py @@ -446,6 +446,22 @@ async def test_sql_trace_route_wraps_the_projection(db_session, enabled): assert result == {"executions": []} +@pytest.mark.asyncio +async def test_shared_sql_trace_route_follows_the_grant(db_session, enabled, monkeypatch): + conversation = await _conversation(db_session) + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + with pytest.raises(HTTPException) as exc: + await files_routes.get_shared_conversation_sql_trace("x" * 40, _store(db_session, "user-b")) + assert exc.value.status_code == 404 + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + result = await files_routes.get_shared_conversation_sql_trace(token, _store(db_session, "user-b")) + assert result == {"executions": []} + + # ── Adversarial: sanitization bypass and header safety ─────────────────────── diff --git a/signalpilot/gateway/tests/test_chat_query_results.py b/signalpilot/gateway/tests/test_chat_query_results.py index 491d42b5e..c0a4ef833 100644 --- a/signalpilot/gateway/tests/test_chat_query_results.py +++ b/signalpilot/gateway/tests/test_chat_query_results.py @@ -336,3 +336,59 @@ async def test_structured_results_flag_off_does_not_block(db_session, enabled, m await _result(db_session, conversation, run, _rows(3)) page = await _get(db_session, conversation.id, "res-1") assert len(page["rows"]) == 3 + + +# ── Route: shared (read-only) access ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_shared_route_pages_the_owners_result_for_a_teammate(db_session, enabled, monkeypatch): + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + conversation, run = await _conversation(db_session) + run.status = "completed" + await db_session.commit() + await _result(db_session, conversation, run, _rows(7)) + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + page = await results_routes.get_shared_query_result( + token, "res-1", _store(db_session, user_id="user-b"), offset=2, limit=3 + ) + assert page["result_id"] == "res-1" + assert [row["id"] for row in page["rows"]] == [2, 3, 4] + assert page["saved_row_count"] == 7 + assert page["connection_name"] == "production" + + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + token, "missing", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + "x" * 40, "res-1", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 + foreign = SimpleNamespace(session=db_session, user_id="user-b", _require_org_id=lambda: "org-b") + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result(token, "res-1", foreign) + assert exc.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_shared_route_hides_results_of_a_running_run(db_session, enabled, monkeypatch): + monkeypatch.setenv("SP_FEATURE_CHAT_ORG_SHARING", "1") + conversation, run = await _conversation(db_session) + await _result(db_session, conversation, run, _rows(2)) + shared = await chat_store.create_share_grant( + db_session, org_id=ORG, user_id=USER, conversation_id=conversation.id + ) + assert shared is not None + _, token = shared + with pytest.raises(HTTPException) as exc: + await results_routes.get_shared_query_result( + token, "res-1", _store(db_session, user_id="user-b") + ) + assert exc.value.status_code == 404 diff --git a/signalpilot/gateway/tests/test_standalone_chat.py b/signalpilot/gateway/tests/test_standalone_chat.py index 91125379b..b959d9d98 100644 --- a/signalpilot/gateway/tests/test_standalone_chat.py +++ b/signalpilot/gateway/tests/test_standalone_chat.py @@ -26,6 +26,7 @@ GatewayConnection, GatewayCredential, GatewayDbtManifest, + GatewayGovernedQueryExecution, GatewayStructuredQueryResult, GatewayWorkspaceProject, ) @@ -193,6 +194,57 @@ async def _completed_shared_conversation( caveats=["Partial current day"], ) ) + db.add( + GatewayGovernedQueryExecution( + id="execution-a", + org_id="org-a", + user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + project_id="project-a", + connection_name="production", + query_path="sdk", + sql_hash="h" * 64, + status="completed", + timeout_seconds=30, + estimated_cost_usd=0.01, + row_count=1, + ) + ) + db.add( + GatewayStructuredQueryResult( + id="result-a", + execution_id="execution-a", + org_id="org-a", + owner_user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + columns_json=[{"name": "revenue"}], + rows_json=[[112]], + preview_rows_json=[[112]], + saved_row_count=1, + source_completeness="complete", + result_completeness="complete", + display_completeness="complete", + provenance_json={"connection_name": "production"}, + ) + ) + db.add( + GatewayChatRunEvent( + id="event-a", + org_id="org-a", + user_id="user-a", + conversation_id=conversation_id, + run_id=run.id, + sequence=1, + event_type="tool_completed", + payload_json={ + "tool": "run_query", + "result": {"result_id": "result-a", "execution_id": "execution-a"}, + }, + ) + ) + run.last_event_sequence = 1 conversation.title = "Revenue trend" conversation.internal_summary = "Hidden execution summary" conversation.message_count = 2 @@ -295,7 +347,7 @@ async def test_same_org_peer_cannot_discover_private_conversation(db_session): @pytest.mark.asyncio async def test_share_grant_is_hashed_same_org_and_trace_free(db_session): - conversation_id, _, _ = await _completed_shared_conversation(db_session) + conversation_id, run, _ = await _completed_shared_conversation(db_session) result = await chat_store.create_share_grant( db_session, org_id="org-a", @@ -315,7 +367,15 @@ async def test_share_grant_is_hashed_same_org_and_trace_free(db_session): assert shared is not None assert shared.conversation.title == "Revenue trend" assert [message.role for message in shared.messages] == ["user", "assistant"] - assert not hasattr(shared.messages[1], "metadata") + assert "internal" not in shared.messages[1].metadata + assert shared.messages[1].metadata["run_id"] == run.id + assert [event.type for event in shared.run_events] == ["tool_completed"] + assert shared.run_events[0].run_id == run.id + assert shared.files == [] + assert shared.conversation.branch == "main" + assert shared.conversation.commit_sha == "a" * 40 + assert not hasattr(shared.conversation, "per_query_budget_usd") + assert not hasattr(shared.conversation, "id") assert ( await chat_store.get_shared_conversation( db_session, @@ -402,11 +462,19 @@ async def test_rotating_revoking_and_archiving_share_returns_not_found(db_sessio @pytest.mark.asyncio -async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): - conversation_id, _, assistant_message_id = await _completed_shared_conversation(db_session) +async def test_same_org_viewer_can_fork_the_whole_chat(db_session): + conversation_id, run, assistant_message_id = await _completed_shared_conversation(db_session) source = await db_session.get(GatewayChatConversation, conversation_id) assert source is not None source.model = "claude-fable-5-1" + db_session.add( + GatewayChatUserPreference( + org_id="org-a", + user_id="user-b", + default_per_query_budget_usd=0.4, + default_chat_budget_usd=1.5, + ) + ) await db_session.commit() shared = await chat_store.create_share_grant( db_session, @@ -422,17 +490,18 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.5, - chat_budget_usd=2.0, ) assert fork is not None assert fork.id != conversation_id assert fork.user_id == "user-b" assert fork.internal_summary is None + assert fork.agent_session_id is None assert fork.commit_sha == "a" * 40 assert fork.forked_from_conversation_id == conversation_id - assert fork.per_query_budget_usd == 0.5 - assert fork.chat_budget_usd == 2.0 + # Budgets come from the caller's saved defaults, not the source chat. + assert fork.per_query_budget_usd == 0.4 + assert fork.chat_budget_usd == 1.5 + assert fork.actual_spend_usd == 0.0 assert fork.model == "claude-fable-5-1" detail = await chat_store.get_conversation_detail( @@ -447,7 +516,41 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): "Revenue increased by 12%.", ] assert detail.messages[1].id != assistant_message_id - assert detail.current_run is None + assert detail.messages[1].metadata["forked"] is True + assert "internal" not in detail.messages[1].metadata + new_run_id = detail.messages[1].metadata["run_id"] + assert new_run_id != run.id + # The copied run is terminal, so the fork is idle and continuable. + assert detail.current_run is not None + assert detail.current_run.id == new_run_id + assert detail.current_run.status == "completed" + assert detail.current_run.runtime_archive_available is False + # The timeline copied with every id reference rewritten. + assert [event.type for event in detail.run_events] == ["tool_completed"] + assert detail.run_events[0].run_id == new_run_id + new_result_id = detail.run_events[0].payload["result"]["result_id"] + new_execution_id = detail.run_events[0].payload["result"]["execution_id"] + assert new_result_id != "result-a" + assert new_execution_id != "execution-a" + copied_result = await db_session.get(GatewayStructuredQueryResult, new_result_id) + assert copied_result is not None + assert copied_result.owner_user_id == "user-b" + assert copied_result.conversation_id == fork.id + assert copied_result.run_id == new_run_id + assert copied_result.execution_id == new_execution_id + assert copied_result.rows_json == [[112]] + copied_execution = await db_session.get(GatewayGovernedQueryExecution, new_execution_id) + assert copied_execution is not None + assert copied_execution.user_id == "user-b" + assert copied_execution.conversation_id == fork.id + assert copied_execution.run_id == new_run_id + copied_run = await db_session.get(GatewayChatRun, new_run_id) + assert copied_run is not None + assert copied_run.user_message_id == detail.messages[0].id + assert copied_run.lease_owner is None + assert copied_run.execution_session_id is None + # The source rows are untouched. + assert (await db_session.get(GatewayStructuredQueryResult, "result-a")).conversation_id == conversation_id reshared = await chat_store.create_share_grant( db_session, @@ -467,41 +570,45 @@ async def test_same_org_viewer_can_fork_share_safe_snapshot(db_session): "What changed in revenue?", "Revenue increased by 12%.", ] + assert [event.run_id for event in reshared_detail.run_events] == [new_run_id] @pytest.mark.asyncio -async def test_fork_preview_preserves_project_commit_and_recipient_budgets(db_session): +async def test_fork_budgets_fall_back_to_product_defaults(db_session): conversation_id, _, _ = await _completed_shared_conversation(db_session) shared = await chat_store.create_share_grant( - db_session, - org_id="org-a", - user_id="user-a", - conversation_id=conversation_id, + db_session, org_id="org-a", user_id="user-a", conversation_id=conversation_id ) assert shared is not None _, token = shared - db_session.add( - GatewayChatUserPreference( - org_id="org-a", - user_id="user-b", - default_per_query_budget_usd=0.4, - default_chat_budget_usd=1.5, - ) + fork = await chat_store.fork_shared_conversation( + db_session, org_id="org-a", user_id="user-b", token=token ) - await db_session.commit() + assert fork is not None + assert fork.per_query_budget_usd == 0.25 + assert fork.chat_budget_usd == 1.0 - preview = await chat_store.get_fork_preview( - db_session, - org_id="org-a", - user_id="user-b", - token=token, - ) - assert preview is not None - assert preview["project_id"] == "project-a" - assert preview["commit_sha"] == "a" * 40 - assert preview["per_query_budget_usd"] == 0.4 - assert preview["chat_budget_usd"] == 1.5 - assert "live warehouse data" in preview["warehouse_cost_notice"] + +def test_remap_ids_rewrites_whole_string_values_only(): + id_map = {"old-run": "new-run", "old-result": "new-result"} + payload = { + "run_id": "old-run", + "nested": {"ids": ["old-result", "keep", 3, None], "text": "old-run/extra"}, + "old-run": "value", + "tuple": ("old-run",), + } + remapped = chat_store.remap_ids(payload, id_map) + assert remapped == { + "run_id": "new-run", + "nested": {"ids": ["new-result", "keep", 3, None], "text": "old-run/extra"}, + "old-run": "value", + "tuple": ("new-run",), + } + # Pure: the input is untouched and the output is a fresh structure. + assert payload["run_id"] == "old-run" + assert remapped["nested"] is not payload["nested"] + assert chat_store.remap_ids("old-run", id_map) == "new-run" + assert chat_store.remap_ids(42, id_map) == 42 @pytest.mark.asyncio @@ -521,15 +628,15 @@ async def test_share_fork_rejects_active_run_and_cross_org(db_session): token=token, ) assert shared_detail is not None - assert [message.role for message in shared_detail.messages] == ["user"] + # The queued run and its user message stay private until it finishes. + assert shared_detail.messages == [] + assert shared_detail.run_events == [] with pytest.raises(RuntimeError, match="finish before forking"): await chat_store.fork_shared_conversation( db_session, org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.25, - chat_budget_usd=1.0, ) assert ( await chat_store.fork_shared_conversation( @@ -537,8 +644,6 @@ async def test_share_fork_rejects_active_run_and_cross_org(db_session): org_id="org-b", user_id="user-b", token=token, - per_query_budget_usd=0.25, - chat_budget_usd=1.0, ) is None ) @@ -1876,10 +1981,12 @@ async def _copy(*, source_key: str, destination_key: str) -> StoredObject: org_id="org-a", user_id="user-b", token=token, - per_query_budget_usd=0.5, - chat_budget_usd=2.0, ) assert fork is not None + detail = await chat_store.get_conversation_detail( + db_session, org_id="org-a", user_id="user-b", conversation_id=fork.id + ) + assert detail is not None and detail.current_run is not None copied_rows = await chat_store.list_conversation_files( db_session, org_id="org-a", user_id="user-b", conversation_id=fork.id @@ -1892,7 +1999,10 @@ async def _copy(*, source_key: str, destination_key: str) -> StoredObject: assert copied.kind == "image" assert copied.mime_type == "image/png" assert copied.origin == "fork" - assert copied.origin_run_id is None + # Inline artifact cards key on the run that wrote the file: the copy + # points at the copied run, never at the source run. + assert copied.origin_run_id == detail.current_run.id + assert copied.origin_run_id != run.id assert copied.object_key != active.object_key assert copied.object_key.startswith(f"{conversation_prefix('org-a', fork.id)}/files/") assert copies == [(active.object_key, copied.object_key)] diff --git a/signalpilot/web/components/chat/chat-artifact-card.tsx b/signalpilot/web/components/chat/chat-artifact-card.tsx index 3e9fd62aa..82254b845 100644 --- a/signalpilot/web/components/chat/chat-artifact-card.tsx +++ b/signalpilot/web/components/chat/chat-artifact-card.tsx @@ -9,7 +9,6 @@ import { AlertCircle, ArrowDownToLine, ChevronDown, ChevronRight } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; -import { downloadConversationFile } from "~/lib/api"; import { cardKindLabel, middleTruncate, @@ -20,6 +19,7 @@ import { import { formatByteSize } from "~/lib/chat-artifacts"; import { kindIcon } from "~/components/chat/artifacts-panel"; import { useChatUi } from "~/components/chat/chat-ui-context"; +import { downloadUiFile } from "~/components/chat/download-ui-file"; import { useFileObjectUrl } from "~/components/chat/use-file-object-url"; import { useToast } from "~/components/ui/toast"; @@ -56,14 +56,13 @@ type CardActionsProps = { function useDownload(conversationId: string | null) { const { toast } = useToast(); + // The shared page has no owner conversation id; its context injects a + // token-scoped `downloadFile` override instead. + const { downloadFile } = useChatUi(); return async (card: ArtifactCardModel) => { - if (!conversationId || !card.file) return; + if (!card.file) return; try { - await downloadConversationFile( - conversationId, - card.file.id, - card.filename, - ); + await downloadUiFile({ conversationId, downloadFile }, card.file); } catch { toast("This file is no longer available.", "error"); } @@ -83,7 +82,7 @@ function ImageThumb({ card, onOpen, }: { - conversationId: string; + conversationId: string | null; card: ArtifactCardModel; onOpen: (fileId: string) => void; }) { @@ -125,7 +124,7 @@ function ImagePreviewToggle({ card, onOpen, }: { - conversationId: string; + conversationId: string | null; card: ArtifactCardModel; onOpen: (fileId: string) => void; }) { @@ -209,6 +208,7 @@ const FullCard = memo(function FullCard({ onOpen, }: CardActionsProps) { const download = useDownload(conversationId); + const { getFileObjectUrl } = useChatUi(); const flash = useUpdateFlash(card.file?.content_hash); const now = useCardNow(); if (card.state === "unfinished") return ; @@ -273,7 +273,7 @@ const FullCard = memo(function FullCard({ - {card.kind === "image" && conversationId && ( + {card.kind === "image" && (conversationId || getFileObjectUrl) && ( { let cancelled = false; - getConversationFileText(conversationId, file.id) + (override + ? override(file.id) + : getConversationFileText(conversationId, file.id) + ) .then((text) => { if (!cancelled) setLoaded({ key, state: { phase: "text", text } }); }) @@ -65,7 +72,7 @@ function useFileText( return () => { cancelled = true; }; - }, [conversationId, file.id, key]); + }, [conversationId, file.id, key, override]); return loaded?.key === key ? loaded.state : { phase: "loading" }; } @@ -213,10 +220,14 @@ export function ChatFileViewer({ conversationId: string; file: ConversationFileInfo; }) { + // Downloads go through the context override when one is set (the shared + // page); owner pages use the conversation route. + const ui = useContext(ChatUiContext); const download = () => { - void downloadConversationFile(conversationId, file.id, file.filename).catch( - () => undefined, - ); + void downloadUiFile( + { conversationId, downloadFile: ui?.downloadFile }, + file, + ).catch(() => undefined); }; return (
, + 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/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 ( +
{ + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+ + Share this chat with your team + + +
+ +
+

+ Teammates who open this link see the whole chat, including the + work timeline and every file it produced, and can fork it into + their own chats. +

+ {pending ? ( +
+ + Generating your team link. Any previous link for this chat is + being revoked. +
+ ) : ( +
+ event.currentTarget.select()} + className="min-w-0 flex-1 rounded-[10px] border border-[var(--color-border)] bg-[var(--color-bg-input)] px-3 py-2 font-mono text-[11px] text-[var(--color-text)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-text)]" + /> + +
+ )} + +
+ +
+
+ Private to your organization +
+ 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 ( -
-

- Files -

-
    - {files.map((file) => ( -
  • - {kindIcon(file.kind, "h-3.5 w-3.5 flex-none text-[var(--color-text-dim)]")} - - - {formatByteSize(file.byte_size)} - - -
  • - ))} -
-
- ); -} - -/** - * 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 - {file?.filename - ) : ( - - )} -
- ); -} 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. +
+
+
+
+ ); +} - const confirmFork = async () => { - setForking(true); - try { - const fork = await forkSharedStandaloneConversation( - token, - perQueryBudget, - chatBudget, - ); - router.push(`/chats/${fork.id}`); - } catch (forkError) { - toast( - forkError instanceof Error - ? forkError.message - : "Could not fork this conversation", - "error", - ); - setForking(false); - } - }; +export function SharedStandaloneDataChat({ token }: { token: string }) { + const replayEnabled = useChatReplaySetting(); + const router = useRouter(); + const goToChats = useCallback(() => router.push("/chats"), [router]); + const { detail, error, isLoading, uiMessages, executions, forkingEnabled } = + useSharedChat(token); + const { forking, fork } = useForkSharedChat(token); + const [artifactsOpen, setArtifactsOpen] = useState(false); + const openArtifactsPanel = useCallback(() => setArtifactsOpen(true), []); + const { openFileRequest, openArtifact } = useOpenArtifact(openArtifactsPanel); + const ui = useSharedChatUi(token, detail, openArtifact); + const [replaying, setReplaying] = useState(false); + const viewportRef = useRef(null); if (isLoading) { return ( @@ -150,197 +99,74 @@ export function SharedStandaloneDataChat({ token }: { token: string }) {
); } - - if (error || !data) { - return ( -
-
- -

- Shared conversation unavailable -

-

- This link is invalid, revoked, archived, or belongs to another - organization. -

- -
-
- ); + if (error || !detail) { + return ; } - return ( -
-
-
-
- -
-
- - Team-shared chat -
-
-
- {data.conversation.title} -
- {data.conversation.origin === "improvement" && ( - - - Automated improvement run - - )} -
-
-
-
- {data.conversation.project_name && ( - - {data.conversation.project_name} - - )} - - Read only - - -
-
+ const files = detail.files; + const artifactsAvailable = hasArtifactsContent([], files, executions); - {forkPreview && ( -
-
-
- Confirm a private fork of {forkPreview.project_name} -
-
- Frozen commit {forkPreview.commit_sha} -
-

{forkPreview.warehouse_cost_notice}

-
- -
- )} - -
-
-
- This authenticated view includes the business conversation and - the files its finished runs produced. SQL, tool traces, and work - details are not shared. Fork it to continue privately in your - own chat. -
- - -
- {data.messages.map((message) => - message.role === "user" ? ( -
-
{message.content}
-
) : ( -
-
- -
-
- -
+
+ {uiMessages.map((message, index) => ( + + ))}
- ), - )} + )} +
+ undefined} + />
- - + {artifactsOpen && ( + void downloadFile(file.id, file.filename), - }} - /> - setLightboxFile(null)} + executions={executions} + openFileRequest={openFileRequest} + onClose={() => setArtifactsOpen(false)} /> -
-
-
+ )} +
- + ); } diff --git a/signalpilot/web/components/chat/shared/shared-chat-header.tsx b/signalpilot/web/components/chat/shared/shared-chat-header.tsx new file mode 100644 index 000000000..42790b662 --- /dev/null +++ b/signalpilot/web/components/chat/shared/shared-chat-header.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { ArrowLeft, GitFork, History, Loader2, LockKeyhole } from "lucide-react"; +import type { SharedConversation } from "~/lib/api"; +import { AutomatedBadge } from "~/components/chat/standalone-chat-helpers"; + +/** + * Header of the read-only shared chat page: title, project, a "shared with + * your team" label, "Replay chat" when there is something to replay, and + * the one primary action, "Fork to my chats". + */ +export function SharedChatHeader({ + conversation, + forkingEnabled, + forking, + onFork, + onBack, + onReplay, +}: { + conversation: SharedConversation; + forkingEnabled: boolean; + forking: boolean; + onFork: () => void; + onBack: () => void; + /** Offered only when the conversation has something to replay. */ + onReplay?: () => void; +}) { + return ( +
+
+ +
+
+ + Shared with your team · read only +
+
+
+ {conversation.title} +
+ {conversation.origin === "improvement" && } +
+
+
+
+ {conversation.project_name && ( + + {conversation.project_name} + + )} + {onReplay && ( + + )} + {forkingEnabled && ( + + )} +
+
+ ); +} diff --git a/signalpilot/web/components/chat/shared/use-shared-chat.ts b/signalpilot/web/components/chat/shared/use-shared-chat.ts new file mode 100644 index 000000000..f76248e1a --- /dev/null +++ b/signalpilot/web/components/chat/shared/use-shared-chat.ts @@ -0,0 +1,147 @@ +"use client"; + +// Data and actions for the read-only shared chat page. The gateway's +// share-token routes are the single source of truth; nothing here derives +// state from the live run stream. + +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; +import type { ChatUiContextValue } from "~/components/chat/chat-ui-context"; +import { useToast } from "~/components/ui/toast"; +import { + downloadSharedConversationFile, + forkSharedStandaloneConversation, + getSharedConversationFileObjectUrl, + getSharedConversationFileText, + getSharedConversationSqlTrace, + getSharedStandaloneConversation, + getStandaloneChatBootstrap, + type SharedConversationDetail, + type SqlTraceExecution, +} from "~/lib/api"; +import { buildStandaloneUiMessages } from "~/lib/standalone-chat-ui-messages"; +import { getSharedToolResult } from "~/lib/api/chat-results"; + +const noop = async () => undefined; +const EMPTY_EXECUTIONS: SqlTraceExecution[] = []; + +/** The shared snapshot, its SQL trace, and whether the viewer may fork. */ +export function useSharedChat(token: string) { + const { data, error, isLoading } = useSWR( + `shared-standalone-chat:${token}`, + () => getSharedStandaloneConversation(token), + { revalidateOnFocus: false }, + ); + // The trace only feeds the Queries tab; a failure hides that tab's rows. + const { data: trace } = useSWR( + data ? `shared-standalone-chat-sql-trace:${token}` : null, + () => getSharedConversationSqlTrace(token), + { revalidateOnFocus: false, shouldRetryOnError: false }, + ); + // Same key as the live page so the bootstrap is fetched once per session. + // A failure only hides the fork button; the transcript still renders. + const { data: bootstrap } = useSWR( + "standalone-chat-bootstrap", + getStandaloneChatBootstrap, + { revalidateOnFocus: false, shouldRetryOnError: false }, + ); + const uiMessages = useMemo( + () => + data + ? buildStandaloneUiMessages({ + detailMessages: data.messages, + events: data.run_events, + }) + : [], + [data], + ); + return { + detail: data, + error, + isLoading, + uiMessages, + executions: trace?.executions ?? EMPTY_EXECUTIONS, + forkingEnabled: Boolean(bootstrap?.enterprise_features.forking), + }; +} + +/** + * The ChatUiContext value for the shared page: every file and result read + * goes through the share-token routes, and run controls are inert. + */ +export function useSharedChatUi( + token: string, + detail: SharedConversationDetail | undefined, + openArtifact: (fileId: string) => void, +): ChatUiContextValue { + const { toast } = useToast(); + const getFileObjectUrl = useCallback( + (fileId: string) => getSharedConversationFileObjectUrl(token, fileId), + [token], + ); + const getFileText = useCallback( + (fileId: string) => getSharedConversationFileText(token, fileId), + [token], + ); + const downloadFile = useCallback( + (fileId: string, filename: string) => + downloadSharedConversationFile(token, fileId, filename).catch(() => { + toast("This file is no longer available.", "error"); + }), + [toast, token], + ); + const getToolResultRows = useCallback( + (resultId: string, opts?: { offset?: number; limit?: number }) => + getSharedToolResult(token, resultId, opts), + [token], + ); + const events = detail?.run_events; + const files = detail?.files; + return useMemo( + () => ({ + events: events ?? [], + conversationId: null, + files: files ?? [], + openArtifact, + getFileObjectUrl, + getFileText, + downloadFile, + getToolResultRows, + readOnly: true, + onStop: noop, + onRetry: noop, + onOpenDashboardPreview: () => undefined, + }), + [ + downloadFile, + events, + files, + getFileObjectUrl, + getFileText, + getToolResultRows, + openArtifact, + ], + ); +} + +/** "Fork to my chats": one call, then land on the new conversation. */ +export function useForkSharedChat(token: string) { + const router = useRouter(); + const { toast } = useToast(); + const [forking, setForking] = useState(false); + const fork = useCallback(async () => { + setForking(true); + try { + const forked = await forkSharedStandaloneConversation(token); + router.push(`/chats/${forked.id}`); + } catch (error) { + toast( + error instanceof Error ? error.message : "Could not fork this chat", + "error", + ); + setForking(false); + } + }, [router, toast, token]); + return { forking, fork }; +} diff --git a/signalpilot/web/components/chat/standalone-chat-panels.tsx b/signalpilot/web/components/chat/standalone-chat-panels.tsx index b53944cf0..2bb3f353e 100644 --- a/signalpilot/web/components/chat/standalone-chat-panels.tsx +++ b/signalpilot/web/components/chat/standalone-chat-panels.tsx @@ -5,7 +5,7 @@ // or the dashboard preview). The container owns the open/close state; this // module only renders it. -import { LayoutDashboard, Loader2, NotebookPen } from "lucide-react"; +import { History, LayoutDashboard, Loader2, NotebookPen, Share2 } from "lucide-react"; import type { ConversationFileInfo, ConversationNotebook, @@ -21,7 +21,7 @@ import { } from "~/components/chat/chat-settings-panel"; const TOGGLE_CLASS = - "absolute top-4 z-20 flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-card)] text-[var(--color-text-muted)] shadow-lg shadow-black/20 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; + "flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-card)] text-[var(--color-text-muted)] shadow-lg shadow-black/20 hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]"; export function ChatPanelToggles({ artifactsAvailable, @@ -31,7 +31,13 @@ export function ChatPanelToggles({ dashboardSessionId, dashboardOpen, onOpenDashboard, + onShare, + onReplay, }: { + /** Owner pages with team sharing: the share-link action. */ + onShare?: () => void; + /** Offered only when the conversation has something to replay. */ + onReplay?: () => void; artifactsAvailable: boolean; artifactsLoading: boolean; artifactsOpen: boolean; @@ -41,8 +47,33 @@ export function ChatPanelToggles({ dashboardOpen: boolean; onOpenDashboard: (sessionId: string) => void; }) { + // One floating row, outermost action last, so absent toggles leave no gap. return ( - <> +
+ {onReplay && ( + + )} + {dashboardSessionId && !dashboardOpen && ( + + )} {(artifactsLoading || artifactsAvailable) && !artifactsOpen && ( )} - +
); } diff --git a/signalpilot/web/components/chat/standalone-data-chat.tsx b/signalpilot/web/components/chat/standalone-data-chat.tsx index 9d87a7c19..4c044b5b0 100644 --- a/signalpilot/web/components/chat/standalone-data-chat.tsx +++ b/signalpilot/web/components/chat/standalone-data-chat.tsx @@ -2,7 +2,7 @@ // Standalone data chat container; UI details live in sibling modules. -import { Bot, PanelLeft, Share2 } from "lucide-react"; +import { Bot, PanelLeft } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import useSWR from "swr"; @@ -26,6 +26,10 @@ import { pickDefaultNotebook } from "~/lib/chat-live-notebook"; import { hasArtifactsContent } from "~/lib/chat-artifacts"; import { ChatUiContext } from "~/components/chat/chat-ui-context"; import { ChatMessage } from "~/components/chat/chat-message"; +import { + ChatReplayView, + useReplayMode, +} from "~/components/chat/chat-replay-view"; import { isImprovementConversation, isStreamingStatus, @@ -53,6 +57,7 @@ import { useStandaloneUiMessages, } from "~/components/chat/use-standalone-chat-run"; import { useStandaloneChatActions } from "~/components/chat/use-standalone-chat-actions"; +import { ShareLinkDialog } from "~/components/chat/share-link-dialog"; import { ChatEmptyHero } from "~/components/chat/chat-empty-hero"; import { chatShellClassName, @@ -269,6 +274,8 @@ export function StandaloneDataChat({ renameConversation, archiveConversation, shareConversation, + shareLink, + dismissShareLink, revokeShare, } = useStandaloneChatActions({ conversationId, @@ -300,6 +307,11 @@ export function StandaloneDataChat({ const runIsStreaming = currentRun?.status === "queued" || currentRun?.status === "running"; + const { canReplay, replaying, enterReplay, exitReplay } = useReplayMode( + conversationId, + events, + runIsStreaming, + ); const disabledReason = composerDisabledReason( selectedProjectId, @@ -424,20 +436,6 @@ export function StandaloneDataChat({ /> )}
- {!embedded && - conversationId && - detail && - bootstrap.enterprise_features.organization_sharing && ( - - )} {conversationId && isImprovementConversation(detail?.conversation) && (
@@ -507,6 +505,12 @@ export function StandaloneDataChat({ )}
+ ) : replaying ? ( + ) : (
{uiMessages.map((message, index) => ( @@ -518,7 +522,7 @@ export function StandaloneDataChat({ ))}
)} - {!isEmptyNewChat && ( + {!isEmptyNewChat && !replaying && (
void shareConversation(detail.conversation) + : undefined + } + onReplay={canReplay ? enterReplay : undefined} /> )}
@@ -581,6 +593,7 @@ export function StandaloneDataChat({ + ); diff --git a/signalpilot/web/components/chat/use-standalone-chat-actions.ts b/signalpilot/web/components/chat/use-standalone-chat-actions.ts index 544daf83e..6653709c7 100644 --- a/signalpilot/web/components/chat/use-standalone-chat-actions.ts +++ b/signalpilot/web/components/chat/use-standalone-chat-actions.ts @@ -4,13 +4,7 @@ // conversation load/select, and rail management. import { useRouter } from "next/navigation"; -import { - useCallback, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useRef, useState } from "react"; import { useSWRConfig } from "swr"; import { archiveStandaloneConversation, @@ -405,20 +399,22 @@ export function useStandaloneChatActions({ toastRequestError(toast, error, "Could not remove the chat"); } }; + // The new link opens in a centered dialog (ShareLinkDialog) that copies + // it and spells out who can open it. A corner toast was too easy to miss. + // `shareLink` drives the dialog: "pending" opens it with a loader the + // moment the button is pressed, a string fills in the link, null closes. + const [shareLink, setShareLink] = useState(null); const shareConversation = async (conversation: StandaloneConversation) => { + setShareLink("pending"); try { const grant = await shareStandaloneConversation(conversation.id); - const url = `${window.location.origin}/chats/shared/${grant.token}`; - try { - await navigator.clipboard.writeText(url); - toast("Team link copied", "success"); - } catch { - window.prompt("Copy team link", url); - } + setShareLink(`${window.location.origin}/chats/shared/${grant.token}`); } catch (error) { + setShareLink(null); toastRequestError(toast, error, "Could not share the chat"); } }; + const dismissShareLink = () => setShareLink(null); const revokeShare = async (conversation: StandaloneConversation) => { if (!window.confirm("Revoke all active team links for this chat?")) return; try { @@ -439,6 +435,8 @@ export function useStandaloneChatActions({ renameConversation, archiveConversation, shareConversation, + shareLink, + dismissShareLink, revokeShare, }; } diff --git a/signalpilot/web/components/chat/use-standalone-chat-run.ts b/signalpilot/web/components/chat/use-standalone-chat-run.ts index e8a487844..ffd8bacc1 100644 --- a/signalpilot/web/components/chat/use-standalone-chat-run.ts +++ b/signalpilot/web/components/chat/use-standalone-chat-run.ts @@ -28,18 +28,14 @@ import { useToast } from "~/components/ui/toast"; import { toastRequestError } from "~/components/chat/toast-request-error"; import { applyStandaloneChatEvent, - assembleStandaloneRunText, containsStandaloneSubmission, - deriveStandaloneRunActivity, isStandaloneRunReconciled, type OptimisticUserMessage, } from "~/lib/standalone-chat-state"; +import { buildStandaloneUiMessages } from "~/lib/standalone-chat-ui-messages"; import type { UiMessage } from "~/components/chat/chat-ui-context"; import type { ChatEventArrival } from "~/lib/chat-telemetry"; -import { - eventText, - isStreamingStatus, -} from "~/components/chat/standalone-chat-helpers"; +import { eventText } from "~/components/chat/standalone-chat-helpers"; export type DetailMutator = KeyedMutator; export type HistoryMutator = KeyedMutator<{ @@ -203,110 +199,17 @@ export function useStandaloneUiMessages({ pendingSubmission: OptimisticUserMessage | null; setPendingSubmission: (value: OptimisticUserMessage | null) => void; }) { - const uiMessages = useMemo(() => { - const messages: UiMessage[] = [...(detailMessages ?? [])]; - if (currentRun) { - const runMessages = messages.filter( - (message) => message.metadata.run_id === currentRun.id, - ); - const hasTerminalMessage = runMessages.some( - (message) => - message.role === "assistant" && - ["completed", "failed", "cancelled"].includes( - typeof message.metadata.status === "string" - ? message.metadata.status - : "", - ), - ); - const hasWaitingMessage = runMessages.some( - (message) => - message.role === "assistant" && - message.metadata.status === "waiting_for_user", - ); - if ( - !hasTerminalMessage && - !(currentRun.status === "waiting_for_user" && hasWaitingMessage) - ) { - const runEvents = events.filter( - (event) => event.run_id === currentRun.id, - ); - const resetSequence = runEvents.reduce( - (latest, event) => - event.type === "status" && event.payload?.reset_text === true - ? Math.max(latest, event.sequence) - : latest, - 0, - ); - const streamed = assembleStandaloneRunText( - runEvents, - currentRun.id, - resetSequence, - ); - const clarification = [...runEvents] - .reverse() - .find((event) => event.type === "clarification_requested"); - const error = [...runEvents] - .reverse() - .find((event) => event.type === "error"); - const content = - (clarification && eventText(clarification, "message")) || - streamed || - (error && eventText(error, "message")) || - (currentRun.status === "cancelled" - ? "This run was stopped." - : currentRun.status === "completed" - ? "Finalizing your answer…" - : ""); - messages.push({ - id: `run-${currentRun.id}`, - role: "assistant", - content, - sequence: Number.MAX_SAFE_INTEGER, - created_at: Date.parse(currentRun.created_at) / 1_000, - metadata: { - run_id: currentRun.id, - optimistic: true, - ...(currentRun.usage ? { token_usage: currentRun.usage } : {}), - }, - runId: currentRun.id, - runStatus: currentRun.status, - activity: deriveStandaloneRunActivity(runEvents, currentRun.id), - synthetic: true, - }); - } - } - if ( - pendingSubmission && - !containsStandaloneSubmission(messages, pendingSubmission) - ) { - messages.push({ - id: pendingSubmission.id, - role: "user", - content: pendingSubmission.content, - sequence: Number.MAX_SAFE_INTEGER - 1, - created_at: pendingSubmission.createdAt, - metadata: { optimistic: true }, - }); - } - if ( - pendingSubmission && - isSubmitting && - !isStreamingStatus(currentRun?.status) - ) { - messages.push({ - id: `pending-assistant-${pendingSubmission.id}`, - role: "assistant", - content: "", - sequence: Number.MAX_SAFE_INTEGER, - created_at: pendingSubmission.createdAt, - metadata: { optimistic: true }, - runStatus: "queued", - activity: deriveStandaloneRunActivity([], ""), - synthetic: true, - }); - } - return messages; - }, [currentRun, detailMessages, events, isSubmitting, pendingSubmission]); + const uiMessages = useMemo( + () => + buildStandaloneUiMessages({ + detailMessages, + currentRun, + events, + isSubmitting, + pendingSubmission, + }), + [currentRun, detailMessages, events, isSubmitting, pendingSubmission], + ); useEffect(() => { if ( diff --git a/signalpilot/web/lib/api/chat-files.ts b/signalpilot/web/lib/api/chat-files.ts index a2c284676..4ce721bd4 100644 --- a/signalpilot/web/lib/api/chat-files.ts +++ b/signalpilot/web/lib/api/chat-files.ts @@ -132,6 +132,11 @@ export const getSharedConversationFiles = (token: string) => `/api/chat/shared/${encodeURIComponent(token)}/files`, ); +export const getSharedConversationSqlTrace = (token: string) => + request<{ executions: SqlTraceExecution[] }>( + `/api/chat/shared/${encodeURIComponent(token)}/sql-trace`, + ); + async function fetchSharedConversationFileContent( token: string, fileId: string, @@ -149,6 +154,15 @@ async function fetchSharedConversationFileContent( return response; } +/** Text content of a shared file. Use for markdown, code, html, and data. */ +export async function getSharedConversationFileText( + token: string, + fileId: string, +): Promise { + const response = await fetchSharedConversationFileContent(token, fileId); + return response.text(); +} + /** Object URL for a shared file's bytes. The caller revokes it. */ export async function getSharedConversationFileObjectUrl( token: string, diff --git a/signalpilot/web/lib/api/chat-results.ts b/signalpilot/web/lib/api/chat-results.ts index c605f1eb9..95fd8bca7 100644 --- a/signalpilot/web/lib/api/chat-results.ts +++ b/signalpilot/web/lib/api/chat-results.ts @@ -33,3 +33,18 @@ export const getConversationToolResult = ( `/api/chat/conversations/${encodeURIComponent(conversationId)}/results/${encodeURIComponent(resultId)}${query ? `?${query}` : ""}`, ); }; + +/** The same page for a shared chat, scoped by the share grant. */ +export const getSharedToolResult = ( + token: string, + resultId: string, + opts: { offset?: number; limit?: number } = {}, +) => { + const params = new URLSearchParams(); + if (opts.offset !== undefined) params.set("offset", String(opts.offset)); + if (opts.limit !== undefined) params.set("limit", String(opts.limit)); + const query = params.toString(); + return request( + `/api/chat/shared/${encodeURIComponent(token)}/results/${encodeURIComponent(resultId)}${query ? `?${query}` : ""}`, + ); +}; diff --git a/signalpilot/web/lib/api/standalone-chat.ts b/signalpilot/web/lib/api/standalone-chat.ts index 4b89d7a4b..9f39800cb 100644 --- a/signalpilot/web/lib/api/standalone-chat.ts +++ b/signalpilot/web/lib/api/standalone-chat.ts @@ -1,5 +1,6 @@ // Chat traces and the standalone data chat. +import type { ConversationFileInfo } from "./chat-files"; import { GATEWAY_URL, getAuthHeaders, request } from "./client"; // The following functions support chat traces on the /chats page. @@ -200,26 +201,31 @@ export type StandaloneConversationDetail = { run_events: StandaloneChatEvent[]; }; -export type SharedConversationDetail = { - conversation: { - title: string; - project_name: string | null; - created_at: number; - updated_at: number; - /** How the conversation was started; "improvement" means an automated improvement run. */ - origin?: string; - }; - messages: Array>; - shared_at: string; +/** Share-safe header of a shared chat: no owner ids, budgets, or spend. */ +export type SharedConversation = { + title: string; + project_name: string | null; + /** How the conversation was started; "improvement" means an automated improvement run. */ + origin: string; + model: StandaloneChatModel; + effort: StandaloneChatEffort; + commit_sha: string | null; + branch: string; + created_at: number; + updated_at: number; }; -export type StandaloneForkPreview = { - project_id: string; - project_name: string; - commit_sha: string; - per_query_budget_usd: number; - chat_budget_usd: number; - warehouse_cost_notice: string; +/** + * Read-only snapshot of a shared chat. Messages and events have the owner + * shapes so the shared page renders through the live chat components; + * `files` is the share-safe manifest. Only finished runs are included. + */ +export type SharedConversationDetail = { + conversation: SharedConversation; + messages: StandaloneChatMessage[]; + run_events: StandaloneChatEvent[]; + files: ConversationFileInfo[]; + shared_at: string; }; export const getStandaloneChatBootstrap = () => @@ -334,25 +340,12 @@ export const getSharedStandaloneConversation = (token: string) => request( `/api/chat/shared/${encodeURIComponent(token)}`, ); -export const getSharedStandaloneForkPreview = (token: string) => - request( - `/api/chat/shared/${encodeURIComponent(token)}/fork-preview`, - ); -export const forkSharedStandaloneConversation = ( - token: string, - perQueryBudgetUsd: number, - chatBudgetUsd: number, -) => +/** Copy the whole shared chat into the caller's chats. Budgets come from + * the caller's saved defaults; there is nothing to confirm. */ +export const forkSharedStandaloneConversation = (token: string) => request<{ id: string }>( `/api/chat/shared/${encodeURIComponent(token)}/fork`, - { - method: "POST", - body: JSON.stringify({ - confirmed: true, - per_query_budget_usd: perQueryBudgetUsd, - chat_budget_usd: chatBudgetUsd, - }), - }, + { method: "POST", body: JSON.stringify({}) }, ); export const createStandaloneRun = ( conversationId: string, diff --git a/signalpilot/web/lib/standalone-chat-ui-messages.test.ts b/signalpilot/web/lib/standalone-chat-ui-messages.test.ts new file mode 100644 index 000000000..12b700ca7 --- /dev/null +++ b/signalpilot/web/lib/standalone-chat-ui-messages.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { + StandaloneChatEvent, + StandaloneChatMessage, + StandaloneChatRun, +} from "~/lib/api"; +import { buildStandaloneUiMessages } from "./standalone-chat-ui-messages"; + +const user: StandaloneChatMessage = { + id: "m1", + role: "user", + content: "What changed in revenue?", + sequence: 1, + created_at: 1, + metadata: { surface: "standalone" }, +}; +const assistant: StandaloneChatMessage = { + id: "m2", + role: "assistant", + content: "Revenue rose 12%.", + sequence: 2, + created_at: 2, + metadata: { surface: "standalone", run_id: "run-1", status: "completed" }, +}; +const run = (status: StandaloneChatRun["status"]): StandaloneChatRun => ({ + id: "run-1", + conversation_id: "c1", + status, + retry_of_run_id: null, + public_error_code: null, + public_error_message: null, + cancellation_requested_at: null, + created_at: "2026-09-08T00:00:00Z", + started_at: null, + terminal_at: null, + last_event_sequence: 0, +}); +const delta = (text: string, sequence: number): StandaloneChatEvent => ({ + run_id: "run-1", + sequence, + type: "text_delta", + payload: { delta: text }, + created_at: "2026-09-08T00:00:01Z", +}); + +describe("buildStandaloneUiMessages", () => { + it("returns finished history unchanged when there is no run to follow", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + events: [delta("Revenue", 1)], + }); + expect(messages).toEqual([user, assistant]); + expect(messages.every((message) => !message.synthetic)).toBe(true); + }); + + it("adds no synthetic row once the run's terminal message is persisted", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + currentRun: run("completed"), + events: [], + }); + expect(messages.map((message) => message.id)).toEqual(["m1", "m2"]); + }); + + it("streams the current run into a synthetic assistant row", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user], + currentRun: run("running"), + events: [delta("Revenue", 1), delta(" rose", 2)], + }); + expect(messages).toHaveLength(2); + const streamed = messages[1]; + expect(streamed.synthetic).toBe(true); + expect(streamed.runId).toBe("run-1"); + expect(streamed.runStatus).toBe("running"); + expect(streamed.content).toBe("Revenue rose"); + }); + + it("appends the optimistic submission and a queued placeholder", () => { + const messages = buildStandaloneUiMessages({ + detailMessages: [user, assistant], + currentRun: run("completed"), + events: [], + isSubmitting: true, + pendingSubmission: { id: "p1", content: "And by region?", createdAt: 3 }, + }); + expect(messages.map((message) => message.id)).toEqual([ + "m1", + "m2", + "p1", + "pending-assistant-p1", + ]); + expect(messages[3].runStatus).toBe("queued"); + }); +}); diff --git a/signalpilot/web/lib/standalone-chat-ui-messages.ts b/signalpilot/web/lib/standalone-chat-ui-messages.ts new file mode 100644 index 000000000..3a3fe20a9 --- /dev/null +++ b/signalpilot/web/lib/standalone-chat-ui-messages.ts @@ -0,0 +1,161 @@ +// Pure projection from the gateway's conversation detail to the rendered +// message list. The live chat page and the read-only shared page both build +// their transcript through this one function. + +import type { + StandaloneChatEvent, + StandaloneChatMessage, + StandaloneChatRun, +} from "~/lib/api"; +import type { UiMessage } from "~/components/chat/chat-ui-context"; +import { + assembleStandaloneRunText, + containsStandaloneSubmission, + deriveStandaloneRunActivity, + type OptimisticUserMessage, +} from "~/lib/standalone-chat-state"; + +function eventText( + event: StandaloneChatEvent | null | undefined, + key: string, +): string { + const value = event?.payload?.[key]; + return typeof value === "string" ? value : ""; +} + +const TERMINAL = new Set(["completed", "failed", "cancelled"]); + +/** + * The synthetic assistant row for a run the gateway has not yet persisted a + * final message for. Null when the transcript already carries the run's + * terminal (or awaited clarification) message. + */ +export function syntheticRunMessage( + currentRun: StandaloneChatRun, + messages: StandaloneChatMessage[], + events: StandaloneChatEvent[], +): UiMessage | null { + const runMessages = messages.filter( + (message) => message.metadata.run_id === currentRun.id, + ); + const hasTerminalMessage = runMessages.some( + (message) => + message.role === "assistant" && + TERMINAL.has( + typeof message.metadata.status === "string" + ? message.metadata.status + : "", + ), + ); + const hasWaitingMessage = runMessages.some( + (message) => + message.role === "assistant" && + message.metadata.status === "waiting_for_user", + ); + if ( + hasTerminalMessage || + (currentRun.status === "waiting_for_user" && hasWaitingMessage) + ) { + return null; + } + const runEvents = events.filter((event) => event.run_id === currentRun.id); + const resetSequence = runEvents.reduce( + (latest, event) => + event.type === "status" && event.payload?.reset_text === true + ? Math.max(latest, event.sequence) + : latest, + 0, + ); + const streamed = assembleStandaloneRunText( + runEvents, + currentRun.id, + resetSequence, + ); + const clarification = [...runEvents] + .reverse() + .find((event) => event.type === "clarification_requested"); + const error = [...runEvents].reverse().find((event) => event.type === "error"); + const content = + (clarification && eventText(clarification, "message")) || + streamed || + (error && eventText(error, "message")) || + (currentRun.status === "cancelled" + ? "This run was stopped." + : currentRun.status === "completed" + ? "Finalizing your answer…" + : ""); + return { + id: `run-${currentRun.id}`, + role: "assistant", + content, + sequence: Number.MAX_SAFE_INTEGER, + created_at: Date.parse(currentRun.created_at) / 1_000, + metadata: { + run_id: currentRun.id, + optimistic: true, + ...(currentRun.usage ? { token_usage: currentRun.usage } : {}), + }, + runId: currentRun.id, + runStatus: currentRun.status, + activity: deriveStandaloneRunActivity(runEvents, currentRun.id), + synthetic: true, + }; +} + +/** + * Build the rendered message list. + * + * Finished history is the persisted messages as they are: every assistant + * row carries its run id in metadata and the timeline folds from `events` + * at render time. On top of that the live page adds the current run's + * streaming row, the optimistic user submission, and a queued placeholder. + * The shared page passes no run and no submission and gets the history. + */ +export function buildStandaloneUiMessages({ + detailMessages, + currentRun = null, + events, + isSubmitting = false, + pendingSubmission = null, +}: { + detailMessages: StandaloneChatMessage[] | undefined; + currentRun?: StandaloneChatRun | null; + events: StandaloneChatEvent[]; + isSubmitting?: boolean; + pendingSubmission?: OptimisticUserMessage | null; +}): UiMessage[] { + const messages: UiMessage[] = [...(detailMessages ?? [])]; + if (currentRun) { + const synthetic = syntheticRunMessage(currentRun, messages, events); + if (synthetic) messages.push(synthetic); + } + if ( + pendingSubmission && + !containsStandaloneSubmission(messages, pendingSubmission) + ) { + messages.push({ + id: pendingSubmission.id, + role: "user", + content: pendingSubmission.content, + sequence: Number.MAX_SAFE_INTEGER - 1, + created_at: pendingSubmission.createdAt, + metadata: { optimistic: true }, + }); + } + const streaming = + currentRun?.status === "queued" || currentRun?.status === "running"; + if (pendingSubmission && isSubmitting && !streaming) { + messages.push({ + id: `pending-assistant-${pendingSubmission.id}`, + role: "assistant", + content: "", + sequence: Number.MAX_SAFE_INTEGER, + created_at: pendingSubmission.createdAt, + metadata: { optimistic: true }, + runStatus: "queued", + activity: deriveStandaloneRunActivity([], ""), + synthetic: true, + }); + } + return messages; +}