authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-08 17:31:22+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 17:31:22+05:30
logbde39d4d7977ce85d6ea396a983d6b6a25bf7e07
tree5d8a1e5c6f325bccb780f376370c4a0d5145e084
parenteb115063634c416c6362cc407f8572cb0c136ddf
signature Signed by PGP key B5690EEEBB952194

feat(web): accept file drops into sidebar threads (#7892)


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";
189import { subscribePreviewAction } from "./preview/previewActionBus";189import { subscribePreviewAction } from "./preview/previewActionBus";
190import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";190import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
191import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";191import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
192import {
193 isSameSidebarThreadRef,
194 useSidebarPendingFileDropStore,
195} from "../sidebarPendingFileDropStore";
192import {196import {
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 }, []);
78697873
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 thread7921 // 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";
78import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";78import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
79import { isTerminalFocused } from "../lib/terminalFocus";79import { isTerminalFocused } from "../lib/terminalFocus";
80import { isMacPlatform } from "../lib/utils";80import { isMacPlatform } from "../lib/utils";
81import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
82import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
81import {83import {
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}
349352
350const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {353const 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-item686 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 );
17501796
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";
94import { useOpenPrLink } from "../lib/openPullRequestLink";94import { useOpenPrLink } from "../lib/openPullRequestLink";
95import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";95import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
96import { readLocalApi } from "../localApi";96import { readLocalApi } from "../localApi";
97import {
98 isSameSidebarThreadRef,
99 useSidebarPendingFileDropStore,
100} from "../sidebarPendingFileDropStore";
97import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";101import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
98import {102import {
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";
197import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";201import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
202import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
198import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";203import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
199import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";204import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
200import {205import {
...@@ -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 show1377 // 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 pointer1378 // 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 sidebar1379 // 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 <li1541 <li
1506 data-thread-item1542 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 <li1694 <li
1658 data-thread-item1695 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 <TooltipTrigger2027 <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.isRouteActive2048 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 );
27172779
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 by2824 // 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";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
8import { resolveThreadSyncPhase } from "../threadSync";8import { resolveThreadSyncPhase } from "../threadSync";
9import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
9import { SidebarInset } from "~/components/ui/sidebar";10import { SidebarInset } from "~/components/ui/sidebar";
10import {11import {
11 useEnvironmentThreadRefs,12 useEnvironmentThreadRefs,
...@@ -62,8 +63,15 @@ function ChatThreadRouteView() {...@@ -62,8 +63,15 @@ function ChatThreadRouteView() {
62 return;63 return;
63 }64 }
6465
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]);
6977
apps/web/src/sidebarPendingFileDropStore.test.ts created+163
...@@ -0,0 +1,163 @@
1import { beforeEach, describe, expect, it } from "vite-plus/test";
2
3import { scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
5
6import {
7 isSameSidebarThreadRef,
8 useSidebarPendingFileDropStore,
9 type SidebarPendingFileDrop,
10} from "./sidebarPendingFileDropStore";
11
12function makeFiles(...names: string[]): File[] {
13 return names.map((name) => new File(["x"], name));
14}
15
16function 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
27function fileNames(files: File[]): string[] {
28 return files.map((file) => file.name);
29}
30
31function refOf(environmentId: string, threadId: string) {
32 return makeEntry(environmentId, threadId, []).threadRef;
33}
34
35beforeEach(() => {
36 useSidebarPendingFileDropStore.setState({ pending: [] });
37});
38
39describe("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
157describe("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 @@
1import { create } from "zustand";
2
3import 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 */
10export 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 */
20export interface SidebarPendingFileDrop {
21 id: string;
22 threadRef: ScopedThreadRef;
23 files: File[];
24}
25
26interface 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
44let nextPendingFileDropId = 0;
45
46export 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.
2424
25Pin a thread from its menu to keep it above your active work.25Pin a thread from its menu to keep it above your active work.
2626
27On web and desktop, you can also drag files from your computer onto any thread row:
28the thread opens and the files are attached in its composer, ready for
29your next message. The same per-message file limits apply as when attaching
30files directly; see [Attach files](./composer.md#attach-files).
31
27Pinning does not prevent automatic settlement. Settling a thread removes its pin.32Pinning does not prevent automatic settlement. Settling a thread removes its pin.
2833
29On web and desktop, drag a thread between sections to change its state. Drag a thread up into34On web and desktop, drag a thread between sections to change its state. Drag a thread up into