Skip to content

Commit 09a6880

Browse files
committed
Release v0.0.60-beta.3
## What's New ### Improvements & Fixes - **Streaming with panes** — Fixed streaming issues with split view panes - **Plugin discovery** — Resolved symlinks in plugin discovery/scanning - **Tab and pane borders** — Fixed tabs and panes border styling
1 parent 8c86d39 commit 09a6880

23 files changed

Lines changed: 699 additions & 281 deletions

bun.lock

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

bun.lockb

494 Bytes
Binary file not shown.

package.json

Lines changed: 2 additions & 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.2",
3+
"version": "0.0.60-beta.3",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {
@@ -96,6 +96,7 @@
9696
"react-dom": "19.2.1",
9797
"react-hotkeys-hook": "^4.6.1",
9898
"react-icons": "^5.5.0",
99+
"react-virtuoso": "^4.18.1",
99100
"react-zoom-pan-pinch": "^3.7.0",
100101
"remark-breaks": "^4.0.0",
101102
"remark-gfm": "^4.0.1",

src/main/lib/fs/dirent.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Dirent } from "fs"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
5+
export interface DirentType {
6+
isDirectory: boolean
7+
isFile: boolean
8+
}
9+
10+
/**
11+
* Resolve entry type, following symlinks when needed.
12+
*/
13+
export async function resolveDirentType(
14+
dir: string,
15+
entry: Dirent,
16+
): Promise<DirentType> {
17+
let isDirectory = entry.isDirectory()
18+
let isFile = entry.isFile()
19+
20+
if (!isDirectory && !isFile && entry.isSymbolicLink()) {
21+
try {
22+
const targetPath = path.join(dir, entry.name)
23+
const stat = await fs.stat(targetPath) // stat() follows symlinks
24+
isDirectory = stat.isDirectory()
25+
isFile = stat.isFile()
26+
} catch {
27+
return { isDirectory: false, isFile: false }
28+
}
29+
}
30+
31+
return { isDirectory, isFile }
32+
}
33+
34+
/**
35+
* Check if a Dirent resolves to a directory (including symlink targets).
36+
*/
37+
export async function isDirentDirectory(
38+
dir: string,
39+
entry: Dirent,
40+
): Promise<boolean> {
41+
const { isDirectory } = await resolveDirentType(dir, entry)
42+
return isDirectory
43+
}

src/main/lib/plugins/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Dirent } from "fs"
33
import * as path from "path"
44
import * as os from "os"
55
import type { McpServerConfig } from "../claude-config"
6+
import { isDirentDirectory } from "../fs/dirent"
67

