}
@@ -2856,7 +2918,7 @@ export default function Sidebar() {
if (isMobile) {
setOpenMobile(false);
}
- void router.navigate({
+ return router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
});
@@ -2864,6 +2926,48 @@ export default function Sidebar() {
[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 queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop);
+ const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop);
+ const handleThreadFileDrop = useCallback(
+ async (threadRef: ScopedThreadRef, files: File[]) => {
+ // Queued, not replaced: a second drop before the thread opens keeps
+ // both files, and the id lets cleanup below touch only this drop.
+ const dropId = queuePendingFileDrop({ threadRef, files });
+ // Key match alone is not "already there": during draft promotion the
+ // resolved route key is the server thread while the URL is still the
+ // draft route, and its composer would swallow the drop then discard it.
+ const landedBefore =
+ router.buildLocation({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(threadRef),
+ }).pathname === router.state.location.pathname;
+ if (landedBefore) return;
+ try {
+ await navigateToThread(threadRef);
+ // A newer drop may have arrived while the navigation was in flight;
+ // clearing by id leaves those files untouched.
+ const landed =
+ router.buildLocation({
+ to: "/$environmentId/$threadId",
+ params: buildThreadRouteParams(threadRef),
+ }).pathname === router.state.location.pathname;
+ if (!landed) {
+ clearPendingFileDrop(dropId);
+ }
+ } catch {
+ // Navigation failed outright; nothing will consume this drop, but a
+ // newer drop for the same thread may still be deliverable.
+ clearPendingFileDrop(dropId);
+ }
+ },
+ [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router],
+ );
+
const navigateToDraft = useCallback(
(draftId: DraftId) => {
// Unconditional: also drops a stale selection anchor left by
@@ -4645,6 +4749,7 @@ export default function Sidebar() {
resultId={`sidebar-thread-search-result-${index}`}
onHighlight={() => setActiveSearchResultIndex(index)}
onSelect={() => selectThreadSearchResult(thread)}
+ onFileDropThreads={handleThreadFileDrop}
/>
);
})}
@@ -4808,6 +4913,7 @@ export default function Sidebar() {
changeRequestSnapshotByKey.get(threadKey) ?? null
}
onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
+ onFileDropThreads={handleThreadFileDrop}
/>
);
};
diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx
index 2dc036969..cc1ed66a7 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.tsx
+++ b/apps/web/src/components/ThreadTerminalDrawer.tsx
@@ -56,7 +56,7 @@ import {
} from "~/terminal/ghostty/surface";
import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
import { useOpenInPreferredEditor } from "../editorPreferences";
-import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
+import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
import {
isDiffToggleShortcut,
isTerminalClearShortcut,
@@ -756,7 +756,6 @@ function TerminalViewport({
}
function handleLinkActivate(text: string, event: MouseEvent): void {
- if (!isTerminalLinkActivation(event)) return;
const latestTerminal = terminalRef.current;
if (!latestTerminal) return;
if (isTerminalUrl(text)) {
@@ -777,6 +776,7 @@ function TerminalViewport({
threadRef,
openPreview,
fallbackToBrowser,
+ forceBrowser: event.metaKey || event.ctrlKey,
});
return;
}
diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx
index 5a5147a3c..997369cfb 100644
--- a/apps/web/src/components/chat/ExpandedImageDialog.tsx
+++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx
@@ -80,9 +80,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
}
: source;
- const navigateImage = useCallback((direction: -1 | 1) => {
- setImageOffset((current) => current + direction);
- }, []);
+ const navigateImage = useCallback(
+ (direction: -1 | 1) => {
+ setImageOffset(
+ (current) => (current + direction + preview.images.length) % preview.images.length,
+ );
+ },
+ [preview.images.length],
+ );
// The element that opened the preview gets focus back on close. Without
// this a close button click leaves focus on the unmounted dialog, and the
@@ -149,13 +154,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
{preview.images.length > 1 && (
)}
@@ -190,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
onError={() => setFailedImageSrc(item.src)}
/>
)}
-