Skip to content

Commit f4ddd6e

Browse files
DavidsonGomesclaude
andcommitted
release: v0.32.3 — workspace folder navigation fix + share link reuse
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent baf8606 commit f4ddd6e

9 files changed

Lines changed: 225 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.32.3] - 2026-04-25
9+
10+
Patch release fixing a long-standing Workspace UI bug where folders refused to open and the dev console flooded with `400 Path is a directory` requests, plus a small UX win on the file share dialog (reuse existing share links instead of generating a new token every time). Also includes the upstream PR #51 (private-repo plugin update flow + ClickUp webhook compat + DetachedInstanceError).
11+
12+
### Fixed
13+
14+
- **`dashboard/frontend/src/App.tsx`** — section-stable `routeKey` for the `SectionBoundary`. Previously every `navigate({replace:true})` inside `/workspace/*`, `/agents/:name`, `/tickets/:id`, `/skills/:name` and `/docs` produced a new `location.key`, which changed the boundary's React `key` and remounted the entire page. In the Workspace this wiped `selectedPath`, `expanded` state in `TreeItem`, and refs on every folder click — folders never stayed open and the URL→state effect re-fired the file probe (`GET /api/workspace/file?path=workspace/development` → 400) on every mount. Now subpaths within the same section share one stable key; the boundary still resets between sections.
15+
- **`dashboard/frontend/src/components/workspace/FileTree.tsx`** — split the toggle in `TreeItem.handleClick` into explicit open / close branches. The previous `setExpanded(prev => !prev)` toggle was vulnerable to any re-trigger flipping a freshly-opened folder back closed.
16+
- **`dashboard/frontend/src/pages/Workspace.tsx`** — added `knownDirsRef` so the URL→`selectedPath` deep-link effect can skip the redundant `GET /api/workspace/file?path=…` probe when the path is already known to be a directory (e.g. user just clicked it). The probe used to 400 on every directory navigation, polluting server logs and racing with `setSelectedPath` re-renders.
17+
18+
### Changed
19+
20+
- **`dashboard/backend/routes/shares.py`** — new `GET /api/shares/by-path?path=X` endpoint returning the most recent **active** (enabled + non-expired) share for a path, or 404. Same permission gate (`workspace.manage`) and folder-access check as `POST /api/shares`.
21+
- **`dashboard/frontend/src/components/workspace/ShareDialog.tsx`** — on open, probe `by-path` and reuse any existing active share instead of always minting a new token. The dialog now shows the existing link with formatted expiry, view counter, and a destructive **Revoke and regenerate** action when you actually want to rotate the link. New share creation only happens when there isn't one already.
22+
23+
### Included from PR #51
24+
25+
- **Plugins + triggers** — private-repo update flow, ClickUp webhook compatibility, and a `DetachedInstanceError` fix landed via PR #51 ahead of this patch.
26+
827
## [0.32.2] - 2026-04-24
928

1029
Patch release working around a bug in `@anthropic-ai/claude-agent-sdk` (v0.2.104+) where Linux auto-discovery tries the `-musl` platform package before glibc regardless of the host's actual libc. On glibc VPS installs (Ubuntu / Debian) with both platform packages present in `node_modules`, the SDK spawned the musl binary and failed with `Claude Code native binary not found` because the musl dynamic loader was absent — breaking every chat session on the affected VPS with no local repro. See upstream [issue #296](https://github.com/anthropics/claude-agent-sdk-typescript/issues/296).

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.32.2",
3+
"version": "0.32.3",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/routes/shares.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,41 @@ def list_shares():
130130
return jsonify({"shares": [s.to_dict() for s in shares]})
131131

132132

