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";
189189import { subscribePreviewAction } from "./preview/previewActionBus";
190190import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
191191import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
192import {
193 isSameSidebarThreadRef,
194 useSidebarPendingFileDropStore,
195} from "../sidebarPendingFileDropStore";
192196import {
193197 selectThreadPreviewMiniPlayer,
194198 usePreviewMiniPlayerStore,
......@@ -7867,6 +7871,53 @@ export default function ChatView(props: ChatViewProps) {
78677871 void onRevertToTurnCountRef.current(targetTurnCount);
78687872 }, []);
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
78707921 // Empty state: no active thread
78717922 if (!activeThread) {
78727923 return <NoActiveThreadState />;
apps/web/src/components/LegacySidebar.tsx+51-4
......@@ -78,6 +78,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink";
7878import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
7979import { isTerminalFocused } from "../lib/terminalFocus";
8080import { isMacPlatform } from "../lib/utils";
81import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
82import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
8183import {
8284 readThreadShell,
8385 useProjects,
......@@ -326,7 +328,7 @@ interface SidebarThreadRowProps {
326328 threadRef: ScopedThreadRef,
327329 orderedProjectThreadKeys: readonly string[],
328330 ) => void;
329 navigateToThread: (threadRef: ScopedThreadRef) => void;
331 navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>;
330332 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
331333 handleThreadContextMenu: (
332334 threadRef: ScopedThreadRef,
......@@ -345,6 +347,7 @@ interface SidebarThreadRowProps {
345347 prUrl: string,
346348 threadRef?: ScopedThreadRef,
347349 ) => boolean;
350 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
348351}
349352
350353const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
......@@ -372,10 +375,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
372375 cancelRename,
373376 attemptArchiveThread,
374377 openPrLink,
378 onFileDropThreads,
375379 thread,
376380 } = props;
377381 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
378382 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]);
379400 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive);
380401 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
381402 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
......@@ -663,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
663684 ref={rowRef}
664685 className="w-full"
665686 data-thread-item
687 {...fileDropHandlers}
666688 onMouseLeave={handleMouseLeave}
667689 onBlurCapture={handleBlurCapture}
668690 >
......@@ -674,7 +696,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
674696 className={`${resolveThreadRowClassName({
675697 isActive,
676698 isSelected,
677 })} relative isolate`}
699 })} relative isolate${isFileDragOver ? " ring-1 ring-inset ring-primary/70" : ""}`}
678700 onClick={handleRowClick}
679701 onDoubleClick={handleRowDoubleClick}
680702 onKeyDown={handleRowKeyDown}
......@@ -916,7 +938,8 @@ interface SidebarProjectThreadListProps {
916938 threadRef: ScopedThreadRef,
917939 orderedProjectThreadKeys: readonly string[],
918940 ) => void;
919 navigateToThread: (threadRef: ScopedThreadRef) => void;
941 navigateToThread: (threadRef: ScopedThreadRef) => Promise<void>;
942 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
920943 handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise<void>;
921944 handleThreadContextMenu: (
922945 threadRef: ScopedThreadRef,
......@@ -968,6 +991,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
968991 attachThreadListAutoAnimateRef,
969992 handleThreadClick,
970993 navigateToThread,
994 onFileDropThreads,
971995 handleMultiSelectContextMenu,
972996 handleThreadContextMenu,
973997 clearSelection,
......@@ -1019,6 +1043,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
10191043 confirmArchiveButtonRefs={confirmArchiveButtonRefs}
10201044 handleThreadClick={handleThreadClick}
10211045 navigateToThread={navigateToThread}
1046 onFileDropThreads={onFileDropThreads}
10221047 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
10231048 handleThreadContextMenu={handleThreadContextMenu}
10241049 clearSelection={clearSelection}
......@@ -1136,6 +1161,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11361161 (settings) => settings.sidebarThreadPreviewCount,
11371162 );
11381163 const router = useRouter();
1164 const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
1165 const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);
11391166 const { isMobile, setOpenMobile } = useSidebar();
11401167 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
11411168 const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded);
......@@ -1740,13 +1767,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17401767 if (isMobile) {
17411768 setOpenMobile(false);
17421769 }
1743 void router.navigate({
1770 return router.navigate({
17441771 to: "/$environmentId/$threadId",
17451772 params: buildThreadRouteParams(threadRef),
17461773 });
17471774 },
17481775 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
17491776 );
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
17511797 const handleThreadClick = useCallback(
17521798 (
......@@ -2409,6 +2455,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
24092455 attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef}
24102456 handleThreadClick={handleThreadClick}
24112457 navigateToThread={navigateToThread}
2458 onFileDropThreads={handleThreadFileDrop}
24122459 handleMultiSelectContextMenu={handleMultiSelectContextMenu}
24132460 handleThreadContextMenu={handleThreadContextMenu}
24142461 clearSelection={clearSelection}
apps/web/src/components/Sidebar.tsx+108-2
......@@ -94,6 +94,10 @@ import { isMacPlatform } from "~/lib/utils";
9494import { useOpenPrLink } from "../lib/openPullRequestLink";
9595import { releaseComposerDraftUploads } from "../lib/composerDraftUploads";
9696import { readLocalApi } from "../localApi";
97import {
98 isSameSidebarThreadRef,
99 useSidebarPendingFileDropStore,
100} from "../sidebarPendingFileDropStore";
97101import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
98102import {
99103 buildSidebarProjectSnapshots,
......@@ -195,6 +199,7 @@ import {
195199 type SnoozePreset,
196200} from "./Sidebar.snooze";
197201import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon";
202import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
198203import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
199204import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
200205import {
......@@ -980,6 +985,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
980985 onUnsnooze: (threadRef: ScopedThreadRef) => void;
981986 onUnpin: (threadRef: ScopedThreadRef) => void;
982987 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;
983994}) {
984995 const {
985996 isRenaming,
......@@ -987,6 +998,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
987998 onCommitRename,
988999 onContextMenu,
9891000 onAcknowledgeWoke,
1001 onFileDropThreads,
9901002 onRenameTitleChange,
9911003 onSettle,
9921004 onSnooze,
......@@ -1224,6 +1236,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12241236 },
12251237 [isRenaming, onStartRename, thread.title, threadRef],
12261238 );
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]);
12271260 const renameCommittedRef = useRef(false);
12281261 useEffect(() => {
12291262 if (isRenaming) renameCommittedRef.current = false;
......@@ -1338,6 +1371,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13381371 !props.isActive &&
13391372 !isSelected &&
13401373 "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",
13411377 // The lifted row is an opaque card so the rows beneath it never show
13421378 // through. The row tint is translucent in dark themes and the pointer
13431379 // keeps the hover color applied, so both the tint and the solid sidebar
......@@ -1505,6 +1541,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15051541 <li
15061542 data-thread-item
15071543 {...sortableRootProps}
1544 {...(fileDropHandlers ?? {})}
15081545 className={cn(
15091546 // Matches the h-9 row so unrendered rows never shift the list when they paint.
15101547 "list-none [content-visibility:auto] [contain-intrinsic-size:auto_36px]",
......@@ -1657,6 +1694,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
16571694 <li
16581695 data-thread-item
16591696 {...sortableRootProps}
1697 {...(fileDropHandlers ?? {})}
16601698 className={cn(
16611699 // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.
16621700 "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",
......@@ -1919,8 +1957,13 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19191957 resultId: string;
19201958 onHighlight: () => void;
19211959 onSelect: () => void;
1960 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
19221961}) {
19231962 const { thread } = props;
1963 const threadRef = useMemo(
1964 () => scopeThreadRef(thread.environmentId, thread.id),
1965 [thread.environmentId, thread.id],
1966 );
19241967 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(
19251968 props.isHighlighted || props.isRouteActive,
19261969 );
......@@ -1961,8 +2004,25 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19612004 threadId: thread.id,
19622005 });
19632006 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]);
19642024 return (
1965 <li role="presentation" className="list-none">
2025 <li role="presentation" className="list-none" {...fileDropHandlers}>
19662026 <Tooltip>
19672027 <TooltipTrigger
19682028 render={
......@@ -1988,6 +2048,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
19882048 props.isHighlighted || props.isRouteActive
19892049 ? "bg-sidebar-row-active text-sidebar-foreground"
19902050 : "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",
19912053 )}
19922054 />
19932055 }
......@@ -2707,7 +2769,7 @@ export default function Sidebar() {
27072769 if (isMobile) {
27082770 setOpenMobile(false);
27092771 }
2710 void router.navigate({
2772 return router.navigate({
27112773 to: "/$environmentId/$threadId",
27122774 params: buildThreadRouteParams(threadRef),
27132775 });
......@@ -2715,6 +2777,48 @@ export default function Sidebar() {
27152777 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
27162778 );
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
27182822 const navigateToDraft = useCallback(
27192823 (draftId: DraftId) => {
27202824 // Unconditional: also drops a stale selection anchor left by
......@@ -4467,6 +4571,7 @@ export default function Sidebar() {
44674571 resultId={`sidebar-thread-search-result-${index}`}
44684572 onHighlight={() => setActiveSearchResultIndex(index)}
44694573 onSelect={() => selectThreadSearchResult(thread)}
4574 onFileDropThreads={handleThreadFileDrop}
44704575 />
44714576 );
44724577 })}
......@@ -4612,6 +4717,7 @@ export default function Sidebar() {
46124717 onUnsnooze={attemptUnsnooze}
46134718 onUnpin={attemptUnpin}
46144719 onAcknowledgeWoke={acknowledgeWoke}
4720 onFileDropThreads={handleThreadFileDrop}
46154721 />
46164722 );
46174723 };
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+10-2
......@@ -6,6 +6,7 @@ import { threadHasStarted } from "../components/ChatView.logic";
66import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
77import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
88import { resolveThreadSyncPhase } from "../threadSync";
9import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
910import { SidebarInset } from "~/components/ui/sidebar";
1011import {
1112 useEnvironmentThreadRefs,
......@@ -62,8 +63,15 @@ function ChatThreadRouteView() {
6263 return;
6364 }
6465
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 }
6775 }
6876 }, [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.
2424
2525Pin 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
2732Pinning does not prevent automatic settlement. Settling a thread removes its pin.
2833
2934On web and desktop, drag a thread between sections to change its state. Drag a thread up into