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
54 changes: 52 additions & 2 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
disconnectProvider,
listProviders,
} from './oauth-handler.js';
import { watcher as watcherCore, workspace } from '@x/core';
import { watcher as watcherCore, workspace, linkedFolders } from '@x/core';
import { WorkDir } from '@x/core/dist/config/config.js';
import { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
Expand All @@ -32,6 +32,7 @@ import { ServiceEvent } from '@x/shared/dist/service-events.js';
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
import { isDurableTurnEvent } from '@x/shared/dist/turns.js';
import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js';
import { listByWorkDir as listSessionsByWorkDir } from '@x/core/dist/runtime/sessions/by-workdir.js';
import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js';
import container from '@x/core/dist/di/container.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
Expand Down Expand Up @@ -872,6 +873,10 @@ export function setupIpcHandlers() {
// Forward knowledge commit events to renderer for panel refresh
versionHistory.onCommit(() => emitKnowledgeCommitEvent());

// Deletions inside a linked folder go to the OS trash — core can't reach
// Electron's shell, so hand it the implementation.
workspace.registerTrashHandler((absPath) => shell.trashItem(absPath));

// Relay backend-confirmed credit grants (first-time-action rewards) to all
// windows so the UI can update balances and celebrate.
subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event));
Expand Down Expand Up @@ -1040,6 +1045,35 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts);
},
'workspace:toAbsolute': async (_event, args) => {
return { path: workspace.resolveWorkspacePath(args.path) };
},
'workspace:listFolders': async () => {
return { folders: linkedFolders.listLinkedFolders() };
},
'workspace:addFolder': async (event, args) => {
let chosen = args.path;
if (!chosen) {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win!, {
title: 'Choose a folder to add as a workspace',
defaultPath: os.homedir(),
buttonLabel: 'Add folder',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return { folder: null };
}
chosen = result.filePaths[0];
}
return { folder: await linkedFolders.addLinkedFolder(chosen!, args.name) };
},
'workspace:renameFolder': async (_event, args) => {
return { folder: await linkedFolders.renameLinkedFolder(args.id, args.name) };
},
'workspace:removeFolder': async (_event, args) => {
return linkedFolders.removeLinkedFolder(args.id);
},
'gmail:getImportant': async (_event, args) => {
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
},
Expand Down Expand Up @@ -1177,7 +1211,23 @@ export function setupIpcHandlers() {
return runsCore.listRuns(args.cursor);
},
'runs:listByWorkDir': async (_event, args) => {
return runsCore.listRunsByWorkDir(args.dir);
// Chats are sessions now; the legacy runs/*.jsonl logs this used to scan
// are migrated away at boot, so the session index is the only source.
await sessionsIndexReady;
const sessions = container.resolve<ISessions>('sessions').listSessions();
return listSessionsByWorkDir(args.dir, sessions
// A session the index couldn't load carries empty timestamps, which
// the response schema rejects — one of those would otherwise fail the
// whole call and blank the panel. It has no title or date to show
// anyway, so leave it out.
.filter((s) => !s.error && s.createdAt)
.map((s) => ({
id: s.sessionId,
...(s.title ? { title: s.title } : {}),
createdAt: s.createdAt,
modifiedAt: s.updatedAt || s.createdAt,
...(s.lastAgentId ? { agentId: s.lastAgentId } : {}),
})));
},
'runs:delete': async (_event, args) => {
await runsCore.deleteRun(args.runId);
Expand Down
74 changes: 62 additions & 12 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view';
import { AppsView } from '@/components/apps/apps-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { WorkspaceView, WORKSPACE_CHATS_CHANGED } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run';
import { SubAgentBlock } from '@/components/sub-agent-block';
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
Expand Down Expand Up @@ -143,8 +143,11 @@ import { useTheme } from '@/contexts/theme-context'
import { TokenUsageMenu } from '@/components/token-usage-menu'

type DirEntry = z.infer<typeof workspace.DirEntry>
type LinkedFolder = z.infer<typeof workspace.LinkedFolder>
type RunEventType = z.infer<typeof RunEvent>

const LINKED_FOLDER_PREFIX = workspace.LINKED_FOLDER_PREFIX

interface TreeNode extends DirEntry {
children?: TreeNode[]
loaded?: boolean
Expand Down Expand Up @@ -630,6 +633,32 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] {
})
}

