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
3 changes: 2 additions & 1 deletion apps/buddy/src/modules/files/explorer/WorkspaceExplorer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const props = defineProps<{
nodes: readonly ExplorerNode[]
loadingKeys: ReadonlySet<string>
target: SpaceFileTarget
reveal?: { path: string, token: string }
language: BuddyLocale
api: Pick<LocalChatApi['spaces'], 'revealFile' | 'copyFilePath' | 'fileHistory' | 'acknowledgeFileOperation' | 'cancelFileOperation' | 'onFilesChanged'>
load: (node: ExplorerNode) => Promise<void>
Expand Down Expand Up @@ -124,7 +125,7 @@ async function acknowledge(operation: SpaceFileOperation) {
</div>
<div class="relative flex min-h-0 flex-1 flex-col">
<DesktopFileLoading v-if="!nodes.length && loadingKeys.has('')" class="absolute inset-0" :loading="true" tree :label="t('desktop.loading.pane')" />
<ExplorerTree v-model:expanded-keys="expandedKeys" :loading-keys="loadingKeys" :aria-busy="loadingKeys.has('')" :nodes="nodes" :selected-key="target.path || null" :language="language" :load="load" @select="emit('select', $event)" @contextmenu="contextmenu" @command="commands.command" />
<ExplorerTree v-model:expanded-keys="expandedKeys" :loading-keys="loadingKeys" :aria-busy="loadingKeys.has('')" :nodes="nodes" :selected-key="target.path || null" :reveal="reveal" :language="language" :load="load" @select="emit('select', $event)" @contextmenu="contextmenu" @command="commands.command" />
</div>
<ExplorerRecovery v-if="recoveryOperations.length" :operations="recoveryOperations" :language="language" :busy="!!busy" @acknowledge="acknowledge" />
<NDropdown :show="!!menu" :x="menuPosition.x" :y="menuPosition.y" :options="menuOptions" trigger="manual" placement="bottom-start" @select="selectCommand" @clickoutside="menu = null" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const selectionSchema = z.tuple([z.union([scopeSchema, z.templateLiteral(['space
const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional() })
const tabSchema = z.discriminatedUnion('kind', [
base.extend({ kind: z.literal('browser'), conversationId: identity.nullable(), browserKey: identity.optional() }),
base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, rootName: z.string() }),
base.extend({ kind: z.literal('files'), target: spaceFileTargetSchema, rootName: z.string(), expand: z.object({ path: z.string().min(1).max(4096), token: identity }).optional(), tree: z.object({ visible: z.boolean(), token: identity }).optional() }),
base.extend({ kind: z.literal('artifact'), artifact: artifactSchema, label: z.string(), viewMode: z.enum(['preview', 'source']) }),
base.extend({ kind: z.literal('changes'), branchId: identity.nullable(), revision: z.string(), changeSet: changeSetSummarySchema.nullable(), conversationId: identity }),
base.extend({ kind: z.literal('view'), viewId: identity, label: z.string() }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface TaskFilesContextTab extends ContextTabSource {
kind: 'files'
target: SpaceFileTarget
rootName: string
// A folder to open in the tree; a new token asks to expand it again after the user collapsed it.
expand?: { path: string, token: string }
// Shows or folds the tree pane; a new token applies it again after the user toggled the pane.
tree?: { visible: boolean, token: string }
}

export interface TaskViewContextTab extends ContextTabSource {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi'
import type { LocalSpace } from '@buddy-shared/spaces/spaceApi'
import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi'
import { isTextArtifactMimeType } from '@buddy-shared/artifacts/artifactApi'

export function cleanChatToolFilePath(path: string): string {
let cleaned = path.trim()
Expand All @@ -17,6 +18,18 @@ export function cleanChatToolFilePath(path: string): string {
return cleaned.split('#')[0]!.split('?')[0]!
}

// Space file previews read at most 1 MiB of text and 8 MiB of image; larger outputs stay on the artifact tab, which reads more.
const SPACE_FILE_TEXT_PREVIEW_BYTES = 1024 * 1024
const SPACE_FILE_IMAGE_PREVIEW_BYTES = 8 * 1024 * 1024

export function fitsSpaceFilePreview(artifact: Pick<LocalArtifact, 'kind' | 'mimeType' | 'sizeBytes'>): boolean {
if (artifact.kind === 'directory')
return true
if (artifact.mimeType.startsWith('image/'))
return artifact.sizeBytes <= SPACE_FILE_IMAGE_PREVIEW_BYTES
return !isTextArtifactMimeType(artifact.mimeType) || artifact.sizeBytes <= SPACE_FILE_TEXT_PREVIEW_BYTES
}

export function resolveChatArtifactTarget(artifacts: readonly LocalArtifact[], path: string): LocalArtifact | null {
const normalized = cleanChatToolFilePath(path).replaceAll('\\', '/').replace(/^\/+([a-z]:\/)/i, '$1').replace(/^(\.\/)+/, '')
if (!normalized || normalized.includes('\0') || normalized.split('/').includes('..')
Expand Down Expand Up @@ -55,3 +68,16 @@ export function resolveChatToolFileTarget(space: LocalSpace | null, path: string
return null
return { spaceId: space.id, directoryId: directory.id, revision: directory.revision, path: segments.join('/') }
}

// Like resolveChatToolFileTarget, but the space root itself is a valid folder and resolves to the empty path.
export function resolveChatToolDirectoryTarget(space: LocalSpace | null, path: string): SpaceFileTarget | null {
const target = resolveChatToolFileTarget(space, path)
const directory = space?.primaryDirectory
if (target || !space || space.revokedAt || !directory || directory.revokedAt || !path || path.includes('\0'))
return target
const candidate = cleanChatToolFilePath(path).replaceAll('\\', '/').replace(/\/+$/, '')
const roots = [directory.canonicalRoot, directory.root].map(root => root.replaceAll('\\', '/').replace(/\/+$/, ''))
const windows = /^[a-z]:/i.test(roots[0]!) || roots[0]!.startsWith('//')
const isRoot = roots.some(root => (windows ? root.toLowerCase() === candidate.toLowerCase() : root === candidate))
return isRoot ? { spaceId: space.id, directoryId: directory.id, revision: directory.revision, path: '' } : null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { DesktopBrowserApi } from '@buddy-electron/shared/desktopApi'
import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi'
import type { LocalSpace } from '@buddy-shared/spaces/spaceApi'
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { shallowRef } from 'vue'
import { useTaskResourcePanel } from '@/modules/tasks/state/context-panel/useTaskResourcePanel'
import { contextPanelFixture } from './contextPanelFixture'

const now = '2026-09-10T00:00:00.000Z'

function space(): LocalSpace {
return {
id: 'space',
name: 'Files',
icon: 'folder',
iconColor: 'default',
memoryScope: 'space_only',
activeRunCount: 0,
additionalDirectories: [],
revokedAt: null,
createdAt: now,
updatedAt: now,
primaryDirectory: { id: 'directory', spaceId: 'space', root: '/workspace', canonicalRoot: '/workspace', revision: 1, accessGrantedAt: now, resourcesTrustedAt: now, revokedAt: null, createdAt: now, updatedAt: now },
}
}

function artifact(overrides: Partial<LocalArtifact> = {}): LocalArtifact {
return { artifactId: 'artifact', conversationId: 'conversation', createdAt: now, kind: 'file', mimeType: 'text/markdown', name: 'report.md', path: '/workspace/docs/report.md', previewUrl: null, runId: 'run', sizeBytes: 100, sourceArtifactId: null, sourceToolCallId: 'tool', updatedAt: now, ...overrides }
}

function folder(overrides: Partial<LocalArtifact> = {}): LocalArtifact {
return artifact({ artifactId: 'folder', kind: 'directory', mimeType: 'inode/directory', name: 'reports', path: '/workspace/docs/reports', sizeBytes: 0, ...overrides })
}

function fixture(activeSpace: LocalSpace | null = space()) {
const browser: Pick<DesktopBrowserApi, 'onStateChanged' | 'ensureSession' | 'close' | 'openArtifact' | 'navigate'> = {
navigate: async () => { throw new Error('unexpected navigation') },
onStateChanged: () => () => {},
ensureSession: async () => { throw new Error('unexpected session') },
close: async () => {},
openArtifact: async () => { throw new Error('unexpected browser artifact') },
}
const { scope, options } = contextPanelFixture({ activeConversationId: shallowRef<string | null>('conversation'), activeSpace: shallowRef(activeSpace) })
const panel = scope.run(() => useTaskResourcePanel({ ...options, autoCollapse: shallowRef(false), linkOpening: shallowRef('in-app'), browser }))!
return { panel }
}

describe('opening task outputs', () => {
it('opens an output inside the task space as a space file', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact())
expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { spaceId: 'space', directoryId: 'directory', path: 'docs/report.md' }, source: { conversationId: 'conversation', runId: 'run' }, tree: { visible: false } })
expect((panel.activeTab.value as { expand?: unknown }).expand).toBeUndefined()
})

it('opens binary outputs as space files so document plugins can take them', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact({ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'brief.docx', path: '/workspace/brief.docx', sizeBytes: 40 * 1024 * 1024 }))
expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: 'brief.docx' } })
})

it('keeps the output tab when the task has no space', async () => {
const { panel } = fixture(null)
await panel.openArtifact(artifact())
expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'artifact' } })
})

it('keeps the output tab for files outside the space directory', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact({ path: '/elsewhere/report.md' }))
expect(panel.activeTab.value).toMatchObject({ kind: 'artifact' })
})

