diff --git a/apps/backend/src/trpc/analytics-event.routes.ts b/apps/backend/src/trpc/analytics-event.routes.ts index 5249dc4d3..380714410 100644 --- a/apps/backend/src/trpc/analytics-event.routes.ts +++ b/apps/backend/src/trpc/analytics-event.routes.ts @@ -23,7 +23,7 @@ async function assertAssetOwnerOrAdmin( if (assetProjectId !== projectId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Chat not found.' }); } - if (userRole === 'admin') { + if (userRole === 'admin' || userRole === 'context_admin') { return; } const ownerId = await chatQueries.getChatOwnerId(chatId); @@ -38,7 +38,7 @@ async function assertAssetOwnerOrAdmin( if (assetProjectId !== projectId) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); } - if (userRole === 'admin') { + if (userRole === 'admin' || userRole === 'context_admin') { return; } const ownerId = await storyQueries.getStoryOwnerId(storyId); @@ -58,14 +58,24 @@ export const analyticsEventRoutes = { assetType: z.enum(ANALYTICS_ASSET_TYPES), chatId: z.string().optional(), storyId: z.string().optional(), + storySlug: z.string().optional(), limit: z.number().int().min(1).max(200).default(100), }), ) .query(async ({ input, ctx }) => { + let storyId = input.storyId; + if (input.assetType === 'story' && !storyId && input.chatId && input.storySlug) { + const story = await storyQueries.getStoryByChatAndSlug(input.chatId, input.storySlug); + if (!story) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found.' }); + } + storyId = story.id; + } + await assertAssetOwnerOrAdmin( input.assetType, input.chatId, - input.storyId, + storyId, ctx.project.id, ctx.user.id, ctx.userRole, @@ -74,7 +84,7 @@ export const analyticsEventRoutes = { const rows = await analyticsEventQueries.listEventsForAsset({ assetType: input.assetType, chatId: input.chatId, - storyId: input.storyId, + storyId, limit: input.limit, }); diff --git a/apps/backend/src/trpc/shared-story.routes.ts b/apps/backend/src/trpc/shared-story.routes.ts index 3a8a40038..8a4121ea4 100644 --- a/apps/backend/src/trpc/shared-story.routes.ts +++ b/apps/backend/src/trpc/shared-story.routes.ts @@ -83,6 +83,11 @@ export const sharedStoryRoutes = { throw new TRPCError({ code: 'NOT_FOUND', message: 'Story not found in this project.' }); } + const storyOwnerId = await storyQueries.getStoryOwnerId(story.id); + if (storyOwnerId !== ctx.user.id && ctx.userRole !== 'admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the creator or an admin can share this.' }); + } + if (input.visibility === 'project') { await storyFolderQueries.moveStoryToFolder(story.id, null, { storyOwnerId: ctx.user.id, diff --git a/apps/frontend/src/components/asset-analytics-dialog.tsx b/apps/frontend/src/components/asset-analytics-dialog.tsx index e336c7185..2b3c51248 100644 --- a/apps/frontend/src/components/asset-analytics-dialog.tsx +++ b/apps/frontend/src/components/asset-analytics-dialog.tsx @@ -29,6 +29,7 @@ interface AssetAnalyticsDialogProps { assetType: AnalyticsAssetType; chatId?: string; storyId?: string; + storySlug?: string; } interface TabDef { @@ -77,13 +78,20 @@ const TAB_DEFS: TabDef[] = [ }, ]; -export function AssetAnalyticsDialog({ open, onOpenChange, assetType, chatId, storyId }: AssetAnalyticsDialogProps) { +export function AssetAnalyticsDialog({ + open, + onOpenChange, + assetType, + chatId, + storyId, + storySlug, +}: AssetAnalyticsDialogProps) { const tabs = TAB_DEFS.filter((tab) => tab.assetTypes.includes(assetType)); const [activeTab, setActiveTab] = useState('page_view'); const [search, setSearch] = useState(''); const query = useQuery({ - ...trpc.analyticsEvent.listForAsset.queryOptions({ assetType, chatId, storyId }), + ...trpc.analyticsEvent.listForAsset.queryOptions({ assetType, chatId, storyId, storySlug }), enabled: open, staleTime: 0, refetchOnMount: 'always', diff --git a/apps/frontend/src/components/settings/chats-replay-panel.tsx b/apps/frontend/src/components/settings/chats-replay-panel.tsx index d75c5c65f..ac002bf23 100644 --- a/apps/frontend/src/components/settings/chats-replay-panel.tsx +++ b/apps/frontend/src/components/settings/chats-replay-panel.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Info } from 'lucide-react'; import { formatDate } from 'date-fns'; import type { ReactNode } from 'react'; import type { StickToBottomContext } from 'use-stick-to-bottom'; import type { ReplayHighlight } from '@/components/settings/usage-route-search'; +import { AssetAnalyticsDialog } from '@/components/asset-analytics-dialog'; import { SidePanelProvider } from '@/contexts/side-panel'; import { SidePanel } from '@/components/side-panel/side-panel'; import { SettingsCard } from '@/components/ui/settings-card'; @@ -110,6 +111,7 @@ export function ChatsReplayPanel({ chatId, onBack, metadataAction, highlightOnLo const isOwner = session?.user?.id === chatReplayQuery.data?.chatOwnerId; const title = chatReplayQuery.data?.title ?? 'Chat replay'; const updatedAt = chatReplayQuery.data?.updatedAt; + const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); return (
@@ -125,6 +127,15 @@ export function ChatsReplayPanel({ chatId, onBack, metadataAction, highlightOnLo {chatReplayQuery.data && }
+ {chatReplayQuery.data && ( + + )} {updatedAt != null ? formatDate(new Date(updatedAt), 'yyyy-MM-dd') : '—'} @@ -169,6 +180,7 @@ export function ChatsReplayPanel({ chatId, onBack, metadataAction, highlightOnLo setCurrentStoryTabIndex={sidePanel.setCurrentStoryTabIndex} chatId={chatId} isReadonlyMode={!isOwner} + isReplay={true} open={sidePanel.open} close={sidePanel.close} > @@ -202,6 +214,13 @@ export function ChatsReplayPanel({ chatId, onBack, metadataAction, highlightOnLo
)} + + ); } diff --git a/apps/frontend/src/components/side-panel/story-header.tsx b/apps/frontend/src/components/side-panel/story-header.tsx index 446ab65d8..b90f7745a 100644 --- a/apps/frontend/src/components/side-panel/story-header.tsx +++ b/apps/frontend/src/components/side-panel/story-header.tsx @@ -7,13 +7,13 @@ import { Ellipsis, Eye, Globe, + Info, Loader2, Maximize2, Pencil, RefreshCw, RotateCcw, Save, - ScanText, Star, Upload, X, @@ -65,6 +65,7 @@ export interface StoryHeaderProps { isAgentRunning: boolean; isSaving?: boolean; isReadonlyMode: boolean; + isReplay?: boolean; isLive: boolean; isRefreshing: boolean; onRefreshData: () => void; @@ -102,6 +103,7 @@ export const StoryHeader = memo(function StoryHeader({ isAgentRunning, isSaving = false, isReadonlyMode, + isReplay = false, isLive, isRefreshing, onRefreshData, @@ -216,7 +218,7 @@ export const StoryHeader = memo(function StoryHeader({ /> ); - const starButton = storyId && ( + const starButton = !isReadonlyMode && storyId && ( - {isLive ? 'Live story settings' : 'Enable live mode'} + + {isReadonlyMode + ? isLive + ? 'Live mode on' + : 'Live mode off' + : isLive + ? 'Live story settings' + : 'Enable live mode'} + {isLive && ( <> {cachedAt && } - - - - - Refresh data - + {!isReadonlyMode && ( + + + + + Refresh data + + )} )} ); + const replayAnalyticsButton = isReplay && isReadonlyMode && ( + + + + + Analytics + + ); + const actionButtons = !isReadonlyMode && ( @@ -294,7 +323,7 @@ export const StoryHeader = memo(function StoryHeader({ Share - + Analytics @@ -324,6 +353,7 @@ export const StoryHeader = memo(function StoryHeader({ {liveControls} {downloadButton} {starButton} + {replayAnalyticsButton} {actionButtons}
@@ -348,6 +378,7 @@ export const StoryHeader = memo(function StoryHeader({ {liveControls} {downloadButton} {starButton} + {replayAnalyticsButton} {actionButtons}
)} diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index a0918fa10..9b72ebd59 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -55,12 +55,13 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp, i const { close: closeSidePanel, isReadonlyMode: contextReadonlyMode, + isReplay, shareId, shareType, setCurrentStorySlug, setCurrentStoryTabIndex, } = useSidePanel(); - const isReadonlyMode = readonlyProp ?? contextReadonlyMode; + const isReadonlyMode = isReplay ? contextReadonlyMode : (readonlyProp ?? contextReadonlyMode); const { viewMode, setViewMode } = useStoryViewerViewMode(); const outerAgent = useOptionalAgentContext(); @@ -237,6 +238,7 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp, i isAgentRunning={isAgentRunning} isSaving={isSaving} isReadonlyMode={isReadonlyMode} + isReplay={isReplay} isLive={isLive} isRefreshing={isRefreshing} onRefreshData={handleRefreshData} @@ -326,6 +328,7 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp, i assetType='story' chatId={chatId} storyId={storyId ?? undefined} + storySlug={resolvedStorySlug} /> - + Analytics
)} diff --git a/apps/frontend/src/contexts/side-panel.tsx b/apps/frontend/src/contexts/side-panel.tsx index db4283112..cc0bdbab4 100644 --- a/apps/frontend/src/contexts/side-panel.tsx +++ b/apps/frontend/src/contexts/side-panel.tsx @@ -12,6 +12,7 @@ interface SidePanelContext { shareId: string | null; shareType: ShareType | null; isReadonlyMode: boolean; + isReplay: boolean; open: (content: React.ReactNode, storySlug?: string) => void; close: () => void; } @@ -28,6 +29,7 @@ const noopSidePanel: SidePanelContext = { shareId: null, shareType: null, isReadonlyMode: false, + isReplay: false, open: () => {}, close: () => {}, }; @@ -47,6 +49,7 @@ export const SidePanelProvider = ({ shareId = null, shareType = null, isReadonlyMode = false, + isReplay = false, open, close, }: { @@ -60,6 +63,7 @@ export const SidePanelProvider = ({ shareId?: string | null; shareType?: ShareType | null; isReadonlyMode?: boolean; + isReplay?: boolean; open: (content: React.ReactNode, storySlug?: string) => void; close: () => void; }) => { @@ -74,6 +78,7 @@ export const SidePanelProvider = ({ shareId, shareType, isReadonlyMode, + isReplay, open, close, }), @@ -87,6 +92,7 @@ export const SidePanelProvider = ({ shareId, shareType, isReadonlyMode, + isReplay, open, close, ], diff --git a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx index 954a01b41..cd809dd6b 100644 --- a/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx +++ b/apps/frontend/src/routes/_sidebar-layout._chat-layout.$chatId.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { createFileRoute, Link, useRouter } from '@tanstack/react-router'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Folder, GitFork, Globe, ScanText, TimerIcon, Upload } from 'lucide-react'; +import { Folder, GitFork, Globe, Info, TimerIcon, Upload } from 'lucide-react'; import type { ForkMetadata } from '@nao/backend/chat'; import type { SelectionData } from '@/components/highlight-bubble'; import { NEW_CHAT_ID } from '@/lib/ai'; @@ -218,7 +218,7 @@ function ChatPage() { disabled={isRunning} aria-label='Analytics' > - +