133+
@bp.route("/api/shares/by-path", methods=["GET"])
134+
@login_required
135+
@require_permission("workspace", "manage")
136+
def get_active_share_by_path():
137+
"""Return the most recent ACTIVE (enabled + not expired) share for a path,
138+
so the UI can reuse it instead of generating a new token every time."""
139+
path = (request.args.get("path") or "").strip()
140+
if not path:
141+
return jsonify({"error": "path is required", "code": "bad_path"}), 400
142+
143+
if not has_workspace_folder_access(current_user.role, path):
144+
return jsonify({"error": "Access to this workspace folder is restricted", "code": "forbidden"}), 403
145+
146+
now = datetime.now(timezone.utc)
147+
candidates = (
148+
FileShare.query
149+
.filter_by(path=path, enabled=True)
150+
.order_by(FileShare.created_at.desc())
151+
.all()
152+
)
153+
for share in candidates:
154+
if share.expires_at is not None:
155+
expires = share.expires_at
156+
if expires.tzinfo is None:
157+
expires = expires.replace(tzinfo=timezone.utc)
158+
if now > expires:
159+
continue
160+
base_url = request.host_url.rstrip("/")
161+
return jsonify({
162+
**share.to_dict(),
163+
"url": f"{base_url}/share/{share.token}",
164+
})
165+
return jsonify({"error": "No active share for this path", "code": "not_found"}), 404
166+
167+
133168
@bp.route("/api/shares/<token>", methods=["DELETE"])
134169
@login_required
135170
@require_permission("workspace", "manage")