it('opens a folder output in the file manager with that folder expanded', async () => {
const { panel } = fixture()
await panel.openArtifact(folder())
expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: '' }, expand: { path: 'docs/reports' }, tree: { visible: true }, source: { runId: 'run' } })
})

it('keeps the file open in the tab when a folder output is opened again', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact())
const tabId = panel.activeTab.value!.id
await panel.openArtifact(folder())
expect(panel.allTabs.value).toHaveLength(1)
expect(panel.activeTab.value).toMatchObject({ id: tabId, target: { path: 'docs/report.md' }, expand: { path: 'docs/reports' }, tree: { visible: true } })
const first = (panel.activeTab.value as { expand: { token: string } }).expand.token
await panel.openArtifact(folder())
expect((panel.activeTab.value as { expand: { token: string } }).expand.token).not.toBe(first)
})

it('opens the space root folder without asking to expand anything', async () => {
const { panel } = fixture()
await panel.openArtifact(folder({ path: '/workspace' }))
expect(panel.activeTab.value).toMatchObject({ kind: 'files', target: { path: '' } })
expect((panel.activeTab.value as { expand?: unknown }).expand).toBeUndefined()
})

it('keeps the output tab for a folder outside the space', async () => {
const { panel } = fixture()
await panel.openArtifact(folder({ path: '/elsewhere/reports' }))
expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { name: 'reports' } })
})

