Skip to content

Commit 355ec30

Browse files
committed
Release v0.0.49
## What's New ### Features - **Copy Plan** — Copy plan content to clipboard - **Raw Plan View** — View raw markdown of implementation plans ### Improvements & Fixes - **New Subchat Button** — Fixed blocked state for add subchat button - **Message Store** — Include meta in message store state - **Community PRs** — Merged community contributions (#117, #118)
1 parent 8b9d174 commit 355ec30

File tree

10 files changed

+208
-88
lines changed

10 files changed

+208
-88
lines changed

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bun.lockb

356 Bytes
Binary file not shown.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.48",
3+
"version": "0.0.49",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {
@@ -21,7 +21,7 @@
2121
"dist:upload": "node scripts/upload-release.mjs",
2222
"claude:download": "node scripts/download-claude-binary.mjs",
2323
"claude:download:all": "node scripts/download-claude-binary.mjs --all",
24-
"release": "rm -rf release && bun run claude:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh",
24+
"release": "rm -rf release && bun i && bun run claude:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh",
2525
"release:dev": "rm -rf release && bun run claude:download && bun run build && bun run package:mac && rm -rf node_modules && bun i",
2626
"sync:public": "./scripts/sync-to-public.sh",
2727
"icon:generate": "node scripts/generate-icon.mjs",
@@ -79,6 +79,7 @@
7979
"electron-updater": "^6.7.3",
8080
"gray-matter": "^4.0.3",
8181
"jotai": "^2.11.1",
82+
"jsonc-parser": "^3.3.1",
8283
"lucide-react": "^0.468.0",
8384
"mermaid": "^11.12.2",
8485
"motion": "^11.15.0",

src/main/lib/vscode-theme-scanner.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as fs from "fs/promises"
99
import * as path from "path"
1010
import * as os from "os"
1111
import { ipcMain } from "electron"
12+
import { parse as parseJsonc } from "jsonc-parser"
1213

1314
/**
1415
* Source editor type
@@ -160,12 +161,8 @@ async function scanExtensionsDir(extensionsDir: string, source: EditorSource): P
160161
let actualThemeName: string | undefined
161162
try {
162163
const themeContent = await fs.readFile(themePath, "utf-8")
163-
// Handle JSONC (JSON with comments and trailing commas)
164-
const jsonContent = themeContent
165-
.replace(/\/\/.*$/gm, "") // Remove single-line comments
166-
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
167-
.replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas
168-
const themeData = JSON.parse(jsonContent)
164+
// Use proper JSONC parser (handles comments and trailing commas)
165+
const themeData = parseJsonc(themeContent)
169166
actualThemeName = themeData.name
170167
} catch {
171168
continue
@@ -235,13 +232,8 @@ export async function scanVSCodeThemes(): Promise<DiscoveredTheme[]> {
235232
export async function loadThemeFromPath(themePath: string): Promise<VSCodeThemeData> {
236233
const content = await fs.readFile(themePath, "utf-8")
237234

238-
// Handle JSONC (JSON with comments and trailing commas) - VS Code theme files use this format
239-
const jsonContent = content
240-
.replace(/\/\/.*$/gm, "") // Remove single-line comments
241-
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove multi-line comments
242-
.replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas
243-
244-
const theme = JSON.parse(jsonContent)
235+
// Use proper JSONC parser (handles comments and trailing commas)
236+
const theme = parseJsonc(content)
245237

246238
// Generate unique ID based on path and timestamp
247239
const id = `imported-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`

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

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ import {
175175
useAgentSubChatStore,
176176
type SubChatMeta,
177177
} from "../stores/sub-chat-store"
178+
import { useShallow } from "zustand/react/shallow"
178179
import {
179180
AgentDiffView,
180181
diffViewModeAtom,
@@ -4153,10 +4154,10 @@ export function ChatView({
41534154
[activeSubChatIdForMode],
41544155
)
41554156
const [subChatMode] = useAtom(subChatModeAtom)
4156-
// Current mode - use subChatMode when there's an active sub-chat, otherwise default to "agent"
4157-
const currentMode: AgentMode = activeSubChatIdForMode ? subChatMode : "agent"
4158-
// Default mode for new sub-chats
4157+
// Default mode for new sub-chats (used as fallback when no active sub-chat)
41594158
const defaultAgentMode = useAtomValue(defaultAgentModeAtom)
4159+
// Current mode - use subChatMode when there's an active sub-chat, otherwise use user's default preference
4160+
const currentMode: AgentMode = activeSubChatIdForMode ? subChatMode : defaultAgentMode
41604161

41614162
const isDesktop = useAtomValue(isDesktopAtom)
41624163
const isFullscreen = useAtomValue(isFullscreenAtom)
@@ -4539,10 +4540,20 @@ export function ChatView({
45394540
})
45404541
}, [chatId, setUnseenChanges])
45414542

4542-
// Get sub-chat state from store (using getState() to avoid re-renders on state changes)
4543-
const activeSubChatId = useAgentSubChatStore.getState().activeSubChatId
4544-
const openSubChatIds = useAgentSubChatStore.getState().openSubChatIds
4545-
const pinnedSubChatIds = useAgentSubChatStore.getState().pinnedSubChatIds
4543+
// Get sub-chat state from store (reactive subscription for tabsToRender)
4544+
const {
4545+
activeSubChatId,
4546+
openSubChatIds,
4547+
pinnedSubChatIds,
4548+
allSubChats,
4549+
} = useAgentSubChatStore(
4550+
useShallow((state) => ({
4551+
activeSubChatId: state.activeSubChatId,
4552+
openSubChatIds: state.openSubChatIds,
4553+
pinnedSubChatIds: state.pinnedSubChatIds,
4554+
allSubChats: state.allSubChats,
4555+
}))
4556+
)
45464557

45474558
// Clear sub-chat "unseen changes" indicator when sub-chat becomes active
45484559
useEffect(() => {
@@ -4556,7 +4567,6 @@ export function ChatView({
45564567
return prev
45574568
})
45584569
}, [activeSubChatId, setSubChatUnseenChanges])
4559-
const allSubChats = useAgentSubChatStore.getState().allSubChats
45604570

45614571
// tRPC utils for optimistic cache updates
45624572
const utils = api.useUtils()
@@ -4676,18 +4686,16 @@ export function ChatView({
46764686
const tabsToRender = useMemo(() => {
46774687
if (!activeSubChatId) return []
46784688

4679-
// Use agentSubChats from server (tRPC/remote API) as the authoritative source for validation.
4680-
// This fixes the race condition where:
4681-
// 1. setChatId resets allSubChats to [] but loads activeSubChatId from localStorage
4682-
// 2. tabsToRender was checking activeSubChatId against empty allSubChats → always failing
4689+
// Combine server data (agentSubChats) with local store (allSubChats) for validation.
4690+
// This handles:
4691+
// 1. Race condition where setChatId resets allSubChats but activeSubChatId loads from localStorage
4692+
// 2. Optimistic updates when creating new sub-chats (new sub-chat is in allSubChats but not in agentSubChats yet)
46834693
//
4684-
// agentSubChats comes from the server and is the "truth" about which sub-chats exist.
4685-
// allSubChats in Zustand is only populated AFTER the init useEffect runs.
4686-
//
4687-
// For optimistic updates when creating new sub-chats, we fall back to allSubChats
4688-
// since the new sub-chat won't be in agentSubChats yet (tRPC query is stale).
4689-
const sourceForValidation = agentSubChats.length > 0 ? agentSubChats : allSubChats
4690-
const validSubChatIds = new Set(sourceForValidation.map(sc => sc.id))
4694+
// By combining both sources, we validate against all known sub-chats from both server and local state.
4695+
const validSubChatIds = new Set([
4696+
...agentSubChats.map(sc => sc.id),
4697+
...allSubChats.map(sc => sc.id),
4698+
])
46914699

46924700
// If active sub-chat doesn't belong to this workspace → return []
46934701
// This prevents rendering sub-chats from another workspace during race condition
@@ -5678,6 +5686,9 @@ Make sure to preserve all functionality from both branches when resolving confli
56785686
mode: newSubChatMode,
56795687
})
56805688

5689+
// Set the mode atomFamily for the new sub-chat (so currentMode reads correct value)
5690+
appStore.set(subChatModeAtomFamily(newId), newSubChatMode)
5691+
56815692
// Add to open tabs and set as active
56825693
store.addToOpenSubChats(newId)
56835694
store.setActiveSubChat(newId)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ const previousMessageState = new Map<string, {
529529
lastPartText: string | undefined
530530
lastPartState: string | undefined
531531
lastPartInputJson: string | undefined
532+
metadataJson: string | undefined
532533
}>()
533534

534535
function hasMessageChanged(subChatId: string, msgId: string, msg: Message): boolean {
@@ -542,6 +543,9 @@ function hasMessageChanged(subChatId: string, msgId: string, msg: Message): bool
542543
lastPartText: lastPart?.text,
543544
lastPartState: lastPart?.state,
544545
lastPartInputJson: lastPart?.input ? JSON.stringify(lastPart.input) : undefined,
546+
// Include metadata in change detection to ensure token usage, costs, etc.
547+
// appear after stream completion (fixes race condition on fast streams)
548+
metadataJson: msg.metadata ? JSON.stringify(msg.metadata) : undefined,
545549
}
546550

547551
if (!prev) {
@@ -553,7 +557,8 @@ function hasMessageChanged(subChatId: string, msgId: string, msg: Message): bool
553557
prev.partsLength !== current.partsLength ||
554558
prev.lastPartText !== current.lastPartText ||
555559
prev.lastPartState !== current.lastPartState ||
556-
prev.lastPartInputJson !== current.lastPartInputJson
560+
prev.lastPartInputJson !== current.lastPartInputJson ||
561+
prev.metadataJson !== current.metadataJson
557562

558563
if (changed) {
559564
previousMessageState.set(cacheKey, current)

src/renderer/features/agents/ui/agent-plan-file-tool.tsx

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
"use client"
22

3-
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
43
import { useAtom, useAtomValue, useSetAtom } from "jotai"
4+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
5+
import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer"
56
import { Button } from "../../../components/ui/button"
6-
import { ExpandIcon, CollapseIcon, PlanIcon } from "../../../components/ui/icons"
7+
import { CheckIcon, CollapseIcon, CopyIcon, ExpandIcon, PlanIcon } from "../../../components/ui/icons"
78
import { Kbd } from "../../../components/ui/kbd"
89
import { TextShimmer } from "../../../components/ui/text-shimmer"
9-
import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer"
10+
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"
1011
import { cn } from "../../../lib/utils"
11-
import { getToolStatus } from "./agent-tool-registry"
12-
import { areToolPropsEqual } from "./agent-tool-utils"
1312
import {
14-
planSidebarOpenAtomFamily,
1513
currentPlanPathAtomFamily,
16-
subChatModeAtomFamily,
1714
pendingBuildPlanSubChatIdAtom,
15+
planSidebarOpenAtomFamily,
16+
subChatModeAtomFamily,
1817
} from "../atoms"
1918
import { useAgentSubChatStore } from "../stores/sub-chat-store"
19+
import { getToolStatus } from "./agent-tool-registry"
20+
import { areToolPropsEqual } from "./agent-tool-utils"
2021

2122
interface AgentPlanFileToolProps {
2223
part: {
@@ -45,6 +46,7 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({
4546
isEdit = false,
4647
}: AgentPlanFileToolProps) {
4748
const [isExpanded, setIsExpanded] = useState(false)
49+
const [copied, setCopied] = useState(false)
4850
const { isPending } = getToolStatus(part, chatStatus)
4951
const isWrite = part.type === "tool-Write"
5052
// Get mode from per-subChat atomFamily
@@ -152,6 +154,13 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({
152154
}
153155
}, [setPendingBuildPlanSubChatId])
154156

157+
// Handle copy plan
158+
const handleCopy = useCallback(() => {
159+
navigator.clipboard.writeText(planContent)
160+
setCopied(true)
161+
setTimeout(() => setCopied(false), 2000)
162+
}, [planContent])
163+
155164
// If no content yet, show minimal view with shimmer (no icon during shimmer)
156165
if (!hasVisibleContent) {
157166
return (
@@ -190,25 +199,58 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({
190199
)}
191200
</div>
192201

193-
<div className="flex items-center gap-1">
202+
<div className="flex items-center gap-0.5">
203+
{/* Copy button */}
204+
{hasVisibleContent && (
205+
<Tooltip>
206+
<TooltipTrigger asChild>
207+
<button
208+
onClick={(e) => {
209+
e.stopPropagation()
210+
handleCopy()
211+
}}
212+
className="group p-1 rounded-md hover:bg-accent transition-[background-color,transform] duration-150 ease-out active:scale-95"
213+
>
214+
<div className="relative w-3.5 h-3.5">
215+
<CopyIcon
216+
className={cn(
217+
"absolute inset-0 w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground transition-[opacity,transform,color] duration-200 ease-out",
218+
copied ? "opacity-0 scale-50" : "opacity-100 scale-100",
219+
)}
220+
/>
221+
<CheckIcon
222+
className={cn(
223+
"absolute inset-0 w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground transition-[opacity,transform,color] duration-200 ease-out",
224+
copied ? "opacity-100 scale-100" : "opacity-0 scale-50",
225+
)}
226+
/>
227+
</div>
228+
</button>
229+
</TooltipTrigger>
230+
<TooltipContent side="top" showArrow={false}>
231+
Copy plan
232+
</TooltipContent>
233+
</Tooltip>
234+
)}
235+
194236
{/* Expand/Collapse button */}
195237
<button
196238
onClick={(e) => {
197239
e.stopPropagation()
198240
handleToggleExpand()
199241
}}
200-
className="p-1 rounded-md hover:bg-accent transition-[background-color,transform] duration-150 ease-out active:scale-95"
242+
className="group p-1 rounded-md hover:bg-accent transition-[background-color,transform] duration-150 ease-out active:scale-95"
201243
>
202244
<div className="relative w-4 h-4">
203245
<ExpandIcon
204246
className={cn(
205-
"absolute inset-0 w-4 h-4 text-muted-foreground transition-[opacity,transform] duration-200 ease-out",
247+
"absolute inset-0 w-4 h-4 text-muted-foreground group-hover:text-foreground transition-[opacity,transform,color] duration-200 ease-out",
206248
isExpanded ? "opacity-0 scale-75" : "opacity-100 scale-100",
207249
)}
208250
/>
209251
<CollapseIcon
210252
className={cn(
211-
"absolute inset-0 w-4 h-4 text-muted-foreground transition-[opacity,transform] duration-200 ease-out",
253+
"absolute inset-0 w-4 h-4 text-muted-foreground group-hover:text-foreground transition-[opacity,transform,color] duration-200 ease-out",
212254
isExpanded ? "opacity-100 scale-100" : "opacity-0 scale-75",
213255
)}
214256
/>
@@ -251,15 +293,17 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({
251293

252294
{/* Footer - action buttons */}
253295
<div className="flex items-center justify-between p-1.5">
254-
<Button
255-
variant="ghost"
256-
size="sm"
257-
onClick={handleOpenSidebar}
258-
disabled={!viewPlanEnabled}
259-
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
260-
>
261-
View plan
262-
</Button>
296+
<div className="flex items-center">
297+
<Button
298+
variant="ghost"
299+
size="sm"
300+
onClick={handleOpenSidebar}
301+
disabled={!viewPlanEnabled}
302+
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
303+
>
304+
View plan
305+
</Button>
306+
</div>
263307

264308
{subChatMode === "plan" && (
265309
<Button

0 commit comments

Comments
 (0)