diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e70c0f5..e592951 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -42,7 +42,11 @@ function App() {
// Right sidebar stays open by default and shows whatever's selected
// (or an empty state if nothing is). User can still toggle it manually.
- const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
+ // Note: deliberately NOT gated on viewMode — the preview overlay sits
+ // on top with z-[1000], so leaving the sidebar mounted underneath
+ // costs nothing visually and avoids the collapse-then-reopen layout
+ // shift the user would otherwise see every time they exit preview.
+ const showRightSidebar = rightSidebarOpen
return (
diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx
index 7b6b631..92db19f 100644
--- a/frontend/src/components/discard/DiscardActionBar.tsx
+++ b/frontend/src/components/discard/DiscardActionBar.tsx
@@ -3,7 +3,7 @@ import { RotateCcw, Trash2 } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
-import { usePhotosQuery } from '../../hooks/usePhotosQuery'
+import { usePhotosQuery, stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
@@ -27,6 +27,14 @@ export function DiscardActionBar() {
const restoreMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.restore(ids),
+ // Pull the restored ids out of the discard view immediately. The
+ // user is sitting on flag=discarded so they should disappear from
+ // sight the moment the click lands; the onSuccess invalidate still
+ // reconciles with server truth shortly after.
+ onMutate: (ids) => {
+ usePhotoStore.getState().removePhotosFromTimeline(ids)
+ stripPhotosFromCache(queryClient, ids)
+ },
onSuccess: (_, ids) => {
registerUndoable(
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
@@ -45,6 +53,10 @@ export function DiscardActionBar() {
const deleteSelectedMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
+ onMutate: (ids) => {
+ usePhotoStore.getState().removePhotosFromTimeline(ids)
+ stripPhotosFromCache(queryClient, ids)
+ },
onSuccess: (data: any) => {
const count = data?.deleted ?? 0
const errors = data?.file_errors ?? 0
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 16d2609..d47cbbd 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -28,6 +28,8 @@ import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
+import { usePhotoStore } from '../../store/photoStore'
+import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import {
useLibraryStatsQuery,
LIBRARY_STATS_QUERY_KEY,
@@ -101,6 +103,13 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
+ // Optimistically pull the dropped photos out of the timeline so the
+ // grid reflows the moment the drop lands, instead of waiting for
+ // the network round-trip + invalidation refetch.
+ onMutate: (photoIds) => {
+ usePhotoStore.getState().removePhotosFromTimeline(photoIds)
+ stripPhotosFromCache(queryClient, photoIds)
+ },
onSuccess: (_data, photoIds) => {
registerUndoable(
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx
index a485341..454e675 100644
--- a/frontend/src/components/layout/RightSidebar.tsx
+++ b/frontend/src/components/layout/RightSidebar.tsx
@@ -12,6 +12,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
+import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
@@ -48,6 +49,13 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
+ // Yank the photos from the timeline before the network round-trip
+ // so the grid reflows immediately. Same pattern as the X hotkey
+ // path in useKeyboardShortcuts.
+ onMutate: (ids) => {
+ usePhotoStore.getState().removePhotosFromTimeline(ids)
+ stripPhotosFromCache(queryClient, ids)
+ },
onSuccess: invalidatePhotoQueries,
})
diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx
index 10ab453..fb0b3dd 100644
--- a/frontend/src/components/preview/PreviewView.tsx
+++ b/frontend/src/components/preview/PreviewView.tsx
@@ -14,13 +14,38 @@ import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
- const closePreview = usePhotoStore((s) => s.closePreview)
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
const containerRef = useRef(null)
const previouslyFocusedRef = useRef(null)
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
+ // Snapshot the photo we opened on, captured once at mount via the
+ // store's getState (which is guaranteed to reflect the value the
+ // openPreview action just wrote, even if the React subscription
+ // hasn't been delivered to this component's first render yet). This
+ // is the id we'll restore on close, no matter how many neighbours
+ // the user arrows through inside the preview.
+ const openOriginRef = useRef(
+ activePhotoId ?? usePhotoStore.getState().activePhotoId
+ )
+ const closePreview = useCallback(() => {
+ // Bypass the store action and write the restoration directly so
+ // the snapshot ref is the single source of truth. Falls back to
+ // the live activePhotoId if the ref was somehow never populated
+ // (defensive — openPreview always sets activePhotoId before
+ // PreviewView mounts).
+ const id =
+ openOriginRef.current ?? usePhotoStore.getState().activePhotoId
+ usePhotoStore.setState({
+ viewMode: 'grid',
+ activePhotoId: id,
+ rangeStartId: id,
+ selectedPhotos: id ? [id] : [],
+ previewOriginPhotoId: null,
+ })
+ }, [])
+
// Same hook Timeline uses, so we share one cache entry rather than looking
// it up by key (which broke when the key gained the filter params).
const { data: rawPhotos = [] } = usePhotosQuery()
@@ -93,7 +118,16 @@ export function PreviewView() {
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
// The handlers themselves are stable (refs internally) so the deps
// array stays empty — useHotkeys won't have to re-bind on every render.
- useHotkeys('escape', closePreview, { preventDefault: true })
+ //
+ // Escape is intentionally NOT bound through useHotkeys here. The
+ // grid-level Timeline component listens for Escape on `window` to
+ // clear the current selection, and react-hotkeys-hook binds at
+ // document level — so a single Esc keypress would land in BOTH
+ // handlers. The grid handler would then wipe the selection we just
+ // restored from the preview origin. Instead, escape is handled by
+ // the dialog's onKeyDown below, which runs first (lower in the
+ // bubble chain) and calls stopPropagation so the keypress never
+ // reaches window.
useHotkeys('left', goPrev, { preventDefault: true })
useHotkeys('right', goNext, { preventDefault: true })
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
@@ -123,8 +157,17 @@ export function PreviewView() {
}, [])
// Trap Tab inside the dialog so users can't accidentally tab into the
- // hidden grid behind. Simple cycle implementation.
+ // hidden grid behind. Simple cycle implementation. Also intercepts
+ // Escape and stops propagation before the keypress reaches the
+ // window-level handler in Timeline (which would otherwise wipe the
+ // selection we just restored to the entry photo).
const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ e.stopPropagation()
+ closePreview()
+ return
+ }
if (e.key !== 'Tab') return
const root = containerRef.current
if (!root) return
@@ -156,7 +199,7 @@ export function PreviewView() {
aria-modal="true"
aria-label="Photo preview"
tabIndex={-1}
- className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
+ className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none"
>