it('keeps the output tab for outputs the file preview cannot hold', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact({ artifactId: 'large-text', sizeBytes: 1024 * 1024 + 1 }))
expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'large-text' } })
await panel.openArtifact(artifact({ artifactId: 'large-image', mimeType: 'image/png', name: 'chart.png', path: '/workspace/chart.png', sizeBytes: 8 * 1024 * 1024 + 1 }))
expect(panel.activeTab.value).toMatchObject({ kind: 'artifact', artifact: { artifactId: 'large-image' } })
})

it('keeps the output tab for another task\'s output', async () => {
const { panel } = fixture()
await panel.openArtifact(artifact({ conversationId: 'other' }))
expect(panel.allTabs.value).toMatchObject([{ kind: 'artifact', artifact: { conversationId: 'other' } }])
})
})
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi'
import type { LocalChangeSetSummary } from '@buddy-shared/changes/changeApi'
import type { ContextPanelSource } from '@buddy-shared/context-panel/contextPanel'
import type { ArtifactViewMode, TaskContextTab } from '../../model/context-panel/taskContextPanel'
import type { LocalSpace } from '@buddy-shared/spaces/spaceApi'
import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi'
import type { ArtifactViewMode, TaskContextTab, TaskFilesContextTab } from '../../model/context-panel/taskContextPanel'
import type { UseTaskContextPanelOptions } from './typing'
import { computed, readonly, watch } from 'vue'
import { useContextPanelControl } from '@/platform/desktop/useContextPanelControl'
import { readContextSelection, readContextTab } from '../../model/context-panel/readContextTab'
import { artifactTabId, browserTabId, contextTabSource, spaceTaskBrowserTab, taskContextPanelScope } from '../../model/context-panel/taskContextPanel'
import { resolveChatToolFileTarget } from '../../model/transcript/chatToolFileTarget'
import { resolveChatToolDirectoryTarget, resolveChatToolFileTarget } from '../../model/transcript/chatToolFileTarget'
import { useContextPanelTabs } from './useContextPanelTabs'

export function useTaskContextPanel(options: UseTaskContextPanelOptions) {
Expand Down Expand Up @@ -134,7 +136,8 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) {
return Boolean(resolveChatToolFileTarget(options.activeSpace?.value ?? null, path))
}

