| author | |
| committer | |
| log | bde39d4d7977ce85d6ea396a983d6b6a25bf7e07 |
| tree | 5d8a1e5c6f325bccb780f376370c4a0d5145e084 |
| parent | eb115063634c416c6362cc407f8572cb0c136ddf |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 462 insertions(+), 8 deletions(-)
apps/web/src/components/ChatView.tsx+51| ... | @@ -189,6 +189,10 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; | ... | @@ -189,6 +189,10 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 189 | import { subscribePreviewAction } from "./preview/previewActionBus"; | 189 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 190 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; | 190 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 191 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | 191 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| 192 | import { | ||
| 193 | isSameSidebarThreadRef, | ||
| 194 | useSidebarPendingFileDropStore, | ||
| 195 | } from "../sidebarPendingFileDropStore"; | ||
| 192 | import { | 196 | import { |
| 193 | selectThreadPreviewMiniPlayer, | 197 | selectThreadPreviewMiniPlayer, |
| 194 | usePreviewMiniPlayerStore, | 198 | usePreviewMiniPlayerStore, |
| ... | @@ -7867,6 +7871,53 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -7867,6 +7871,53 @@ export default function ChatView(props: ChatViewProps) { |
| 7867 | void onRevertToTurnCountRef.current(targetTurnCount); | 7871 | void onRevertToTurnCountRef.current(targetTurnCount); |
| 7868 | }, []); | 7872 | }, []); |
| 7869 | 7873 | ||
| 7874 | // Files dropped on a sidebar row land here once the dropped-on thread is | ||
| 7875 | // actually open, then take the exact same path as a workspace drop: | ||
| 7876 | // validate, compress, focus the composer, never send. Kept above the | ||
| 7877 | // no-active-thread early return so hook order never changes. | ||
| 7878 | const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); | ||
| 7879 | const consumePendingFileDrop = useSidebarPendingFileDropStore( | ||
| 7880 | (state) => state.consumePendingFileDrop, | ||
| 7881 | ); | ||
| 7882 | useEffect(() => { | ||
| 7883 | if (pendingSidebarFileDrops.length === 0) return; | ||
| 7884 | // A promoting draft can mount this view with the server thread id while | ||
| 7885 | // its composer is still draft-keyed; finalization would discard what we | ||
| 7886 | // attach there. Only the canonical thread target may consume a drop. | ||
| 7887 | if ( | ||
| 7888 | typeof composerDraftTarget === "string" || | ||
| 7889 | !pendingSidebarFileDrops.some((drop) => | ||
| 7890 | isSameSidebarThreadRef(composerDraftTarget, drop.threadRef), | ||
| 7891 | ) | ||
| 7892 | ) { | ||
| 7893 | return; | ||
| 7894 | } | ||
| 7895 | if (!activeThread) return; | ||
| 7896 | if (!composerRef.current) { | ||
| 7897 | const raf = window.requestAnimationFrame(() => { | ||
| 7898 | if (!composerRef.current) return; | ||
| 7899 | if (typeof composerDraftTarget === "string") return; | ||
| 7900 | // Consume matches by target, so a newer drop that arrived meanwhile | ||
| 7901 | // is collected too rather than orphaned. | ||
| 7902 | const files = consumePendingFileDrop(composerDraftTarget); | ||
| 7903 | if (files !== null) { | ||
| 7904 | composerRef.current?.addDroppedFiles(files); | ||
| 7905 | } | ||
| 7906 | }); | ||
| 7907 | return () => window.cancelAnimationFrame(raf); | ||
| 7908 | } | ||
| 7909 | const files = consumePendingFileDrop(composerDraftTarget); | ||
| 7910 | if (files !== null) { | ||
| 7911 | composerRef.current.addDroppedFiles(files); | ||
| 7912 | } | ||
| 7913 | }, [ | ||
| 7914 | activeThread, | ||
| 7915 | composerDraftTarget, | ||
| 7916 | composerRef, | ||
| 7917 | consumePendingFileDrop, | ||
| 7918 | pendingSidebarFileDrops, | ||
| 7919 | ]); | ||
| 7920 | |||
| 7870 | // Empty state: no active thread | 7921 | // Empty state: no active thread |
| 7871 | if (!activeThread) { | 7922 | if (!activeThread) { |
| 7872 | return <NoActiveThreadState />; | 7923 | return <NoActiveThreadState />; |
apps/web/src/components/LegacySidebar.tsx+51-4| ... | @@ -78,6 +78,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; | ... | @@ -78,6 +78,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 78 | import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; | 78 | import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; |
| 79 | import { isTerminalFocused } from "../lib/terminalFocus"; | 79 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 80 | import { isMacPlatform } from "../lib/utils"; | 80 | import { isMacPlatform } from "../lib/utils"; |
| 81 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | ||
| 82 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | ||
| 81 | import { | 83 | import { |
| 82 | readThreadShell, | 84 | readThreadShell, |
| 83 | useProjects, | 85 | useProjects, |
| ... | @@ -326,7 +328,7 @@ interface SidebarThreadRowProps { | ... | @@ -326,7 +328,7 @@ interface SidebarThreadRowProps { |
| 326 | threadRef: ScopedThreadRef, | 328 | threadRef: ScopedThreadRef, |
| 327 | orderedProjectThreadKeys: readonly string[], | 329 | orderedProjectThreadKeys: readonly string[], |
| 328 | ) => void; | 330 | ) => void; |
| 329 | navigateToThread: (threadRef: ScopedThreadRef) => void; | 331 | navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>; |
| 330 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; | 332 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 331 | handleThreadContextMenu: ( | 333 | handleThreadContextMenu: ( |
| 332 | threadRef: ScopedThreadRef, | 334 | threadRef: ScopedThreadRef, |
| ... | @@ -345,6 +347,7 @@ interface SidebarThreadRowProps { | ... | @@ -345,6 +347,7 @@ interface SidebarThreadRowProps { |
| 345 | prUrl: string, | 347 | prUrl: string, |
| 346 | threadRef?: ScopedThreadRef, | 348 | threadRef?: ScopedThreadRef, |
| 347 | ) => boolean; | 349 | ) => boolean; |
| 350 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | ||
| 348 | } | 351 | } |
| 349 | 352 | ||
| 350 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { | 353 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { |
| ... | @@ -372,10 +375,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -372,10 +375,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 372 | cancelRename, | 375 | cancelRename, |
| 373 | attemptArchiveThread, | 376 | attemptArchiveThread, |
| 374 | openPrLink, | 377 | openPrLink, |
| 378 | onFileDropThreads, | ||
| 375 | thread, | 379 | thread, |
| 376 | } = props; | 380 | } = props; |
| 377 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); | 381 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 378 | const threadKey = scopedThreadKey(threadRef); | 382 | const threadKey = scopedThreadKey(threadRef); |
| 383 | const [isFileDragOver, setIsFileDragOver] = useState(false); | ||
| 384 | const fileDropHandlers = useMemo( | ||
| 385 | () => | ||
| 386 | makeWorkspaceFileDropHandlers({ | ||
| 387 | setDragActive: setIsFileDragOver, | ||
| 388 | addFiles: (files) => { | ||
| 389 | onFileDropThreads(threadRef, files); | ||
| 390 | }, | ||
| 391 | }), | ||
| 392 | [onFileDropThreads, threadRef], | ||
| 393 | ); | ||
| 394 | useEffect(() => { | ||
| 395 | if (!isFileDragOver) return; | ||
| 396 | const clearFileDrag = () => setIsFileDragOver(false); | ||
| 397 | window.addEventListener("dragend", clearFileDrag); | ||
| 398 | return () => window.removeEventListener("dragend", clearFileDrag); | ||
| 399 | }, [isFileDragOver]); | ||
| 379 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); | 400 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); |
| 380 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); | 401 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 381 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); | 402 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| ... | @@ -663,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -663,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 663 | ref={rowRef} | 684 | ref={rowRef} |
| 664 | className="w-full" | 685 | className="w-full" |
| 665 | data-thread-item | 686 | data-thread-item |
| 687 | {...fileDropHandlers} | ||
| 666 | onMouseLeave={handleMouseLeave} | 688 | onMouseLeave={handleMouseLeave} |
| 667 | onBlurCapture={handleBlurCapture} | 689 | onBlurCapture={handleBlurCapture} |
| 668 | > | 690 | > |
| ... | @@ -674,7 +696,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -674,7 +696,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 674 | className={`${resolveThreadRowClassName({ | 696 | className={`${resolveThreadRowClassName({ |
| 675 | isActive, | 697 | isActive, |
| 676 | isSelected, | 698 | isSelected, |
| 677 | })} relative isolate`} | 699 | })} relative isolate${isFileDragOver ? " ring-1 ring-inset ring-primary/70" : ""}`} |
| 678 | onClick={handleRowClick} | 700 | onClick={handleRowClick} |
| 679 | onDoubleClick={handleRowDoubleClick} | 701 | onDoubleClick={handleRowDoubleClick} |
| 680 | onKeyDown={handleRowKeyDown} | 702 | onKeyDown={handleRowKeyDown} |
| ... | @@ -916,7 +938,8 @@ interface SidebarProjectThreadListProps { | ... | @@ -916,7 +938,8 @@ interface SidebarProjectThreadListProps { |
| 916 | threadRef: ScopedThreadRef, | 938 | threadRef: ScopedThreadRef, |
| 917 | orderedProjectThreadKeys: readonly string[], | 939 | orderedProjectThreadKeys: readonly string[], |
| 918 | ) => void; | 940 | ) => void; |
| 919 | navigateToThread: (threadRef: ScopedThreadRef) => void; | 941 | navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>; |
| 942 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | ||
| 920 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; | 943 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 921 | handleThreadContextMenu: ( | 944 | handleThreadContextMenu: ( |
| 922 | threadRef: ScopedThreadRef, | 945 | threadRef: ScopedThreadRef, |
| ... | @@ -968,6 +991,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( | ... | @@ -968,6 +991,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 968 | attachThreadListAutoAnimateRef, | 991 | attachThreadListAutoAnimateRef, |
| 969 | handleThreadClick, | 992 | handleThreadClick, |
| 970 | navigateToThread, | 993 | navigateToThread, |
| 994 | onFileDropThreads, | ||
| 971 | handleMultiSelectContextMenu, | 995 | handleMultiSelectContextMenu, |
| 972 | handleThreadContextMenu, | 996 | handleThreadContextMenu, |
| 973 | clearSelection, | 997 | clearSelection, |
| ... | @@ -1019,6 +1043,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( | ... | @@ -1019,6 +1043,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 1019 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} | 1043 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} |
| 1020 | handleThreadClick={handleThreadClick} | 1044 | handleThreadClick={handleThreadClick} |
| 1021 | navigateToThread={navigateToThread} | 1045 | navigateToThread={navigateToThread} |
| 1046 | onFileDropThreads={onFileDropThreads} | ||
| 1022 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} | 1047 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} |
| 1023 | handleThreadContextMenu={handleThreadContextMenu} | 1048 | handleThreadContextMenu={handleThreadContextMenu} |
| 1024 | clearSelection={clearSelection} | 1049 | clearSelection={clearSelection} |
| ... | @@ -1136,6 +1161,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1136,6 +1161,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1136 | (settings) => settings.sidebarThreadPreviewCount, | 1161 | (settings) => settings.sidebarThreadPreviewCount, |
| 1137 | ); | 1162 | ); |
| 1138 | const router = useRouter(); | 1163 | const router = useRouter(); |
| 1164 | const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); | ||
| 1165 | const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); | ||
| 1139 | const { isMobile, setOpenMobile } = useSidebar(); | 1166 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1140 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); | 1167 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); |
| 1141 | const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); | 1168 | const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); |
| ... | @@ -1740,13 +1767,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1740,13 +1767,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1740 | if (isMobile) { | 1767 | if (isMobile) { |
| 1741 | setOpenMobile(false); | 1768 | setOpenMobile(false); |
| 1742 | } | 1769 | } |
| 1743 | void router.navigate({ | 1770 | return router.navigate({ |
| 1744 | to: "/$environmentId/$threadId", | 1771 | to: "/$environmentId/$threadId", |
| 1745 | params: buildThreadRouteParams(threadRef), | 1772 | params: buildThreadRouteParams(threadRef), |
| 1746 | }); | 1773 | }); |
| 1747 | }, | 1774 | }, |
| 1748 | [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], | 1775 | [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], |
| 1749 | ); | 1776 | ); |
| 1777 | const handleThreadFileDrop = useCallback( | ||
| 1778 | async (threadRef: ScopedThreadRef, files: File[]) => { | ||
| 1779 | const dropId = queuePendingFileDrop({ threadRef, files }); | ||
| 1780 | const targetPathname = router.buildLocation({ | ||
| 1781 | to: "/$environmentId/$threadId", | ||
| 1782 | params: buildThreadRouteParams(threadRef), | ||
| 1783 | }).pathname; | ||
| 1784 | if (targetPathname === router.state.location.pathname) return; | ||
| 1785 | try { | ||
| 1786 | await navigateToThread(threadRef); | ||
| 1787 | if (targetPathname !== router.state.location.pathname) { | ||
| 1788 | clearPendingFileDrop(dropId); | ||
| 1789 | } | ||
| 1790 | } catch { | ||
| 1791 | clearPendingFileDrop(dropId); | ||
| 1792 | } | ||
| 1793 | }, | ||
| 1794 | [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], | ||
| 1795 | ); | ||
| 1750 | 1796 | ||
| 1751 | const handleThreadClick = useCallback( | 1797 | const handleThreadClick = useCallback( |
| 1752 | ( | 1798 | ( |
| ... | @@ -2409,6 +2455,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -2409,6 +2455,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2409 | attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} | 2455 | attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} |
| 2410 | handleThreadClick={handleThreadClick} | 2456 | handleThreadClick={handleThreadClick} |
| 2411 | navigateToThread={navigateToThread} | 2457 | navigateToThread={navigateToThread} |
| 2458 | onFileDropThreads={handleThreadFileDrop} | ||
| 2412 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} | 2459 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} |
| 2413 | handleThreadContextMenu={handleThreadContextMenu} | 2460 | handleThreadContextMenu={handleThreadContextMenu} |
| 2414 | clearSelection={clearSelection} | 2461 | clearSelection={clearSelection} |
apps/web/src/components/Sidebar.tsx+108-2| ... | @@ -94,6 +94,10 @@ import { isMacPlatform } from "~/lib/utils"; | ... | @@ -94,6 +94,10 @@ import { isMacPlatform } from "~/lib/utils"; |
| 94 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 94 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 95 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; | 95 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; |
| 96 | import { readLocalApi } from "../localApi"; | 96 | import { readLocalApi } from "../localApi"; |
| 97 | import { | ||
| 98 | isSameSidebarThreadRef, | ||
| 99 | useSidebarPendingFileDropStore, | ||
| 100 | } from "../sidebarPendingFileDropStore"; | ||
| 97 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | 101 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 98 | import { | 102 | import { |
| 99 | buildSidebarProjectSnapshots, | 103 | buildSidebarProjectSnapshots, |
| ... | @@ -195,6 +199,7 @@ import { | ... | @@ -195,6 +199,7 @@ import { |
| 195 | type SnoozePreset, | 199 | type SnoozePreset, |
| 196 | } from "./Sidebar.snooze"; | 200 | } from "./Sidebar.snooze"; |
| 197 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; | 201 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 202 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | ||
| 198 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | 203 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 199 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; | 204 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 200 | import { | 205 | import { |
| ... | @@ -980,6 +985,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -980,6 +985,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 980 | onUnsnooze: (threadRef: ScopedThreadRef) => void; | 985 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 981 | onUnpin: (threadRef: ScopedThreadRef) => void; | 986 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 982 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; | 987 | onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; |
| 988 | /** | ||
| 989 | * External files dropped onto this row. The row highlights while the drag | ||
| 990 | * is over it; the callback opens the thread and hands the files to its | ||
| 991 | * composer. Absent when the sidebar cannot open server threads. | ||
| 992 | */ | ||
| 993 | onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; | ||
| 983 | }) { | 994 | }) { |
| 984 | const { | 995 | const { |
| 985 | isRenaming, | 996 | isRenaming, |
| ... | @@ -987,6 +998,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -987,6 +998,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 987 | onCommitRename, | 998 | onCommitRename, |
| 988 | onContextMenu, | 999 | onContextMenu, |
| 989 | onAcknowledgeWoke, | 1000 | onAcknowledgeWoke, |
| 1001 | onFileDropThreads, | ||
| 990 | onRenameTitleChange, | 1002 | onRenameTitleChange, |
| 991 | onSettle, | 1003 | onSettle, |
| 992 | onSnooze, | 1004 | onSnooze, |
| ... | @@ -1224,6 +1236,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1224,6 +1236,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1224 | }, | 1236 | }, |
| 1225 | [isRenaming, onStartRename, thread.title, threadRef], | 1237 | [isRenaming, onStartRename, thread.title, threadRef], |
| 1226 | ); | 1238 | ); |
| 1239 | const [isFileDragOver, setIsFileDragOver] = useState(false); | ||
| 1240 | const fileDropHandlers = useMemo( | ||
| 1241 | () => | ||
| 1242 | onFileDropThreads | ||
| 1243 | ? makeWorkspaceFileDropHandlers({ | ||
| 1244 | setDragActive: setIsFileDragOver, | ||
| 1245 | addFiles: (files) => { | ||
| 1246 | onFileDropThreads(threadRef, files); | ||
| 1247 | }, | ||
| 1248 | }) | ||
| 1249 | : null, | ||
| 1250 | [onFileDropThreads, threadRef], | ||
| 1251 | ); | ||
| 1252 | // A drop lands on a child or outside the window entirely, so dragend is | ||
| 1253 | // the reset of last resort for the row's highlight. | ||
| 1254 | useEffect(() => { | ||
| 1255 | if (!isFileDragOver) return; | ||
| 1256 | const clearFileDrag = () => setIsFileDragOver(false); | ||
| 1257 | window.addEventListener("dragend", clearFileDrag); | ||
| 1258 | return () => window.removeEventListener("dragend", clearFileDrag); | ||
| 1259 | }, [isFileDragOver]); | ||
| 1227 | const renameCommittedRef = useRef(false); | 1260 | const renameCommittedRef = useRef(false); |
| 1228 | useEffect(() => { | 1261 | useEffect(() => { |
| 1229 | if (isRenaming) renameCommittedRef.current = false; | 1262 | if (isRenaming) renameCommittedRef.current = false; |
| ... | @@ -1338,6 +1371,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1338,6 +1371,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1338 | !props.isActive && | 1371 | !props.isActive && |
| 1339 | !isSelected && | 1372 | !isSelected && |
| 1340 | "opacity-70 transition-opacity hover:opacity-100", | 1373 | "opacity-70 transition-opacity hover:opacity-100", |
| 1374 | isFileDragOver && "ring-1 ring-inset ring-primary/70", | ||
| 1375 | // The hover tint must not clobber an active/selected row's own surface. | ||
| 1376 | isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", | ||
| 1341 | // The lifted row is an opaque card so the rows beneath it never show | 1377 | // The lifted row is an opaque card so the rows beneath it never show |
| 1342 | // through. The row tint is translucent in dark themes and the pointer | 1378 | // through. The row tint is translucent in dark themes and the pointer |
| 1343 | // keeps the hover color applied, so both the tint and the solid sidebar | 1379 | // keeps the hover color applied, so both the tint and the solid sidebar |
| ... | @@ -1505,6 +1541,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1505,6 +1541,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1505 | <li | 1541 | <li |
| 1506 | data-thread-item | 1542 | data-thread-item |
| 1507 | {...sortableRootProps} | 1543 | {...sortableRootProps} |
| 1544 | {...(fileDropHandlers ?? {})} | ||
| 1508 | className={cn( | 1545 | className={cn( |
| 1509 | // Matches the h-9 row so unrendered rows never shift the list when they paint. | 1546 | // Matches the h-9 row so unrendered rows never shift the list when they paint. |
| 1510 | "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", | 1547 | "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", |
| ... | @@ -1657,6 +1694,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1657,6 +1694,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1657 | <li | 1694 | <li |
| 1658 | data-thread-item | 1695 | data-thread-item |
| 1659 | {...sortableRootProps} | 1696 | {...sortableRootProps} |
| 1697 | {...(fileDropHandlers ?? {})} | ||
| 1660 | className={cn( | 1698 | className={cn( |
| 1661 | // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. | 1699 | // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. |
| 1662 | "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", | 1700 | "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", |
| ... | @@ -1919,8 +1957,13 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -1919,8 +1957,13 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1919 | resultId: string; | 1957 | resultId: string; |
| 1920 | onHighlight: () => void; | 1958 | onHighlight: () => void; |
| 1921 | onSelect: () => void; | 1959 | onSelect: () => void; |
| 1960 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | ||
| 1922 | }) { | 1961 | }) { |
| 1923 | const { thread } = props; | 1962 | const { thread } = props; |
| 1963 | const threadRef = useMemo( | ||
| 1964 | () => scopeThreadRef(thread.environmentId, thread.id), | ||
| 1965 | [thread.environmentId, thread.id], | ||
| 1966 | ); | ||
| 1924 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( | 1967 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( |
| 1925 | props.isHighlighted || props.isRouteActive, | 1968 | props.isHighlighted || props.isRouteActive, |
| 1926 | ); | 1969 | ); |
| ... | @@ -1961,8 +2004,25 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -1961,8 +2004,25 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1961 | threadId: thread.id, | 2004 | threadId: thread.id, |
| 1962 | }); | 2005 | }); |
| 1963 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 2006 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 2007 | const [isFileDragOver, setIsFileDragOver] = useState(false); | ||
| 2008 | const fileDropHandlers = useMemo( | ||
| 2009 | () => | ||
| 2010 | makeWorkspaceFileDropHandlers({ | ||
| 2011 | setDragActive: setIsFileDragOver, | ||
| 2012 | addFiles: (files) => { | ||
| 2013 | props.onFileDropThreads(threadRef, files); | ||
| 2014 | }, | ||
| 2015 | }), | ||
| 2016 | [props.onFileDropThreads, threadRef], | ||
| 2017 | ); | ||
| 2018 | useEffect(() => { | ||
| 2019 | if (!isFileDragOver) return; | ||
| 2020 | const clearFileDrag = () => setIsFileDragOver(false); | ||
| 2021 | window.addEventListener("dragend", clearFileDrag); | ||
| 2022 | return () => window.removeEventListener("dragend", clearFileDrag); | ||
| 2023 | }, [isFileDragOver]); | ||
| 1964 | return ( | 2024 | return ( |
| 1965 | <li role="presentation" className="list-none"> | 2025 | <li role="presentation" className="list-none" {...fileDropHandlers}> |
| 1966 | <Tooltip> | 2026 | <Tooltip> |
| 1967 | <TooltipTrigger | 2027 | <TooltipTrigger |
| 1968 | render={ | 2028 | render={ |
| ... | @@ -1988,6 +2048,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -1988,6 +2048,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1988 | props.isHighlighted || props.isRouteActive | 2048 | props.isHighlighted || props.isRouteActive |
| 1989 | ? "bg-sidebar-row-active text-sidebar-foreground" | 2049 | ? "bg-sidebar-row-active text-sidebar-foreground" |
| 1990 | : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | 2050 | : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", |
| 2051 | isFileDragOver && "ring-1 ring-inset ring-primary/70", | ||
| 2052 | isFileDragOver && !props.isRouteActive && "bg-sidebar-row-hover", | ||
| 1991 | )} | 2053 | )} |
| 1992 | /> | 2054 | /> |
| 1993 | } | 2055 | } |
| ... | @@ -2707,7 +2769,7 @@ export default function Sidebar() { | ... | @@ -2707,7 +2769,7 @@ export default function Sidebar() { |
| 2707 | if (isMobile) { | 2769 | if (isMobile) { |
| 2708 | setOpenMobile(false); | 2770 | setOpenMobile(false); |
| 2709 | } | 2771 | } |
| 2710 | void router.navigate({ | 2772 | return router.navigate({ |
| 2711 | to: "/$environmentId/$threadId", | 2773 | to: "/$environmentId/$threadId", |
| 2712 | params: buildThreadRouteParams(threadRef), | 2774 | params: buildThreadRouteParams(threadRef), |
| 2713 | }); | 2775 | }); |
| ... | @@ -2715,6 +2777,48 @@ export default function Sidebar() { | ... | @@ -2715,6 +2777,48 @@ export default function Sidebar() { |
| 2715 | [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], | 2777 | [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], |
| 2716 | ); | 2778 | ); |
| 2717 | 2779 | ||
| 2780 | // Dropping files on a row opens that thread and attaches the files there. | ||
| 2781 | // The composer only accepts drops for its OWN thread, so when the row is | ||
| 2782 | // not the open thread we stash the files and let ChatView hand them over | ||
| 2783 | // once the navigation actually lands; if the route bounced (thread gone), | ||
| 2784 | // nothing will consume them, so clear instead of surprising the user later. | ||
| 2785 | const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); | ||
| 2786 | const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); | ||
| 2787 | const handleThreadFileDrop = useCallback( | ||
| 2788 | async (threadRef: ScopedThreadRef, files: File[]) => { | ||
| 2789 | // Queued, not replaced: a second drop before the thread opens keeps | ||
| 2790 | // both files, and the id lets cleanup below touch only this drop. | ||
| 2791 | const dropId = queuePendingFileDrop({ threadRef, files }); | ||
| 2792 | // Key match alone is not "already there": during draft promotion the | ||
| 2793 | // resolved route key is the server thread while the URL is still the | ||
| 2794 | // draft route, and its composer would swallow the drop then discard it. | ||
| 2795 | const landedBefore = | ||
| 2796 | router.buildLocation({ | ||
| 2797 | to: "/$environmentId/$threadId", | ||
| 2798 | params: buildThreadRouteParams(threadRef), | ||
| 2799 | }).pathname === router.state.location.pathname; | ||
| 2800 | if (landedBefore) return; | ||
| 2801 | try { | ||
| 2802 | await navigateToThread(threadRef); | ||
| 2803 | // A newer drop may have arrived while the navigation was in flight; | ||
| 2804 | // clearing by id leaves those files untouched. | ||
| 2805 | const landed = | ||
| 2806 | router.buildLocation({ | ||
| 2807 | to: "/$environmentId/$threadId", | ||
| 2808 | params: buildThreadRouteParams(threadRef), | ||
| 2809 | }).pathname === router.state.location.pathname; | ||
| 2810 | if (!landed) { | ||
| 2811 | clearPendingFileDrop(dropId); | ||
| 2812 | } | ||
| 2813 | } catch { | ||
| 2814 | // Navigation failed outright; nothing will consume this drop, but a | ||
| 2815 | // newer drop for the same thread may still be deliverable. | ||
| 2816 | clearPendingFileDrop(dropId); | ||
| 2817 | } | ||
| 2818 | }, | ||
| 2819 | [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], | ||
| 2820 | ); | ||
| 2821 | |||
| 2718 | const navigateToDraft = useCallback( | 2822 | const navigateToDraft = useCallback( |
| 2719 | (draftId: DraftId) => { | 2823 | (draftId: DraftId) => { |
| 2720 | // Unconditional: also drops a stale selection anchor left by | 2824 | // Unconditional: also drops a stale selection anchor left by |
| ... | @@ -4467,6 +4571,7 @@ export default function Sidebar() { | ... | @@ -4467,6 +4571,7 @@ export default function Sidebar() { |
| 4467 | resultId={`sidebar-thread-search-result-${index}`} | 4571 | resultId={`sidebar-thread-search-result-${index}`} |
| 4468 | onHighlight={() => setActiveSearchResultIndex(index)} | 4572 | onHighlight={() => setActiveSearchResultIndex(index)} |
| 4469 | onSelect={() => selectThreadSearchResult(thread)} | 4573 | onSelect={() => selectThreadSearchResult(thread)} |
| 4574 | onFileDropThreads={handleThreadFileDrop} | ||
| 4470 | /> | 4575 | /> |
| 4471 | ); | 4576 | ); |
| 4472 | })} | 4577 | })} |
| ... | @@ -4612,6 +4717,7 @@ export default function Sidebar() { | ... | @@ -4612,6 +4717,7 @@ export default function Sidebar() { |
| 4612 | onUnsnooze={attemptUnsnooze} | 4717 | onUnsnooze={attemptUnsnooze} |
| 4613 | onUnpin={attemptUnpin} | 4718 | onUnpin={attemptUnpin} |
| 4614 | onAcknowledgeWoke={acknowledgeWoke} | 4719 | onAcknowledgeWoke={acknowledgeWoke} |
| 4720 | onFileDropThreads={handleThreadFileDrop} | ||
| 4615 | /> | 4721 | /> |
| 4616 | ); | 4722 | ); |
| 4617 | }; | 4723 | }; |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+10-2| ... | @@ -6,6 +6,7 @@ import { threadHasStarted } from "../components/ChatView.logic"; | ... | @@ -6,6 +6,7 @@ import { threadHasStarted } from "../components/ChatView.logic"; |
| 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; | 6 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; | 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; |
| 8 | import { resolveThreadSyncPhase } from "../threadSync"; | 8 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 9 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | ||
| 9 | import { SidebarInset } from "~/components/ui/sidebar"; | 10 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 10 | import { | 11 | import { |
| 11 | useEnvironmentThreadRefs, | 12 | useEnvironmentThreadRefs, |
| ... | @@ -62,8 +63,15 @@ function ChatThreadRouteView() { | ... | @@ -62,8 +63,15 @@ function ChatThreadRouteView() { |
| 62 | return; | 63 | return; |
| 63 | } | 64 | } |
| 64 | 65 | ||
| 65 | if (renderState === "missing" && environmentHasAnyThreads) { | 66 | // Navigation already resolved onto this path, so a drop aimed here |
| 66 | void navigate({ to: "/", replace: true }); | 67 | // passed its landing check; once the thread reads as missing it can |
| 68 | // never be attached, release it even when there is nowhere to redirect. | ||
| 69 | if (renderState === "missing") { | ||
| 70 | const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); | ||
| 71 | clearPendingFileDropsForThread(threadRef); | ||
| 72 | if (environmentHasAnyThreads) { | ||
| 73 | void navigate({ to: "/", replace: true }); | ||
| 74 | } | ||
| 67 | } | 75 | } |
| 68 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); | 76 | }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); |
| 69 | 77 |
apps/web/src/sidebarPendingFileDropStore.test.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 1 | import { beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 4 | import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | isSameSidebarThreadRef, | ||
| 8 | useSidebarPendingFileDropStore, | ||
| 9 | type SidebarPendingFileDrop, | ||
| 10 | } from "./sidebarPendingFileDropStore"; | ||
| 11 | |||
| 12 | function makeFiles(...names: string[]): File[] { | ||
| 13 | return names.map((name) => new File(["x"], name)); | ||
| 14 | } | ||
| 15 | |||
| 16 | function makeEntry( | ||
| 17 | environmentId: string, | ||
| 18 | threadId: string, | ||
| 19 | files: File[], | ||
| 20 | ): Omit<SidebarPendingFileDrop, "id"> { | ||
| 21 | return { | ||
| 22 | threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)), | ||
| 23 | files, | ||
| 24 | }; | ||
| 25 | } | ||
| 26 | |||
| 27 | function fileNames(files: File[]): string[] { | ||
| 28 | return files.map((file) => file.name); | ||
| 29 | } | ||
| 30 | |||
| 31 | function refOf(environmentId: string, threadId: string) { | ||
| 32 | return makeEntry(environmentId, threadId, []).threadRef; | ||
| 33 | } | ||
| 34 | |||
| 35 | beforeEach(() => { | ||
| 36 | useSidebarPendingFileDropStore.setState({ pending: [] }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | describe("sidebarPendingFileDropStore", () => { | ||
| 40 | it("starts empty", () => { | ||
| 41 | expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("stashes and consumes a drop for the matching thread", () => { | ||
| 45 | const files = makeFiles("a.png", "b.png"); | ||
| 46 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 47 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", files)); | ||
| 48 | |||
| 49 | expect( | ||
| 50 | useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("env-1", "thread-1")), | ||
| 51 | ).toEqual(files); | ||
| 52 | expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("accumulates repeat drops onto the same thread instead of replacing", () => { | ||
| 56 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 57 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); | ||
| 58 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); | ||
| 59 | |||
| 60 | expect( | ||
| 61 | fileNames( | ||
| 62 | useSidebarPendingFileDropStore | ||
| 63 | .getState() | ||
| 64 | .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], | ||
| 65 | ), | ||
| 66 | ).toEqual(["a.png", "b.png"]); | ||
| 67 | expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("keeps drops for other threads when consuming one thread", () => { | ||
| 71 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 72 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); | ||
| 73 | store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("b.png"))); | ||
| 74 | |||
| 75 | expect( | ||
| 76 | fileNames( | ||
| 77 | useSidebarPendingFileDropStore | ||
| 78 | .getState() | ||
| 79 | .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [], | ||
| 80 | ), | ||
| 81 | ).toEqual(["b.png"]); | ||
| 82 | expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("clears only the drop matching a stale navigation's id", () => { | ||
| 86 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 87 | const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); | ||
| 88 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); | ||
| 89 | |||
| 90 | useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId); | ||
| 91 | expect( | ||
| 92 | fileNames( | ||
| 93 | useSidebarPendingFileDropStore | ||
| 94 | .getState() | ||
| 95 | .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], | ||
| 96 | ), | ||
| 97 | ).toEqual(["b.png"]); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("keeps a newer drop deliverable after the first navigation fails", () => { | ||
| 101 | // Mirrors handleThreadFileDrop: two drops queued for the same unopened | ||
| 102 | // thread, then the first navigation fails (or lands elsewhere) and cleans | ||
| 103 | // up by its own drop id. The newer drop must survive with its files | ||
| 104 | // intact so the thread opening still attaches them. | ||
| 105 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 106 | const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); | ||
| 107 | const secondId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); | ||
| 108 | |||
| 109 | // First navigation fails: handler clears only its own drop. | ||
| 110 | useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId); | ||
| 111 | const remaining = useSidebarPendingFileDropStore.getState().pending; | ||
| 112 | expect(remaining).toHaveLength(1); | ||
| 113 | expect(remaining[0]?.id).toBe(secondId); | ||
| 114 | expect(fileNames(remaining[0]?.files ?? [])).toEqual(["b.png"]); | ||
| 115 | |||
| 116 | // Thread opens: the surviving drop is still attached. | ||
| 117 | expect( | ||
| 118 | fileNames( | ||
| 119 | useSidebarPendingFileDropStore | ||
| 120 | .getState() | ||
| 121 | .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], | ||
| 122 | ), | ||
| 123 | ).toEqual(["b.png"]); | ||
| 124 | expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("clears every drop for a missing thread", () => { | ||
| 128 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 129 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); | ||
| 130 | store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); | ||
| 131 | store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("c.png"))); | ||
| 132 | |||
| 133 | useSidebarPendingFileDropStore | ||
| 134 | .getState() | ||
| 135 | .clearPendingFileDropsForThread(refOf("env-1", "thread-1")); | ||
| 136 | expect( | ||
| 137 | fileNames( | ||
| 138 | useSidebarPendingFileDropStore | ||
| 139 | .getState() | ||
| 140 | .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [], | ||
| 141 | ), | ||
| 142 | ).toEqual(["c.png"]); | ||
| 143 | expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it("does not confuse refs whose joined keys collide on colons", () => { | ||
| 147 | const store = useSidebarPendingFileDropStore.getState(); | ||
| 148 | store.queuePendingFileDrop(makeEntry("a", "b:c", makeFiles("a.png"))); | ||
| 149 | |||
| 150 | expect( | ||
| 151 | useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("a:b", "c")), | ||
| 152 | ).toBeNull(); | ||
| 153 | expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1); | ||
| 154 | }); | ||
| 155 | }); | ||
| 156 | |||
| 157 | describe("isSameSidebarThreadRef", () => { | ||
| 158 | it("compares fields, not joined keys", () => { | ||
| 159 | expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a", "b:c"))).toBe(true); | ||
| 160 | expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a:b", "c"))).toBe(false); | ||
| 161 | expect(isSameSidebarThreadRef(refOf("a", "b"), refOf("a", "c"))).toBe(false); | ||
| 162 | }); | ||
| 163 | }); | ||
apps/web/src/sidebarPendingFileDropStore.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Field-wise ref equality. `scopedThreadKey` joins with `:`, so two distinct | ||
| 7 | * refs can collide when an id itself contains one; drops must never cross | ||
| 8 | * threads on that account. | ||
| 9 | */ | ||
| 10 | export function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean { | ||
| 11 | return a.environmentId === b.environmentId && a.threadId === b.threadId; | ||
| 12 | } | ||
| 13 | |||
| 14 | /** | ||
| 15 | * One sidebar row drop. Drops queue up instead of replacing each other, so a | ||
| 16 | * second drop onto the same thread before it opens keeps both files; each | ||
| 17 | * entry carries its own id so a stale navigation can only ever clear the drop | ||
| 18 | * that started it. | ||
| 19 | */ | ||
| 20 | export interface SidebarPendingFileDrop { | ||
| 21 | id: string; | ||
| 22 | threadRef: ScopedThreadRef; | ||
| 23 | files: File[]; | ||
| 24 | } | ||
| 25 | |||
| 26 | interface SidebarPendingFileDropStoreState { | ||
| 27 | pending: SidebarPendingFileDrop[]; | ||
| 28 | /** | ||
| 29 | * Appends a drop to the queue and returns its id, for later | ||
| 30 | * identity-checked cleanup. | ||
| 31 | */ | ||
| 32 | queuePendingFileDrop: (entry: Omit<SidebarPendingFileDrop, "id">) => string; | ||
| 33 | /** Removes the single drop with this id, leaving newer drops untouched. */ | ||
| 34 | clearPendingFileDrop: (id: string) => void; | ||
| 35 | /** Removes every queued drop aimed at this thread (e.g. it went missing). */ | ||
| 36 | clearPendingFileDropsForThread: (threadRef: ScopedThreadRef) => void; | ||
| 37 | /** | ||
| 38 | * Returns every queued drop's files for `threadRef`, oldest first, and | ||
| 39 | * removes them; returns null (leaving state untouched) when none match. | ||
| 40 | */ | ||
| 41 | consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null; | ||
| 42 | } | ||
| 43 | |||
| 44 | let nextPendingFileDropId = 0; | ||
| 45 | |||
| 46 | export const useSidebarPendingFileDropStore = create<SidebarPendingFileDropStoreState>()( | ||
| 47 | (set, get) => ({ | ||
| 48 | pending: [], | ||
| 49 | queuePendingFileDrop: (entry) => { | ||
| 50 | const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`; | ||
| 51 | set((state) => ({ pending: [...state.pending, { ...entry, id }] })); | ||
| 52 | return id; | ||
| 53 | }, | ||
| 54 | clearPendingFileDrop: (id) => { | ||
| 55 | set((state) => ({ pending: state.pending.filter((drop) => drop.id !== id) })); | ||
| 56 | }, | ||
| 57 | clearPendingFileDropsForThread: (threadRef) => { | ||
| 58 | set((state) => ({ | ||
| 59 | pending: state.pending.filter((drop) => !isSameSidebarThreadRef(drop.threadRef, threadRef)), | ||
| 60 | })); | ||
| 61 | }, | ||
| 62 | consumePendingFileDrop: (threadRef) => { | ||
| 63 | const matches = get().pending.filter((drop) => | ||
| 64 | isSameSidebarThreadRef(drop.threadRef, threadRef), | ||
| 65 | ); | ||
| 66 | if (matches.length === 0) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | const matchedIds = new Set(matches.map((drop) => drop.id)); | ||
| 70 | set((state) => ({ pending: state.pending.filter((drop) => !matchedIds.has(drop.id)) })); | ||
| 71 | return matches.flatMap((drop) => drop.files); | ||
| 72 | }, | ||
| 73 | }), | ||
| 74 | ); | ||
docs/user/thread-sidebar.md+5| ... | @@ -24,6 +24,11 @@ worktree**, each background submission creates its own worktree. | ... | @@ -24,6 +24,11 @@ worktree**, each background submission creates its own worktree. |
| 24 | 24 | ||
| 25 | Pin a thread from its menu to keep it above your active work. | 25 | Pin a thread from its menu to keep it above your active work. |
| 26 | 26 | ||
| 27 | On web and desktop, you can also drag files from your computer onto any thread row: | ||
| 28 | the thread opens and the files are attached in its composer, ready for | ||
| 29 | your next message. The same per-message file limits apply as when attaching | ||
| 30 | files directly; see [Attach files](./composer.md#attach-files). | ||
| 31 | |||
| 27 | Pinning does not prevent automatic settlement. Settling a thread removes its pin. | 32 | Pinning does not prevent automatic settlement. Settling a thread removes its pin. |
| 28 | 33 | ||
| 29 | On web and desktop, drag a thread between sections to change its state. Drag a thread up into | 34 | On web and desktop, drag a thread between sections to change its state. Drag a thread up into |