| 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 | 189 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 190 | 190 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| 191 | 191 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; |
| 192 | import { | |
| 193 | isSameSidebarThreadRef, | |
| 194 | useSidebarPendingFileDropStore, | |
| 195 | } from "../sidebarPendingFileDropStore"; | |
| 192 | 196 | import { |
| 193 | 197 | selectThreadPreviewMiniPlayer, |
| 194 | 198 | usePreviewMiniPlayerStore, |
| ... | ... | @@ -7867,6 +7871,53 @@ export default function ChatView(props: ChatViewProps) { |
| 7867 | 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 | 7921 | // Empty state: no active thread |
| 7871 | 7922 | if (!activeThread) { |
| 7872 | 7923 | return <NoActiveThreadState />; |
apps/web/src/components/LegacySidebar.tsx+51-4| ... | ... | @@ -78,6 +78,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 78 | 78 | import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; |
| 79 | 79 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| 80 | 80 | import { isMacPlatform } from "../lib/utils"; |
| 81 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | |
| 82 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | |
| 81 | 83 | import { |
| 82 | 84 | readThreadShell, |
| 83 | 85 | useProjects, |
| ... | ... | @@ -326,7 +328,7 @@ interface SidebarThreadRowProps { |
| 326 | 328 | threadRef: ScopedThreadRef, |
| 327 | 329 | orderedProjectThreadKeys: readonly string[], |
| 328 | 330 | ) => void; |
| 329 | navigateToThread: (threadRef: ScopedThreadRef) => void; | |
| 331 | navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>; | |
| 330 | 332 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 331 | 333 | handleThreadContextMenu: ( |
| 332 | 334 | threadRef: ScopedThreadRef, |
| ... | ... | @@ -345,6 +347,7 @@ interface SidebarThreadRowProps { |
| 345 | 347 | prUrl: string, |
| 346 | 348 | threadRef?: ScopedThreadRef, |
| 347 | 349 | ) => boolean; |
| 350 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | |
| 348 | 351 | } |
| 349 | 352 | |
| 350 | 353 | const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { |
| ... | ... | @@ -372,10 +375,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 372 | 375 | cancelRename, |
| 373 | 376 | attemptArchiveThread, |
| 374 | 377 | openPrLink, |
| 378 | onFileDropThreads, | |
| 375 | 379 | thread, |
| 376 | 380 | } = props; |
| 377 | 381 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 378 | 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 | 400 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); |
| 380 | 401 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 381 | 402 | const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); |
| ... | ... | @@ -663,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 663 | 684 | ref={rowRef} |
| 664 | 685 | className="w-full" |
| 665 | 686 | data-thread-item |
| 687 | {...fileDropHandlers} | |
| 666 | 688 | onMouseLeave={handleMouseLeave} |
| 667 | 689 | onBlurCapture={handleBlurCapture} |
| 668 | 690 | > |
| ... | ... | @@ -674,7 +696,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 674 | 696 | className={`${resolveThreadRowClassName({ |
| 675 | 697 | isActive, |
| 676 | 698 | isSelected, |
| 677 | })} relative isolate`} | |
| 699 | })} relative isolate${isFileDragOver ? " ring-1 ring-inset ring-primary/70" : ""}`} | |
| 678 | 700 | onClick={handleRowClick} |
| 679 | 701 | onDoubleClick={handleRowDoubleClick} |
| 680 | 702 | onKeyDown={handleRowKeyDown} |
| ... | ... | @@ -916,7 +938,8 @@ interface SidebarProjectThreadListProps { |
| 916 | 938 | threadRef: ScopedThreadRef, |
| 917 | 939 | orderedProjectThreadKeys: readonly string[], |
| 918 | 940 | ) => void; |
| 919 | navigateToThread: (threadRef: ScopedThreadRef) => void; | |
| 941 | navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>; | |
| 942 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | |
| 920 | 943 | handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>; |
| 921 | 944 | handleThreadContextMenu: ( |
| 922 | 945 | threadRef: ScopedThreadRef, |
| ... | ... | @@ -968,6 +991,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 968 | 991 | attachThreadListAutoAnimateRef, |
| 969 | 992 | handleThreadClick, |
| 970 | 993 | navigateToThread, |
| 994 | onFileDropThreads, | |
| 971 | 995 | handleMultiSelectContextMenu, |
| 972 | 996 | handleThreadContextMenu, |
| 973 | 997 | clearSelection, |
| ... | ... | @@ -1019,6 +1043,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( |
| 1019 | 1043 | confirmArchiveButtonRefs={confirmArchiveButtonRefs} |
| 1020 | 1044 | handleThreadClick={handleThreadClick} |
| 1021 | 1045 | navigateToThread={navigateToThread} |
| 1046 | onFileDropThreads={onFileDropThreads} | |
| 1022 | 1047 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} |
| 1023 | 1048 | handleThreadContextMenu={handleThreadContextMenu} |
| 1024 | 1049 | clearSelection={clearSelection} |
| ... | ... | @@ -1136,6 +1161,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1136 | 1161 | (settings) => settings.sidebarThreadPreviewCount, |
| 1137 | 1162 | ); |
| 1138 | 1163 | const router = useRouter(); |
| 1164 | const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); | |
| 1165 | const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); | |
| 1139 | 1166 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1140 | 1167 | const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); |
| 1141 | 1168 | const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); |
| ... | ... | @@ -1740,13 +1767,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1740 | 1767 | if (isMobile) { |
| 1741 | 1768 | setOpenMobile(false); |
| 1742 | 1769 | } |
| 1743 | void router.navigate({ | |
| 1770 | return router.navigate({ | |
| 1744 | 1771 | to: "/$environmentId/$threadId", |
| 1745 | 1772 | params: buildThreadRouteParams(threadRef), |
| 1746 | 1773 | }); |
| 1747 | 1774 | }, |
| 1748 | 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 | 1797 | const handleThreadClick = useCallback( |
| 1752 | 1798 | ( |
| ... | ... | @@ -2409,6 +2455,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 2409 | 2455 | attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} |
| 2410 | 2456 | handleThreadClick={handleThreadClick} |
| 2411 | 2457 | navigateToThread={navigateToThread} |
| 2458 | onFileDropThreads={handleThreadFileDrop} | |
| 2412 | 2459 | handleMultiSelectContextMenu={handleMultiSelectContextMenu} |
| 2413 | 2460 | handleThreadContextMenu={handleThreadContextMenu} |
| 2414 | 2461 | clearSelection={clearSelection} |
apps/web/src/components/Sidebar.tsx+108-2| ... | ... | @@ -94,6 +94,10 @@ import { isMacPlatform } from "~/lib/utils"; |
| 94 | 94 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 95 | 95 | import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; |
| 96 | 96 | import { readLocalApi } from "../localApi"; |
| 97 | import { | |
| 98 | isSameSidebarThreadRef, | |
| 99 | useSidebarPendingFileDropStore, | |
| 100 | } from "../sidebarPendingFileDropStore"; | |
| 97 | 101 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; |
| 98 | 102 | import { |
| 99 | 103 | buildSidebarProjectSnapshots, |
| ... | ... | @@ -195,6 +199,7 @@ import { |
| 195 | 199 | type SnoozePreset, |
| 196 | 200 | } from "./Sidebar.snooze"; |
| 197 | 201 | import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; |
| 202 | import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; | |
| 198 | 203 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 199 | 204 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 200 | 205 | import { |
| ... | ... | @@ -980,6 +985,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 980 | 985 | onUnsnooze: (threadRef: ScopedThreadRef) => void; |
| 981 | 986 | onUnpin: (threadRef: ScopedThreadRef) => void; |
| 982 | 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 | 995 | const { |
| 985 | 996 | isRenaming, |
| ... | ... | @@ -987,6 +998,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 987 | 998 | onCommitRename, |
| 988 | 999 | onContextMenu, |
| 989 | 1000 | onAcknowledgeWoke, |
| 1001 | onFileDropThreads, | |
| 990 | 1002 | onRenameTitleChange, |
| 991 | 1003 | onSettle, |
| 992 | 1004 | onSnooze, |
| ... | ... | @@ -1224,6 +1236,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1224 | 1236 | }, |
| 1225 | 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 | 1260 | const renameCommittedRef = useRef(false); |
| 1228 | 1261 | useEffect(() => { |
| 1229 | 1262 | if (isRenaming) renameCommittedRef.current = false; |
| ... | ... | @@ -1338,6 +1371,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1338 | 1371 | !props.isActive && |
| 1339 | 1372 | !isSelected && |
| 1340 | 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 | 1377 | // The lifted row is an opaque card so the rows beneath it never show |
| 1342 | 1378 | // through. The row tint is translucent in dark themes and the pointer |
| 1343 | 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 | 1541 | <li |
| 1506 | 1542 | data-thread-item |
| 1507 | 1543 | {...sortableRootProps} |
| 1544 | {...(fileDropHandlers ?? {})} | |
| 1508 | 1545 | className={cn( |
| 1509 | 1546 | // Matches the h-9 row so unrendered rows never shift the list when they paint. |
| 1510 | 1547 | "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]", |
| ... | ... | @@ -1657,6 +1694,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1657 | 1694 | <li |
| 1658 | 1695 | data-thread-item |
| 1659 | 1696 | {...sortableRootProps} |
| 1697 | {...(fileDropHandlers ?? {})} | |
| 1660 | 1698 | className={cn( |
| 1661 | 1699 | // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. |
| 1662 | 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 | 1957 | resultId: string; |
| 1920 | 1958 | onHighlight: () => void; |
| 1921 | 1959 | onSelect: () => void; |
| 1960 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | |
| 1922 | 1961 | }) { |
| 1923 | 1962 | const { thread } = props; |
| 1963 | const threadRef = useMemo( | |
| 1964 | () => scopeThreadRef(thread.environmentId, thread.id), | |
| 1965 | [thread.environmentId, thread.id], | |
| 1966 | ); | |
| 1924 | 1967 | const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( |
| 1925 | 1968 | props.isHighlighted || props.isRouteActive, |
| 1926 | 1969 | ); |
| ... | ... | @@ -1961,8 +2004,25 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1961 | 2004 | threadId: thread.id, |
| 1962 | 2005 | }); |
| 1963 | 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 | 2024 | return ( |
| 1965 | <li role="presentation" className="list-none"> | |
| 2025 | <li role="presentation" className="list-none" {...fileDropHandlers}> | |
| 1966 | 2026 | <Tooltip> |
| 1967 | 2027 | <TooltipTrigger |
| 1968 | 2028 | render={ |
| ... | ... | @@ -1988,6 +2048,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 1988 | 2048 | props.isHighlighted || props.isRouteActive |
| 1989 | 2049 | ? "bg-sidebar-row-active text-sidebar-foreground" |
| 1990 | 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 | 2769 | if (isMobile) { |
| 2708 | 2770 | setOpenMobile(false); |
| 2709 | 2771 | } |
| 2710 | void router.navigate({ | |
| 2772 | return router.navigate({ | |
| 2711 | 2773 | to: "/$environmentId/$threadId", |
| 2712 | 2774 | params: buildThreadRouteParams(threadRef), |
| 2713 | 2775 | }); |
| ... | ... | @@ -2715,6 +2777,48 @@ export default function Sidebar() { |
| 2715 | 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 | 2822 | const navigateToDraft = useCallback( |
| 2719 | 2823 | (draftId: DraftId) => { |
| 2720 | 2824 | // Unconditional: also drops a stale selection anchor left by |
| ... | ... | @@ -4467,6 +4571,7 @@ export default function Sidebar() { |
| 4467 | 4571 | resultId={`sidebar-thread-search-result-${index}`} |
| 4468 | 4572 | onHighlight={() => setActiveSearchResultIndex(index)} |
| 4469 | 4573 | onSelect={() => selectThreadSearchResult(thread)} |
| 4574 | onFileDropThreads={handleThreadFileDrop} | |
| 4470 | 4575 | /> |
| 4471 | 4576 | ); |
| 4472 | 4577 | })} |
| ... | ... | @@ -4612,6 +4717,7 @@ export default function Sidebar() { |
| 4612 | 4717 | onUnsnooze={attemptUnsnooze} |
| 4613 | 4718 | onUnpin={attemptUnpin} |
| 4614 | 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 | import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; |
| 7 | 7 | import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; |
| 8 | 8 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 9 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | |
| 9 | 10 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 10 | 11 | import { |
| 11 | 12 | useEnvironmentThreadRefs, |
| ... | ... | @@ -62,8 +63,15 @@ function ChatThreadRouteView() { |
| 62 | 63 | return; |
| 63 | 64 | } |
| 64 | 65 | |
| 65 | if (renderState === "missing" && environmentHasAnyThreads) { | |
| 66 | void navigate({ to: "/", replace: true }); | |
| 66 | // Navigation already resolved onto this path, so a drop aimed here | |
| 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 | 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 | 24 | |
| 25 | 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 | 32 | Pinning does not prevent automatic settlement. Settling a thread removes its pin. |
| 28 | 33 | |
| 29 | 34 | On web and desktop, drag a thread between sections to change its state. Drag a thread up into |