Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
import { subscribePreviewAction } from "./preview/previewActionBus";
import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import {
selectThreadPreviewMiniPlayer,
usePreviewMiniPlayerStore,
Expand Down Expand Up @@ -6407,6 +6408,35 @@ function ChatViewContent(props: ChatViewProps) {
void onRevertToTurnCountRef.current(targetTurnCount);
}, []);

// Files dropped on a sidebar row land here once the dropped-on thread is
// actually open, then take the exact same path as a workspace drop:
// validate, compress, focus the composer, never send. Kept above the
// no-active-thread early return so hook order never changes.
const pendingSidebarFileDrop = useSidebarPendingFileDropStore((state) => state.pending);
const consumePendingFileDrop = useSidebarPendingFileDropStore(
(state) => state.consumePendingFileDrop,
);
useEffect(() => {
if (pendingSidebarFileDrop === null) return;
if (
activeThreadId === null ||
pendingSidebarFileDrop.threadRef.threadId !== activeThreadId ||
activeThreadEnvironmentId !== pendingSidebarFileDrop.threadRef.environmentId
) {
return;
}
const files = consumePendingFileDrop(pendingSidebarFileDrop.threadRef);
if (files !== null) {
composerRef.current?.addDroppedFiles(files);
}
}, [
activeThreadId,
activeThreadEnvironmentId,
composerRef,
consumePendingFileDrop,
pendingSidebarFileDrop,
]);

