Skip to content
Closed
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
38 changes: 37 additions & 1 deletion apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
import { type DroppedFile, isDroppedOsDirectory, partitionDroppedFiles } from './use-composer-actions'

// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
Expand Down Expand Up @@ -55,3 +55,39 @@ describe('partitionDroppedFiles', () => {
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
})
})

describe('isDroppedOsDirectory', () => {
it('detects Finder/Explorer folder drops before file.attach sees them as files', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readDir: async () => ({ entries: [] }) }
})

await expect(isDroppedOsDirectory(osDrop('/tmp/hermes-folder-drop'))).resolves.toBe(true)
})

it('keeps normal file drops on the file upload path', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readDir: async () => ({ entries: [], error: 'ENOTDIR' }) }
})

await expect(isDroppedOsDirectory(osDrop('/tmp/report.pdf'))).resolves.toBe(false)
})

it('does not probe path-only in-app refs', async () => {
let probed = false
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readDir: async () => {
probed = true
return { entries: [] }
}
}
})

await expect(isDroppedOsDirectory(inAppRef('src'))).resolves.toBe(false)
expect(probed).toBe(false)
})
})
53 changes: 53 additions & 0 deletions apps/desktop/src/app/chat/hooks/use-composer-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
setComposerTerminalSelection
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'

import type { ImageDetachResponse } from '../../types'

Expand Down Expand Up @@ -211,6 +212,38 @@ export function partitionDroppedFiles(candidates: DroppedFile[]): {
return { osDrops, inAppRefs }
}

/**
* Finder/Explorer folder drops still arrive with a native File handle, so the
* OS-drop split correctly routes them through the attachment path. Before
* treating that handle as a file, ask the local Desktop bridge whether the
* resolved path is actually a directory.
*/
export async function isDroppedOsDirectory(candidate: DroppedFile): Promise<boolean> {
if (candidate.isDirectory) {
return true
}

const filePath = candidate.path.trim()

if (!candidate.file || !filePath) {
return false
}

const readDir = window.hermesDesktop?.readDir

if (!readDir) {
return false
}

try {
const result = await readDir(filePath)

return !result.error
} catch {
return false
}
}

interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
Expand Down Expand Up @@ -506,6 +539,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''

const filePath = knownPath || fallbackPath || ''
const isDirectoryDrop = await isDroppedOsDirectory({ file, isDirectory, path: filePath })

if (isDirectoryDrop) {
if ($connection.get()?.mode === 'remote') {
lastFailure = `Cannot attach local folder ${filePath || file.name || 'folder'} to a remote gateway. Use a folder path that exists on the remote host.`

continue
}

if (filePath && attachContextFolderPath(filePath)) {
attached = true

continue
}

lastFailure = `Could not attach folder ${filePath || file.name || ''}`

continue
}

const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))

if (isImage) {
Expand Down