Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ runtime/__pycache__

# IDE
.idea
.vscode/

# Codex logs
.codex-logs/
Expand All @@ -80,3 +81,8 @@ CLAUDE.md
.mavis/
.opencode/
graphify-out/

# claude code
.specstory/
tests/doc/
result/
177 changes: 88 additions & 89 deletions desktop/bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/katex": "^0.16.8",
"@types/node": "^25.9.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
Expand Down
13 changes: 11 additions & 2 deletions desktop/src/api/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export type WorkspaceTreeEntry = {
name: string
path: string
isDirectory: boolean
isSymlink: boolean
}

export type WorkspaceTreeResult = {
Expand Down Expand Up @@ -297,11 +298,17 @@ function buildWorkspacePath(
sessionId: string,
resource: 'status' | 'tree' | 'file' | 'diff',
workspacePath?: string,
extraParams?: Record<string, string>,
) {
const query = new URLSearchParams()
if (typeof workspacePath === 'string' && workspacePath.length > 0) {
query.set('path', workspacePath)
}
if (extraParams) {
for (const [key, value] of Object.entries(extraParams)) {
if (value) query.set(key, value)
}
}

const qs = query.toString()
return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}`
Expand Down Expand Up @@ -388,8 +395,10 @@ export const sessionsApi = {
return api.get<WorkspaceStatusResult>(buildWorkspacePath(sessionId, 'status'))
},

getWorkspaceTree(sessionId: string, workspacePath = '') {
return api.get<WorkspaceTreeResult>(buildWorkspacePath(sessionId, 'tree', workspacePath))
getWorkspaceTree(sessionId: string, workspacePath = '', showHidden = false) {
return api.get<WorkspaceTreeResult>(
buildWorkspacePath(sessionId, 'tree', workspacePath, showHidden ? { showHidden: 'true' } : undefined),
)
},

getWorkspaceFile(sessionId: string, workspacePath: string) {
Expand Down
79 changes: 79 additions & 0 deletions desktop/src/components/chat/PlantUMLRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'

const postMock = vi.hoisted(() => vi.fn())

vi.mock('../../api/client', () => ({
api: {
post: postMock,
},
}))

import { PlantUMLRenderer } from './PlantUMLRenderer'

const SVG_MOCK =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect width="200" height="100"/></svg>'

describe('PlantUMLRenderer', () => {
beforeEach(() => {
postMock.mockReset()
})

it('renders SVG from the backend and sanitizes with DOMPurify', async () => {
postMock.mockResolvedValue({ svg: SVG_MOCK })

render(<PlantUMLRenderer code={'@startuml\nAlice -> Bob\n@enduml'} />)

await waitFor(() => {
expect(screen.getByRole('img', { name: 'PlantUML diagram' })).toBeInTheDocument()
})

expect(postMock).toHaveBeenCalledWith('/api/settings/plantuml/render', {
code: '@startuml\nAlice -> Bob\n@enduml',
})
})

it('shows the PlantUML header and preview button', async () => {
postMock.mockResolvedValue({ svg: SVG_MOCK })

render(<PlantUMLRenderer code={'@startuml\nAlice -> Bob\n@enduml'} />)

await screen.findByText('PlantUML')
expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument()
})

it('opens preview modal with the diagram', async () => {
postMock.mockResolvedValue({ svg: SVG_MOCK })

render(<PlantUMLRenderer code={'@startuml\nAlice -> Bob\n@enduml'} />)

const previewButton = await screen.findByRole('button', { name: /preview/i })
fireEvent.click(previewButton)

await screen.findByText('PlantUML Diagram')
})

it('falls back to CodeViewer when server returns null svg', async () => {
postMock.mockResolvedValue({ svg: null })

render(<PlantUMLRenderer code={'@startuml\nAlice -> Bob\n@enduml'} />)

await waitFor(() => {
expect(screen.queryByRole('heading')).toBeNull()
})

// CodeViewer should render the plantuml code as text
expect(postMock).toHaveBeenCalled()
})

it('shows error state when render fails', async () => {
postMock.mockRejectedValue(new Error('PlantUML render failed'))

render(<PlantUMLRenderer code={'@startuml\nbad syntax\n@enduml'} />)

await waitFor(() => {
expect(screen.getByText('PlantUML Render Error')).toBeInTheDocument()
})
})
})
130 changes: 130 additions & 0 deletions desktop/src/components/chat/PlantUMLRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react'
import DOMPurify from 'dompurify'
import { CodeViewer } from './CodeViewer'
import { Modal } from '../shared/Modal'
import { CopyButton } from '../shared/CopyButton'
import { api } from '../../api/client'

type Props = {
code: string
}

export function PlantUMLRenderer({ code }: Props) {
const [svg, setSvg] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)

useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)

api.post<{ svg: string | null; error?: string }>('/api/settings/plantuml/render', { code })
.then((result) => {
if (cancelled) return
if (result.error) {
setError(result.error)
setSvg(null)
} else if (result.svg) {
setSvg(DOMPurify.sanitize(result.svg, {
ADD_TAGS: ['foreignObject'],
USE_PROFILES: { svg: true, svgFilters: true },
}))
setError(null)
} else {
setSvg(null)
}
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : 'PlantUML render failed')
})
.finally(() => {
if (!cancelled) setLoading(false)
})

return () => { cancelled = true }
}, [code])

if (loading) {
return (
<div className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)]/50 bg-[var(--color-surface-container-low)] py-8">
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
Rendering diagram...
</div>
</div>
)
}

if (!svg) {
if (error) {
return (
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-error)]/40 bg-[var(--color-surface-container-low)]">
<div className="flex items-center gap-2 border-b border-[var(--color-error)]/20 px-3 py-2 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="font-semibold">PlantUML Render Error</span>
</div>
<pre className="overflow-auto p-3 text-[11px] text-[var(--color-text-tertiary)]">{error}</pre>
</div>
)
}
return (
<div className="my-4">
<CodeViewer code={code} language="plantuml" />
</div>
)
}

return (
<>
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px]">schema</span>
<span className="font-semibold uppercase tracking-[0.14em]">PlantUML</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setPreviewOpen(true)}
className="flex items-center gap-1 rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[12px]">fullscreen</span>
Preview
</button>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
/>
</div>
</div>
<div
className="flex items-center justify-center overflow-auto bg-white p-4"
style={{ maxHeight: 400 }}
onClick={() => setPreviewOpen(true)}
dangerouslySetInnerHTML={{ __html: svg }}
role="img"
aria-label="PlantUML diagram"
/>
</div>

<Modal open={previewOpen} onClose={() => setPreviewOpen(false)} width={1100}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[18px]">schema</span>
PlantUML Diagram
</div>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-border)] px-2.5 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
<div className="overflow-auto rounded-xl bg-white" style={{ maxHeight: '75vh' }}>
<div className="p-6" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
</div>
</Modal>
</>
)
}
Loading
Loading