Skip to content

Commit a0ba1dd

Browse files
committed
Release v0.0.41
## What's New ### Features - **Large Text Paste** — Auto-convert large pasted text to file attachments ### Improvements & Fixes - **Details Sidebar** — Improved caching, Plan widget expand, and UI updates - **Plan Indicator** — Fixed pending plan indicator (use mode as source of truth) - **Tooltip Fix** — Hide tooltip when workspace/chat is archived
1 parent 7cf0dc0 commit a0ba1dd

File tree

18 files changed

+849
-312
lines changed

18 files changed

+849
-312
lines changed

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.40",
3+
"version": "0.0.41",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {

src/main/lib/trpc/routers/chats.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,7 @@ export const chatsRouter = router({
15211521

15221522
/**
15231523
* Get sub-chats with pending plan approvals
1524-
* Parses messages to find plan file Write without subsequent "Implement plan" user message
1524+
* Uses mode field as source of truth: mode="plan" + completed ExitPlanMode = pending approval
15251525
* Logic must match active-chat.tsx hasUnapprovedPlan
15261526
* REQUIRES openSubChatIds to avoid loading all sub-chats (performance optimization)
15271527
*/
@@ -1535,11 +1535,12 @@ export const chatsRouter = router({
15351535
return []
15361536
}
15371537

1538-
// Query only the specified sub-chats (VS Code style: load only what's needed)
1538+
// Query only the specified sub-chats, including mode for filtering
15391539
const allSubChats = db
15401540
.select({
15411541
chatId: subChats.chatId,
15421542
subChatId: subChats.id,
1543+
mode: subChats.mode,
15431544
messages: subChats.messages,
15441545
})
15451546
.from(subChats)
@@ -1549,7 +1550,13 @@ export const chatsRouter = router({
15491550
const pendingApprovals: Array<{ subChatId: string; chatId: string }> = []
15501551

15511552
for (const row of allSubChats) {
1552-
if (!row.messages || !row.subChatId || !row.chatId) continue
1553+
if (!row.subChatId || !row.chatId) continue
1554+
1555+
// If mode is "agent", plan is already approved - skip
1556+
if (row.mode === "agent") continue
1557+
1558+
// Only check for ExitPlanMode in plan mode sub-chats
1559+
if (!row.messages) continue
15531560

15541561
try {
15551562
const messages = JSON.parse(row.messages) as Array<{
@@ -1562,23 +1569,12 @@ export const chatsRouter = router({
15621569
}>
15631570
}>
15641571

1565-
// Traverse messages from end to find unapproved ExitPlanMode
1566-
// Logic matches active-chat.tsx hasUnapprovedPlan
1567-
const checkHasUnapprovedPlan = (): boolean => {
1572+
// Check if there's a completed ExitPlanMode in messages
1573+
const hasCompletedExitPlanMode = (): boolean => {
15681574
for (let i = messages.length - 1; i >= 0; i--) {
15691575
const msg = messages[i]
15701576
if (!msg) continue
15711577

1572-
// If user message says "Build plan" or "Implement plan" (exact match), plan is already approved
1573-
if (msg.role === "user") {
1574-
const textPart = msg.parts?.find((p) => p.type === "text")
1575-
const text = textPart?.text || ""
1576-
const normalizedText = text.trim().toLowerCase()
1577-
if (normalizedText === "implement plan" || normalizedText === "build plan") {
1578-
return false // Plan was approved
1579-
}
1580-
}
1581-
15821578
// If assistant message with completed ExitPlanMode, we found an unapproved plan
15831579
if (msg.role === "assistant" && msg.parts) {
15841580
const exitPlanPart = msg.parts.find(
@@ -1593,9 +1589,7 @@ export const chatsRouter = router({
15931589
return false
15941590
}
15951591

1596-
const hasUnapprovedPlan = checkHasUnapprovedPlan()
1597-
1598-
if (hasUnapprovedPlan) {
1592+
if (hasCompletedExitPlanMode()) {
15991593
pendingApprovals.push({
16001594
subChatId: row.subChatId,
16011595
chatId: row.chatId,

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export type DiffViewDisplayMode = "side-peek" | "center-peek" | "full-page"
259259

260260
export const diffViewDisplayModeAtom = atomWithStorage<DiffViewDisplayMode>(
261261
"agents:diffViewDisplayMode",
262-
"side-peek", // default to current behavior
262+
"center-peek", // default to dialog for new users
263263
undefined,
264264
{ getOnInit: true },
265265
)
@@ -705,3 +705,72 @@ export const planEditRefetchTriggerAtomFamily = atomFamily((chatId: string) =>
705705
},
706706
),
707707
)
708+
709+
// ============================================================================
710+
// Diff Data Cache (per workspace) - prevents data loss when switching workspaces
711+
// ============================================================================
712+
713+
// ParsedDiffFile type (same as in shared/changes-types.ts but avoiding import cycle)
714+
export interface CachedParsedDiffFile {
715+
key: string
716+
oldPath: string
717+
newPath: string
718+
diffText: string
719+
isBinary: boolean
720+
additions: number
721+
deletions: number
722+
isValid: boolean
723+
fileLang: string | null
724+
isNewFile: boolean
725+
isDeletedFile: boolean
726+
}
727+
728+
export interface DiffStatsCache {
729+
fileCount: number
730+
additions: number
731+
deletions: number
732+
isLoading: boolean
733+
hasChanges: boolean
734+
}
735+
736+
export interface WorkspaceDiffCache {
737+
parsedFileDiffs: CachedParsedDiffFile[] | null
738+
diffStats: DiffStatsCache
739+
prefetchedFileContents: Record<string, string>
740+
diffContent: string | null
741+
}
742+
743+
// Default stats for loading state
744+
const DEFAULT_DIFF_STATS: DiffStatsCache = {
745+
fileCount: 0,
746+
additions: 0,
747+
deletions: 0,
748+
isLoading: true,
749+
hasChanges: false,
750+
}
751+
752+
// Runtime cache for diff data per workspace (not persisted)
753+
const workspaceDiffCacheStorageAtom = atom<Record<string, WorkspaceDiffCache>>({})
754+
755+
// Default cache value
756+
const DEFAULT_DIFF_CACHE: WorkspaceDiffCache = {
757+
parsedFileDiffs: null,
758+
diffStats: DEFAULT_DIFF_STATS,
759+
prefetchedFileContents: {},
760+
diffContent: null,
761+
}
762+
763+
export const workspaceDiffCacheAtomFamily = atomFamily((chatId: string) =>
764+
atom(
765+
(get) => get(workspaceDiffCacheStorageAtom)[chatId] ?? DEFAULT_DIFF_CACHE,
766+
(get, set, update: WorkspaceDiffCache | ((prev: WorkspaceDiffCache) => WorkspaceDiffCache)) => {
767+
const current = get(workspaceDiffCacheStorageAtom)
768+
const prevCache = current[chatId] ?? DEFAULT_DIFF_CACHE
769+
const newCache = typeof update === 'function' ? update(prevCache) : update
770+
set(workspaceDiffCacheStorageAtom, {
771+
...current,
772+
[chatId]: newCache,
773+
})
774+
},
775+
),
776+
)

src/renderer/features/agents/hooks/use-pasted-text-files.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useState, useCallback, useRef } from "react"
22
import { trpc } from "../../../lib/trpc"
3-
import { toast } from "sonner"
43

54
export interface PastedTextFile {
65
id: string
@@ -48,14 +47,8 @@ export function usePastedTextFiles(subChatId: string): UsePastedTextFilesReturn
4847
}
4948

5049
setPastedTexts((prev) => [...prev, newPasted])
51-
52-
const sizeKB = Math.round(result.size / 1024)
53-
toast.info(`Text saved as file (${sizeKB}KB)`, {
54-
description: "Large text will be sent as a file attachment.",
55-
})
5650
} catch (error) {
5751
console.error("[usePastedTextFiles] Failed to write:", error)
58-
toast.error("Failed to save pasted text")
5952
}
6053
},
6154
[subChatId, writePastedTextMutation]

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

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
pendingBuildPlanSubChatIdAtom,
108108
pendingPlanApprovalsAtom,
109109
planEditRefetchTriggerAtomFamily,
110+
workspaceDiffCacheAtomFamily,
110111
pendingPrMessageAtom,
111112
pendingReviewMessageAtom,
112113
pendingUserQuestionsAtom,
@@ -2806,6 +2807,14 @@ const ChatViewInner = memo(function ChatViewInner({
28062807
// Update store mode synchronously BEFORE sending (transport reads from store)
28072808
useAgentSubChatStore.getState().updateSubChatMode(subChatId, "agent")
28082809

2810+
// Sync mode to database for sidebar indicator (getPendingPlanApprovals)
2811+
if (!subChatId.startsWith("temp-")) {
2812+
updateSubChatModeMutation.mutate({ subChatId, mode: "agent" })
2813+
}
2814+
2815+
// Update ref BEFORE setIsPlanMode to prevent useEffect from triggering duplicate mutation
2816+
lastIsPlanModeRef.current = false
2817+
28092818
// Update React state (for UI)
28102819
setIsPlanMode(false)
28112820

@@ -2818,7 +2827,7 @@ const ChatViewInner = memo(function ChatViewInner({
28182827
role: "user",
28192828
parts: [{ type: "text", text: "Build plan" }],
28202829
})
2821-
}, [subChatId, setIsPlanMode, scrollToBottom])
2830+
}, [subChatId, setIsPlanMode, scrollToBottom, updateSubChatModeMutation])
28222831

28232832
// Handle pending "Build plan" from sidebar
28242833
useEffect(() => {
@@ -3342,9 +3351,11 @@ const ChatViewInner = memo(function ChatViewInner({
33423351
return `@[${MENTION_PREFIXES.DIFF}${dtc.filePath}:${lineNum}:${preview}:${encodedText}]`
33433352
})
33443353

3345-
// Add pasted text files as file mentions (they are already saved as files)
3354+
// Add pasted text as pasted mentions (format: pasted:size:preview|filepath)
3355+
// Using | as separator since filepath can contain colons
33463356
const pastedTextMentions = currentPastedTexts.map((pt) => {
3347-
return `@[${MENTION_PREFIXES.FILE}local:${pt.filePath}]`
3357+
// Preview is already truncated and has newlines replaced
3358+
return `@[${MENTION_PREFIXES.PASTED}${pt.size}:${pt.preview}|${pt.filePath}]`
33483359
})
33493360

33503361
mentionPrefix = [...quoteMentions, ...diffMentions, ...pastedTextMentions].join(" ") + " "
@@ -3632,21 +3643,15 @@ const ChatViewInner = memo(function ChatViewInner({
36323643
}
36333644
}
36343645

3635-
// Check if there's an unapproved plan (ExitPlanMode without subsequent "Build plan" or "Implement plan")
3646+
// Check if there's an unapproved plan (in plan mode with completed ExitPlanMode)
36363647
const hasUnapprovedPlan = useMemo(() => {
3637-
// Traverse messages from end to find unapproved ExitPlanMode
3648+
// If already in agent mode, plan is approved (mode is the source of truth)
3649+
if (!isPlanMode) return false
3650+
3651+
// Look for completed ExitPlanMode in messages
36383652
for (let i = messages.length - 1; i >= 0; i--) {
36393653
const msg = messages[i]
36403654

3641-
// If user message says "Build plan" or "Implement plan", plan is already approved
3642-
if (msg.role === "user") {
3643-
const text = msg.parts?.find((p: any) => p.type === "text")?.text || ""
3644-
const normalizedText = text.trim().toLowerCase()
3645-
if (normalizedText === "build plan" || normalizedText === "implement plan") {
3646-
return false
3647-
}
3648-
}
3649-
36503655
// If assistant message with completed ExitPlanMode, we found an unapproved plan
36513656
if (msg.role === "assistant" && msg.parts) {
36523657
const exitPlanPart = msg.parts.find(
@@ -3659,7 +3664,7 @@ const ChatViewInner = memo(function ChatViewInner({
36593664
}
36603665
}
36613666
return false
3662-
}, [messages])
3667+
}, [messages, isPlanMode])
36633668

36643669
// Keep ref in sync for use in initializeScroll (which runs in useLayoutEffect)
36653670
hasUnapprovedPlanRef.current = hasUnapprovedPlan
@@ -4130,40 +4135,49 @@ export function ChatView({
41304135
[chatId],
41314136
)
41324137
const [isTerminalSidebarOpen, setIsTerminalSidebarOpen] = useAtom(terminalSidebarAtom)
4133-
const [diffStats, setDiffStatsRaw] = useState({
4134-
fileCount: 0,
4135-
additions: 0,
4136-
deletions: 0,
4137-
isLoading: true,
4138-
hasChanges: false,
4139-
})
4140-
// Smart setter that only updates if values actually changed
4138+
4139+
// Diff data cache - stored in atoms to persist across workspace switches
4140+
const diffCacheAtom = useMemo(
4141+
() => workspaceDiffCacheAtomFamily(chatId),
4142+
[chatId],
4143+
)
4144+
const [diffCache, setDiffCache] = useAtom(diffCacheAtom)
4145+
4146+
// Extract diff data from cache
4147+
const diffStats = diffCache.diffStats
4148+
const parsedFileDiffs = diffCache.parsedFileDiffs as ParsedDiffFile[] | null
4149+
const prefetchedFileContents = diffCache.prefetchedFileContents
4150+
const diffContent = diffCache.diffContent
4151+
4152+
// Smart setters that update the cache
41414153
const setDiffStats = useCallback((val: any) => {
4142-
setDiffStatsRaw((prev: typeof diffStats) => {
4143-
// Handle function updates
4144-
const newVal = typeof val === 'function' ? val(prev) : val
4154+
setDiffCache((prev) => {
4155+
const newVal = typeof val === 'function' ? val(prev.diffStats) : val
41454156
// Only update if something changed
41464157
if (
4147-
prev.fileCount === newVal.fileCount &&
4148-
prev.additions === newVal.additions &&
4149-
prev.deletions === newVal.deletions &&
4150-
prev.isLoading === newVal.isLoading &&
4151-
prev.hasChanges === newVal.hasChanges
4158+
prev.diffStats.fileCount === newVal.fileCount &&
4159+
prev.diffStats.additions === newVal.additions &&
4160+
prev.diffStats.deletions === newVal.deletions &&
4161+
prev.diffStats.isLoading === newVal.isLoading &&
4162+
prev.diffStats.hasChanges === newVal.hasChanges
41524163
) {
41534164
return prev // Return same reference to prevent re-render
41544165
}
4155-
return newVal
4166+
return { ...prev, diffStats: newVal }
41564167
})
4157-
}, [])
4158-
// Store raw diff content to pass to AgentDiffView (avoids double fetch)
4159-
const [diffContent, setDiffContent] = useState<string | null>(null)
4160-
// Store pre-parsed file diffs (avoids double parsing in AgentDiffView)
4161-
// Server returns extended type with fileLang, isNewFile, isDeletedFile
4162-
const [parsedFileDiffs, setParsedFileDiffs] = useState<ParsedDiffFile[] | null>(null)
4163-
// Store prefetched file contents for instant diff view opening
4164-
const [prefetchedFileContents, setPrefetchedFileContents] = useState<
4165-
Record<string, string>
4166-
>({})
4168+
}, [setDiffCache])
4169+
4170+
const setParsedFileDiffs = useCallback((files: ParsedDiffFile[] | null) => {
4171+
setDiffCache((prev) => ({ ...prev, parsedFileDiffs: files as any }))
4172+
}, [setDiffCache])
4173+
4174+
const setPrefetchedFileContents = useCallback((contents: Record<string, string>) => {
4175+
setDiffCache((prev) => ({ ...prev, prefetchedFileContents: contents }))
4176+
}, [setDiffCache])
4177+
4178+
const setDiffContent = useCallback((content: string | null) => {
4179+
setDiffCache((prev) => ({ ...prev, diffContent: content }))
4180+
}, [setDiffCache])
41674181
const [diffMode, setDiffMode] = useAtom(diffViewModeAtom)
41684182
const [diffDisplayMode, setDiffDisplayMode] = useAtom(diffViewDisplayModeAtom)
41694183
const subChatsSidebarMode = useAtomValue(agentsSubChatsSidebarModeAtom)

src/renderer/features/agents/main/new-chat-form.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -831,11 +831,12 @@ export function NewChatForm({
831831
},
832832
}))
833833

834-
// Add pasted text files as file mentions
834+
// Add pasted text as pasted mentions (format: pasted:size:preview|filepath)
835+
// Using | as separator since filepath can contain colons
835836
let finalMessage = message.trim()
836837
if (pastedTexts.length > 0) {
837838
const pastedMentions = pastedTexts
838-
.map((pt) => `@[${MENTION_PREFIXES.FILE}local:${pt.filePath}]`)
839+
.map((pt) => `@[${MENTION_PREFIXES.PASTED}${pt.size}:${pt.preview}|${pt.filePath}]`)
839840
.join(" ")
840841
finalMessage = pastedMentions + (finalMessage ? " " + finalMessage : "")
841842
}

src/renderer/features/agents/mentions/agents-mentions-editor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const MENTION_PREFIXES = {
4242
TOOL: "tool:", // MCP tools
4343
QUOTE: "quote:", // Selected text from assistant messages
4444
DIFF: "diff:", // Selected text from diff sidebar
45+
PASTED: "pasted:", // Large pasted text saved as files
4546
} as const
4647

4748
type TriggerPayload = {

0 commit comments

Comments
 (0)