Skip to content

Commit 04530f4

Browse files
committed
Release v0.0.30
## What's New ### Features - **Custom Slash Commands** — Create your own slash commands with custom prompts - **Configurable Keyboard Shortcuts** — Customize keyboard shortcuts in Settings - **Thinking Toggle** — Enable/disable extended thinking from the model selector dropdown - **Hover-to-Select** — Quick switch dialogs now select items on hover - **File Stats in Archive** — See file counts in the archive popover ### Improvements & Fixes - Markdown tables now render correctly (GFM support) - Fixed newlines being lost in user messages - Fixed "Send now" not stopping the current stream before sending queued message - Improved pending question and plan approval indicators - Fixed diff sidebar issues in dialog and fullscreen modes - Fixed React 19 ref cleanup error when closing diff sidebar - Workspace icon setting now respected in archive popover
1 parent aac3ce2 commit 04530f4

39 files changed

Lines changed: 2248 additions & 910 deletions

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.29",
3+
"version": "0.0.30",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {
@@ -35,14 +35,14 @@
3535
"@anthropic-ai/claude-agent-sdk": "^0.2.12",
3636
"@git-diff-view/react": "^0.0.35",
3737
"@git-diff-view/shiki": "^0.0.36",
38-
"@radix-ui/react-accordion": "^1.2.11",
39-
"@radix-ui/react-alert-dialog": "^1.1.1",
38+
"@radix-ui/react-accordion": "^1.2.12",
39+
"@radix-ui/react-alert-dialog": "^1.1.15",
4040
"@radix-ui/react-checkbox": "^1.3.3",
4141
"@radix-ui/react-collapsible": "^1.1.12",
4242
"@radix-ui/react-context-menu": "^2.2.16",
4343
"@radix-ui/react-dialog": "^1.1.15",
4444
"@radix-ui/react-dropdown-menu": "^2.1.16",
45-
"@radix-ui/react-hover-card": "^1.1.14",
45+
"@radix-ui/react-hover-card": "^1.1.15",
4646
"@radix-ui/react-icons": "^1.3.2",
4747
"@radix-ui/react-label": "^2.1.8",
4848
"@radix-ui/react-popover": "^1.1.15",
@@ -89,6 +89,7 @@
8989
"react-icons": "^5.5.0",
9090
"react-syntax-highlighter": "^16.1.0",
9191
"remark-breaks": "^4.0.0",
92+
"remark-gfm": "^4.0.1",
9293
"shiki": "^1.24.4",
9394
"simple-git": "^3.28.0",
9495
"sonner": "^1.7.1",

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

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,28 +1167,50 @@ export const chatsRouter = router({
11671167
/**
11681168
* Get file change stats for workspaces
11691169
* Parses messages from specified sub-chats and aggregates Edit/Write tool calls
1170-
* REQUIRES openSubChatIds to avoid loading all sub-chats (performance optimization)
1170+
* Supports two modes:
1171+
* - openSubChatIds: query specific sub-chats (used by main sidebar)
1172+
* - chatIds: query all sub-chats for given chats (used by archive popover)
11711173
*/
11721174
getFileStats: publicProcedure
1173-
.input(z.object({ openSubChatIds: z.array(z.string()) }))
1175+
.input(z.object({
1176+
openSubChatIds: z.array(z.string()).optional(),
1177+
chatIds: z.array(z.string()).optional(),
1178+
}))
11741179
.query(({ input }) => {
11751180
const db = getDatabase()
11761181

1177-
// Early return if no sub-chats to check
1178-
if (input.openSubChatIds.length === 0) {
1182+
// Early return if nothing to check
1183+
if ((!input.openSubChatIds || input.openSubChatIds.length === 0) &&
1184+
(!input.chatIds || input.chatIds.length === 0)) {
11791185
return []
11801186
}
11811187

1182-
// Query only the specified sub-chats (VS Code style: load only what's needed)
1183-
const allChats = db
1184-
.select({
1185-
chatId: subChats.chatId,
1186-
subChatId: subChats.id,
1187-
messages: subChats.messages,
1188-
})
1189-
.from(subChats)
1190-
.where(inArray(subChats.id, input.openSubChatIds))
1191-
.all()
1188+
// Query sub-chats based on input mode
1189+
let allChats: Array<{ chatId: string | null; subChatId: string; messages: string | null }>
1190+
1191+
if (input.chatIds && input.chatIds.length > 0) {
1192+
// Archive mode: query all sub-chats for given chat IDs
1193+
allChats = db
1194+
.select({
1195+
chatId: subChats.chatId,
1196+
subChatId: subChats.id,
1197+
messages: subChats.messages,
1198+
})
1199+
.from(subChats)
1200+
.where(inArray(subChats.chatId, input.chatIds))
1201+
.all()
1202+
} else {
1203+
// Main sidebar mode: query specific sub-chats
1204+
allChats = db
1205+
.select({
1206+
chatId: subChats.chatId,
1207+
subChatId: subChats.id,
1208+
messages: subChats.messages,
1209+
})
1210+
.from(subChats)
1211+
.where(inArray(subChats.id, input.openSubChatIds!))
1212+
.all()
1213+
}
11921214

11931215
// Aggregate stats per workspace (chatId)
11941216
const statsMap = new Map<
@@ -1198,6 +1220,7 @@ export const chatsRouter = router({
11981220

11991221
for (const row of allChats) {
12001222
if (!row.messages || !row.chatId) continue
1223+
const chatId = row.chatId // TypeScript narrowing
12011224

12021225
try {
12031226
const messages = JSON.parse(row.messages) as Array<{
@@ -1274,15 +1297,15 @@ export const chatsRouter = router({
12741297
}
12751298

12761299
// Add to workspace total
1277-
const existing = statsMap.get(row.chatId) || {
1300+
const existing = statsMap.get(chatId) || {
12781301
additions: 0,
12791302
deletions: 0,
12801303
fileCount: 0,
12811304
}
12821305
existing.additions += subChatAdditions
12831306
existing.deletions += subChatDeletions
12841307
existing.fileCount += subChatFileCount
1285-
statsMap.set(row.chatId, existing)
1308+
statsMap.set(chatId, existing)
12861309
} catch {
12871310
// Skip invalid JSON
12881311
}
@@ -1339,32 +1362,34 @@ export const chatsRouter = router({
13391362

13401363
// Traverse messages from end to find unapproved ExitPlanMode
13411364
// Logic matches active-chat.tsx hasUnapprovedPlan
1342-
let hasUnapprovedPlan = false
1343-
1344-
for (let i = messages.length - 1; i >= 0; i--) {
1345-
const msg = messages[i]
1346-
if (!msg) continue
1347-
1348-
// If user message says "Build plan" or "Implement plan" (exact match), plan is already approved
1349-
if (msg.role === "user") {
1350-
const textPart = msg.parts?.find((p) => p.type === "text")
1351-
const text = textPart?.text || ""
1352-
const normalizedText = text.trim().toLowerCase()
1353-
if (normalizedText === "implement plan" || normalizedText === "build plan") {
1354-
break // Plan was approved, stop searching
1365+
const checkHasUnapprovedPlan = (): boolean => {
1366+
for (let i = messages.length - 1; i >= 0; i--) {
1367+
const msg = messages[i]
1368+
if (!msg) continue
1369+
1370+
// If user message says "Build plan" or "Implement plan" (exact match), plan is already approved
1371+
if (msg.role === "user") {
1372+
const textPart = msg.parts?.find((p) => p.type === "text")
1373+
const text = textPart?.text || ""
1374+
const normalizedText = text.trim().toLowerCase()
1375+
if (normalizedText === "implement plan" || normalizedText === "build plan") {
1376+
return false // Plan was approved
1377+
}
13551378
}
1356-
}
13571379

1358-
// If assistant message with ExitPlanMode, we found an unapproved plan
1359-
if (msg.role === "assistant" && msg.parts) {
1360-
const exitPlanPart = msg.parts.find((p) => p.type === "tool-ExitPlanMode")
1361-
if (exitPlanPart) {
1362-
hasUnapprovedPlan = true
1363-
break
1380+
// If assistant message with ExitPlanMode that has output.plan, we found an unapproved plan
1381+
if (msg.role === "assistant" && msg.parts) {
1382+
const exitPlanPart = msg.parts.find((p) => p.type === "tool-ExitPlanMode") as { output?: { plan?: string } } | undefined
1383+
if (exitPlanPart?.output?.plan) {
1384+
return true
1385+
}
13641386
}
13651387
}
1388+
return false
13661389
}
13671390

1391+
const hasUnapprovedPlan = checkHasUnapprovedPlan()
1392+
13681393
if (hasUnapprovedPlan) {
13691394
pendingApprovals.push({
13701395
subChatId: row.subChatId,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { z } from "zod"
2+
import { router, publicProcedure } from "../index"
3+
import * as fs from "fs/promises"
4+
import * as path from "path"
5+
import * as os from "os"
6+
import matter from "gray-matter"
7+
8+
interface FileCommand {
9+
name: string
10+
description: string
11+
argumentHint?: string
12+
source: "user" | "project"
13+
path: string
14+
}
15+
16+
/**
17+
* Parse command .md frontmatter to extract description and argument-hint
18+
*/
19+
function parseCommandMd(content: string): {
20+
description?: string
21+
argumentHint?: string
22+
} {
23+
try {
24+
const { data } = matter(content)
25+
return {
26+
description:
27+
typeof data.description === "string" ? data.description : undefined,
28+
argumentHint:
29+
typeof data["argument-hint"] === "string"
30+
? data["argument-hint"]
31+
: undefined,
32+
}
33+
} catch (err) {
34+
console.error("[commands] Failed to parse frontmatter:", err)
35+
return {}
36+
}
37+
}
38+
39+
/**
40+
* Validate entry name for security (prevent path traversal)
41+
*/
42+
function isValidEntryName(name: string): boolean {
43+
return !name.includes("..") && !name.includes("/") && !name.includes("\\")
44+
}
45+
46+
/**
47+
* Recursively scan a directory for .md command files
48+
* Supports namespaces via nested folders: git/commit.md → git:commit
49+
*/
50+
async function scanCommandsDirectory(
51+
dir: string,
52+
source: "user" | "project",
53+
prefix = "",
54+
): Promise<FileCommand[]> {
55+
const commands: FileCommand[] = []
56+
57+
try {
58+
// Check if directory exists
59+
try {
60+
await fs.access(dir)
61+
} catch {
62+
return commands
63+
}
64+
65+
const entries = await fs.readdir(dir, { withFileTypes: true })
66+
67+
for (const entry of entries) {
68+
if (!isValidEntryName(entry.name)) {
69+
console.warn(`[commands] Skipping invalid entry name: ${entry.name}`)
70+
continue
71+
}
72+
73+
const fullPath = path.join(dir, entry.name)
74+
75+
if (entry.isDirectory()) {
76+
// Recursively scan nested directories
77+
const nestedCommands = await scanCommandsDirectory(
78+
fullPath,
79+
source,
80+
prefix ? `${prefix}:${entry.name}` : entry.name,
81+
)
82+
commands.push(...nestedCommands)
83+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
84+
const baseName = entry.name.replace(/\.md$/, "")
85+
const commandName = prefix ? `${prefix}:${baseName}` : baseName
86+
87+
try {
88+
const content = await fs.readFile(fullPath, "utf-8")
89+
const parsed = parseCommandMd(content)
90+
91+
commands.push({
92+
name: commandName,
93+
description: parsed.description || "",
94+
argumentHint: parsed.argumentHint,
95+
source,
96+
path: fullPath,
97+
})
98+
} catch (err) {
99+
console.warn(`[commands] Failed to read ${fullPath}:`, err)
100+
}
101+
}
102+
}
103+
} catch (err) {
104+
console.error(`[commands] Failed to scan directory ${dir}:`, err)
105+
}
106+
107+
return commands
108+
}
109+
110+
export const commandsRouter = router({
111+
/**
112+
* List all commands from filesystem
113+
* - User commands: ~/.claude/commands/
114+
* - Project commands: .claude/commands/ (relative to projectPath)
115+
*/
116+
list: publicProcedure
117+
.input(
118+
z
119+
.object({
120+
projectPath: z.string().optional(),
121+
})
122+
.optional(),
123+
)
124+
.query(async ({ input }) => {
125+
const userCommandsDir = path.join(os.homedir(), ".claude", "commands")
126+
const userCommandsPromise = scanCommandsDirectory(userCommandsDir, "user")
127+
128+
let projectCommandsPromise = Promise.resolve<FileCommand[]>([])
129+
if (input?.projectPath) {
130+
const projectCommandsDir = path.join(
131+
input.projectPath,
132+
".claude",
133+
"commands",
134+
)
135+
projectCommandsPromise = scanCommandsDirectory(
136+
projectCommandsDir,
137+
"project",
138+
)
139+
}
140+
141+
// Scan both directories in parallel
142+
const [userCommands, projectCommands] = await Promise.all([
143+
userCommandsPromise,
144+
projectCommandsPromise,
145+
])
146+
147+
// Project commands first (more specific), then user commands
148+
return [...projectCommands, ...userCommands]
149+
}),
150+
151+
/**
152+
* Get content of a specific command file (without frontmatter)
153+
*/
154+
getContent: publicProcedure
155+
.input(z.object({ path: z.string() }))
156+
.query(async ({ input }) => {
157+
// Security: prevent path traversal
158+
if (input.path.includes("..")) {
159+
throw new Error("Invalid path")
160+
}
161+
162+
try {
163+
const content = await fs.readFile(input.path, "utf-8")
164+
const { content: body } = matter(content)
165+
return { content: body.trim() }
166+
} catch (err) {
167+
console.error(`[commands] Failed to read command content:`, err)
168+
return { content: "" }
169+
}
170+
}),
171+
})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { debugRouter } from "./debug"
1111
import { skillsRouter } from "./skills"
1212
import { agentsRouter } from "./agents"
1313
import { worktreeConfigRouter } from "./worktree-config"
14+
import { commandsRouter } from "./commands"
1415
import { createGitRouter } from "../../git"
1516
import { BrowserWindow } from "electron"
1617

@@ -32,6 +33,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) {
3233
skills: skillsRouter,
3334
agents: agentsRouter,
3435
worktreeConfig: worktreeConfigRouter,
36+
commands: commandsRouter,
3537
// Git operations - named "changes" to match Superset API
3638
changes: createGitRouter(),
3739
})

src/renderer/components/chat-markdown-renderer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cn } from "../lib/utils"
22
import { memo, useState, useCallback, useEffect, useMemo } from "react"
33
import { Streamdown, parseMarkdownIntoBlocks } from "streamdown"
44
import remarkBreaks from "remark-breaks"
5+
import remarkGfm from "remark-gfm"
56
import { Copy, Check } from "lucide-react"
67
import { useCodeTheme } from "../lib/hooks/use-code-theme"
78
import { highlightCode } from "../lib/themes/shiki-theme-loader"
@@ -436,7 +437,7 @@ export const ChatMarkdownRenderer = memo(function ChatMarkdownRenderer({
436437
<Streamdown
437438
mode="streaming"
438439
components={components}
439-
remarkPlugins={[remarkBreaks]}
440+
remarkPlugins={[remarkGfm, remarkBreaks]}
440441
isAnimating={isStreaming}
441442
parseIncompleteMarkdown={isStreaming}
442443
controls={false}
@@ -675,7 +676,7 @@ const MemoizedMarkdownBlock = memo(
675676
<Streamdown
676677
mode="static"
677678
components={components}
678-
remarkPlugins={[remarkBreaks]}
679+
remarkPlugins={[remarkGfm, remarkBreaks]}
679680
controls={false}
680681
>
681682
{content}

0 commit comments

Comments
 (0)