dashboard/frontend/src/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,19 @@ interface OnboardingUser {
106106

107107
function AppContent() {
108108
const location = useLocation()
109-
const routeKey = location.key || location.pathname
109+
// Section-stable key: collapse all subpaths of /workspace, /agents/:name,
110+
// /tickets/:id, /skills/:name, /docs into a single key per section so the
111+
// SectionBoundary doesn't remount the page on every URL update (which would
112+
// wipe component state — e.g. expanded folders, selected file, refs).
113+
const routeKey = (() => {
114+
const p = location.pathname
115+
if (p === '/workspace' || p.startsWith('/workspace/')) return '/workspace'
116+
if (p === '/docs' || p.startsWith('/docs/')) return '/docs'
117+
if (/^\/agents\/[^/]+/.test(p)) return p.split('/').slice(0, 3).join('/')
118+
if (/^\/tickets\/[^/]+/.test(p)) return p.split('/').slice(0, 3).join('/')
119+
if (/^\/skills\/[^/]+/.test(p)) return p.split('/').slice(0, 3).join('/')
120+
return p
121+
})()
110122
const isDocs = location.pathname === '/docs' || location.pathname.startsWith('/docs/')
111123
const isShare = location.pathname.startsWith('/share/')
112124
const isOnboarding = location.pathname.startsWith('/onboarding')

dashboard/frontend/src/components/workspace/FileTree.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,12 @@ function TreeItem({ node, level, selectedPath, onSelect, onNavigate, searchTerm
105105

106106
const handleClick = () => {
107107
if (node.is_dir) {
108-
if (!expanded) loadChildren()
109-
setExpanded(prev => !prev)
108+
if (!expanded) {
109+
loadChildren()
110+
setExpanded(true)
111+
} else {
112+
setExpanded(false)
113+
}
110114
onSelect(node.path, true)
111115
} else {
112116
onSelect(node.path, false)

dashboard/frontend/src/components/workspace/ShareDialog.tsx

Lines changed: 133 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1-
import { useState } from 'react'
2-
import { X, Copy, Check, Share2, Loader2 } from 'lucide-react'
1+
import { useState, useEffect } from 'react'
2+
import { X, Copy, Check, Share2, Loader2, RefreshCw, Eye, Clock } from 'lucide-react'
33
import { api } from '../../lib/api'
44

55
interface ShareDialogProps {
66
path: string
77
onClose: () => void
88
}
99

10+
interface ExistingShare {
11+
token: string
12+
url: string
13+
expires_at: string | null
14+
view_count: number
15+
created_at: string | null
16+
}
17+
1018
const EXPIRY_OPTIONS = [
1119
{ value: '1h', label: '1 hora' },
1220
{ value: '24h', label: '24 horas' },
@@ -15,28 +23,89 @@ const EXPIRY_OPTIONS = [
1523
{ value: null, label: 'Sem expiração' },
1624
]
1725

26+
function formatExpiry(iso: string | null): string {
27+
if (!iso) return 'Sem expiração'
28+
const date = new Date(iso)
29+
const now = new Date()
30+
const diffMs = date.getTime() - now.getTime()
31+
if (diffMs <= 0) return 'Expirado'
32+
const diffH = Math.floor(diffMs / (1000 * 60 * 60))
33+
if (diffH < 1) return `Expira em <1h`
34+
if (diffH < 24) return `Expira em ${diffH}h`
35+
const diffD = Math.floor(diffH / 24)
36+
return `Expira em ${diffD}d`
37+
}
38+
1839
export default function ShareDialog({ path, onClose }: ShareDialogProps) {
1940
const [expiresIn, setExpiresIn] = useState<string | null>('7d')
2041
const [shareUrl, setShareUrl] = useState<string | null>(null)
42+
const [existing, setExisting] = useState<ExistingShare | null>(null)
2143
const [loading, setLoading] = useState(false)
44+
const [checking, setChecking] = useState(true)
2245
const [error, setError] = useState<string | null>(null)
2346
const [copied, setCopied] = useState(false)
2447

48+
// On open: check for an existing active share
49+
useEffect(() => {
50+
let cancelled = false
51+
;(async () => {
52+
try {
53+
const data = await api.get(`/shares/by-path?path=${encodeURIComponent(path)}`)
54+
if (cancelled) return
55+
setExisting({
56+
token: data.token,
57+
url: data.url,
58+
expires_at: data.expires_at,
59+
view_count: data.view_count ?? 0,
60+
created_at: data.created_at,
61+
})
62+
setShareUrl(data.url)
63+
} catch {
64+
// 404 = no active share, fine
65+
} finally {
66+
if (!cancelled) setChecking(false)
67+
}
68+
})()
69+
return () => { cancelled = true }
70+
}, [path])
71+
2572
const handleCreate = async () => {
2673
setLoading(true)
2774
setError(null)
2875
try {
2976
const data = await api.post('/shares', { path, expires_in: expiresIn })
30-
// Build the full public URL using the current host
3177
const base = `${window.location.protocol}//${window.location.host}`
32-
setShareUrl(`${base}/share/${data.token}`)
78+
const url = `${base}/share/${data.token}`
79+
setShareUrl(url)
80+
setExisting({
81+
token: data.token,
82+
url,
83+
expires_at: data.expires_at,
84+
view_count: 0,
85+
created_at: data.created_at,
86+
})
3387
} catch {
3488
setError('Erro ao criar link de compartilhamento. Tente novamente.')
3589
} finally {
3690
setLoading(false)
3791
}
3892
}
3993

94+
const handleRevokeAndRegenerate = async () => {
95+
if (!existing) return
96+
setLoading(true)
97+
setError(null)
98+
try {
99+
await api.delete(`/shares/${existing.token}`)
100+
setExisting(null)
101+
setShareUrl(null)
102+
} catch {
103+
setError('Erro ao revogar link existente.')
104+
} finally {
105+
setLoading(false)
106+
}
107+
}
108+
40109
const handleCopy = () => {
41110
if (!shareUrl) return
42111
navigator.clipboard.writeText(shareUrl).then(() => {
@@ -45,6 +114,9 @@ export default function ShareDialog({ path, onClose }: ShareDialogProps) {
45114
})
46115
}
47116

117+
const showExisting = existing && shareUrl
118+
const showGenerator = !checking && !showExisting
119+
48120
return (
49121
<div
50122
className="fixed inset-0 z-50 flex items-center justify-center"
@@ -95,8 +167,40 @@ export default function ShareDialog({ path, onClose }: ShareDialogProps) {
95167
</p>
96168
</div>
97169

98-
{/* Expiration selector — only show before link is generated */}
99-
{!shareUrl && (
170+
{/* Loading state on initial check */}
171+
{checking && (
172+
<div className="flex items-center gap-2 px-3 py-2 text-xs" style={{ color: 'var(--text-muted)' }}>
173+
<Loader2 size={13} className="animate-spin" />
174+
Verificando links existentes…
175+
</div>
176+
)}
177+
178+
{/* Existing active share — show it instead of generating new */}
179+
{showExisting && (
180+
<>
181+
<div
182+
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs"
183+
style={{ background: 'rgba(0,255,167,0.08)', color: 'var(--evo-green)', border: '1px solid rgba(0,255,167,0.2)' }}
184+
>
185+
<Check size={13} />
186+
<span>Este arquivo já tem um link ativo. Reutilizando.</span>
187+
</div>
188+
189+
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
190+
<span className="flex items-center gap-1">
191+
<Clock size={12} />
192+
{formatExpiry(existing!.expires_at)}
193+
</span>
194+
<span className="flex items-center gap-1">
195+
<Eye size={12} />
196+
{existing!.view_count} {existing!.view_count === 1 ? 'visualização' : 'visualizações'}
197+
</span>
198+
</div>
199+
</>
200+
)}
201+
202+
{/* Expiration selector — only show when generating new */}
203+
{showGenerator && (
100204
<div>
101205
<p className="text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Expiração</p>
102206
<div className="flex flex-wrap gap-2">
@@ -128,7 +232,7 @@ export default function ShareDialog({ path, onClose }: ShareDialogProps) {
128232
{/* Generated URL */}
129233
{shareUrl && (
130234
<div>
131-
<p className="text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Link público gerado</p>
235+
<p className="text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Link público</p>
132236
<div className="flex gap-2">
133237
<input
134238
readOnly
@@ -163,16 +267,36 @@ export default function ShareDialog({ path, onClose }: ShareDialogProps) {
163267
className="flex justify-end gap-2 px-5 py-4"
164268
style={{ borderTop: '1px solid var(--border)' }}
165269
>
270+
{showExisting && (
271+
<button
272+
onClick={handleRevokeAndRegenerate}
273+
disabled={loading}
274+
className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors mr-auto"
275+
style={{
276+
color: '#f87171',
277+
border: '1px solid rgba(239,68,68,0.3)',
278+
background: 'transparent',
279+
opacity: loading ? 0.5 : 1,
280+
cursor: loading ? 'not-allowed' : 'pointer',
281+
}}
282+
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.background = 'rgba(239,68,68,0.1)' }}
283+
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
284+
title="Revoga o link atual e gera um novo (links antigos param de funcionar)"
285+
>
286+
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
287+
Revogar e gerar novo
288+
</button>
289+
)}
166290
<button
167291
onClick={onClose}
168292
className="px-4 py-2 text-sm rounded-lg transition-colors"
169293
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border)' }}
170294
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
171295
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
172296
>
173-
{shareUrl ? 'Fechar' : 'Cancelar'}
297+
Fechar
174298
</button>
175-
{!shareUrl && (
299+
{showGenerator && (
176300
<button
177301
onClick={handleCreate}
178302
disabled={loading}

dashboard/frontend/src/pages/Workspace.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ export default function Workspace() {
152152
// Overwrite promise resolvers
153153
const overwriteResolverRef = useRef<((v: boolean) => void) | null>(null)
154154

155+
// Track which paths we already know are directories so the URL→state effect
156+
// can skip the redundant /api/workspace/file probe (the file probe returns
157+
// 400 for directories — harmless but noisy and re-triggers setSelectedPath)
158+
const knownDirsRef = useRef<Set<string>>(new Set())
159+
155160
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
156161
const id = ++toastCounter
157162
setToasts(prev => [...prev, { id, message, type }])
@@ -189,6 +194,15 @@ export default function Workspace() {
189194
decoded = `workspace/${decoded}`
190195
}
191196
if (decoded === selectedPath) return
197+
// If we already know this path is a directory (e.g. user just clicked on
198+
// it in the tree), skip the file probe — it would 400 and noisily re-fire
199+
// setSelectedPath. Just confirm via tree and set state.
200+
if (knownDirsRef.current.has(decoded)) {
201+
setSelectedPath(decoded)
202+
setIsDir(true)
203+
setMode('preview')
204+
return
205+
}
192206
;(async () => {
193207
try {
194208
// Probe as file first; fall back to dir on 400
@@ -206,6 +220,7 @@ export default function Workspace() {
206220
credentials: 'include',
207221
})
208222
if (treeRes.ok) {
223+
knownDirsRef.current.add(decoded)
209224
setSelectedPath(decoded)
210225
setIsDir(true)
211226
setMode('preview')
@@ -273,6 +288,7 @@ export default function Workspace() {
273288
const handleSelect = useCallback((path: string, dir: boolean) => {
274289
// Directories: just navigate, no tab
275290
if (dir) {
291+
knownDirsRef.current.add(path)
276292
saveCurrentTabState()
277293
setSelectedPath(path)
278294
setIsDir(true)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "evo-nexus"
3-
version = "0.32.2"
3+
version = "0.32.3"
44
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
55
requires-python = ">=3.10"
66
dependencies = [

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)