From 65014b3a58c60a9dba19f892aeed33c29890313e Mon Sep 17 00:00:00 2001 From: Alejandro Tamayo Date: Sat, 18 Apr 2026 18:31:22 +0200 Subject: [PATCH] feat: drag-to-split, queue reorder, Cmd+Shift+T, PR auto-refresh Adds drag-and-drop to create/extend the split view by dragging sub-chats from the sidebar, drag reordering for queued messages, a Cmd+Shift+T shortcut (and tooltip) that opens a new sub-chat directly in split view, and PR status auto-refresh on git commit/push. Also adds a per-pane close button inline with the title, hides the Background/sandbox option, and lifts the sidebar's DndContext to a shared parent so drag can cross from sidebar to main content without overflow clipping (DragOverlay portals the preview at document root). Fix: `removeFromSplit` now shifts `activeSubChatId` when the removed pane was active so the correct pane stays visible after close. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agents/components/work-mode-selector.tsx | 15 +- .../features/agents/main/active-chat.tsx | 133 +++++++++++++++--- .../agents/stores/message-queue-store.ts | 24 ++++ .../features/agents/stores/sub-chat-store.ts | 61 ++++++-- .../agents/ui/agent-queue-indicator.tsx | 103 ++++++++++++-- .../features/agents/ui/agents-content.tsx | 75 +++++++++- .../agents/ui/split-view-container.tsx | 64 ++++++++- .../features/agents/ui/sub-chat-selector.tsx | 16 ++- .../sidebar/agents-subchats-sidebar.tsx | 45 +----- 9 files changed, 446 insertions(+), 90 deletions(-) diff --git a/src/renderer/features/agents/components/work-mode-selector.tsx b/src/renderer/features/agents/components/work-mode-selector.tsx index 2f477fc62..563158a9b 100644 --- a/src/renderer/features/agents/components/work-mode-selector.tsx +++ b/src/renderer/features/agents/components/work-mode-selector.tsx @@ -28,13 +28,14 @@ const workModeOptions = [ label: "Worktree", icon: GitBranch, }, - { - id: "sandbox" as const, - label: "Background", - icon: CloudIcon, - disabled: true, - soon: true, - }, + // Hidden until ready — uncomment to re-enable the Background/sandbox mode. + // { + // id: "sandbox" as const, + // label: "Background", + // icon: CloudIcon, + // disabled: true, + // soon: true, + // }, ] export function WorkModeSelector({ diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index 1d7aca900..359cf9011 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -41,7 +41,8 @@ import { ChevronDown, GitFork, ListTree, - TerminalSquare + TerminalSquare, + X as XIcon, } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { @@ -229,10 +230,11 @@ import { MobileChatHeader } from "../ui/mobile-chat-header" import { QuickCommentInput } from "../ui/quick-comment-input" import { SubChatSelector } from "../ui/sub-chat-selector" import { SubChatStatusCard } from "../ui/sub-chat-status-card" -import { SplitViewContainer } from "../ui/split-view-container" +import { SplitViewContainer, SplitDropZone } from "../ui/split-view-container" import { TextSelectionPopover } from "../ui/text-selection-popover" import { autoRenameAgentChat } from "../utils/auto-rename" import { generateCommitToPrMessage, generatePrMessage, generateReviewMessage } from "../utils/pr-message" +import { extractGitActivity } from "../utils/git-activity" import { ChatInputArea } from "./chat-input-area" import { IsolatedMessagesSection } from "./isolated-messages-section" const clearSubChatSelectionAtom = atom(null, () => {}) @@ -775,6 +777,37 @@ function PlayButton({ ) } +// Persistent (not hover-to-reveal) — hiding the button on hover caused it to +// vanish as the pointer approached it. +const SplitPaneInlineClose = memo(function SplitPaneInlineClose({ + subChatId, +}: { + subChatId: string +}) { + const removeFromSplit = useAgentSubChatStore((s) => s.removeFromSplit) + const splitPaneCount = useAgentSubChatStore((s) => s.splitPaneIds.length) + const isLastPair = splitPaneCount === 2 + const label = isLastPair ? "Close split view" : "Remove from split" + return ( + + + + + {label} + + ) +}) + // Isolated scroll-to-bottom button - uses own scroll listener to avoid re-renders of parent const ScrollToBottomButton = memo(function ScrollToBottomButton({ containerRef, @@ -2153,6 +2186,7 @@ const ChatViewInner = memo(function ChatViewInner({ // tRPC utils for cache invalidation const utils = api.useUtils() + const trpcUtils = trpc.useUtils() // Get sub-chat name from store const subChatName = useAgentSubChatStore( @@ -2824,6 +2858,30 @@ const ChatViewInner = memo(function ChatViewInner({ } }, [isStreaming, subChatId, pendingQuestions, setPendingQuestionsMap]) + // PR status auto-refresh on stream end. `messages` is tracked via a ref so + // the effect doesn't re-run on every streamed chunk — only on the transition. + const prAutoRefreshWasStreamingRef = useRef(false) + const prAutoRefreshMessagesRef = useRef(messages) + prAutoRefreshMessagesRef.current = messages + useEffect(() => { + const wasStreaming = prAutoRefreshWasStreamingRef.current + prAutoRefreshWasStreamingRef.current = isStreaming + if (!(wasStreaming && !isStreaming)) return + + const allParts = prAutoRefreshMessagesRef.current.flatMap( + (m: any) => m.parts || [], + ) + const activity = extractGitActivity(allParts) + if (!activity) return + + trpcUtils.chats.getPrStatus.invalidate({ chatId: parentChatId }) + if (projectPath) { + trpcUtils.changes.getGitHubStatus.invalidate({ + worktreePath: projectPath, + }) + } + }, [isStreaming, parentChatId, projectPath, trpcUtils]) + // Sync pending questions with messages state // This handles: 1) restoring on chat switch, 2) clearing when question is answered/timed out useEffect(() => { @@ -4653,14 +4711,22 @@ const ChatViewInner = memo(function ChatViewInner({ isSubChatsSidebarOpen ? "pt-[52px]" : "pt-2", )} > - + {/* Title row: ChatTitleEditor on the left, per-pane close X on the + right for split panes. Flex layout ensures the X sits on the same + visual row as the title rather than floating in a corner. */} +
+
+ +
+ {isSplitPane && } +
{/* Workspace subtitle: repo • branch */} {(workspaceRepoName || workspaceBranch) && (
@@ -4762,6 +4828,9 @@ const ChatViewInner = memo(function ChatViewInner({ queue={queue} onRemoveItem={handleRemoveFromQueue} onSendNow={handleSendFromQueue} + onReorder={(from, to) => + useMessageQueueStore.getState().reorderQueue(subChatId, from, to) + } isStreaming={isStreaming} hasStatusCardBelow={shouldShowStatusCard} /> @@ -7063,6 +7132,8 @@ Make sure to preserve all functionality from both branches when resolving confli agentChatStore.setStreamId(newId, null) // New chat has no active stream forceUpdate({}) // Trigger re-render } + + return newId }, [ worktreePath, chatId, @@ -7080,22 +7151,48 @@ Make sure to preserve all functionality from both branches when resolving confli agentChat?.name, ]) + // Create a new sub-chat AND place it in split view with the previously active tab. + // Used by Cmd+Shift+T. Passes the pre-creation active tab as the explicit first pane + // because handleCreateNewSubChat flips activeSubChatId to the new id. + const handleCreateNewSubChatInSplit = useCallback(() => { + const prevActive = useAgentSubChatStore.getState().activeSubChatId + const newId = handleCreateNewSubChat() + if (!newId || !prevActive) return + useAgentSubChatStore.getState().addToSplit(newId, prevActive) + }, [handleCreateNewSubChat]) + // Keyboard shortcut: New sub-chat // Web: Opt+Cmd+T (browser uses Cmd+T for new tab) // Desktop: Cmd+T + // Cmd+Shift+T (desktop) / Opt+Cmd+Shift+T (web) opens the new sub-chat in split view. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isDesktop = isDesktopApp() - // Desktop: Cmd+T (without Alt) - if (isDesktop && e.metaKey && e.code === "KeyT" && !e.altKey) { + // Desktop: Cmd+Shift+T — new sub-chat in split view. + // Must be checked BEFORE the plain Cmd+T branch (which doesn't require Shift). + if (isDesktop && e.metaKey && e.shiftKey && e.code === "KeyT" && !e.altKey) { + e.preventDefault() + handleCreateNewSubChatInSplit() + return + } + + // Web: Opt+Cmd+Shift+T — new sub-chat in split view. + if (e.altKey && e.metaKey && e.shiftKey && e.code === "KeyT") { + e.preventDefault() + handleCreateNewSubChatInSplit() + return + } + + // Desktop: Cmd+T (without Alt, without Shift) + if (isDesktop && e.metaKey && e.code === "KeyT" && !e.altKey && !e.shiftKey) { e.preventDefault() handleCreateNewSubChat() return } - // Web: Opt+Cmd+T (with Alt) - if (e.altKey && e.metaKey && e.code === "KeyT") { + // Web: Opt+Cmd+T (with Alt, without Shift) + if (e.altKey && e.metaKey && e.code === "KeyT" && !e.shiftKey) { e.preventDefault() handleCreateNewSubChat() } @@ -7103,7 +7200,7 @@ Make sure to preserve all functionality from both branches when resolving confli window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [handleCreateNewSubChat]) + }, [handleCreateNewSubChat, handleCreateNewSubChatInSplit]) // NOTE: Desktop notifications for pending questions are now triggered directly // in ipc-chat-transport.ts when the ask-user-question chunk arrives. @@ -7815,7 +7912,8 @@ Make sure to preserve all functionality from both branches when resolving confli } /> ) : ( - tabsToRender.map(subChatId => { + + {tabsToRender.map(subChatId => { const chat = getOrCreateChat(subChatId) const isActive = subChatId === activeSubChatId const isFirstSubChat = getFirstSubChatId(agentSubChats) === subChatId @@ -7873,7 +7971,8 @@ Make sure to preserve all functionality from both branches when resolving confli />
) - }) + })} + )} ) : ( diff --git a/src/renderer/features/agents/stores/message-queue-store.ts b/src/renderer/features/agents/stores/message-queue-store.ts index 4af9d9bfa..433048dfc 100644 --- a/src/renderer/features/agents/stores/message-queue-store.ts +++ b/src/renderer/features/agents/stores/message-queue-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand" import { subscribeWithSelector } from "zustand/middleware" +import { arrayMove } from "@dnd-kit/sortable" import type { AgentQueueItem } from "../lib/queue-utils" import { removeQueueItem } from "../lib/queue-utils" @@ -27,6 +28,8 @@ interface MessageQueueState { prependItem: (subChatId: string, item: AgentQueueItem) => void // Signal that a queued message was auto-sent (for scroll triggering) triggerQueueSent: (subChatId: string) => void + // Reorder queue via drag-and-drop (user-driven reprioritization). + reorderQueue: (subChatId: string, fromIndex: number, toIndex: number) => void } export const useMessageQueueStore = create()( @@ -108,4 +111,25 @@ export const useMessageQueueStore = create()( }, })) }, + + reorderQueue: (subChatId, fromIndex, toIndex) => { + set((state) => { + const current = state.queues[subChatId] || [] + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= current.length || + toIndex >= current.length + ) { + return state + } + return { + queues: { + ...state.queues, + [subChatId]: arrayMove(current, fromIndex, toIndex), + }, + } + }) + }, }))) diff --git a/src/renderer/features/agents/stores/sub-chat-store.ts b/src/renderer/features/agents/stores/sub-chat-store.ts index 3beebf4e9..b400d7ad3 100644 --- a/src/renderer/features/agents/stores/sub-chat-store.ts +++ b/src/renderer/features/agents/stores/sub-chat-store.ts @@ -8,7 +8,29 @@ import { clearSubChatRuntimeCaches } from "./sub-chat-runtime-cleanup" import { getDefaultRatios, addPaneRatio, removePaneRatio } from "../atoms" import { trpcClient } from "../../../lib/trpc" -const MAX_SPLIT_PANES = 4 +export const MAX_SPLIT_PANES = 4 + +/** + * Whether a sub-chat can be added to split via drag-and-drop. + * Mirrors the guards in `addToSplit`; used by droppables to skip the + * "drop would silently do nothing" case so no hover highlight shows. + */ +export function canAddToSplit( + state: Pick< + AgentSubChatStore, + "activeSubChatId" | "splitPaneIds" + >, + subChatId: string, +): boolean { + if (state.splitPaneIds.includes(subChatId)) return false + if (state.splitPaneIds.length >= MAX_SPLIT_PANES) return false + if (state.splitPaneIds.length === 0) { + // Need an active tab to pair with the dragged one. + if (!state.activeSubChatId) return false + if (subChatId === state.activeSubChatId) return false + } + return true +} export interface SubChatMeta { id: string @@ -42,7 +64,7 @@ interface AgentSubChatStore { updateSubChatName: (subChatId: string, name: string) => void updateSubChatMode: (subChatId: string, mode: "plan" | "agent") => void updateSubChatTimestamp: (subChatId: string) => void - addToSplit: (subChatId: string) => void + addToSplit: (subChatId: string, explicitFirstPane?: string) => void removeFromSplit: (subChatId: string) => void closeSplit: () => void setSplitRatios: (ratios: number[]) => void @@ -318,17 +340,20 @@ export const useAgentSubChatStore = create((set, get) => ({ }) }, - addToSplit: (subChatId) => { + addToSplit: (subChatId, explicitFirstPane) => { const { chatId, activeSubChatId, splitPaneIds, splitRatios, openSubChatIds } = get() - if (subChatId === activeSubChatId) return + // Pane 1 source: explicit override (for "create new in split" flows where active + // has already been flipped to the new id) or the current active tab. + const firstPane = explicitFirstPane ?? activeSubChatId + if (subChatId === firstPane) return if (splitPaneIds.includes(subChatId)) return let newPaneIds: string[] let newRatios: number[] if (splitPaneIds.length === 0) { - // Start new split group: [active, new] - if (!activeSubChatId) return - newPaneIds = [activeSubChatId, subChatId] + // Start new split group: [firstPane, new] + if (!firstPane) return + newPaneIds = [firstPane, subChatId] newRatios = getDefaultRatios(2) } else if (splitPaneIds.length < MAX_SPLIT_PANES) { newPaneIds = [...splitPaneIds, subChatId] @@ -352,7 +377,7 @@ export const useAgentSubChatStore = create((set, get) => ({ }, removeFromSplit: (subChatId) => { - const { chatId, splitPaneIds, splitRatios } = get() + const { chatId, splitPaneIds, splitRatios, activeSubChatId } = get() if (!splitPaneIds.includes(subChatId)) return const removeIdx = splitPaneIds.indexOf(subChatId) @@ -360,10 +385,28 @@ export const useAgentSubChatStore = create((set, get) => ({ let newRatios = removePaneRatio(splitRatios, removeIdx) if (newPaneIds.length < 2) { newPaneIds = []; newRatios = [] } - set({ splitPaneIds: newPaneIds, splitRatios: newRatios }) + // If the removed pane was active, shift active to an adjacent remaining + // pane. Without this, clicking X on the active pane collapses the split + // but leaves `activeSubChatId` pointing at the just-removed pane — the + // user sees the closed chat stay visible and the other one "disappear". + let newActiveSubChatId = activeSubChatId + if (activeSubChatId === subChatId) { + const remaining = splitPaneIds.filter((id) => id !== subChatId) + newActiveSubChatId = + remaining[removeIdx] ?? remaining[removeIdx - 1] ?? remaining[0] ?? activeSubChatId + } + + set({ + splitPaneIds: newPaneIds, + splitRatios: newRatios, + activeSubChatId: newActiveSubChatId, + }) if (chatId) { saveToLS(chatId, "splitPanes", newPaneIds) saveToLS(chatId, "splitRatios", newRatios) + if (newActiveSubChatId !== activeSubChatId) { + saveToLS(chatId, "active", newActiveSubChatId) + } } }, diff --git a/src/renderer/features/agents/ui/agent-queue-indicator.tsx b/src/renderer/features/agents/ui/agent-queue-indicator.tsx index 35ffa31f0..810ba1eba 100644 --- a/src/renderer/features/agents/ui/agent-queue-indicator.tsx +++ b/src/renderer/features/agents/ui/agent-queue-indicator.tsx @@ -1,8 +1,22 @@ "use client" -import { memo, useState, useCallback, useEffect } from "react" -import { ChevronDown, ArrowUp, X } from "lucide-react" +import { memo, useState, useCallback, useEffect, useMemo } from "react" +import { ChevronDown, ArrowUp, X, GripVertical } from "lucide-react" import { motion, AnimatePresence } from "motion/react" +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { Tooltip, TooltipContent, @@ -21,11 +35,27 @@ const QueueItemRow = memo(function QueueItemRow({ item, onRemove, onSendNow, + isReorderable = false, }: { item: AgentQueueItem onRemove?: (itemId: string) => void onSendNow?: (itemId: string) => void + isReorderable?: boolean }) { + // Items currently being processed must not be draggable — the queue processor + // and the user would fight over ordering, and the row would disappear mid-drag. + const isDraggable = isReorderable && item.status !== "processing" + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: item.id, disabled: !isDraggable }) + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + position: "relative", + } + const handleRemove = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -67,7 +97,25 @@ const QueueItemRow = memo(function QueueItemRow({ } return ( -
+
+ {isDraggable && ( +