Merge pull request #1532 from CREDO23/imporve-artifacts-accessibility

[Feat] Artifacts sidebar for chat deliverables
This commit is contained in:
Rohan Verma 2026-06-25 13:31:45 -07:00 committed by GitHub
commit efa9efc80b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1306 additions and 43 deletions

View file

@ -0,0 +1,11 @@
"use client";
import { useParams } from "next/navigation";
import { ArtifactsLibrary } from "@/features/artifacts-library";
export default function ArtifactsPage() {
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
return <ArtifactsLibrary searchSpaceId={searchSpaceId} />;
}

View file

@ -52,6 +52,7 @@ import {
} from "@/components/assistant-ui/token-usage-context";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useSyncChatArtifacts } from "@/features/chat-artifacts";
import {
type HitlDecision,
PendingInterruptProvider,
@ -138,6 +139,13 @@ const MobileReportPanel = dynamic(
})),
{ ssr: false }
);
const MobileArtifactsPanel = dynamic(
() =>
import("@/features/chat-artifacts/ui/artifacts-panel").then((m) => ({
default: m.MobileArtifactsPanel,
})),
{ ssr: false }
);
/**
* Generate a synthetic ``toolCallId`` for an action_request that has no
@ -2488,6 +2496,9 @@ export default function NewChatPage() {
await handleRegenerate(null);
}, [handleRegenerate]);
// Surface the thread's deliverables to the layout-level artifacts sidebar.
useSyncChatArtifacts(messages);
// Create external store runtime
const runtime = useExternalStoreRuntime({
messages,
@ -2547,6 +2558,7 @@ export default function NewChatPage() {
<MobileReportPanel />
<MobileEditorPanel />
<MobileHitlEditPanel />
<MobileArtifactsPanel />
</div>
</PendingInterruptProvider>
<EditMessageDialog

View file

@ -1,6 +1,12 @@
import { atom } from "jotai";
export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit" | "citation";
export type RightPanelTab =
| "sources"
| "report"
| "editor"
| "hitl-edit"
| "citation"
| "artifacts";
export const rightPanelTabAtom = atom<RightPanelTab>("sources");

View file

@ -58,6 +58,7 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { withArtifactAnchor } from "@/features/chat-artifacts";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
@ -433,12 +434,12 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
* body and is picked up by the timeline instead.
*/
const BODY_TOOLS = {
generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
generate_report: withArtifactAnchor(GenerateReportToolUI),
generate_resume: withArtifactAnchor(GenerateResumeToolUI),
generate_podcast: withArtifactAnchor(GeneratePodcastToolUI),
generate_video_presentation: withArtifactAnchor(GenerateVideoPresentationToolUI),
display_image: withArtifactAnchor(GenerateImageToolUI),
generate_image: withArtifactAnchor(GenerateImageToolUI),
} as const;
const NullBodyTool: ToolCallMessagePartComponent = () => null;

View file

@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AlarmClock, AlertTriangle, Inbox, LibraryBig } from "lucide-react";
import { AlarmClock, AlertTriangle, Boxes, Inbox, LibraryBig } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@ -328,6 +328,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
// in the sidebar (also surfaced in the icon rail's collapsed mode via this
// list). Announcements has been moved to the avatar dropdown.
const isAutomationsActive = pathname?.includes("/automations") === true;
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
const navItems: NavItem[] = useMemo(
() =>
(
@ -345,6 +346,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
icon: AlarmClock,
isActive: isAutomationsActive,
},
{
title: "Artifacts",
url: `/dashboard/${searchSpaceId}/artifacts`,
icon: Boxes,
isActive: isArtifactsActive,
},
isMobile
? {
title: "Documents",
@ -362,6 +369,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
totalUnreadCount,
searchSpaceId,
isAutomationsActive,
isArtifactsActive,
]
);

View file

