Skip to content

Commit aad4b92

Browse files
committed
Release v0.0.60-beta.4
## What's New ### Improvements & Fixes - **Plan sidebar in split view** — Fixed plan dialog staying open when switching between panes - **Split view pane activation** — Clicking a pane now activates it immediately - **Plan approval in split view** — Fixed plan approval flow in split view mode
1 parent 09a6880 commit aad4b92

8 files changed

Lines changed: 168 additions & 26 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.60-beta.3",
3+
"version": "0.0.60-beta.4",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/renderer/features/agents/atoms/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,16 @@ export const agentsPlanSidebarWidthAtom = atomWithStorage<number>(
780780
{ getOnInit: true },
781781
)
782782

783+
// Plan sidebar display mode - sidebar (side peek) or center dialog
784+
export type PlanDisplayMode = "side-peek" | "center-peek"
785+
786+
export const planDisplayModeAtom = atomWithStorage<PlanDisplayMode>(
787+
"agents:planDisplayMode",
788+
"side-peek",
789+
undefined,
790+
{ getOnInit: true },
791+
)
792+
783793
// Plan sidebar open state storage - stores per chatId (persisted)
784794
// Uses window-scoped storage so each window can have independent plan sidebar states
785795
const planSidebarOpenStorageAtom = atomWithWindowStorage<Record<string, boolean>>(

src/renderer/features/agents/main/active-chat.tsx

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import {
132132
pendingPrMessageAtom,
133133
pendingReviewMessageAtom,
134134
pendingUserQuestionsAtom,
135+
planDisplayModeAtom,
135136
planEditRefetchTriggerAtomFamily,
136137
planSidebarOpenAtomFamily,
137138
QUESTIONS_SKIPPED_MESSAGE,
@@ -145,6 +146,7 @@ import {
145146
undoStackAtom,
146147
workspaceDiffCacheAtomFamily,
147148
type AgentMode,
149+
type PlanDisplayMode,
148150
type SelectedCommit
149151
} from "../atoms"
150152
import { BUILTIN_SLASH_COMMANDS } from "../commands"
@@ -3075,11 +3077,11 @@ const ChatViewInner = memo(function ChatViewInner({
30753077
// Handle pending "Build plan" from sidebar
30763078
useEffect(() => {
30773079
// Only trigger if this is the target sub-chat and we're active
3078-
if (pendingBuildPlanSubChatId === subChatId && isActive && !isSplitPane) {
3080+
if (pendingBuildPlanSubChatId === subChatId && isActive) {
30793081
setPendingBuildPlanSubChatId(null) // Clear immediately to prevent double-trigger
30803082
handleApprovePlan()
30813083
}
3082-
}, [pendingBuildPlanSubChatId, subChatId, isActive, isSplitPane, setPendingBuildPlanSubChatId, handleApprovePlan])
3084+
}, [pendingBuildPlanSubChatId, subChatId, isActive, setPendingBuildPlanSubChatId, handleApprovePlan])
30833085

30843086
// Detect PR URLs in assistant messages and store them
30853087
// Initialize with existing PR URL to prevent duplicate toast on re-mount
@@ -4710,12 +4712,20 @@ export function ChatView({
47104712
[activeSubChatIdForPlan],
47114713
)
47124714
const [isPlanSidebarOpen, setIsPlanSidebarOpen] = useAtom(planSidebarAtom)
4715+
const [planDisplayMode, setPlanDisplayMode] = useAtom(planDisplayModeAtom)
47134716
const currentPlanPathAtom = useMemo(
47144717
() => currentPlanPathAtomFamily(activeSubChatIdForPlan || ""),
47154718
[activeSubChatIdForPlan],
47164719
)
47174720
const [currentPlanPath, setCurrentPlanPath] = useAtom(currentPlanPathAtom)
47184721

4722+
// Effective plan display mode: force center-peek in split view, otherwise use user preference
4723+
// Computed early because mutual exclusion logic needs it
4724+
const isSplitViewForPlan = useAgentSubChatStore(
4725+
useShallow((state) => state.splitPaneIds.length >= 2 && state.splitPaneIds.includes(state.activeSubChatId))
4726+
)
4727+
const effectivePlanDisplayMode: PlanDisplayMode = isSplitViewForPlan ? "center-peek" : planDisplayMode
4728+
47194729
// File viewer sidebar state - per-chat open file path
47204730
const fileViewerAtom = useMemo(
47214731
() => fileViewerOpenAtomFamily(chatId),
@@ -4736,16 +4746,16 @@ export function ChatView({
47364746
const toggleTerminalHotkey = useResolvedHotkeyDisplay("toggle-terminal")
47374747

47384748
// Close plan sidebar when switching to a sub-chat that has no plan
4749+
// Skip in split view — clicking between panes shouldn't close the plan dialog
47394750
const prevSubChatIdRef = useRef(activeSubChatIdForPlan)
47404751
useEffect(() => {
47414752
if (prevSubChatIdRef.current !== activeSubChatIdForPlan) {
4742-
// Sub-chat changed - if new one has no plan path, close sidebar
4743-
if (!currentPlanPath) {
4753+
if (!currentPlanPath && !isSplitViewForPlan) {
47444754
setIsPlanSidebarOpen(false)
47454755
}
47464756
prevSubChatIdRef.current = activeSubChatIdForPlan
47474757
}
4748-
}, [activeSubChatIdForPlan, currentPlanPath, setIsPlanSidebarOpen])
4758+
}, [activeSubChatIdForPlan, currentPlanPath, isSplitViewForPlan, setIsPlanSidebarOpen])
47494759
const setPendingBuildPlanSubChatId = useSetAtom(pendingBuildPlanSubChatIdAtom)
47504760

47514761
// Read plan edit refetch trigger from atom (set by ChatViewInner when Edit completes)
@@ -4813,14 +4823,16 @@ export function ChatView({
48134823
// Track previous states to detect opens/closes
48144824
const prevSidebarStatesRef = useRef({
48154825
details: isDetailsSidebarOpen,
4816-
plan: isPlanSidebarOpen && !!currentPlanPath,
4826+
plan: isPlanSidebarOpen && !!currentPlanPath && effectivePlanDisplayMode === "side-peek",
48174827
terminal: isTerminalSidebarOpen,
48184828
})
48194829

48204830
useEffect(() => {
48214831
const prev = prevSidebarStatesRef.current
48224832
const auto = autoClosedStateRef.current
4823-
const isPlanOpen = isPlanSidebarOpen && !!currentPlanPath
4833+
// Only treat plan as a physical sidebar conflict when in side-peek mode
4834+
// In center-peek (dialog) mode, plan floats above everything — no conflict
4835+
const isPlanOpen = isPlanSidebarOpen && !!currentPlanPath && effectivePlanDisplayMode === "side-peek"
48244836

48254837
// Detect state changes
48264838
const detailsJustOpened = isDetailsSidebarOpen && !prev.details
@@ -4885,6 +4897,7 @@ export function ChatView({
48854897
isDetailsSidebarOpen,
48864898
isPlanSidebarOpen,
48874899
currentPlanPath,
4900+
effectivePlanDisplayMode,
48884901
isTerminalSidebarOpen,
48894902
terminalDisplayMode,
48904903
setIsDetailsSidebarOpen,
@@ -5102,6 +5115,12 @@ export function ChatView({
51025115
}))
51035116
)
51045117

5118+
// isSplitView alias using local splitPaneIds (for JSX rendering)
5119+
const isSplitView = splitPaneIds.length >= 2 && splitPaneIds.includes(activeSubChatId)
5120+
const handlePlanDisplayModeChange = useCallback((mode: PlanDisplayMode) => {
5121+
setPlanDisplayMode(mode)
5122+
}, [setPlanDisplayMode])
5123+
51055124
// Clear sub-chat "unseen changes" indicator when sub-chat becomes active
51065125
useEffect(() => {
51075126
if (!activeSubChatId) return
@@ -7259,9 +7278,8 @@ Make sure to preserve all functionality from both branches when resolving confli
72597278
)}
72607279
</div>
72617280

7262-
{/* Plan Sidebar - shows plan files on the right (leftmost right sidebar) */}
7263-
{/* Only show when we have an active sub-chat with a plan */}
7264-
{!isMobileFullscreen && activeSubChatIdForPlan && (
7281+
{/* Plan Sidebar - side-peek mode (ResizableSidebar) */}
7282+
{!isMobileFullscreen && activeSubChatIdForPlan && effectivePlanDisplayMode === "side-peek" && (
72657283
<ResizableSidebar
72667284
isOpen={isPlanSidebarOpen && !!currentPlanPath}
72677285
onClose={() => setIsPlanSidebarOpen(false)}
@@ -7283,9 +7301,30 @@ Make sure to preserve all functionality from both branches when resolving confli
72837301
onBuildPlan={handleApprovePlanFromSidebar}
72847302
refetchTrigger={planEditRefetchTrigger}
72857303
mode={currentMode}
7304+
displayMode="side-peek"
7305+
onDisplayModeChange={handlePlanDisplayModeChange}
72867306
/>
72877307
</ResizableSidebar>
72887308
)}
7309+
{/* Plan Sidebar - center-peek mode (Dialog overlay) */}
7310+
{activeSubChatIdForPlan && effectivePlanDisplayMode === "center-peek" && isPlanSidebarOpen && !!currentPlanPath && (
7311+
<DiffCenterPeekDialog
7312+
isOpen={true}
7313+
onClose={() => setIsPlanSidebarOpen(false)}
7314+
>
7315+
<AgentPlanSidebar
7316+
chatId={activeSubChatIdForPlan}
7317+
planPath={currentPlanPath}
7318+
onClose={() => setIsPlanSidebarOpen(false)}
7319+
onBuildPlan={handleApprovePlanFromSidebar}
7320+
refetchTrigger={planEditRefetchTrigger}
7321+
mode={currentMode}
7322+
displayMode="center-peek"
7323+
onDisplayModeChange={handlePlanDisplayModeChange}
7324+
isSplitView={isSplitView}
7325+
/>
7326+
</DiffCenterPeekDialog>
7327+
)}
72897328

72907329
{/* Diff View - hidden on mobile fullscreen and when diff is not available */}
72917330
{/* Supports three display modes: side-peek (sidebar), center-peek (dialog), full-page */}
@@ -7497,7 +7536,7 @@ Make sure to preserve all functionality from both branches when resolving confli
74977536
onBuildPlan={handleApprovePlanFromSidebar}
74987537
planRefetchTrigger={planEditRefetchTrigger}
74997538
activeSubChatId={activeSubChatIdForPlan}
7500-
isPlanSidebarOpen={isPlanSidebarOpen && !!currentPlanPath}
7539+
isPlanSidebarOpen={isPlanSidebarOpen && !!currentPlanPath && effectivePlanDisplayMode === "side-peek"}
75017540
isTerminalSidebarOpen={isTerminalSidebarOpen}
75027541
isDiffSidebarOpen={isDiffSidebarOpen}
75037542
diffDisplayMode={diffDisplayMode}

src/renderer/features/agents/main/isolated-message-group.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,15 @@ export const IsolatedMessageGroup = memo(function IsolatedMessageGroup({
197197
if (imageParts.length > 0) {
198198
parts.push(imageParts.length === 1 ? "image" : `${imageParts.length} images`)
199199
}
200-
const quoteCount = textMentions.filter(m => m.type === "quote" || m.type === "pasted").length
200+
const quoteCount = textMentions.filter(m => m.type === "quote").length
201+
const pastedCount = textMentions.filter(m => m.type === "pasted").length
201202
const codeCount = textMentions.filter(m => m.type === "diff").length
202203
if (quoteCount > 0) {
203204
parts.push(quoteCount === 1 ? "selected text" : `${quoteCount} text selections`)
204205
}
206+
if (pastedCount > 0) {
207+
parts.push(pastedCount === 1 ? "pasted text" : `${pastedCount} pasted texts`)
208+
}
205209
if (codeCount > 0) {
206210
parts.push(codeCount === 1 ? "code selection" : `${codeCount} code selections`)
207211
}

src/renderer/features/agents/stores/message-store.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,12 +889,16 @@ export const syncMessagesWithStatusAtom = atom(
889889
// 1. msg object itself is mutated in-place
890890
// 2. msg.parts array is mutated in-place
891891
// 3. Individual part objects inside parts are mutated in-place
892+
const lastMessageId = newIds[newIds.length - 1] ?? null
892893
for (const msg of messages) {
893894
const currentAtomValue = get(messageAtomFamily(msg.id))
894895
const msgChanged = hasMessageChanged(currentSubChatId, msg.id, msg)
896+
const isLastMessage = msg.id === lastMessageId
895897

896898
// CRITICAL FIX: Also update if atom is null (not yet populated)
897-
if (msgChanged || !currentAtomValue) {
899+
// Always refresh the last message because AI SDK can mutate non-last parts
900+
// of the current streaming assistant message without changing the last part.
901+
if (msgChanged || !currentAtomValue || isLastMessage) {
898902
// Deep clone message with new parts array and new part objects
899903
const clonedMsg = {
900904
...msg,

src/renderer/features/agents/ui/agent-plan-sidebar.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,80 @@
11
"use client"
22

33
import { useCallback, useEffect, useMemo, useState } from "react"
4-
import { useAtomValue } from "jotai"
4+
import { Check, X } from "lucide-react"
55
import { Button } from "../../../components/ui/button"
6-
import { IconDoubleChevronRight, IconSpinner, PlanIcon, MarkdownIcon, CodeIcon } from "../../../components/ui/icons"
6+
import { IconDoubleChevronRight, IconSpinner, PlanIcon, MarkdownIcon, CodeIcon, IconSidePeek, IconCenterPeek } from "../../../components/ui/icons"
77
import { Kbd } from "../../../components/ui/kbd"
88
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
} from "../../../components/ui/dropdown-menu"
915
import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer"
1016
import { cn } from "../../../lib/utils"
1117
import { trpc } from "../../../lib/trpc"
1218
import { CopyButton } from "./message-action-buttons"
13-
import type { AgentMode } from "../atoms"
19+
import type { AgentMode, PlanDisplayMode } from "../atoms"
20+
21+
const PLAN_VIEW_MODES = [
22+
{ value: "side-peek" as const, label: "Sidebar", Icon: IconSidePeek },
23+
{ value: "center-peek" as const, label: "Dialog", Icon: IconCenterPeek },
24+
]
25+
26+
function PlanViewModeSwitcher({
27+
mode,
28+
onModeChange,
29+
isSplitView = false,
30+
}: {
31+
mode: PlanDisplayMode
32+
onModeChange: (mode: PlanDisplayMode) => void
33+
isSplitView?: boolean
34+
}) {
35+
const currentMode = PLAN_VIEW_MODES.find((m) => m.value === mode) ?? PLAN_VIEW_MODES[0]
36+
const CurrentIcon = currentMode.Icon
37+
38+
return (
39+
<DropdownMenu>
40+
<DropdownMenuTrigger asChild>
41+
<Button
42+
variant="ghost"
43+
size="sm"
44+
className="h-6 w-6 p-0 flex-shrink-0 hover:bg-foreground/10"
45+
>
46+
<CurrentIcon className="size-4 text-muted-foreground" />
47+
</Button>
48+
</DropdownMenuTrigger>
49+
<DropdownMenuContent align="start" className="min-w-[160px]">
50+
{PLAN_VIEW_MODES.map(({ value, label, Icon }) => {
51+
const isDisabled = value === "side-peek" && isSplitView
52+
return (
53+
<DropdownMenuItem
54+
key={value}
55+
onClick={() => !isDisabled && onModeChange(value)}
56+
className="flex items-center gap-2"
57+
disabled={isDisabled}
58+
>
59+
<Icon className="size-4 text-muted-foreground" />
60+
<span className="flex-1">
61+
{label}
62+
{isDisabled && (
63+
<span className="text-xs text-muted-foreground/60 ml-1">
64+
(split view)
65+
</span>
66+
)}
67+
</span>
68+
{mode === value && !isDisabled && (
69+
<Check className="size-4 text-muted-foreground ml-auto" />
70+
)}
71+
</DropdownMenuItem>
72+
)
73+
})}
74+
</DropdownMenuContent>
75+
</DropdownMenu>
76+
)
77+
}
1478

1579
interface AgentPlanSidebarProps {
1680
chatId: string
@@ -21,6 +85,9 @@ interface AgentPlanSidebarProps {
2185
refetchTrigger?: number
2286
/** Current agent mode (plan or agent) */
2387
mode?: AgentMode
88+
displayMode?: PlanDisplayMode
89+
onDisplayModeChange?: (mode: PlanDisplayMode) => void
90+
isSplitView?: boolean
2491
}
2592

2693
export function AgentPlanSidebar({
@@ -30,6 +97,9 @@ export function AgentPlanSidebar({
3097
onBuildPlan,
3198
refetchTrigger,
3299
mode = "agent",
100+
displayMode = "side-peek",
101+
onDisplayModeChange,
102+
isSplitView = false,
33103
}: AgentPlanSidebarProps) {
34104
// View mode: rendered markdown or plaintext
35105
const [viewMode, setViewMode] = useState<"rendered" | "plaintext">("rendered")
@@ -71,8 +141,19 @@ export function AgentPlanSidebar({
71141
className="h-6 w-6 p-0 hover:bg-foreground/10 transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] text-foreground flex-shrink-0 rounded-md"
72142
aria-label="Close plan"
73143
>
74-
<IconDoubleChevronRight className="h-4 w-4" />
144+
{displayMode === "side-peek" ? (
145+
<IconDoubleChevronRight className="h-4 w-4" />
146+
) : (
147+
<X className="h-4 w-4" />
148+
)}
75149
</Button>
150+
{onDisplayModeChange && (
151+
<PlanViewModeSwitcher
152+
mode={displayMode}
153+
onModeChange={onDisplayModeChange}
154+
isSplitView={isSplitView}
155+
/>
156+
)}
76157
<span className="text-sm font-medium truncate">{planTitle}</span>
77158
</div>
78159
<div className="flex items-center gap-1 flex-shrink-0">

src/renderer/features/agents/ui/split-view-container.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export function SplitViewContainer({
5454
<div
5555
style={{ width: `${currentRatios[i] * 100}%` }}
5656
className="h-full overflow-hidden relative flex flex-col"
57+
onPointerDown={() => {
58+
const store = useAgentSubChatStore.getState()
59+
if (store.activeSubChatId !== pane.id) {
60+
store.setActiveSubChat(pane.id)
61+
}
62+
}}
5763
>
5864
{pane.content}
5965
</div>

src/renderer/features/agents/ui/sub-chat-context-menu.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ export function SubChatContextMenu({
9292
splitPaneIds,
9393
}: SubChatContextMenuProps) {
9494
const closeTabShortcut = useCloseTabShortcut()
95-
const newAgentSplitHotkey = useResolvedHotkeyDisplay("new-agent-split")
9695

9796
const handleExport = useCallback((format: ExportFormat) => {
9897
if (!chatId) return
@@ -138,11 +137,7 @@ export function SubChatContextMenu({
138137
</ContextMenuSubContent>
139138
</ContextMenuSub>
140139
)}
141-
{isDesktopApp() && chatId && (
142-
<ContextMenuItem onClick={() => openInNewWindow(chatId, subChat.id, isSplitTab ? splitPaneIds : undefined)}>
143-
Open in new window
144-
</ContextMenuItem>
145-
)}
140+
<ContextMenuSeparator />
146141
{isSplitTab ? (
147142
<>
148143
{splitPaneCount > 2 && onRemoveFromSplit && (
@@ -160,12 +155,15 @@ export function SubChatContextMenu({
160155
<ContextMenuItem
161156
onClick={() => onOpenInSplit(subChat.id)}
162157
disabled={isActiveTab || isOnlyChat || splitPaneCount >= 6}
163-
className="justify-between"
164158
>
165159
Add as Split
166-
{newAgentSplitHotkey && <Kbd>{newAgentSplitHotkey}</Kbd>}
167160
</ContextMenuItem>
168161
) : null}
162+
{isDesktopApp() && chatId && (
163+
<ContextMenuItem onClick={() => openInNewWindow(chatId, subChat.id, isSplitTab ? splitPaneIds : undefined)}>
164+
Open in new window
165+
</ContextMenuItem>
166+
)}
169167
<ContextMenuSeparator />
170168

171169
{showCloseTabOptions ? (

0 commit comments

Comments
 (0)