78
export interface PluginInfo {
89
name: string
@@ -79,7 +80,13 @@ export async function discoverInstalledPlugins(): Promise<PluginInfo[]> {
7980
}
8081

8182
for (const marketplace of marketplaces) {
82-
if (!marketplace.isDirectory() || marketplace.name.startsWith(".")) continue
83+
if (marketplace.name.startsWith(".")) continue
84+
85+
const isMarketplaceDir = await isDirentDirectory(
86+
marketplacesDir,
87+
marketplace,
88+
)
89+
if (!isMarketplaceDir) continue
8390

8491
const marketplacePath = path.join(marketplacesDir, marketplace.name)
8592
const marketplaceJsonPath = path.join(marketplacePath, ".claude-plugin", "marketplace.json")
@@ -108,7 +115,8 @@ export async function discoverInstalledPlugins(): Promise<PluginInfo[]> {
108115

109116
const pluginPath = path.resolve(marketplacePath, sourcePath)
110117
try {
111-
await fs.access(pluginPath)
118+
const pluginStat = await fs.stat(pluginPath)
119+
if (!pluginStat.isDirectory()) continue
112120
plugins.push({
113121
name: plugin.name,
114122
version: plugin.version || "0.0.0",

src/main/lib/trpc/routers/agent-utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from "path"
33
import * as os from "os"
44
import matter from "gray-matter"
55
import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins"
6+
import { resolveDirentType } from "../../fs/dirent"
67
import { getEnabledPlugins } from "./claude-settings"
78

89
// Valid model values for agents
@@ -208,7 +209,8 @@ export async function scanAgentsDirectory(
208209
}
209210

210211
// Accept .md files (Claude Code native format)
211-
if (entry.isFile() && entry.name.endsWith(".md")) {
212+
const { isFile } = await resolveDirentType(dir, entry)
213+
if (isFile && entry.name.endsWith(".md")) {
212214
const agentPath = path.join(dir, entry.name)
213215
try {
214216
const content = await fs.readFile(agentPath, "utf-8")

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as path from "path"
55
import * as os from "os"
66
import matter from "gray-matter"
77
import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins"
8+
import { resolveDirentType } from "../../fs/dirent"
89
import { getEnabledPlugins } from "./claude-settings"
910

1011
export interface FileCommand {
@@ -76,16 +77,17 @@ async function scanCommandsDirectory(
7677
}
7778

7879
const fullPath = path.join(dir, entry.name)
80+
const { isDirectory, isFile } = await resolveDirentType(dir, entry)
7981

80-
if (entry.isDirectory()) {
82+
if (isDirectory) {
8183
// Recursively scan nested directories
8284
const nestedCommands = await scanCommandsDirectory(
8385
fullPath,
8486
source,
8587
prefix ? `${prefix}:${entry.name}` : entry.name,
8688
)
8789
commands.push(...nestedCommands)
88-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
90+
} else if (isFile && entry.name.endsWith(".md")) {
8991
const baseName = entry.name.replace(/\.md$/, "")
9092
const fallbackName = prefix ? `${prefix}:${baseName}` : baseName
9193

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { router, publicProcedure } from "../index"
22
import * as fs from "fs/promises"
33
import * as path from "path"
44
import matter from "gray-matter"
5+
import { resolveDirentType } from "../../fs/dirent"
56
import {
67
discoverInstalledPlugins,
78
getPluginComponentPaths,
@@ -60,12 +61,13 @@ async function scanPluginCommands(dir: string): Promise<PluginComponent[]> {
6061
if (!isValidEntryName(entry.name)) continue
6162

6263
const fullPath = path.join(dir, entry.name)
64+
const { isDirectory, isFile } = await resolveDirentType(dir, entry)
6365

64-
if (entry.isDirectory()) {
66+
if (isDirectory) {
6567
// Recursively scan nested directories for namespaced commands
6668
const nested = await scanPluginCommands(fullPath)
6769
components.push(...nested)
68-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
70+
} else if (isFile && entry.name.endsWith(".md")) {
6971
try {
7072
const content = await fs.readFile(fullPath, "utf-8")
7173
const { data } = matter(content)
@@ -103,7 +105,10 @@ async function scanPluginSkills(dir: string): Promise<PluginComponent[]> {
103105
const entries = await fs.readdir(dir, { withFileTypes: true })
104106

105107
for (const entry of entries) {
106-
if (!entry.isDirectory() || !isValidEntryName(entry.name)) continue
108+
if (!isValidEntryName(entry.name)) continue
109+
110+
const { isDirectory } = await resolveDirentType(dir, entry)
111+
if (!isDirectory) continue
107112

108113
const skillMdPath = path.join(dir, entry.name, "SKILL.md")
109114
try {
@@ -141,8 +146,10 @@ async function scanPluginAgents(dir: string): Promise<PluginComponent[]> {
141146
const entries = await fs.readdir(dir, { withFileTypes: true })
142147

143148
for (const entry of entries) {
144-
if (!entry.isFile() || !entry.name.endsWith(".md") || !isValidEntryName(entry.name))
145-
continue
149+
if (!entry.name.endsWith(".md") || !isValidEntryName(entry.name)) continue
150+
151+
const { isFile } = await resolveDirentType(dir, entry)
152+
if (!isFile) continue
146153

147154
const fullPath = path.join(dir, entry.name)
148155
try {

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

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as path from "path"
55
import * as os from "os"
66
import matter from "gray-matter"
77
import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins"
8+
import { isDirentDirectory } from "../../fs/dirent"
89
import { getEnabledPlugins } from "./claude-settings"
910

1011
export interface FileSkill {
@@ -54,18 +55,8 @@ async function scanSkillsDirectory(
5455
const entries = await fs.readdir(dir, { withFileTypes: true })
5556

5657
for (const entry of entries) {
57-
// Check if entry is a directory or a symlink pointing to a directory
58-
let isDir = entry.isDirectory()
59-
if (!isDir && entry.isSymbolicLink()) {
60-
try {
61-
const targetPath = path.join(dir, entry.name)
62-
const stat = await fs.stat(targetPath) // stat() follows symlinks
63-
isDir = stat.isDirectory()
64-
} catch {
65-
// Symlink target doesn't exist or is inaccessible - skip it
66-
continue
67-
}
68-
}
58+
// Check if entry is a directory (follows symlinks)
59+
const isDir = await isDirentDirectory(dir, entry)
6960
if (!isDir) continue
7061

7162
// Validate entry name for security (prevent path traversal)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,20 @@ export const diffFilesCollapsedAtomFamily = atomFamily((chatId: string) =>
378378
),
379379
)
380380

381+
// Collapsible steps expanded state per message (session-only)
382+
// Map<`${subChatId}:${messageId}`, isExpanded>
383+
const assistantMessageStepsExpandedStorageAtom = atom<Record<string, boolean | undefined>>({})
384+
385+
export const assistantMessageStepsExpandedAtomFamily = atomFamily((key: string) =>
386+
atom(
387+
(get) => get(assistantMessageStepsExpandedStorageAtom)[key],
388+
(get, set, isExpanded: boolean) => {
389+
const current = get(assistantMessageStepsExpandedStorageAtom)
390+
set(assistantMessageStepsExpandedStorageAtom, { ...current, [key]: isExpanded })
391+
},
392+
),
393+
)
394+
381395
// Helpers for split view ratio management
382396
export function getDefaultRatios(n: number): number[] {
383397
if (n <= 0) return []

0 commit comments

Comments
 (0)