Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/components/modals/CardDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,13 @@ export function CardDetailModal({
// Since WorkspaceCard now subscribes directly to its own isSelected state,
// changing the selection will only re-render the affected card, not all cards
const toggleCardSelection = useUIStore((state) => state.toggleCardSelection);
const selectedCardIds = useUIStore((state) => state.selectedCardIds);

// Auto-select card when modal opens
// Auto-select card when modal opens (read selection state imperatively to avoid infinite loops)
useEffect(() => {
if (!isOpen || !item?.id) return;

// Check if card was already selected at the time of opening
const wasAlreadySelected = selectedCardIds.has(item.id);
const wasAlreadySelected = useUIStore.getState().selectedCardIds.has(item.id);

// If not already selected, select it now
if (!wasAlreadySelected) {
Expand All @@ -62,8 +61,6 @@ export function CardDetailModal({
return undefined;
}, [isOpen, item?.id]); // eslint-disable-line react-hooks/exhaustive-deps

// eslint-disable-next-line react-hooks/exhaustive-deps

// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
Expand Down
18 changes: 5 additions & 13 deletions src/components/modals/PDFViewerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"use client";

import { X } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import ItemHeader from "@/components/workspace-canvas/ItemHeader";
import SpotlightModal from "@/components/SpotlightModal";
import { getCardColorCSS, getCardAccentColor, getWhiteTintedColor } from "@/lib/workspace-state/colors";
import type { Item, PdfData } from "@/lib/workspace-state/types";
import { useUIStore, selectSelectedCardIdsArray } from "@/lib/stores/ui-store";
import { useShallow } from "zustand/react/shallow";
import { useUIStore } from "@/lib/stores/ui-store";
import { formatKeyboardShortcut } from "@/lib/utils/keyboard-shortcut";
import { ItemPanelContent } from "@/components/workspace-canvas/ItemPanelContent";

Expand All @@ -33,19 +32,12 @@ export function PDFViewerModal({
const setIsChatExpanded = useUIStore((state) => state.setIsChatExpanded);
const toggleCardSelection = useUIStore((state) => state.toggleCardSelection);

// Use array selector with shallow comparison to prevent unnecessary re-renders and SSR issues
const selectedCardIdsArray = useUIStore(
useShallow(selectSelectedCardIdsArray)
);
const selectedCardIds = useMemo(() => new Set(selectedCardIdsArray), [selectedCardIdsArray]);

// Track whether we selected the card (so we know whether to deselect on cleanup)
// Auto-select card when modal opens (read selection state imperatively to avoid infinite loops)
useEffect(() => {
// Only run when modal is open and we have an item
if (!isOpen || !item?.id) return;

// Check if card was already selected at the time of opening
const wasAlreadySelected = selectedCardIds.has(item.id);
const wasAlreadySelected = useUIStore.getState().selectedCardIds.has(item.id);

// If not already selected, select it now (adds it to context)
if (!wasAlreadySelected) {
Expand All @@ -59,7 +51,7 @@ export function PDFViewerModal({

// If it was already selected, don't change anything on cleanup
return undefined;
}, [isOpen, item?.id, selectedCardIds, toggleCardSelection]);
}, [isOpen, item?.id]); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
Expand Down
26 changes: 4 additions & 22 deletions src/components/workspace-canvas/FolderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ function FolderCardComponent({
const [isDragHover, setIsDragHover] = useState(false);
const [selectedCount, setSelectedCount] = useState<number | null>(null);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);

// Subscribe directly to this folder's selection state from the store
const isSelected = useUIStore(
Expand All @@ -103,19 +102,8 @@ function FolderCardComponent({

const folderColor = item.color || "#6366F1"; // Default to indigo

// Auto-focus and scroll into view for newly created folders (name is "New Folder")
useEffect(() => {
if (item.name === "New Folder") {
setShouldAutoFocus(true);
// Scroll the folder card into view
const element = document.getElementById(`item-${item.id}`);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
}, [item.id, item.name]);
// Note: Auto-focus removed - user must click to edit title
// Scroll behavior is handled by useReactiveNavigation hook

// Listen for drag hover events
useEffect(() => {
Expand Down Expand Up @@ -406,19 +394,13 @@ function FolderCardComponent({
subtitle=""
description=""
onNameChange={handleNameChange}
onNameCommit={(value) => {
handleNameCommit(value);
// Clear auto-focus after first commit
if (shouldAutoFocus) {
setShouldAutoFocus(false);
}
}}
onNameCommit={handleNameCommit}
onSubtitleChange={() => { }}
onTitleFocus={() => setIsEditingTitle(true)}
onTitleBlur={() => setIsEditingTitle(false)}
readOnly={false}
noMargin={true}
autoFocus={shouldAutoFocus}
autoFocus={false}
/>
{/* Item count as subtext */}
<p className="text-sm text-muted-foreground mt-1">
Expand Down
24 changes: 12 additions & 12 deletions src/components/workspace-canvas/SelectionActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,28 @@ export default function SelectionActionBar({
return (
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 bottom-4",
"inline-flex items-center gap-2 px-3 py-2 rounded-md",
"absolute inset-x-0 mx-auto bottom-4 max-w-[calc(100%-2rem)] w-fit",
"flex items-center gap-2 px-3 py-2 rounded-md overflow-hidden",
"bg-white/5 border border-white/10",
"shadow-lg backdrop-blur-md",
"transition-all duration-300 ease-out",
"animate-in slide-in-from-bottom-4"
)}
>
{/* Selection count */}
<span className="text-sm font-medium text-foreground/90 whitespace-nowrap dark:text-white/90">
<span className="text-sm font-medium text-foreground/90 dark:text-white/90 truncate min-w-0">
{isCompactMode ? (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-foreground/90 dark:text-white/90" />
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 text-foreground/90 dark:text-white/90" />
<span>{selectedCount}</span>
</div>
</span>
) : (
`${selectedCount} ${selectedCount === 1 ? 'item' : 'items'} selected`
)}
</span>

{/* Separator */}
<div className="h-5 w-px bg-foreground/20 dark:bg-white/20" />
<div className="h-5 w-px shrink-0 bg-foreground/20 dark:bg-white/20" />

{/* New Folder Button */}
<Tooltip>
Expand All @@ -53,7 +53,7 @@ export default function SelectionActionBar({
type="button"
onClick={onCreateFolderFromSelection}
className={cn(
"inline-flex items-center gap-2 px-2 py-2 rounded-md",
"inline-flex items-center gap-2 px-2 py-2 rounded-md shrink-0",
"text-sm font-medium text-amber-400",
"bg-amber-500/10 border border-amber-500/20",
"hover:bg-amber-500/20 hover:border-amber-500/30",
Expand All @@ -75,7 +75,7 @@ export default function SelectionActionBar({
type="button"
onClick={onMoveSelected}
className={cn(
"inline-flex items-center gap-2 px-2 py-2 rounded-md",
"inline-flex items-center gap-2 px-2 py-2 rounded-md shrink-0",
"text-sm font-medium text-blue-400",
"bg-blue-500/10 border border-blue-500/20",
"hover:bg-blue-500/20 hover:border-blue-500/30",
Expand All @@ -97,7 +97,7 @@ export default function SelectionActionBar({
type="button"
onClick={onDeleteSelected}
className={cn(
"inline-flex items-center gap-2 px-2 py-2 rounded-md",
"inline-flex items-center gap-2 px-2 py-2 rounded-md shrink-0",
"text-sm font-medium text-red-400",
"bg-red-500/10 border border-red-500/20",
"hover:bg-red-500/20 hover:border-red-500/30",
Expand All @@ -113,7 +113,7 @@ export default function SelectionActionBar({
</Tooltip>

{/* Separator before Clear */}
<div className="h-5 w-px bg-foreground/20 dark:bg-white/20" />
<div className="h-5 w-px shrink-0 bg-foreground/20 dark:bg-white/20" />

{/* Clear Selection Button */}
<Tooltip>
Expand All @@ -122,7 +122,7 @@ export default function SelectionActionBar({
type="button"
onClick={onClearSelection}
className={cn(
"inline-flex items-center justify-center p-2 rounded-md",
"inline-flex items-center justify-center p-2 rounded-md shrink-0",
"text-foreground/60 dark:text-white/60",
"hover:text-foreground/90 hover:bg-foreground/5 dark:hover:text-white/90 dark:hover:bg-white/5",
"transition-all duration-200"
Expand Down
1 change: 1 addition & 0 deletions src/components/workspace-canvas/WorkspaceGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ export function WorkspaceGrid({
`}</style>
{mounted && (
<ResponsiveGridLayout
key={singleColumnMode ? 'single-col' : 'multi-col'}
className="layout"
width={width}
layouts={layouts}
Expand Down
13 changes: 10 additions & 3 deletions src/components/workspace-canvas/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ export default function WorkspaceHeader({
const { toggleWorkspaceSplitView } = useUIStore.getState();
toggleWorkspaceSplitView();
}}
className="h-8 flex items-center justify-center gap-1.5 rounded-md border border-sidebar-border text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors cursor-pointer px-3"
className="h-8 flex items-center justify-center gap-1.5 rounded-md border border-sidebar-border text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors cursor-pointer px-3 mr-2"
aria-label={workspaceSplitViewActive ? "Focus" : "Split View"}
>
{workspaceSplitViewActive ? (
Expand All @@ -833,7 +833,7 @@ export default function WorkspaceHeader({
</button>
</TooltipTrigger>
<TooltipContent>
{workspaceSplitViewActive ? "Focus on this item" : "Split View"}
{workspaceSplitViewActive ? "Focus on this item" : "Split"}
</TooltipContent>
</Tooltip>

Expand Down Expand Up @@ -962,7 +962,14 @@ export default function WorkspaceHeader({
}
}
},
onCreateFolder: () => { if (addItem) addItem("folder"); },
onCreateFolder: () => {
if (addItem) {
const itemId = addItem("folder");
if (onItemCreated && itemId) {
onItemCreated([itemId]);
}
}
},
onUpload: () => { setShowUploadDialog(true); setIsNewMenuOpen(false); },
onAudio: () => { openAudioDialog(); setIsNewMenuOpen(false); },
onYouTube: () => { setShowYouTubeDialog(true); setIsNewMenuOpen(false); },
Expand Down
24 changes: 15 additions & 9 deletions src/components/workspace-canvas/WorkspaceSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { RefObject, useState, useMemo, useCallback } from "react";
import { useElementWidth } from "@/hooks/use-element-width";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -164,6 +165,9 @@ export function WorkspaceSection({
const setSelectedActions = useUIStore((state) => state.setSelectedActions);
const { data: session } = useSession();

// Measure container width for responsive SelectionActionBar
const containerWidth = useElementWidth(scrollAreaRef);

// Assistant API for Deep Research action
// Note: WorkspaceSection is inside WorkspaceRuntimeProvider in DashboardLayout, so this hook works
const aui = useAui();
Expand Down Expand Up @@ -416,7 +420,8 @@ export function WorkspaceSection({
// Clear the selection
clearCardSelection();

// Note: FolderCard auto-focuses the title when name is "New Folder"
// Navigate to the newly created folder
handleCreatedItems([folderId]);
};

// Handle PDF upload from BottomActionBar
Expand Down Expand Up @@ -645,22 +650,23 @@ export function WorkspaceSection({
onCreateNote: () => {
if (addItem) {
const itemId = addItem("note");
if (handleCreatedItems && itemId) {
handleCreatedItems([itemId]);
}
handleCreatedItems([itemId]);
}
Comment on lines 650 to +654
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing possibly undefined ID

addItem is typed to return string, but several call sites pass its return value directly into handleCreatedItems([itemId]) (e.g. note/folder/flashcard). If addItem ever returns undefined (as noted elsewhere in this PR), useReactiveNavigation will set pendingNavigationId to undefined and then call navigateToItem(undefined), which is a runtime bug. Either make addItem consistently return a string everywhere, or guard before calling handleCreatedItems.

Also appears at src/components/workspace-canvas/WorkspaceSection.tsx:656-670.

},
onCreateFolder: () => {
if (addItem) {
const itemId = addItem("folder");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type mismatch: addItem in this component has type string | void (line 37 of WorkspaceGrid.tsx), but WorkspaceHeader.tsx declares it as returning string. The null check if (itemId) guards against void, but the types should be consistent across components.

handleCreatedItems([itemId]);
}
},
onCreateFolder: () => { if (addItem) addItem("folder"); },
onUpload: () => handleUploadMenuItemClick(),
onAudio: () => openAudioDialog(),
onYouTube: () => setShowYouTubeDialog(true),
onWebsite: () => setShowWebsiteDialog(true),
onFlashcards: () => {
if (addItem) {
const itemId = addItem("flashcard");
if (handleCreatedItems && itemId) {
handleCreatedItems([itemId]);
}
handleCreatedItems([itemId]);
}
},
onQuiz: () => {
Expand Down Expand Up @@ -690,7 +696,7 @@ export function WorkspaceSection({
onDeleteSelected={handleDeleteRequest}
onCreateFolderFromSelection={handleCreateFolderFromSelection}
onMoveSelected={handleMoveSelected}
isCompactMode={isItemPanelOpen && isChatExpanded}
isCompactMode={containerWidth !== undefined && containerWidth < 400}
/>
)}
{/* Move To Dialog */}
Expand Down
9 changes: 2 additions & 7 deletions src/hooks/ui/use-reactive-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@

import { useState, useEffect, useCallback } from "react";
import { useUIStore } from "@/lib/stores/ui-store";
import { useNavigateToItem } from "./use-navigate-to-item";
import type { AgentState } from "@/lib/workspace-state/types";

/**
* Hook to handle navigation and selection after item creation.
* Hook to handle navigation after item creation.
* It waits for the item to appear in the workspace state before attempting to scroll to it,
* solving race conditions and stale closure issues.
*/
export function useReactiveNavigation(workspaceState: AgentState) {
const [pendingNavigationId, setPendingNavigationId] = useState<string | null>(null);
const navigateToItem = useNavigateToItem();
const selectMultipleCards = useUIStore((state) => state.selectMultipleCards);

const handleCreatedItems = useCallback((createdIds: string[]) => {
// Select the newly created items
selectMultipleCards(createdIds);

// Set pending navigation to trigger in useEffect once item is available in state
if (createdIds.length > 0) {
setPendingNavigationId(createdIds[0]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accepts array but only uses first item

handleCreatedItems takes an array but setPendingNavigationId(createdIds[0]) only navigates to the first item. If multiple items are created simultaneously, only the first receives navigation focus.

Suggested change
setPendingNavigationId(createdIds[0]);
setPendingNavigationId(createdIds[createdIds.length - 1]);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
}, [selectMultipleCards]);
}, []);

// Effect to handle navigation once item appears in state
useEffect(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/stores/ui-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const useUIStore = create<UIState>()(
activeFolderId: folderId,
openPanelIds: [],
maximizedItemId: null,
workspaceSplitViewActive: false,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
};
Expand All @@ -217,6 +218,7 @@ export const useUIStore = create<UIState>()(
activeFolderId: null,
openPanelIds: [],
maximizedItemId: null,
workspaceSplitViewActive: false,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
};
Expand All @@ -236,6 +238,7 @@ export const useUIStore = create<UIState>()(
return {
openPanelIds: [],
maximizedItemId: null,
workspaceSplitViewActive: false,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
};
Expand Down Expand Up @@ -288,6 +291,7 @@ export const useUIStore = create<UIState>()(
return {
openPanelIds: [],
maximizedItemId: null,
workspaceSplitViewActive: false,
selectedCardIds: newSelectedCardIds,
panelAutoSelectedCardIds: new Set(),
};
Expand Down Expand Up @@ -512,6 +516,7 @@ export const useUIStore = create<UIState>()(
openPanelIds: [],
itemPrompt: null,
maximizedItemId: null,
workspaceSplitViewActive: false,
showVersionHistory: false,
showCreateWorkspaceModal: false,
showSheetModal: false,
Expand Down