This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-02-01 18:02:27 -08:00
commit 8301e0169c
71 changed files with 2889 additions and 732 deletions

View file

@ -240,6 +240,10 @@ export const ConnectorIndicator: FC = () => {
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
// Sync last_indexed_at with live data from Electric SQL for real-time updates
last_indexed_at:
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
?.last_indexed_at ?? editingConnector.last_indexed_at,
}}
startDate={startDate}
endDate={endDate}

View file

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
interface DateRangeSelectorProps {
@ -26,19 +27,10 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
allowFutureDates = false,
lastIndexedAt,
}) => {
// Get the placeholder text for start date based on whether connector was previously indexed
const getStartDatePlaceholder = () => {
if (lastIndexedAt) {
const date = new Date(lastIndexedAt);
const currentYear = new Date().getFullYear();
const indexedYear = date.getFullYear();
// Show year only if different from current year
const formatStr = indexedYear === currentYear ? "MMM d, HH:mm" : "MMM d, yyyy HH:mm";
const formattedDate = format(date, formatStr);
return `Since (${formattedDate})`;
}
return "Default (1 year ago)";
};
const startDatePlaceholder = lastIndexedAt
? `From ${formatRelativeDate(lastIndexedAt)}`
: "Default (1 year)";
const handleLast30Days = () => {
const today = new Date();
onStartDateChange(subDays(today, 30));
@ -88,7 +80,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : getStartDatePlaceholder()}
{startDate ? format(startDate, "PPP") : startDatePlaceholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 z-[100]" align="start">

View file

@ -1,29 +1,188 @@
"use client";
import { Info } from "lucide-react";
import type { FC } from "react";
import { AlertCircle, CheckCircle2, Hash, Info, Megaphone, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { connectorsApiService, type DiscordChannel } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
export interface DiscordConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const DiscordConfig: FC<DiscordConfigProps> = () => {
export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
const [channels, setChannels] = useState<DiscordChannel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const fetchChannels = useCallback(async () => {
if (!connector?.id) return;
setIsLoading(true);
setError(null);
try {
const data = await connectorsApiService.getDiscordChannels(connector.id);
setChannels(data);
setLastFetched(new Date());
} catch (err) {
console.error("Failed to fetch Discord channels:", err);
setError(err instanceof Error ? err.message : "Failed to fetch channels");
} finally {
setIsLoading(false);
}
}, [connector?.id]);
// Fetch channels on mount
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
// Auto-refresh when user returns to tab
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && connector?.id) {
fetchChannels();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [connector?.id, fetchChannels]);
// Separate channels by indexing capability
const readyToIndex = channels.filter((ch) => ch.can_index);
const needsPermissions = channels.filter((ch) => !ch.can_index);
// Format last fetched time
const formatLastFetched = () => {
if (!lastFetched) return null;
const now = new Date();
const diffMs = now.getTime() - lastFetched.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
if (diffSecs < 60) return "just now";
if (diffMins === 1) return "1 minute ago";
if (diffMins < 60) return `${diffMins} minutes ago`;
return lastFetched.toLocaleTimeString();
};
return (
<div className="space-y-6">
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Servers</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Before indexing, make sure the Discord bot has been added to the servers (guilds) you
want to index. The bot can only access messages from servers it's been added to. Use the
OAuth authorization flow to add the bot to your servers.
The bot needs &quot;Read Message History&quot; permission to index channels. Ask a
server admin to grant this permission for channels shown below.
</p>
</div>
</div>
{/* Channels Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Channel Access</h3>
</div>
<div className="flex items-center gap-2">
{lastFetched && (
<span className="text-[10px] text-muted-foreground">{formatLastFetched()}</span>
)}
<Button
variant="secondary"
size="sm"
onClick={fetchChannels}
disabled={isLoading}
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
>
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</div>
)}
{isLoading && channels.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Spinner size="sm" />
<span className="ml-2 text-sm text-muted-foreground">Loading channels</span>
</div>
) : channels.length === 0 && !error ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No channels found. Make sure the bot has been added to your Discord server with proper
permissions.
</div>
) : (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
{/* Ready to index */}
{readyToIndex.length > 0 && (
<div className={cn("p-3", needsPermissions.length > 0 && "border-b border-border")}>
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="size-3.5 text-emerald-500" />
<span className="text-[11px] font-medium">Ready to index</span>
<span className="text-[10px] text-muted-foreground">
{readyToIndex.length} {readyToIndex.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{readyToIndex.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
{/* Needs permissions */}
{needsPermissions.length > 0 && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="size-3.5 text-amber-500" />
<span className="text-[11px] font-medium">Grant permissions to index</span>
<span className="text-[10px] text-muted-foreground">
{needsPermissions.length}{" "}
{needsPermissions.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{needsPermissions.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
interface ChannelPillProps {
channel: DiscordChannel;
}
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
{channel.type === "announcement" ? (
<Megaphone className="size-2.5 text-muted-foreground" />
) : (
<Hash className="size-2.5 text-muted-foreground" />
)}
<span>{channel.name}</span>
</div>
);
};

View file

@ -1,16 +1,79 @@
"use client";
import { Info } from "lucide-react";
import type { FC } from "react";
import { AlertCircle, CheckCircle2, Hash, Info, Lock, RefreshCw } from "lucide-react";
import { type FC, useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { connectorsApiService, type SlackChannel } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
export interface SlackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const SlackConfig: FC<SlackConfigProps> = () => {
export const SlackConfig: FC<SlackConfigProps> = ({ connector }) => {
const [channels, setChannels] = useState<SlackChannel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastFetched, setLastFetched] = useState<Date | null>(null);
const fetchChannels = useCallback(async () => {
if (!connector?.id) return;
setIsLoading(true);
setError(null);
try {
const data = await connectorsApiService.getSlackChannels(connector.id);
setChannels(data);
setLastFetched(new Date());
} catch (err) {
console.error("Failed to fetch Slack channels:", err);
setError(err instanceof Error ? err.message : "Failed to fetch channels");
} finally {
setIsLoading(false);
}
}, [connector?.id]);
// Fetch channels on mount
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
// Auto-refresh when user returns to tab
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && connector?.id) {
fetchChannels();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, [connector?.id, fetchChannels]);
// Separate channels by bot membership
const channelsWithBot = channels.filter((ch) => ch.is_member);
const channelsWithoutBot = channels.filter((ch) => !ch.is_member);
// Format last fetched time
const formatLastFetched = () => {
if (!lastFetched) return null;
const now = new Date();
const diffMs = now.getTime() - lastFetched.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
if (diffSecs < 60) return "just now";
if (diffMins === 1) return "1 minute ago";
if (diffMins < 60) return `${diffMins} minutes ago`;
return lastFetched.toLocaleTimeString();
};
return (
<div className="space-y-6">
{/* Info box */}
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
@ -25,6 +88,103 @@ export const SlackConfig: FC<SlackConfigProps> = () => {
</p>
</div>
</div>
{/* Channels Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Channel Access</h3>
</div>
<div className="flex items-center gap-2">
{lastFetched && (
<span className="text-[10px] text-muted-foreground">{formatLastFetched()}</span>
)}
<Button
variant="secondary"
size="sm"
onClick={fetchChannels}
disabled={isLoading}
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
>
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
{error}
</div>
)}
{isLoading && channels.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Spinner size="sm" />
<span className="ml-2 text-sm text-muted-foreground">Loading channels</span>
</div>
) : channels.length === 0 && !error ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No channels found. Make sure the bot has been added to your Slack workspace.
</div>
) : (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
{/* Channels with bot access */}
{channelsWithBot.length > 0 && (
<div className={cn("p-3", channelsWithoutBot.length > 0 && "border-b border-border")}>
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="size-3.5 text-emerald-500" />
<span className="text-[11px] font-medium">Ready to index</span>
<span className="text-[10px] text-muted-foreground">
{channelsWithBot.length} {channelsWithBot.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{channelsWithBot.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
{/* Channels without bot access */}
{channelsWithoutBot.length > 0 && (
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="size-3.5 text-amber-500" />
<span className="text-[11px] font-medium">Add bot to index</span>
<span className="text-[10px] text-muted-foreground">
{channelsWithoutBot.length}{" "}
{channelsWithoutBot.length === 1 ? "channel" : "channels"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{channelsWithoutBot.map((channel) => (
<ChannelPill key={channel.id} channel={channel} />
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
interface ChannelPillProps {
channel: SlackChannel;
}
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
return (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
{channel.is_private ? (
<Lock className="size-2.5 text-muted-foreground" />
) : (
<Hash className="size-2.5 text-muted-foreground" />
)}
<span>{channel.name}</span>
</div>
);
};

View file

@ -5,6 +5,44 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { InboxItem } from "@/contracts/types/inbox.types";
import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
/**
* Timeout thresholds for stuck task detection
*
* These align with the backend Celery configuration:
* - HARD_TIMEOUT: 8 hours (task_time_limit=28800 in Celery)
* Any task running longer than this is definitely dead.
*
* - STALE_THRESHOLD: 15 minutes without notification updates
* If heartbeats are being sent every 30s, missing 15+ minutes of updates
* indicates the task has likely crashed or the worker is down.
*/
const HARD_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 hours in milliseconds
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes in milliseconds
/**
* Check if a notification is stale (no updates for too long)
* @param updatedAt - ISO timestamp of last notification update
* @returns true if the notification hasn't been updated recently
*/
function isNotificationStale(updatedAt: string | null | undefined): boolean {
if (!updatedAt) return false;
const lastUpdate = new Date(updatedAt).getTime();
const now = Date.now();
return now - lastUpdate > STALE_THRESHOLD_MS;
}
/**
* Check if a task has exceeded the hard timeout (definitely dead)
* @param startedAt - ISO timestamp when the task started
* @returns true if the task has been running longer than the hard limit
*/
function isTaskTimedOut(startedAt: string | null | undefined): boolean {
if (!startedAt) return false;
const startTime = new Date(startedAt).getTime();
const now = Date.now();
return now - startTime > HARD_TIMEOUT_MS;
}
/**
* Hook to track which connectors are currently indexing using local state.
*
@ -13,6 +51,8 @@ import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
* 3. Clearing indexing state when notifications become completed or failed
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
* 5. Detecting stale/stuck tasks that haven't updated in 15+ minutes
* 6. Detecting hard timeout (8h) - tasks that definitely cannot still be running
*
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
*/
@ -57,6 +97,7 @@ export function useIndexingConnectors(
// Detect notification status changes and update indexing state accordingly
// This restores spinner state after component remounts and handles all status transitions
// Also detects stale/stuck tasks that haven't been updated in a while
useEffect(() => {
if (!inboxItems || inboxItems.length === 0) return;
@ -71,11 +112,26 @@ export function useIndexingConnectors(
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
if (!metadata) continue;
// If status is "in_progress", add connector to indexing set
// If status is "in_progress", check if it's actually still running
if (metadata.status === "in_progress") {
if (!newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.add(metadata.connector_id);
hasChanges = true;
// Check for hard timeout (8h) - task is definitely dead
const timedOut = isTaskTimedOut(metadata.started_at);
// Check for stale notification (15min without updates) - task likely crashed
const stale = isNotificationStale(item.updated_at);
if (timedOut || stale) {
// Task is stuck - don't show as indexing
if (newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.delete(metadata.connector_id);
hasChanges = true;
}
} else {
// Task appears to be genuinely running
if (!newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.add(metadata.connector_id);
hasChanges = true;
}
}
}
// If status is "completed" or "failed", remove connector from indexing set

View file

@ -1,6 +1,5 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Plus, Server } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
@ -8,6 +7,7 @@ import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { useConnectorStatus } from "../hooks/use-connector-status";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
@ -32,38 +32,6 @@ function isIndexableConnector(connectorType: string): boolean {
return !nonIndexableTypes.includes(connectorType);
}
/**
* Format last indexed date with contextual messages
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) {
return "Just now";
}
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
return format(date, "MMM d, yyyy");
}
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorType,
connectorTitle,
@ -215,7 +183,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{isIndexableConnector(connector.connector_type)
? connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
? `Last indexed: ${formatRelativeDate(connector.last_indexed_at)}`
: "Never indexed"
: "Active"}
</p>

View file

@ -1,6 +1,6 @@
"use client";
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
import { FolderOpen, PenSquare } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -122,33 +122,15 @@ export function Sidebar({
{/* Chat sections - fills available space */}
{isCollapsed ? (
<div className="flex-1 flex flex-col items-center gap-2 py-2 w-[60px]">
{(chats.length > 0 || sharedChats.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => onToggleCollapse?.()}
>
<MessageSquare className="h-4 w-4" />
<span className="sr-only">{t("chats")}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t("chats")} ({chats.length + sharedChats.length})
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex-1 w-[60px]" />
) : (
<div className="flex-1 flex flex-col gap-1 py-2 w-[240px] min-h-0 overflow-hidden">
{/* Shared Chats Section - takes half the space */}
{/* Shared Chats Section - takes only space needed, max 50% */}
<SidebarSection
title={t("shared_chats")}
defaultOpen={true}
fillHeight={true}
fillHeight={false}
className="shrink-0 max-h-[50%] flex flex-col"
action={
onViewAllSharedChats ? (
<Tooltip>
@ -170,9 +152,9 @@ export function Sidebar({
}
>
{sharedChats.length > 0 ? (
<div className="relative flex-1 min-h-0">
<div className="relative min-h-0 flex-1">
<div
className={`flex flex-col gap-0.5 h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-8" : ""}`}
className={`flex flex-col gap-0.5 max-h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-8" : ""}`}
>
{sharedChats.slice(0, 20).map((chat) => (
<ChatListItem
@ -196,7 +178,7 @@ export function Sidebar({
)}
</SidebarSection>
{/* Private Chats Section - takes half the space */}
{/* Private Chats Section - fills remaining space */}
<SidebarSection
title={t("chats")}
defaultOpen={true}

View file

@ -30,7 +30,12 @@ export function SidebarSection({
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn("overflow-hidden", fillHeight && "flex flex-col flex-1 min-h-0", className)}
className={cn(
"overflow-hidden",
fillHeight && "flex flex-col min-h-0",
fillHeight && isOpen && "flex-1",
className
)}
>
<div className="flex items-center group/section shrink-0">
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
@ -56,12 +61,8 @@ export function SidebarSection({
)}
</div>
<CollapsibleContent
className={cn("overflow-hidden", fillHeight && "flex-1 flex flex-col min-h-0")}
>
<div
className={cn("px-2 pb-2", fillHeight && "flex-1 flex flex-col min-h-0 overflow-hidden")}
>
<CollapsibleContent className={cn("overflow-hidden flex-1 flex flex-col min-h-0")}>
<div className={cn("px-2 pb-2 flex-1 flex flex-col min-h-0 overflow-hidden")}>
{children}
</div>
</CollapsibleContent>

View file

@ -2,10 +2,10 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Globe, Link2, User, Users } from "lucide-react";
import { Globe, User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -49,19 +49,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
// Public share mutation
const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue(
togglePublicShareMutationAtom
// Snapshot creation mutation
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
createSnapshotMutationAtom
);
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
const isPublicEnabled =
currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false;
const publicShareToken = currentThreadState.publicShareToken ?? null;
const handleVisibilityChange = useCallback(
async (newVisibility: ChatVisibility) => {
@ -96,45 +92,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
);
const handlePublicShareToggle = useCallback(async () => {
const handleCreatePublicLink = useCallback(async () => {
if (!thread) return;
try {
const response = await togglePublicShare({
thread_id: thread.id,
enabled: !isPublicEnabled,
});
// Update atom state with response
setCurrentThreadState((prev) => ({
...prev,
publicShareEnabled: response.enabled,
publicShareToken: response.share_token,
}));
await createSnapshot({ thread_id: thread.id });
setOpen(false);
} catch (error) {
console.error("Failed to toggle public share:", error);
console.error("Failed to create public link:", error);
}
}, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]);
const handleCopyPublicLink = useCallback(async () => {
if (!publicShareToken) return;
const publicUrl = `${window.location.origin}/public/${publicShareToken}`;
await navigator.clipboard.writeText(publicUrl);
toast.success("Public link copied to clipboard");
}, [publicShareToken]);
}, [thread, createSnapshot]);
// Don't show if no thread (new chat that hasn't been created yet)
if (!thread) {
return null;
}
const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = isPublicEnabled
? "Public"
: currentVisibility === "PRIVATE"
? "Private"
: "Shared";
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
return (
<Popover open={open} onOpenChange={setOpen}>
@ -211,67 +186,31 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
{/* Divider */}
<div className="border-t border-border my-1" />
{/* Public Share Option */}
{/* Public Link Option */}
<button
type="button"
onClick={handlePublicShareToggle}
disabled={isTogglingPublic}
onClick={handleCreatePublicLink}
disabled={isCreatingSnapshot}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
"focus:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed",
isPublicEnabled && "bg-accent/80"
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
<div
className={cn(
"size-7 rounded-md shrink-0 grid place-items-center",
isPublicEnabled ? "bg-primary/10" : "bg-muted"
)}
>
<Globe
className={cn(
"size-4 block",
isPublicEnabled ? "text-primary" : "text-muted-foreground"
)}
/>
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
<Globe className="size-4 block text-muted-foreground" />
</div>
<div className="flex-1 text-left min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm font-medium", isPublicEnabled && "text-primary")}>
Public
<span className="text-sm font-medium">
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
</span>
{isPublicEnabled && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
ON
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Anyone with the link can read
Creates a shareable snapshot of this chat
</p>
</div>
{isPublicEnabled && publicShareToken && (
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
handleCopyPublicLink();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
handleCopyPublicLink();
}
}}
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors cursor-pointer"
title="Copy public link"
>
<Link2 className="size-4 text-muted-foreground" />
</div>
)}
</button>
</div>
</PopoverContent>

View file

@ -26,7 +26,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
share_token: shareToken,
});
// Redirect to the new chat page (content will be loaded there)
// Redirect to the new chat page with cloned content
router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to copy chat";

View file

@ -2,6 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { AlertCircleIcon, MicIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Audio } from "@/components/tool-ui/audio";
@ -172,9 +173,6 @@ function AudioLoadingState({ title }: { title: string }) {
);
}
/**
* Podcast Player Component - Fetches audio and transcript with authentication
*/
function PodcastPlayer({
podcastId,
title,
@ -186,6 +184,11 @@ function PodcastPlayer({
description: string;
durationMs?: number;
}) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
const [audioSrc, setAudioSrc] = useState<string | null>(null);
const [transcript, setTranscript] = useState<PodcastTranscriptEntry[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -217,30 +220,46 @@ function PodcastPlayer({
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout
try {
// Fetch audio blob and podcast details in parallel
const [audioResponse, rawPodcastDetails] = await Promise.all([
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
{ method: "GET", signal: controller.signal }
),
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
]);
let audioBlob: Blob;
let rawPodcastDetails: unknown = null;
if (!audioResponse.ok) {
throw new Error(`Failed to load audio: ${audioResponse.status}`);
if (shareToken) {
// Public view - use public endpoints (baseApiService handles no-auth for /api/v1/public/)
const [blob, details] = await Promise.all([
baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`),
baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`),
]);
audioBlob = blob;
rawPodcastDetails = details;
} else {
// Authenticated view - fetch audio and details in parallel
const [audioResponse, details] = await Promise.all([
authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
{ method: "GET", signal: controller.signal }
),
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
]);
if (!audioResponse.ok) {
throw new Error(`Failed to load audio: ${audioResponse.status}`);
}
audioBlob = await audioResponse.blob();
rawPodcastDetails = details;
}
const audioBlob = await audioResponse.blob();
// Create object URL from blob
const objectUrl = URL.createObjectURL(audioBlob);
objectUrlRef.current = objectUrl;
setAudioSrc(objectUrl);
// Parse and validate podcast details, then set transcript
const podcastDetails = parsePodcastDetails(rawPodcastDetails);
if (podcastDetails.podcast_transcript) {
setTranscript(podcastDetails.podcast_transcript);
if (rawPodcastDetails) {
const podcastDetails = parsePodcastDetails(rawPodcastDetails);
if (podcastDetails.podcast_transcript) {
setTranscript(podcastDetails.podcast_transcript);
}
}
} finally {
clearTimeout(timeoutId);
@ -255,7 +274,7 @@ function PodcastPlayer({
} finally {
setIsLoading(false);
}
}, [podcastId]);
}, [podcastId, shareToken]);
// Load podcast when component mounts
useEffect(() => {