diff --git a/package.json b/package.json
index 3cb0c85c..f2e8285e 100644
--- a/package.json
+++ b/package.json
@@ -95,7 +95,7 @@
"@react-email/render": "^2.0.4",
"@shikijs/themes": "^3.22.0",
"@shikijs/types": "^3.22.0",
- "@streamdown/code": "^1.0.3",
+ "@streamdown/code": "^1.1.0",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@supabase/supabase-js": "^2.97.0",
@@ -153,7 +153,7 @@
"resend": "^6.9.2",
"shiki": "^3.22.0",
"sonner": "^2.0.7",
- "streamdown": "^2.3.0",
+ "streamdown": "^2.4.0",
"tailwind-merge": "^3.5.0",
"thinkex": "link:",
"tw-shimmer": "^0.4.6",
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 250fa00a..9a3ae21e 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -105,7 +105,7 @@ function getSelectedCardsContext(body: any): string {
*/
function injectSelectionContext(
messages: any[],
- metadata?: { replySelections?: Array<{ text: string }>; blockNoteSelection?: { cardName: string; text: string } },
+ metadata?: { replySelections?: Array<{ text: string; title?: string }>; blockNoteSelection?: { cardName: string; text: string } },
selectedCardsContext?: string
): void {
const parts: string[] = [];
@@ -115,11 +115,11 @@ function injectSelectionContext(
parts.push(`[Selected cards context:\n${selectedCardsContext.trim()}]`);
}
- // Reply selections (quoted text from assistant messages)
+ // Reply selections (quoted text from assistant messages or PDF selections)
if (metadata?.replySelections && metadata.replySelections.length > 0) {
const quoted = metadata.replySelections
- .map((sel) => `> ${sel.text}`)
- .join("\n");
+ .map((sel) => (sel.title ? `> (from ${sel.title})\n> ${sel.text}` : `> ${sel.text}`))
+ .join("\n\n");
parts.push(`[Referring to:\n${quoted}]`);
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 77428936..24691abe 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -6,7 +6,9 @@
/* Path to your installed `@blocknote/shadcn` package. */
@source "../node_modules/@blocknote/shadcn";
-/* Streamdown markdown library - custom components handle styling, no @source needed */
+/* Streamdown - required for Tailwind v4 to include syntax highlighting classes */
+@source "../../node_modules/streamdown/dist/*.js";
+@source "../../node_modules/@streamdown/code/dist/*.js";
/* Streamdown font sizes - smaller for chat context */
.streamdown-content {
@@ -33,7 +35,7 @@
font-size: 1rem;
}
-/* Streamdown code block: language and buttons on same row */
+/* Streamdown code block: language and action buttons on same row (override default flex-col) */
.streamdown-content [data-streamdown="code-block"] {
display: grid !important;
grid-template-areas: "header actions" "body body";
@@ -47,6 +49,7 @@
}
.streamdown-content [data-streamdown="code-block"] > [data-streamdown="code-block-body"] {
grid-area: body;
+ padding-inline: 0;
}
/* Remove BlockNote editor background */
diff --git a/src/components/assistant-ui/YouTubeSearchToolUI.tsx b/src/components/assistant-ui/YouTubeSearchToolUI.tsx
index b1e65223..304c3204 100644
--- a/src/components/assistant-ui/YouTubeSearchToolUI.tsx
+++ b/src/components/assistant-ui/YouTubeSearchToolUI.tsx
@@ -22,6 +22,24 @@ import { ToolUIErrorBoundary } from "@/components/tool-ui/shared";
const ANIMATION_DURATION = 200;
const SHIMMER_DURATION = 1000;
+/** Format ISO date to relative or short date (e.g. "2 weeks ago", "Mar 2024"). */
+function formatPublishedAt(iso: string): string {
+ try {
+ const date = new Date(iso);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ if (diffDays <= 0) return "today";
+ if (diffDays === 1) return "1 day ago";
+ if (diffDays < 7) return `${diffDays} days ago`;
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? "" : "s"} ago`;
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? "" : "s"} ago`;
+ return date.toLocaleDateString(undefined, { month: "short", year: "numeric" });
+ } catch {
+ return "";
+ }
+}
+
/**
* Root collapsible container that manages open/closed state and scroll lock.
*/
@@ -167,6 +185,8 @@ interface VideoResult {
thumbnailUrl: string;
publishedAt: string;
url: string;
+ duration?: string;
+ viewCount?: string;
}
interface SearchYoutubeArgs {
@@ -291,19 +311,37 @@ const YouTubeSearchContent: FC<{
alt={video.title}
className="object-cover w-full h-full"
/>
+ {video.duration && (
+
+ {video.duration}
+
+ )}
-
+
{/* Video Info */}
{video.title}
-
-
-
- {video.channelTitle}
-
+
+ {video.channelTitle}
+ {(video.viewCount || video.publishedAt) && (
+
+ {video.viewCount && (
+ {video.viewCount} views
+ )}
+ {video.viewCount && video.publishedAt && (
+ •
+ )}
+ {video.publishedAt && (
+ {formatPublishedAt(video.publishedAt)}
+ )}
+
+ )}
diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx
index 91047f51..c0f69aaa 100644
--- a/src/components/assistant-ui/markdown-text.tsx
+++ b/src/components/assistant-ui/markdown-text.tsx
@@ -261,7 +261,6 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => {
allowedTags={{ citation: [] }}
animated={animateConfig}
isAnimating={isRunning}
- caret="block"
className={cn(
"streamdown-content size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
)}
diff --git a/src/components/assistant-ui/reasoning.tsx b/src/components/assistant-ui/reasoning.tsx
index 53bdb2c3..052a58dc 100644
--- a/src/components/assistant-ui/reasoning.tsx
+++ b/src/components/assistant-ui/reasoning.tsx
@@ -235,6 +235,12 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
return lastIndex >= startIndex && lastIndex <= endIndex;
});
+ const isLastMessage = useAuiState(({ thread, message }) => {
+ const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages ?? [];
+ const idx = messages.findIndex((m) => m.id === message.id);
+ return idx >= 0 && idx === messages.length - 1;
+ });
+
// Subscribe to reasoning text length so we re-run scroll effect on each stream chunk
const reasoningTextSnapshot = useAuiState(({ message }) => {
let len = 0;
@@ -275,6 +281,9 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
[isReasoningStreaming],
);
+ // Fully hide old reasoning (not in last message) - no trigger, no content
+ if (!isLastMessage) return null;
+
return (
diff --git a/src/components/assistant-ui/tool-group.tsx b/src/components/assistant-ui/tool-group.tsx
index d353a26b..76080498 100644
--- a/src/components/assistant-ui/tool-group.tsx
+++ b/src/components/assistant-ui/tool-group.tsx
@@ -24,9 +24,9 @@ const ANIMATION_DURATION = 200;
const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
variants: {
variant: {
- outline: "rounded-lg border border-border/50 p-0 mb-4 overflow-hidden",
+ outline: "rounded-lg border px-3 py-2 mb-4",
ghost: "",
- muted: "rounded-lg border border-muted-foreground/30 bg-muted/30 p-0 mb-4 overflow-hidden",
+ muted: "rounded-lg bg-muted/50 px-3 py-2 mb-4",
},
},
defaultVariants: { variant: "outline" },
@@ -104,15 +104,14 @@ function ToolGroupTrigger({
count: number;
active?: boolean;
}) {
- const label = `${count} ${count === 1 ? "action" : "actions"}${active ? "" : " done"}`;
+ const noun = count === 1 ? "action" : "actions";
+ const label = active ? `Taking ${count} ${noun}` : `Took ${count} ${noun}`;
return (
{label}
{active && (
@@ -145,7 +140,7 @@ function ToolGroupTrigger({
-
- {children}
-
+ {children}
);
}
@@ -213,12 +201,32 @@ const ToolGroupImpl: FC<
return lastIndex >= startIndex && lastIndex <= endIndex;
});
- // Default to open, user can manually toggle
- const [open, setOpen] = useState(true);
+ const isLastMessage = useAuiState(({ thread, message }) => {
+ const messages = (thread as unknown as { messages?: Array<{ id?: string }> })?.messages ?? [];
+ const idx = messages.findIndex((m) => m.id === message.id);
+ return idx >= 0 && idx === messages.length - 1;
+ });
+
+ const [isManuallyOpen, setIsManuallyOpen] = useState(isLastMessage);
+ const isOpen = isToolGroupStreaming || isManuallyOpen;
+
+ // Only auto-collapse when this message is no longer the last one (newer messages below)
+ useEffect(() => {
+ if (!isLastMessage) {
+ setIsManuallyOpen(false);
+ }
+ }, [isLastMessage]);
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (isToolGroupStreaming && !open) return;
+ setIsManuallyOpen(open);
+ },
+ [isToolGroupStreaming],
+ );
return (
- // Auto-expand while streaming, auto-collapse when done
-
+
{children}
diff --git a/src/components/chat/CardContextDisplay.tsx b/src/components/chat/CardContextDisplay.tsx
index 2dbb95e7..faf839dd 100644
--- a/src/components/chat/CardContextDisplay.tsx
+++ b/src/components/chat/CardContextDisplay.tsx
@@ -5,6 +5,7 @@ import { useState, useMemo, memo } from "react";
import { X, ChevronDown, ChevronUp } from "lucide-react";
import { MdFormatColorText } from "react-icons/md";
import { useUIStore, selectSelectedCardIdsArray, selectBlockNoteSelection } from "@/lib/stores/ui-store";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShallow } from "zustand/react/shallow";
import type { Item } from "@/lib/workspace-state/types";
@@ -55,34 +56,40 @@ function CardContextDisplayImpl({ items }: CardContextDisplayProps) {
>
{/* BlockNote Selection - Always shown first */}
{blockNoteSelection && (
-
- {/* Selection Indicator / Remove Button Container */}
-
- {/* Text selection icon - visible by default, hidden on hover */}
-
- {/* X icon - hidden by default, visible on hover */}
-
+ {/* Selection Indicator / Remove Button Container */}
+
+ {/* Text selection icon - visible by default, hidden on hover */}
+
+ {/* X icon - hidden by default, visible on hover */}
+
+
+
+ {/* Text Preview */}
+
+ {getTextPreview(blockNoteSelection.text)}
+
+
+
+
+ {blockNoteSelection.text}
+
+
)}
{/* Selected Cards */}
diff --git a/src/components/chat/MessageContextBadges.tsx b/src/components/chat/MessageContextBadges.tsx
index 28661786..2926ec9c 100644
--- a/src/components/chat/MessageContextBadges.tsx
+++ b/src/components/chat/MessageContextBadges.tsx
@@ -4,6 +4,7 @@ import { memo } from "react";
import { useAuiState } from "@assistant-ui/react";
import { MdFormatColorText } from "react-icons/md";
import { BsArrowReturnRight } from "react-icons/bs";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type BlockNoteSelectionMeta = {
cardId: string;
@@ -15,6 +16,7 @@ type ReplySelectionMeta = {
text: string;
messageContext?: string;
userPrompt?: string;
+ title?: string;
};
type MessageCustomMetadata = {
@@ -43,27 +45,38 @@ function MessageContextBadgesImpl() {
return (
{blockNoteSelection && (
-
-
-
- From "{blockNoteSelection.cardName}": {truncate(blockNoteSelection.text, 40)}
-
-
+
+
+
+
+
+ From "{truncate(blockNoteSelection.cardName, 25)}": {truncate(blockNoteSelection.text, 40)}
+
+
+
+
+ {blockNoteSelection.text}
+
+
)}
{replySelections?.map((sel, i) => (
-
-
-
- {truncate(sel.text, 40)}
-
-
+
+
+
+
+
+ {sel.title ? (
+ <>From "{truncate(sel.title, 25)}": {truncate(sel.text, 40)}>
+ ) : (
+ truncate(sel.text, 40)
+ )}
+
+
+
+
+ {sel.title ? `From ${sel.title}: ${sel.text}` : sel.text}
+
+
))}
);
diff --git a/src/components/chat/ReplyContextDisplay.tsx b/src/components/chat/ReplyContextDisplay.tsx
index 64e199e1..d2b0bd42 100644
--- a/src/components/chat/ReplyContextDisplay.tsx
+++ b/src/components/chat/ReplyContextDisplay.tsx
@@ -4,6 +4,7 @@ import { useState, memo } from "react";
import { X, ChevronDown, ChevronUp } from "lucide-react";
import { BsArrowReturnRight } from "react-icons/bs";
import { useUIStore } from "@/lib/stores/ui-store";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
/**
* Displays reply selections as individual chips above the chat input.
@@ -37,34 +38,44 @@ function ReplyContextDisplayImpl() {
} ${!showExpandButton ? "pr-7" : ""}`}
>
{replySelections.map((selection, index) => (
-
- {/* Remove Button Container */}
-
- {/* Blue arrow icon - visible by default, hidden on hover */}
-
- {/* X icon - hidden by default, visible on hover */}
-
+ {/* Selection Text */}
+
+ {selection.title ? (
+ <>From "{truncateText(selection.title, 25)}": {truncateText(selection.text)}>
+ ) : (
+ truncateText(selection.text)
+ )}
+
+
+
+
+ {selection.title ? `From ${selection.title}: ${selection.text}` : selection.text}
+
+
))}
diff --git a/src/components/pdf/AppPdfViewer.tsx b/src/components/pdf/AppPdfViewer.tsx
index 0166f4e0..c5d7d6c4 100644
--- a/src/components/pdf/AppPdfViewer.tsx
+++ b/src/components/pdf/AppPdfViewer.tsx
@@ -202,9 +202,11 @@ const AnnotationSelectionMenu = ({
const TextSelectionMenu = ({
menuWrapperProps,
placement,
- documentId
-}: SelectionSelectionMenuProps & { documentId: string }) => {
+ documentId,
+ itemName,
+}: SelectionSelectionMenuProps & { documentId: string; itemName?: string }) => {
const { provides: selectionCapability } = useSelectionCapability();
+ const { state: scrollState } = useScroll(documentId);
const addReplySelection = useUIStore((state) => state.addReplySelection);
const [copied, setCopied] = useState(false);
@@ -234,8 +236,13 @@ const TextSelectionMenu = ({
const text = lines.join('\n');
if (text && text.trim().length > 0) {
+ const page = scrollState?.currentPage;
+ const title = itemName
+ ? (page != null && page >= 1 ? `${itemName}, p. ${page}` : itemName)
+ : undefined;
addReplySelection({
text: text.trim(),
+ title,
});
scope.clear();
toast.success("Added to context");
@@ -252,7 +259,7 @@ const TextSelectionMenu = ({
console.error("Ask AI Error:", err);
toast.error("Failed to add context");
}
- }, [selectionCapability, documentId, addReplySelection]);
+ }, [selectionCapability, documentId, addReplySelection, scrollState?.currentPage, itemName]);
return (
@@ -1050,7 +1057,7 @@ const AppPdfViewer = ({ pdfSrc, showThumbnails = false, renderHeader, itemName,
pageIndex={pageIndex}
background="rgba(147, 197, 253, 0.55)"
selectionMenu={(props) => (
-
+
)}
/>
{/* Search highlight layer */}
diff --git a/src/components/ui/streamdown-markdown.tsx b/src/components/ui/streamdown-markdown.tsx
index 930f9ae3..5c16f669 100644
--- a/src/components/ui/streamdown-markdown.tsx
+++ b/src/components/ui/streamdown-markdown.tsx
@@ -33,7 +33,6 @@ const StreamdownMarkdownImpl: React.FC = ({
return (
) : (
-
+
)}
{/* OCR processing indicator overlay */}
{isOcrProcessing && (
diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts
index b1c116fb..61915e12 100644
--- a/src/lib/stores/ui-store.ts
+++ b/src/lib/stores/ui-store.ts
@@ -50,7 +50,7 @@ interface UIState {
itemScrollLocked: Map;
// Reply selection state
- replySelections: Array<{ text: string; messageContext?: string; userPrompt?: string }>;
+ replySelections: Array<{ text: string; messageContext?: string; userPrompt?: string; title?: string }>;
// BlockNote text selection state
blockNoteSelection: { cardId: string; cardName: string; text: string } | null;
@@ -124,7 +124,7 @@ interface UIState {
toggleItemScrollLocked: (itemId: string) => void;
// Actions - Reply selection
- addReplySelection: (selection: { text: string; messageContext?: string; userPrompt?: string }) => void;
+ addReplySelection: (selection: { text: string; messageContext?: string; userPrompt?: string; title?: string }) => void;
removeReplySelection: (index: number) => void;
clearReplySelections: () => void;
diff --git a/src/lib/youtube.ts b/src/lib/youtube.ts
index a3b11b65..22c800ce 100644
--- a/src/lib/youtube.ts
+++ b/src/lib/youtube.ts
@@ -1,5 +1,33 @@
import { logger } from "@/lib/utils/logger";
+/**
+ * Parse ISO 8601 duration (PT1H2M30S, PT15M33S, PT30S) to human-readable format.
+ */
+function parseDuration(isoDuration: string): string {
+ const match = isoDuration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
+ if (!match) return "";
+ const hours = parseInt(match[1] ?? "0", 10);
+ const minutes = parseInt(match[2] ?? "0", 10);
+ const seconds = parseInt(match[3] ?? "0", 10);
+ const parts: string[] = [];
+ if (hours > 0) parts.push(hours.toString());
+ parts.push(minutes.toString().padStart(hours > 0 ? 2 : 1, "0"));
+ parts.push(seconds.toString().padStart(2, "0"));
+ return parts.join(":");
+}
+
+/**
+ * Format view count to compact string (e.g. 1234567 -> "1.2M").
+ */
+function formatViewCount(count: string): string {
+ const n = parseInt(count, 10);
+ if (isNaN(n)) return "";
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return n.toString();
+}
+
interface YouTubeSearchResult {
id: {
videoId: string;
@@ -26,6 +54,14 @@ interface YouTubeSearchResponse {
};
}
+interface YouTubeVideosListResponse {
+ items?: Array<{
+ id: string;
+ contentDetails?: { duration?: string };
+ statistics?: { viewCount?: string };
+ }>;
+}
+
export interface VideoResult {
id: string;
title: string;
@@ -34,6 +70,10 @@ export interface VideoResult {
thumbnailUrl: string;
publishedAt: string;
url: string;
+ /** Human-readable duration (e.g. "12:34", "1:23:45") */
+ duration?: string;
+ /** View count as formatted string (e.g. "1.2M views") */
+ viewCount?: string;
}
/**
@@ -75,7 +115,7 @@ export async function searchVideos(query: string, maxResults = 5): Promise