// Empty state: no active thread
if (!activeThread) {
return <NoActiveThreadState />;
Expand Down
63 changes: 62 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
import { isMacPlatform } from "~/lib/utils";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { readLocalApi } from "../localApi";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import {
buildSidebarProjectSnapshots,
Expand Down Expand Up @@ -163,6 +164,7 @@ import {
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon } from "./ProjectFavicon";
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
import {
Expand Down Expand Up @@ -739,6 +741,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onUnsnooze: (threadRef: ScopedThreadRef) => void;
onUnpin: (threadRef: ScopedThreadRef) => void;
onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
/**
* External files dropped onto this row. The row highlights while the drag
* is over it; the callback opens the thread and hands the files to its
* composer. Absent when the sidebar cannot open server threads.
*/
onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined;
changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
onChangeRequestSnapshot: (
threadKey: string,
Expand Down Expand Up @@ -995,6 +1003,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
},
[isRenaming, onStartRename, thread.title, threadRef],
);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const fileDropHandlers = useMemo(
() =>
props.onFileDropThreads
? makeWorkspaceFileDropHandlers({
setDragActive: setIsFileDragOver,
addFiles: (files) => {
props.onFileDropThreads?.(threadRef, files);
},
})
: null,
[props.onFileDropThreads, threadRef],
);
// A drop lands on a child or outside the window entirely, so dragend is
// the reset of last resort for the row's highlight.
useEffect(() => {
if (!isFileDragOver) return;
const clearFileDrag = () => setIsFileDragOver(false);
window.addEventListener("dragend", clearFileDrag);
return () => window.removeEventListener("dragend", clearFileDrag);
}, [isFileDragOver]);
const renameCommittedRef = useRef(false);
useEffect(() => {
if (isRenaming) renameCommittedRef.current = false;
Expand Down Expand Up @@ -1106,6 +1135,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
!props.isActive &&
!isSelected &&
"opacity-70 transition-opacity hover:opacity-100",
isFileDragOver && "bg-sidebar-row-hover ring-1 ring-inset ring-primary/70",
);

const title = isRenaming ? (
Expand Down Expand Up @@ -1217,6 +1247,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
return (
<li
data-thread-item
{...(fileDropHandlers ?? {})}
className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
>
<Tooltip>
Expand Down Expand Up @@ -1375,6 +1406,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
: undefined
}
{...(sortable?.listeners ?? {})}
{...(fileDropHandlers ?? {})}
className={cn(
"list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]",
sortable?.isDragging && "z-20 opacity-80",
Expand Down Expand Up @@ -2302,14 +2334,42 @@ export default function Sidebar() {
if (isMobile) {
setOpenMobile(false);
}
void router.navigate({
return router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
});
},
[clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
);

// Dropping files on a row opens that thread and attaches the files there.
// The composer only accepts drops for its OWN thread, so when the row is
// not the open thread we stash the files and let ChatView hand them over
// once the navigation actually lands; if the route bounced (thread gone),
// nothing will consume them, so clear instead of surprising the user later.
const setPendingFileDrop = useSidebarPendingFileDropStore((s) => s.setPendingFileDrop);
const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);
const handleThreadFileDrop = useCallback(
async (threadRef: ScopedThreadRef, files: File[]) => {
setPendingFileDrop({ threadRef, files });
const threadKey = scopedThreadKey(threadRef);
if (routeThreadKeyRef.current === threadKey) return;
await navigateToThread(threadRef);
// A newer drop may have replaced ours while the navigation was in
// flight; only clear the stash if it still belongs to this drop.
const landed =
router.buildLocation({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
}).pathname === router.state.location.pathname;
const pending = useSidebarPendingFileDropStore.getState().pending;
if (!landed && pending !== null && scopedThreadKey(pending.threadRef) === threadKey) {
clearPendingFileDrop();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
},
[clearPendingFileDrop, navigateToThread, router, setPendingFileDrop],
);

const navigateToDraft = useCallback(
(draftId: DraftId) => {
// Unconditional: also drops a stale selection anchor left by
Expand Down Expand Up @@ -3739,6 +3799,7 @@ export default function Sidebar() {
onAcknowledgeWoke={acknowledgeWoke}
changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
onFileDropThreads={handleThreadFileDrop}
/>
);
};
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/routes/_chat.$environmentId.$threadId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { threadHasStarted } from "../components/ChatView.logic";
import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
import { resolveThreadSyncPhase } from "../threadSync";
import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import { SidebarInset } from "~/components/ui/sidebar";
import {
useEnvironmentThreadRefs,
Expand Down Expand Up @@ -62,8 +64,17 @@ function ChatThreadRouteView() {
return;
}

if (renderState === "missing" && environmentHasAnyThreads) {
void navigate({ to: "/", replace: true });
// Navigation already resolved onto this path, so a drop aimed here
// passed its landing check; once the thread reads as missing it can
// never be attached, release it even when there is nowhere to redirect.
if (renderState === "missing") {
const { pending, clearPendingFileDrop } = useSidebarPendingFileDropStore.getState();
if (pending && scopedThreadKey(pending.threadRef) === scopedThreadKey(threadRef)) {
clearPendingFileDrop();
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (environmentHasAnyThreads) {
void navigate({ to: "/", replace: true });
}
}
}, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);

Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/sidebarPendingFileDropStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vite-plus/test";

import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, ThreadId } from "@t3tools/contracts";

import {
useSidebarPendingFileDropStore,
type SidebarPendingFileDrop,
} from "./sidebarPendingFileDropStore";

function makeFiles(count: number): File[] {
return Array.from({ length: count }, (_, index) => new File(["x"], `f${index}.png`));
}

function makeEntry(environmentId: string, threadId: string, files: File[]): SidebarPendingFileDrop {
return {
threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)),
files,
};
}

beforeEach(() => {
useSidebarPendingFileDropStore.setState({ pending: null });
});

describe("sidebarPendingFileDropStore", () => {
it("starts empty", () => {
expect(useSidebarPendingFileDropStore.getState().pending).toBeNull();
});

it("stashes and consumes a drop for the matching thread", () => {
const files = makeFiles(2);
const entry = makeEntry("env-1", "thread-1", files);
useSidebarPendingFileDropStore.getState().setPendingFileDrop(entry);

expect(
useSidebarPendingFileDropStore.getState().consumePendingFileDrop(entry.threadRef),
).toEqual(files);
expect(useSidebarPendingFileDropStore.getState().pending).toBeNull();
});

it("refuses to consume for a different thread without clearing the stash", () => {
const entry = makeEntry("env-1", "thread-1", makeFiles(1));
useSidebarPendingFileDropStore.getState().setPendingFileDrop(entry);

const otherThread = makeEntry("env-1", "thread-2", []).threadRef;
expect(
useSidebarPendingFileDropStore.getState().consumePendingFileDrop(otherThread),
).toBeNull();
expect(useSidebarPendingFileDropStore.getState().pending).toEqual(entry);

useSidebarPendingFileDropStore.getState().clearPendingFileDrop();
expect(useSidebarPendingFileDropStore.getState().pending).toBeNull();
});

it("replaces an earlier pending drop with a newer one", () => {
const first = makeEntry("env-1", "thread-1", makeFiles(1));
const second = makeEntry("env-2", "thread-2", makeFiles(3));
useSidebarPendingFileDropStore.getState().setPendingFileDrop(first);
useSidebarPendingFileDropStore.getState().setPendingFileDrop(second);

expect(useSidebarPendingFileDropStore.getState().pending).toEqual(second);
expect(
useSidebarPendingFileDropStore.getState().consumePendingFileDrop(first.threadRef),
).toBeNull();
expect(
useSidebarPendingFileDropStore.getState().consumePendingFileDrop(second.threadRef),
).not.toBeNull();
});
});
47 changes: 47 additions & 0 deletions apps/web/src/sidebarPendingFileDropStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { create } from "zustand";

import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import type { ScopedThreadRef } from "@t3tools/contracts";

/**
* Files dropped onto a sidebar thread row while another thread was open.
* The row hands these off and navigates; ChatView attaches them through the
* composer's normal drop path once the dropped-on thread actually becomes
* active, so they can never land on the wrong thread's draft.
*/
export interface SidebarPendingFileDrop {
threadRef: ScopedThreadRef;
files: File[];
}

interface SidebarPendingFileDropStoreState {
pending: SidebarPendingFileDrop | null;
/** Replaces any earlier pending drop: only the latest handoff matters. */
setPendingFileDrop: (entry: SidebarPendingFileDrop) => void;
clearPendingFileDrop: () => void;
/**
* Returns the stashed files when `threadRef` matches the drop target and
* clears the entry; returns null (leaving state untouched) otherwise.
*/
consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null;
}

export const useSidebarPendingFileDropStore = create<SidebarPendingFileDropStoreState>()(
(set, get) => ({
pending: null,
setPendingFileDrop: (entry) => {
set({ pending: entry });
},
clearPendingFileDrop: () => {
set({ pending: null });
},
consumePendingFileDrop: (threadRef) => {
const pending = get().pending;
if (pending === null || scopedThreadKey(pending.threadRef) !== scopedThreadKey(threadRef)) {
return null;
}
set({ pending: null });
return pending.files;
},
}),
);
3 changes: 3 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ If reordering is unavailable for one environment, update the T3 Code server runn
environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
their pinned threads keep the default newest-first order below the ones you have arranged.

On web and desktop, you can also drag files from your computer onto any thread row: the thread opens
and the files are attached as images in its composer, ready for your next message.

## Environment artwork

Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
Expand Down
Loading