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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,15 @@ runtime/__pycache__

# IDE
.idea
.vscode/

# Codex logs
.codex-logs/

# Local AI assistant artifacts (graph, notes)
CLAUDE.md
graphify-out/

# claude code
.specstory/
tests/doc/
27 changes: 15 additions & 12 deletions desktop/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.1",
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 @@ -260,6 +260,7 @@ export type WorkspaceTreeEntry = {
name: string
path: string
isDirectory: boolean
isSymlink: boolean
}

export type WorkspaceTreeResult = {
Expand Down Expand Up @@ -296,11 +297,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 @@ -379,8 +386,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()
})
})
})
Loading
Loading