/**
* Show linked folders (workspaces that live outside WorkDir) alongside the real
* subfolders of knowledge/Workspace. Their children are loaded on demand by the
* workspace view — a linked folder can be an entire repo, so the recursive walk
* that builds this tree deliberately stops at the root.
*/
function graftLinkedFolders(nodes: TreeNode[], folders: LinkedFolder[]): TreeNode[] {
if (folders.length === 0) return nodes
const linkedNodes: TreeNode[] = folders.map((folder) => ({
name: folder.name,
path: `${LINKED_FOLDER_PREFIX}/${folder.id}`,
kind: 'dir' as const,
children: [],
loaded: false,
}))
const workspaceNode = nodes.find((n) => n.path === WORKSPACE_ROOT)
if (!workspaceNode) {
// knowledge/Workspace doesn't exist on disk yet — the linked folders are
// still workspaces, so surface the parent for them to hang off.
return [...nodes, { name: 'Workspace', path: WORKSPACE_ROOT, kind: 'dir', children: linkedNodes, loaded: true }]
}
return nodes.map((n) =>
n === workspaceNode ? { ...n, children: [...(n.children ?? []), ...linkedNodes] } : n,
)
}

/** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */
function extractDateFromFilename(name: string): string | null {
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
Expand Down Expand Up @@ -1857,6 +1886,9 @@ function App() {
path: `config/workdir-${runId}.json`,
data: JSON.stringify(value ? { path: value } : {}, null, 2),
})
// Nothing watches config/, so tell an open workspace view itself — this
// chat just joined (or left) one of its folders.
window.dispatchEvent(new Event(WORKSPACE_CHATS_CHANGED))
} catch (err) {
console.error('Failed to persist work directory for run', runId, err)
}
Expand Down Expand Up @@ -2282,10 +2314,10 @@ function App() {
}
}, [runId, processingRunIds])

// Load directory tree (knowledge + bases)
// Load directory tree (knowledge + bases + linked folders)
const loadDirectory = useCallback(async () => {
try {
const [knowledgeResult, basesResult] = await Promise.all([
const [knowledgeResult, basesResult, foldersResult] = await Promise.all([
window.ipc.invoke('workspace:readdir', {
path: 'knowledge',
opts: { recursive: true, includeHidden: false, includeStats: true }
Expand All @@ -2294,8 +2326,12 @@ function App() {
path: 'bases',
opts: { recursive: false, includeHidden: false, includeStats: true }
}).catch(() => [] as DirEntry[]),
window.ipc.invoke('workspace:listFolders', null).catch(() => ({ folders: [] })),
])
const knowledgeTree = flattenMeetingsTree(buildTree(knowledgeResult))
const knowledgeTree = graftLinkedFolders(
flattenMeetingsTree(buildTree(knowledgeResult)),
foldersResult.folders,
)
const basesChildren: TreeNode[] = (basesResult as DirEntry[])
.filter((e) => e.name.endsWith('.base'))
.map((e) => ({ ...e, kind: 'file' as const }))
Expand Down Expand Up @@ -5952,14 +5988,27 @@ function App() {
}
},
copyPath: (path: string) => {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = fullPath
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
// Linked folders live outside WorkDir, so ask the main process where the
// path actually points rather than assuming the workspace root.
const resolve = async (): Promise<string> => {
if (path.startsWith(`${LINKED_FOLDER_PREFIX}/`)) {
try {
return (await window.ipc.invoke('workspace:toAbsolute', { path })).path
} catch {
return path
}
}
return workspaceRoot ? `${workspaceRoot}/${path}` : path
}
void resolve().then((fullPath) => {
navigator.clipboard.writeText(fullPath).catch(() => {
const textarea = document.createElement('textarea')
textarea.value = fullPath
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
})
})
},
revealInFileManager: (path: string, isDir: boolean) => {
Expand Down Expand Up @@ -6983,6 +7032,7 @@ function App() {
onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }}
onOpenNote={(path) => navigateToFile(path)}
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
onWorkspacesChanged={async () => { setTree(await loadDirectory()) }}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
/>
</div>
Expand Down
Loading
Loading