@ -7,6 +7,7 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer
import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { ArtifactsToggleButton } from "@/features/chat-artifacts";
import type { ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps {
@ -71,6 +72,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */}
<div className="ml-auto flex items-center gap-2">
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
{hasThread && <ArtifactsToggleButton />}
{threadForButton && <ChatShareButton thread={threadForButton} />}
</div>
</header>

View file

@ -8,9 +8,14 @@ import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import {
type RightPanelTab,
rightPanelCollapsedAtom,
rightPanelTabAtom,
} from "@/atoms/layout/right-panel.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { artifactsPanelOpenAtom, closeArtifactsPanelAtom } from "@/features/chat-artifacts";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
import { cn } from "@/lib/utils";
import { DocumentsSidebar } from "../sidebar";
@ -47,6 +52,14 @@ const ReportPanelContent = dynamic(
{ ssr: false, loading: () => null }
);
const ArtifactsPanelContent = dynamic(
() =>
import("@/features/chat-artifacts").then((m) => ({
default: m.ArtifactsPanelContent,
})),
{ ssr: false, loading: () => null }
);
interface RightPanelProps {
documentsPanel?: {
open: boolean;
@ -100,6 +113,7 @@ export function RightPanelToggleButton({
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
@ -110,7 +124,8 @@ export function RightPanelToggleButton({
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
const hasContent =
documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
const label = collapsed ? "Expand panel" : "Collapse panel";
if (!hasContent) return null;
@ -152,6 +167,7 @@ export function RightPanelExpandButton() {
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen =
editorState.isOpen &&
@ -162,7 +178,8 @@ export function RightPanelExpandButton() {
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
const hasContent =
documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
if (!collapsed || !hasContent) return null;
@ -179,8 +196,31 @@ const PANEL_WIDTHS = {
editor: 640,
"hitl-edit": 640,
citation: 560,
artifacts: 420,
} as const;
/**
* Priority order used to fall back to another open surface when the active
* tab's content closes. Artifacts sit just above the always-available sources
* tab.
*/
const TAB_FALLBACK_ORDER: RightPanelTab[] = [
"hitl-edit",
"citation",
"editor",
"report",
"artifacts",
"sources",
];
function resolveEffectiveTab(
activeTab: RightPanelTab,
openByTab: Record<RightPanelTab, boolean>
): RightPanelTab {
if (openByTab[activeTab]) return activeTab;
return TAB_FALLBACK_ORDER.find((tab) => openByTab[tab]) ?? "sources";
}
export function RightPanel({
documentsPanel,
showCollapseButton = true,
@ -195,6 +235,8 @@ export function RightPanel({
const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom);
const citationState = useAtomValue(citationPanelAtom);
const closeCitation = useSetAtom(closeCitationPanelAtom);
const artifactsOpen = useAtomValue(artifactsPanelOpenAtom);
const closeArtifacts = useSetAtom(closeArtifactsPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
const documentsOpen = documentsPanel?.open ?? false;
@ -210,13 +252,14 @@ export function RightPanel({
const citationOpen = citationState.isOpen && citationState.chunkId != null;
useEffect(() => {
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return;
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (hitlEditOpen) closeHitlEdit();
else if (citationOpen) closeCitation();
else if (editorOpen) closeEditor();
else if (reportOpen) closeReport();
else if (artifactsOpen) closeArtifacts();
}
};
document.addEventListener("keydown", handleKeyDown);
@ -226,41 +269,26 @@ export function RightPanel({
editorOpen,
hitlEditOpen,
citationOpen,
artifactsOpen,
closeReport,
closeEditor,
closeHitlEdit,
closeCitation,
closeArtifacts,
]);
const isVisible =
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed;
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen) &&
!collapsed;
let effectiveTab = activeTab;
if (effectiveTab === "hitl-edit" && !hitlEditOpen) {
effectiveTab = citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
} else if (effectiveTab === "citation" && !citationOpen) {
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "editor" && !editorOpen) {
effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources";
} else if (effectiveTab === "report" && !reportOpen) {
effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources";
} else if (effectiveTab === "sources" && !documentsOpen) {
effectiveTab = hitlEditOpen
? "hitl-edit"
: citationOpen
? "citation"
: editorOpen
? "editor"
: reportOpen
? "report"
: "sources";
}
const effectiveTab = resolveEffectiveTab(activeTab, {
sources: documentsOpen,
report: reportOpen,
editor: editorOpen,
"hitl-edit": hitlEditOpen,
citation: citationOpen,
artifacts: artifactsOpen,
});
const targetWidth = PANEL_WIDTHS[effectiveTab];
const collapseButton = showCollapseButton ? (
@ -329,6 +357,11 @@ export function RightPanel({
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
</div>
)}
{effectiveTab === "artifacts" && artifactsOpen && (
<div className="h-full flex flex-col">
<ArtifactsPanelContent onClose={closeArtifacts} />
</div>
)}
</div>
</aside>
);

View file

@ -145,6 +145,10 @@ export function Sidebar({
() => navItems.find((item) => item.url.endsWith("/automations")),
[navItems]
);
const artifactsItem = useMemo(
() => navItems.find((item) => item.url.endsWith("/artifacts")),
[navItems]
);
const documentsItem = useMemo(
() => navItems.find((item) => item.url === "#documents"),
[navItems]
@ -153,7 +157,10 @@ export function Sidebar({
() =>
navItems.filter(
(item) =>
item.url !== "#inbox" && item.url !== "#documents" && !item.url.endsWith("/automations")
item.url !== "#inbox" &&
item.url !== "#documents" &&
!item.url.endsWith("/automations") &&
!item.url.endsWith("/artifacts")
),
[navItems]
);
@ -242,6 +249,16 @@ export function Sidebar({
tooltipContent={isCollapsed ? automationsItem.title : undefined}
/>
)}
{artifactsItem && (
<SidebarButton
icon={artifactsItem.icon}
label={artifactsItem.title}
onClick={() => onNavItemClick?.(artifactsItem)}
isCollapsed={isCollapsed}
isActive={artifactsItem.isActive}
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
/>
)}
{documentsItem && (
<SidebarButton
icon={documentsItem.icon}

View file

@ -127,7 +127,6 @@ export function CombinedPlayer({ slides }: CombinedPlayerProps) {
compositionHeight={1080}
style={{ width: "100%", aspectRatio: "16/9" }}
controls
autoPlay
loop
acknowledgeRemotionLicense
/>

View file

@ -485,7 +485,7 @@ function VideoPresentationPlayer({
);
}
function StatusPoller({
export function StatusPoller({
presentationId,
title,
shareToken,

View file

@ -1 +1,4 @@
export { GenerateVideoPresentationToolUI } from "./generate-video-presentation";
export {
GenerateVideoPresentationToolUI,
StatusPoller as VideoPresentationViewer,
} from "./generate-video-presentation";

View file

@ -0,0 +1,27 @@
import { z } from "zod";
// =============================================================================
// Image generations — mirror app/schemas/image_generation.py.
// =============================================================================
export const imageGenerationListItem = z.object({
id: z.number(),
prompt: z.string(),
search_space_id: z.number(),
created_at: z.string(),
is_success: z.boolean(),
image_count: z.number().nullish(),
});
export type ImageGenerationListItem = z.infer<typeof imageGenerationListItem>;
export const imageGenerationList = z.array(imageGenerationListItem);
// Detail carries the raw provider response, which holds the displayable image
// as either a hosted url or inline base64.
export const imageGenerationDetail = z.object({
id: z.number(),
prompt: z.string(),
response_data: z.record(z.string(), z.unknown()).nullish(),
error_message: z.string().nullish(),
});
export type ImageGenerationDetail = z.infer<typeof imageGenerationDetail>;

View file

@ -155,3 +155,16 @@ export const podcastDetail = z.object({
thread_id: z.number().nullable(),
});
export type PodcastDetail = z.infer<typeof podcastDetail>;
// Lightweight list item — mirror app/podcasts/api/schemas.py PodcastSummary.
export const podcastSummary = z.object({
id: z.number(),
title: z.string(),
status: podcastStatus,
created_at: z.string(),
search_space_id: z.number(),
thread_id: z.number().nullish(),
});
export type PodcastSummary = z.infer<typeof podcastSummary>;
export const podcastSummaryList = z.array(podcastSummary);

View file

@ -0,0 +1,25 @@
import { z } from "zod";
// =============================================================================
// Reports — mirror app/schemas/reports.py ReportRead (list view, no content).
// Resumes are reports with content_type === "typst".
// =============================================================================
export const reportMetadata = z
.object({
status: z.enum(["ready", "failed"]).nullish(),
word_count: z.number().nullish(),
})
.nullish();
export const reportListItem = z.object({
id: z.number(),
title: z.string(),
content_type: z.string().default("markdown"),
report_metadata: reportMetadata,
thread_id: z.number().nullish(),
created_at: z.string(),
});
export type ReportListItem = z.infer<typeof reportListItem>;
export const reportList = z.array(reportListItem);

View file

@ -0,0 +1,20 @@
import { z } from "zod";
// =============================================================================
// Video presentations — mirror app/schemas/video_presentations.py status enum.
// =============================================================================
export const videoPresentationStatus = z.enum(["pending", "generating", "ready", "failed"]);
export type VideoPresentationStatus = z.infer<typeof videoPresentationStatus>;
export const videoPresentationListItem = z.object({
id: z.number(),
title: z.string(),
status: videoPresentationStatus.default("ready"),
created_at: z.string(),
search_space_id: z.number(),
thread_id: z.number().nullish(),
});
export type VideoPresentationListItem = z.infer<typeof videoPresentationListItem>;
export const videoPresentationList = z.array(videoPresentationListItem);

View file

@ -0,0 +1,98 @@
import { useQuery } from "@tanstack/react-query";
import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { reportsApiService } from "@/lib/apis/reports-api.service";
import { videoPresentationsApiService } from "@/lib/apis/video-presentations-api.service";
import type { LibraryArtifact, LibraryArtifactStatus } from "../model/artifact";
function podcastStatus(status: string): LibraryArtifactStatus {
if (status === "ready") return "ready";
if (status === "failed" || status === "cancelled") return "error";
return "running";
}
function videoStatus(status: string): LibraryArtifactStatus {
if (status === "ready") return "ready";
if (status === "failed") return "error";
return "running";
}
// Each list is fetched independently; one failing source shouldn't blank the
// whole library, so failures degrade to an empty slice.
async function fetchLibraryArtifacts(searchSpaceId: number): Promise<LibraryArtifact[]> {
const [reports, podcasts, videos, images] = await Promise.all([
reportsApiService.list(searchSpaceId).catch(() => []),
podcastsApiService.list(searchSpaceId).catch(() => []),
videoPresentationsApiService.list(searchSpaceId).catch(() => []),
imageGenerationsApiService.list(searchSpaceId).catch(() => []),
]);
const artifacts: LibraryArtifact[] = [];
for (const report of reports) {
const isResume = report.content_type === "typst";
artifacts.push({
key: `report-${report.id}`,
kind: isResume ? "resume" : "report",
entityId: report.id,
title: report.title,
status: report.report_metadata?.status === "failed" ? "error" : "ready",
createdAt: report.created_at,
contentType: isResume ? "typst" : "markdown",
sourceThreadId: report.thread_id,
});
}
for (const podcast of podcasts) {
artifacts.push({
key: `podcast-${podcast.id}`,
kind: "podcast",
entityId: podcast.id,
title: podcast.title,
status: podcastStatus(podcast.status),
createdAt: podcast.created_at,
contentType: "markdown",
sourceThreadId: podcast.thread_id,
});
}
for (const video of videos) {
artifacts.push({
key: `video-${video.id}`,
kind: "video",
entityId: video.id,
title: video.title,
status: videoStatus(video.status),
createdAt: video.created_at,
contentType: "markdown",
sourceThreadId: video.thread_id,
});
}
for (const image of images) {
artifacts.push({
key: `image-${image.id}`,
kind: "image",
entityId: image.id,
title: image.prompt,
status: image.is_success ? "ready" : "error",
createdAt: image.created_at,
contentType: "markdown",
});
}
return artifacts.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
export function useLibraryArtifacts(searchSpaceId: number) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifacts-library", searchSpaceId],
queryFn: () => fetchLibraryArtifacts(searchSpaceId),
enabled: Number.isFinite(searchSpaceId) && searchSpaceId > 0,
staleTime: 60 * 1000,
});
return { artifacts: data ?? [], loading: isLoading, error, refresh: refetch };
}

View file

@ -0,0 +1 @@
export { ArtifactsLibrary } from "./ui/artifacts-library";

View file

@ -0,0 +1,23 @@
/** Deliverable kinds surfaced in the search-space-wide artifacts library. */
export type LibraryArtifactKind = "report" | "resume" | "podcast" | "video" | "image";
export type LibraryArtifactStatus = "ready" | "running" | "error";
/**
* A deliverable aggregated from the search space's list endpoints. The heavy
* content (report body, audio, video frames, image bytes) is fetched lazily by
* the viewer when a card is opened.
*/
export interface LibraryArtifact {
/** Stable list key — `${kind}-${entityId}`. */
key: string;
kind: LibraryArtifactKind;
entityId: number;
title: string;
status: LibraryArtifactStatus;
createdAt: string;
/** Report panel content type — "typst" for resumes, "markdown" otherwise. */
contentType: "markdown" | "typst";
/** Chat thread that produced this artifact, when the source recorded one. */
sourceThreadId?: number | null;
}

View file

@ -0,0 +1,63 @@
import { MessageSquareText } from "lucide-react";
import Link from "next/link";
import { formatRelativeDate } from "@/lib/format-date";
import type { LibraryArtifact } from "../model/artifact";
import { KIND_META } from "./kind-meta";
export function ArtifactCard({
artifact,
searchSpaceId,
onOpen,
}: {
artifact: LibraryArtifact;
searchSpaceId: number;
onOpen: (artifact: LibraryArtifact) => void;
}) {
const meta = KIND_META[artifact.kind];
const Icon = meta.icon;
const subtitle =
artifact.status === "running"
? "Generating…"
: artifact.status === "error"
? "Failed"
: meta.label;
return (
<div className="group relative flex items-start gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40 hover:bg-accent/50">
{/* Stretched overlay makes the whole card open the viewer; sibling controls sit above it via z-10. */}
<button
type="button"
onClick={() => onOpen(artifact)}
className="absolute inset-0 rounded-xl"
>
<span className="sr-only">Open {artifact.title}</span>
</button>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{artifact.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={artifact.status === "error" ? "text-destructive" : undefined}>
{subtitle}
</span>
<span aria-hidden>·</span>
<span>{formatRelativeDate(artifact.createdAt)}</span>
</span>
</span>
{artifact.sourceThreadId ? (
<Link
href={`/dashboard/${searchSpaceId}/new-chat/${artifact.sourceThreadId}`}
title="Open source chat"
className="relative z-10 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
>
<MessageSquareText className="size-4" />
<span className="sr-only">Open source chat</span>
</Link>
) : null}
</div>
);
}

View file

@ -0,0 +1,142 @@
"use client";
import { useSetAtom } from "jotai";
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { MobileReportPanel } from "@/components/report-panel/report-panel";
import { Button } from "@/components/ui/button";
import { useLibraryArtifacts } from "../hooks/use-library-artifacts";
import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact";
import { ArtifactCard } from "./artifact-card";
import { KIND_META, KIND_ORDER } from "./kind-meta";
import { MediaViewerDialog } from "./media-viewer-dialog";
const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5", "s6"];
function LoadingState() {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{SKELETON_KEYS.map((key) => (
<div key={key} className="h-[68px] animate-pulse rounded-xl border bg-muted/40" />
))}
</div>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-20 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<TriangleAlert className="size-6" />
</span>
<div>
<p className="font-medium text-foreground">Couldn't load artifacts</p>
<p className="mt-1 text-sm text-muted-foreground">
Something went wrong fetching this search space's deliverables.
</p>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
Retry
</Button>
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-20 text-center">
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Boxes className="size-6" />
</span>
<div>
<p className="font-medium text-foreground">No artifacts yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Reports, resumes, podcasts, presentations, and images you generate appear here.
</p>
</div>
</div>
);
}
export function ArtifactsLibrary({ searchSpaceId }: { searchSpaceId: number }) {
const { artifacts, loading, error, refresh } = useLibraryArtifacts(searchSpaceId);
const openReportPanel = useSetAtom(openReportPanelAtom);
const [selectedMedia, setSelectedMedia] = useState<LibraryArtifact | null>(null);
const grouped = useMemo(() => {
const map = new Map<LibraryArtifactKind, LibraryArtifact[]>();
for (const artifact of artifacts) {
const bucket = map.get(artifact.kind);
if (bucket) bucket.push(artifact);
else map.set(artifact.kind, [artifact]);
}
return map;
}, [artifacts]);
const handleOpen = (artifact: LibraryArtifact) => {
// Reports/resumes reuse the shared report panel; the rest open in the dialog.
if (artifact.kind === "report" || artifact.kind === "resume") {
openReportPanel({
reportId: artifact.entityId,
title: artifact.title,
contentType: artifact.contentType,
});
return;
}
setSelectedMedia(artifact);
};
return (
<div className="mx-auto w-full max-w-5xl px-6 py-8">
<header className="mb-6 flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-foreground">Artifacts</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every deliverable created across this search space.
</p>
</div>
{!loading && artifacts.length > 0 ? (
<span className="shrink-0 text-sm text-muted-foreground">{artifacts.length} total</span>
) : null}
</header>
{loading ? (
<LoadingState />
) : error ? (
<ErrorState onRetry={() => refresh()} />
) : artifacts.length === 0 ? (
<EmptyState />
) : (
<div className="space-y-8">
{KIND_ORDER.map((kind) => {
const items = grouped.get(kind);
if (!items || items.length === 0) return null;
return (
<section key={kind}>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">
{KIND_META[kind].group}
<span className="ml-1.5 text-muted-foreground/60">{items.length}</span>
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((artifact) => (
<ArtifactCard
key={artifact.key}
artifact={artifact}
searchSpaceId={searchSpaceId}
onOpen={handleOpen}
/>
))}
</div>
</section>
);
})}
</div>
)}
<MediaViewerDialog artifact={selectedMedia} onClose={() => setSelectedMedia(null)} />
<MobileReportPanel />
</div>
);
}

View file

@ -0,0 +1,16 @@
import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react";
import type { ComponentType } from "react";
import type { LibraryArtifactKind } from "../model/artifact";
export const KIND_META: Record<
LibraryArtifactKind,
{ icon: ComponentType<{ className?: string }>; label: string; group: string }
> = {
report: { icon: FileText, label: "Report", group: "Reports" },
resume: { icon: Contact, label: "Resume", group: "Resumes" },
podcast: { icon: AudioLines, label: "Podcast", group: "Podcasts" },
video: { icon: Presentation, label: "Presentation", group: "Presentations" },
image: { icon: ImageIcon, label: "Image", group: "Images" },
};
export const KIND_ORDER: LibraryArtifactKind[] = ["report", "resume", "podcast", "video", "image"];

View file

@ -0,0 +1,45 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Image, ImageLoading } from "@/components/tool-ui/image";
import { imageGenerationsApiService } from "@/lib/apis/image-generations-api.service";
function extractImageSrc(responseData: Record<string, unknown> | null | undefined): string | null {
const data = (responseData as { data?: unknown } | null | undefined)?.data;
if (!Array.isArray(data) || data.length === 0) return null;
const first = data[0] as { url?: string; b64_json?: string };
if (first?.url) return first.url;
if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`;
return null;
}
export function LibraryImageViewer({ imageId, prompt }: { imageId: number; prompt: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["image-generation-detail", imageId],
queryFn: () => imageGenerationsApiService.getDetail(imageId),
});
if (isLoading) return <ImageLoading title="Loading image" maxWidth="640px" />;
const src = extractImageSrc(data?.response_data);
if (error || !src) {
return (
<p className="px-6 py-10 text-center text-sm text-muted-foreground">
{data?.error_message || "Image not available"}
</p>
);
}
return (
<Image
id={`library-image-${imageId}`}
assetId={String(imageId)}
src={src}
alt={prompt}
title={prompt}
domain="ai-generated"
ratio="auto"
maxWidth="640px"
/>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import dynamic from "next/dynamic";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import type { LibraryArtifact, LibraryArtifactKind } from "../model/artifact";
import { LibraryImageViewer } from "./library-image-viewer";
const ViewerFallback = () => (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
);
const PodcastPlayer = dynamic(
() => import("@/components/tool-ui/podcast/player").then((m) => m.PodcastPlayer),
{ ssr: false, loading: ViewerFallback }
);
const VideoPresentationViewer = dynamic(
() => import("@/components/tool-ui/video-presentation").then((m) => m.VideoPresentationViewer),
{ ssr: false, loading: ViewerFallback }
);
// `stretch` overrides the players' inline-chat max-w/margins so they fill the dialog.
function dialogLayout(kind: LibraryArtifactKind): { width: string; stretch: boolean } {
if (kind === "video") return { width: "max-w-4xl", stretch: true };
if (kind === "podcast") return { width: "max-w-2xl", stretch: true };
return { width: "max-w-2xl", stretch: false };
}
function MediaViewerBody({ artifact }: { artifact: LibraryArtifact }) {
if (artifact.kind === "podcast") {
return <PodcastPlayer podcastId={artifact.entityId} title={artifact.title} />;
}
if (artifact.kind === "video") {
return <VideoPresentationViewer presentationId={artifact.entityId} title={artifact.title} />;
}
return <LibraryImageViewer imageId={artifact.entityId} prompt={artifact.title} />;
}
/**
* Modal viewer for inline-media artifacts (podcast, video, image). Reports and
* resumes use the shared report panel instead and never reach this dialog.
*/
export function MediaViewerDialog({
artifact,
onClose,
}: {
artifact: LibraryArtifact | null;
onClose: () => void;
}) {
const layout = artifact ? dialogLayout(artifact.kind) : null;
return (
<Dialog
open={artifact !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
className={cn(
// pt-12 keeps content clear of the absolute top-right close button.
"flex max-h-[88vh] w-[95vw] flex-col overflow-y-auto pt-12",
layout?.width ?? "max-w-2xl"
)}
>
<DialogTitle className="sr-only">{artifact?.title ?? "Artifact"}</DialogTitle>
{artifact ? (
<div
className={cn(
layout?.stretch
? "w-full [&>div]:!my-0 [&>div]:!max-w-none [&>div>*]:!max-w-none"
: "flex justify-center"
)}
>
<MediaViewerBody artifact={artifact} />
</div>
) : null}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,22 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { collectArtifacts } from "../lib/collect-artifacts";
import { chatArtifactsAtom } from "../state/artifacts-panel.atom";
/**
* Keep `chatArtifactsAtom` in sync with the active thread's messages so the
* right-panel sidebar (rendered in the layout shell, outside the chat runtime)
* can read the deliverable list. Clears on unmount and on thread switch (a new
* `messages` array recomputes to the new thread's artifacts).
*/
export function useSyncChatArtifacts(messages: readonly ThreadMessageLike[]): void {
const setArtifacts = useSetAtom(chatArtifactsAtom);
const artifacts = useMemo(() => collectArtifacts(messages), [messages]);
useEffect(() => {
setArtifacts(artifacts);
}, [artifacts, setArtifacts]);
useEffect(() => () => setArtifacts([]), [setArtifacts]);
}

View file

@ -0,0 +1,14 @@
export { useSyncChatArtifacts } from "./hooks/use-sync-chat-artifacts";
export { collectArtifacts } from "./lib/collect-artifacts";
export { ARTIFACT_ANCHOR_ATTR, scrollToArtifact } from "./lib/scroll-to-artifact";
export type { ArtifactKind, ArtifactStatus, ChatArtifact } from "./model/artifact";
export {
artifactsPanelOpenAtom,
chatArtifactsAtom,
closeArtifactsPanelAtom,
openArtifactsPanelAtom,
toggleArtifactsPanelAtom,
} from "./state/artifacts-panel.atom";
export { withArtifactAnchor } from "./ui/artifact-anchor";
export { ArtifactsPanelContent, MobileArtifactsPanel } from "./ui/artifacts-panel";
export { ArtifactsToggleButton } from "./ui/artifacts-toggle-button";

View file

@ -0,0 +1,139 @@
import type { ThreadMessageLike } from "@assistant-ui/react";
import {
ARTIFACT_TOOL_KINDS,
type ArtifactKind,
type ArtifactStatus,
type ChatArtifact,
} from "../model/artifact";
interface ToolCallPart {
type: "tool-call";
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
result?: unknown;
}
function isToolCallPart(part: unknown): part is ToolCallPart {
return (
typeof part === "object" &&
part !== null &&
(part as { type?: unknown }).type === "tool-call" &&
typeof (part as { toolCallId?: unknown }).toolCallId === "string" &&
typeof (part as { toolName?: unknown }).toolName === "string"
);
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value;
}
return null;
}
function numericId(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/** Extracts entity id, title, and status for a single deliverable tool call. */
function describeArtifact(
kind: ArtifactKind,
args: Record<string, unknown>,
result: Record<string, unknown>,
hasResult: boolean
): { title: string; entityId: number | null; status: ArtifactStatus } {
const resultStatus = typeof result.status === "string" ? result.status : null;
const failed = resultStatus === "failed" || resultStatus === "error" || !!result.error;
switch (kind) {
case "report": {
const entityId = numericId(result.report_id);
return {
title: firstString(result.title, args.topic) ?? "Report",
entityId,
status: failed ? "error" : entityId != null ? "ready" : "running",
};
}
case "resume": {
const entityId = numericId(result.report_id);
return {
title: firstString(result.title) ?? "Resume",
entityId,
status: failed ? "error" : entityId != null ? "ready" : "running",
};
}
case "podcast": {
const entityId = numericId(result.podcast_id);
return {
title: firstString(result.title, args.podcast_title) ?? "Podcast",
entityId,
status: failed ? "error" : entityId != null ? "ready" : "running",
};
}
case "video": {
const entityId = numericId(result.video_presentation_id);
return {
title: firstString(result.title, args.video_title) ?? "Presentation",
entityId,
status: failed ? "error" : entityId != null ? "ready" : "running",
};
}
case "image": {
const ready = typeof result.src === "string" && result.src.length > 0;
return {
title: firstString(result.title, args.prompt) ?? "Image",
entityId: null,
status: failed ? "error" : ready ? "ready" : hasResult ? "ready" : "running",
};
}
}
}
/**
* Aggregate the deliverable artifacts referenced across a thread's messages.
*
* Scans assistant tool-call parts, keeps recognized deliverable tools, and
* dedupes by backing entity (so a regenerated report collapses to one entry,
* refreshed in place to keep chronological order). Errored deliverables are
* dropped they have nothing to open or jump to.
*/
export function collectArtifacts(messages: readonly ThreadMessageLike[]): ChatArtifact[] {
const byKey = new Map<string, ChatArtifact>();
for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
for (const part of message.content) {
if (!isToolCallPart(part)) continue;
const kind = ARTIFACT_TOOL_KINDS[part.toolName];
if (!kind) continue;
const args = asRecord(part.args);
const result = asRecord(part.result);
const { title, entityId, status } = describeArtifact(
kind,
args,
result,
part.result !== undefined
);
if (status === "error") continue;
const key = entityId != null ? `${kind}:${entityId}` : part.toolCallId;
byKey.set(key, {
key,
kind,
title,
status,
toolCallId: part.toolCallId,
entityId,
contentType: kind === "resume" ? "typst" : "markdown",
});
}
}
return Array.from(byKey.values());
}

View file

@ -0,0 +1,42 @@
/** Data attribute stamped on each deliverable card wrapper by `ArtifactAnchor`. */
export const ARTIFACT_ANCHOR_ATTR = "data-artifact-tool-call-id";
const HIGHLIGHT_CLASSES = ["ring-2", "ring-primary/60"];
const HIGHLIGHT_DURATION_MS = 1600;
const RETRY_INTERVAL_MS = 120;
const MAX_WAIT_MS = 1500;
function isInView(el: HTMLElement): boolean {
const { top, bottom } = el.getBoundingClientRect();
return bottom > window.innerHeight * 0.2 && top < window.innerHeight * 0.8;
}
/**
* Scroll the inline card for `toolCallId` into view and pulse a ring. Retries
* because the thread viewport's initialize auto-scroll can fire after the first
* jump and snap back to the bottom; scrolling off-bottom disengages it.
*/
export function scrollToArtifact(toolCallId: string): void {
if (typeof document === "undefined") return;
const selector = `[${ARTIFACT_ANCHOR_ATTR}="${CSS.escape(toolCallId)}"]`;
const deadline = Date.now() + MAX_WAIT_MS;
let highlighted = false;
const attempt = () => {
const anchor = document.querySelector<HTMLElement>(selector);
if (anchor) {
anchor.scrollIntoView({ behavior: "smooth", block: "center" });
if (!highlighted) {
highlighted = true;
const card = (anchor.firstElementChild as HTMLElement | null) ?? anchor;
card.classList.add(...HIGHLIGHT_CLASSES);
window.setTimeout(() => card.classList.remove(...HIGHLIGHT_CLASSES), HIGHLIGHT_DURATION_MS);
}
if (isInView(anchor)) return;
}
if (Date.now() < deadline) window.setTimeout(attempt, RETRY_INTERVAL_MS);
};
attempt();
}

View file

@ -0,0 +1,33 @@
/** Deliverable kinds the agent can produce and surface in the artifacts sidebar. */
export type ArtifactKind = "report" | "resume" | "podcast" | "video" | "image";
export type ArtifactStatus = "running" | "ready" | "error";
/**
* A chat deliverable, aggregated from the assistant message stream. One entry
* per deliverable tool call; the heavy content stays in the inline card and is
* fetched lazily by the panel/card on demand.
*/
export interface ChatArtifact {
/** Stable identity for list keys + dedupe — entity id when known, else the tool call id. */
key: string;
kind: ArtifactKind;
title: string;
status: ArtifactStatus;
/** Anchors the scroll-to-card jump back into the conversation. */
toolCallId: string;
/** Backing entity id for report/resume/podcast/video; null for images. */
entityId: number | null;
/** Report panel content type — "typst" for resumes, "markdown" otherwise. */
contentType: "markdown" | "typst";
}
/** Maps deliverable tool names to artifact kinds. Mirrors the body tools in assistant-message. */
export const ARTIFACT_TOOL_KINDS: Record<string, ArtifactKind> = {
generate_report: "report",
generate_resume: "resume",
generate_podcast: "podcast",
generate_video_presentation: "video",
generate_image: "image",
display_image: "image",
};

View file

@ -0,0 +1,39 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import type { ChatArtifact } from "../model/artifact";
/** Artifacts of the active thread, synced from the message stream by `useSyncChatArtifacts`. */
export const chatArtifactsAtom = atom<ChatArtifact[]>([]);
/** Open === artifacts owns the tab; derived so the toggle can't drift. */
export const artifactsPanelOpenAtom = atom((get) => get(rightPanelTabAtom) === "artifacts");
/** Snapshot of `rightPanelCollapsedAtom` taken before the panel opens, restored on close. */
const preArtifactsCollapsedAtom = atom<boolean | null>(null);
export const openArtifactsPanelAtom = atom(null, (get, set) => {
if (get(rightPanelTabAtom) !== "artifacts") {
set(preArtifactsCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(rightPanelTabAtom, "artifacts");
set(rightPanelCollapsedAtom, false);
});
export const closeArtifactsPanelAtom = atom(null, (get, set) => {
// Don't clobber the tab when another surface owns it.
if (get(rightPanelTabAtom) !== "artifacts") return;
// RightPanel's fallback then re-reveals any surface underneath (e.g. a report).
set(rightPanelTabAtom, "sources");
const prev = get(preArtifactsCollapsedAtom);
if (prev !== null) {
set(rightPanelCollapsedAtom, prev);
set(preArtifactsCollapsedAtom, null);
}
});
export const toggleArtifactsPanelAtom = atom(null, (get, set) => {
// Only close when artifacts is actually visible; otherwise a click always opens it.
const shown = get(rightPanelTabAtom) === "artifacts" && !get(rightPanelCollapsedAtom);
if (shown) set(closeArtifactsPanelAtom);
else set(openArtifactsPanelAtom);
});

View file

@ -0,0 +1,20 @@
import type { ToolCallMessagePartComponent, ToolCallMessagePartProps } from "@assistant-ui/react";
import { ARTIFACT_ANCHOR_ATTR } from "../lib/scroll-to-artifact";
/**
* Wrap a body tool component so its rendered card carries a DOM anchor keyed by
* tool call id. The artifacts sidebar uses it to scroll a deliverable back into
* view. The wrapper is layout-neutral the card keeps its own margins.
*/
export function withArtifactAnchor(
Tool: ToolCallMessagePartComponent
): ToolCallMessagePartComponent {
function AnchoredTool(props: ToolCallMessagePartProps) {
return (
<div {...{ [ARTIFACT_ANCHOR_ATTR]: props.toolCallId }}>
<Tool {...props} />
</div>
);
}
return AnchoredTool;
}

View file

@ -0,0 +1,66 @@
import { useSetAtom } from "jotai";
import { AudioLines, Contact, FileText, ImageIcon, Presentation } from "lucide-react";
import type { ComponentType } from "react";
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { Button } from "@/components/ui/button";
import { useMediaQuery } from "@/hooks/use-media-query";
import { scrollToArtifact } from "../lib/scroll-to-artifact";
import type { ArtifactKind, ChatArtifact } from "../model/artifact";
import { closeArtifactsPanelAtom } from "../state/artifacts-panel.atom";
const KIND_META: Record<
ArtifactKind,
{ icon: ComponentType<{ className?: string }>; label: string }
> = {
report: { icon: FileText, label: "Report" },
resume: { icon: Contact, label: "Resume" },
podcast: { icon: AudioLines, label: "Podcast" },
video: { icon: Presentation, label: "Presentation" },
image: { icon: ImageIcon, label: "Image" },
};
export function ArtifactRow({ artifact }: { artifact: ChatArtifact }) {
const openReportPanel = useSetAtom(openReportPanelAtom);
const closeArtifactsPanel = useSetAtom(closeArtifactsPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
const meta = KIND_META[artifact.kind];
const Icon = meta.icon;
const isReportLike = artifact.kind === "report" || artifact.kind === "resume";
const handleOpen = () => {
// Reports/resumes open in the report viewer, which claims the tab itself.
if (isReportLike && artifact.entityId != null) {
openReportPanel({
reportId: artifact.entityId,
title: artifact.title,
contentType: artifact.contentType,
});
scrollToArtifact(artifact.toolCallId);
return;
}
// Inline media has no viewer — just jump to the card. Mobile dismisses the
// drawer first since it covers the chat; desktop leaves the panel open.
if (!isDesktop) closeArtifactsPanel();
scrollToArtifact(artifact.toolCallId);
};
return (
<Button
type="button"
variant="ghost"
onClick={handleOpen}
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left font-normal hover:bg-accent hover:text-accent-foreground"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{artifact.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{artifact.status === "running" ? "Generating…" : meta.label}
</span>
</span>
</Button>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Boxes, XIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { useMediaQuery } from "@/hooks/use-media-query";
import type { ArtifactKind, ChatArtifact } from "../model/artifact";
import {
artifactsPanelOpenAtom,
chatArtifactsAtom,
closeArtifactsPanelAtom,
} from "../state/artifacts-panel.atom";
import { ArtifactRow } from "./artifact-row";
const GROUP_ORDER: { kind: ArtifactKind; label: string }[] = [
{ kind: "report", label: "Reports" },
{ kind: "resume", label: "Resumes" },
{ kind: "podcast", label: "Podcasts" },
{ kind: "video", label: "Presentations" },
{ kind: "image", label: "Images" },
];
function groupByKind(artifacts: ChatArtifact[]): { label: string; items: ChatArtifact[] }[] {
return GROUP_ORDER.map(({ kind, label }) => ({
label,
items: artifacts.filter((a) => a.kind === kind),
})).filter((group) => group.items.length > 0);
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center select-none">
<Boxes className="size-6 text-muted-foreground/60" />
<p className="text-sm font-medium text-foreground">No artifacts yet</p>
<p className="text-xs text-muted-foreground">
Reports, podcasts, presentations, and images you generate will appear here.
</p>
</div>
);
}
function ArtifactGroups({ artifacts }: { artifacts: ChatArtifact[] }) {
const groups = useMemo(() => groupByKind(artifacts), [artifacts]);
if (groups.length === 0) return <EmptyState />;
return (
<div className="flex-1 overflow-y-auto px-2 py-2">
{groups.map((group) => (
<div key={group.label} className="mb-3 last:mb-0">
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80 select-none">
{group.label}
</p>
<div className="flex flex-col gap-0.5">
{group.items.map((artifact) => (
<ArtifactRow key={artifact.key} artifact={artifact} />
))}
</div>
</div>
))}
</div>
);
}
/** Inner content shared by the desktop right-panel tab and the mobile drawer. */
export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) {
const artifacts = useAtomValue(chatArtifactsAtom);
return (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
<h2 className="select-none text-lg font-semibold">Artifacts</h2>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close artifacts panel</span>
</Button>
)}
</div>
<ArtifactGroups artifacts={artifacts} />
</>
);
}
/**
* Mobile artifacts drawer. Desktop renders inside the layout-level RightPanel
* tab instead, so this no-ops on large screens.
*/
export function MobileArtifactsPanel() {
const isOpen = useAtomValue(artifactsPanelOpenAtom);
const close = useSetAtom(closeArtifactsPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !isOpen) return null;
return (
<Drawer
open={isOpen}
onOpenChange={(open) => {
if (!open) close();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[85vh] max-h-[85vh] z-80 overflow-hidden bg-sidebar"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Artifacts</DrawerTitle>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ArtifactsPanelContent onClose={close} />
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Boxes } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
artifactsPanelOpenAtom,
chatArtifactsAtom,
toggleArtifactsPanelAtom,
} from "../state/artifacts-panel.atom";
/** Header toggle that opens the artifacts sidebar. Hidden when the thread has none. */
export function ArtifactsToggleButton() {
const artifacts = useAtomValue(chatArtifactsAtom);
const isOpen = useAtomValue(artifactsPanelOpenAtom);
const toggle = useSetAtom(toggleArtifactsPanelAtom);
if (artifacts.length === 0) return null;
const label = isOpen ? "Hide artifacts" : "Show artifacts";
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => toggle()}
aria-pressed={isOpen}
className={cn(
"relative h-8 w-8 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
isOpen && "bg-accent text-accent-foreground"
)}
>
<Boxes className="h-4 w-4" />
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground tabular-nums">
{artifacts.length}
</span>
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,23 @@
import {
imageGenerationDetail,
imageGenerationList,
} from "@/contracts/types/image-generations.types";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/image-generations";
class ImageGenerationsApiService {
list = async (searchSpaceId: number, limit = 100) => {
const qs = new URLSearchParams({
search_space_id: String(searchSpaceId),
limit: String(limit),
}).toString();
return baseApiService.get(`${BASE}?${qs}`, imageGenerationList);
};
getDetail = async (imageGenId: number) => {
return baseApiService.get(`${BASE}/${imageGenId}`, imageGenerationDetail);
};
}
export const imageGenerationsApiService = new ImageGenerationsApiService();

View file

@ -3,6 +3,7 @@ import {
languageOptions,
type PodcastSpec,
podcastDetail,
podcastSummaryList,
updateSpecRequest,
voiceOption,
} from "@/contracts/types/podcast.types";
@ -14,6 +15,14 @@ const BASE = "/api/v1/podcasts";
const voiceOptionList = z.array(voiceOption);
class PodcastsApiService {
list = async (searchSpaceId: number, limit = 200) => {
const qs = new URLSearchParams({
search_space_id: String(searchSpaceId),
limit: String(limit),
}).toString();
return baseApiService.get(`${BASE}?${qs}`, podcastSummaryList);
};
// Full state including the deserialized brief and transcript; thin lifecycle
// fields (status, spec, spec_version) also arrive live via Zero.
getDetail = async (podcastId: number) => {

View file

@ -0,0 +1,16 @@
import { reportList } from "@/contracts/types/reports.types";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/reports";
class ReportsApiService {
list = async (searchSpaceId: number, limit = 200) => {
const qs = new URLSearchParams({
search_space_id: String(searchSpaceId),
limit: String(limit),
}).toString();
return baseApiService.get(`${BASE}?${qs}`, reportList);
};
}
export const reportsApiService = new ReportsApiService();

View file

@ -0,0 +1,16 @@
import { videoPresentationList } from "@/contracts/types/video-presentations.types";
import { baseApiService } from "./base-api.service";
const BASE = "/api/v1/video-presentations";
class VideoPresentationsApiService {
list = async (searchSpaceId: number, limit = 200) => {
const qs = new URLSearchParams({
search_space_id: String(searchSpaceId),
limit: String(limit),
}).toString();
return baseApiService.get(`${BASE}?${qs}`, videoPresentationList);
};
}
export const videoPresentationsApiService = new VideoPresentationsApiService();