function previewFile(path: string, source?: ContextPanelSource) {
// collapseTree: the file is the point, so the tree starts folded away (the user can still open it).
function previewFile(path: string, source?: ContextPanelSource, collapseTree = false) {
const space = options.activeSpace?.value ?? null
const target = resolveChatToolFileTarget(space, path)
if (!target || !space?.primaryDirectory)
Expand All @@ -147,37 +150,75 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) {
&& tab.target.path === target.path
&& (!source || tab.source?.runId === source.runId),
)
const tree = collapseTree ? { visible: false, token: crypto.randomUUID() } : undefined
if (exactTab) {
if (tree)
updateFilesTab(exactTab.id, { tree })
store.select(exactTab.id)
void control.open()
return
}

const spaceTab = findSpaceFilesTab(target, source)
if (spaceTab) {
selectFile(spaceTab.id, target.path)
if (tree)
updateFilesTab(spaceTab.id, { tree })
store.select(spaceTab.id)
openPanel()
return
}

openTab({ ...newFilesTab(space, target, source), tree })
}

function updateFilesTab(tabId: string, change: Partial<Pick<TaskFilesContextTab, 'expand' | 'tree'>>) {
store.update(tab => tab.id === tabId && tab.kind === 'files' ? { ...tab, ...change } : tab)
}

function findSpaceFilesTab(target: SpaceFileTarget, source?: ContextPanelSource) {
const active = store.activeTab.value
const spaceTab = (active?.kind === 'files' && active.target.spaceId === target.spaceId && active.target.directoryId === target.directoryId && (!source || active.source?.runId === source.runId))
return (active?.kind === 'files' && active.target.spaceId === target.spaceId && active.target.directoryId === target.directoryId && (!source || active.source?.runId === source.runId))
? active
: store.tabs.value.find(tab =>
tab.kind === 'files'
&& tab.target.spaceId === target.spaceId
&& tab.target.directoryId === target.directoryId
&& (!source || tab.source?.runId === source.runId),
)
}

if (spaceTab) {
selectFile(spaceTab.id, target.path)
store.select(spaceTab.id)
void control.open()
return
}

openTab({
function newFilesTab(space: LocalSpace, target: SpaceFileTarget, source?: ContextPanelSource): TaskFilesContextTab {
return {
id: `files:${source ? taskContextPanelScope(source.conversationId) : store.scope.value}:${target.spaceId}:${target.directoryId}:${source?.runId ?? 'preview'}`,
scope: source ? taskContextPanelScope(source.conversationId) : store.scope.value,
kind: 'files',
rootName: space.primaryDirectory.root.split(/[\\/]/).filter(Boolean).at(-1) ?? space.primaryDirectory.root,
rootName: space.primaryDirectory!.root.split(/[\\/]/).filter(Boolean).at(-1) ?? space.primaryDirectory!.root,
target,
source: source ?? currentSource(),
})
}
}

function canOpenDirectory(path: string) {
return Boolean(resolveChatToolDirectoryTarget(options.activeSpace?.value ?? null, path))
}

// Shows a folder in the file manager's tree without changing the file already open in the tab.
function openDirectory(path: string, source?: ContextPanelSource) {
const space = options.activeSpace?.value ?? null
const target = resolveChatToolDirectoryTarget(space, path)
if (!target || !space?.primaryDirectory)
return
const expand = target.path ? { path: target.path, token: crypto.randomUUID() } : undefined
const tree = { visible: true, token: crypto.randomUUID() }
const spaceTab = findSpaceFilesTab(target, source)
if (spaceTab) {
updateFilesTab(spaceTab.id, { expand, tree })
store.select(spaceTab.id)
void control.open()
return
}
openTab({ ...newFilesTab(space, { ...target, path: '' }, source), expand, tree })
}

function isAvailable(tab: TaskContextTab): boolean {
Expand Down Expand Up @@ -229,13 +270,15 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) {
adoptDraft: store.adoptDraft,
discardDraft: store.discardDraft,
addBrowser,
canOpenDirectory,
canPreviewFile,
closeTab: store.close,
discardConversation: (id: string) => store.discard(taskContextPanelScope(id)),
hasTab,
openArtifact,
openBrowser,
openChanges,
openDirectory,
openFiles,
previewFile,
selectFile,
Expand Down
Loading
Loading