diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3ed9f6a0..a1f6c4a6 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -813,6 +813,9 @@ export function setupIpcHandlers() { 'runs:list': async (_event, args) => { return runsCore.listRuns(args.cursor); }, + 'runs:listByWorkDir': async (_event, args) => { + return runsCore.listRunsByWorkDir(args.dir); + }, 'runs:delete': async (_event, args) => { await runsCore.deleteRun(args.runId); return { success: true }; diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 87752392..bfe71c85 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5925,6 +5925,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) }} + onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} /> ) : isKnowledgeViewOpen ? ( diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 12362d59..d5738e9b 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, Copy, @@ -9,6 +9,8 @@ import { FolderOpen, FolderPlus, Home, + Loader2, + MessageSquare, Pencil, Plus, Trash2, @@ -85,6 +87,24 @@ type WorkspaceViewProps = { onNavigate: (path: string) => void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise + // Opens a previous chat (run) whose work directory is set to this workspace. + onOpenRun: (runId: string) => void +} + +type WorkspaceChat = { + id: string + title?: string + createdAt: string + modifiedAt: string +} + +function formatChatAt(iso: string): string { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return '' + const now = new Date() + const sameDay = d.toDateString() === now.toDateString() + if (sameDay) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) } function getFileManagerName(): string { @@ -143,9 +163,12 @@ function readFileAsBase64(file: File): Promise { }) } -export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) { +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) { const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) + const [chatsOpen, setChatsOpen] = useState(false) + const [chats, setChats] = useState([]) + const [chatsLoading, setChatsLoading] = useState(false) const [newName, setNewName] = useState('') const [creating, setCreating] = useState(false) const [error, setError] = useState(null) @@ -182,6 +205,32 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo }) }, [currentPath, isRoot]) + // Load the chats whose work directory is this workspace folder (or nested + // inside it). The work directory is stored as an absolute path per run, so + // resolve this folder against the workspace root before querying. + const loadChats = useCallback(async () => { + if (isRoot) { + setChats([]) + return + } + setChatsLoading(true) + try { + const { root } = await window.ipc.invoke('workspace:getRoot', null) + const abs = `${root.replace(/\/$/, '')}/${currentPath}` + const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs }) + setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt }))) + } catch (err) { + console.error('Failed to load workspace chats:', err) + setChats([]) + } finally { + setChatsLoading(false) + } + }, [currentPath, isRoot]) + + useEffect(() => { + void loadChats() + }, [loadChats]) + const handleItemClick = useCallback( (item: TreeNode) => { if (renameTarget) return @@ -352,25 +401,34 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo ) })} -
+
+ {!isRoot && ( + + )} {isRoot ? ( - ) : ( - @@ -422,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo }} /> +
+ {!isRoot && chatsOpen && ( + + )} +
+ { diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index f08cac7c..c312b116 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -44,10 +44,46 @@ function runLogPath(runId: string): string { return path.join(WorkDir, 'runs', `${runId}.jsonl`); } +// Per-run work directory sidecar, written by the renderer when a chat's work +// directory is set (see `persistRunWorkDir` in the app). A run "belongs to" a +// workspace folder when its work directory is that folder or nested inside it. +function runWorkDirConfigPath(runId: string): string { + return path.join(WorkDir, 'config', `workdir-${runId}.json`); +} + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +async function readRunWorkDir(runId: string): Promise { + try { + const raw = await fsp.readFile(runWorkDirConfigPath(runId), 'utf8'); + const parsed = JSON.parse(raw) as { path?: unknown }; + return typeof parsed?.path === 'string' && parsed.path ? parsed.path : null; + } catch { + return null; + } +} + +// Code-section sessions are runs (id == runId) but live in the Code view, not +// the chat list. Their metadata sidecar identifies them so they can be kept out +// of the workspace chats panel (opening one as a plain chat wouldn't resume code +// mode). See FSCodeSessionsRepo — one JSON file per session id. +async function isCodeSession(runId: string): Promise { + try { + await fsp.access(path.join(WorkDir, 'code-mode', 'sessions-meta', `${runId}.json`)); + return true; + } catch { + return false; + } +} + export interface IRunsRepo { create(options: CreateRunRepoOptions): Promise>; fetch(id: string): Promise>; list(cursor?: string): Promise>; + listByWorkDir(dir: string): Promise>; appendEvents(runId: string, events: z.infer[]): Promise; delete(id: string): Promise; } @@ -325,6 +361,63 @@ export class FSRunsRepo implements IRunsRepo { }; } + /** + * List runs whose work directory is `dir` or nested inside it. Unlike + * `list`, this scans every run (no pagination) and reads each run's + * work-directory sidecar to decide membership, so the caller gets the + * complete set of chats scoped to a workspace folder. Newest first. + */ + async listByWorkDir(dir: string): Promise> { + const target = path.resolve(dir); + const runsDir = path.join(WorkDir, 'runs'); + + let files: string[] = []; + try { + const entries = await fsp.readdir(runsDir, { withFileTypes: true }); + files = entries + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => e.name); + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') { + return { runs: [] }; + } + throw err; + } + + files.sort((a, b) => b.localeCompare(a)); + + const runs: z.infer['runs'] = []; + for (const name of files) { + const runId = name.slice(0, -'.jsonl'.length); + const workDir = await readRunWorkDir(runId); + if (!workDir || !isPathInside(target, path.resolve(workDir))) { + continue; + } + // Code-section sessions share the run/workdir machinery but belong + // in the Code view, not the workspace chats list. + if (await isCodeSession(runId)) { + continue; + } + const filePath = path.join(runsDir, name); + const metadata = await this.readRunMetadata(filePath); + if (!metadata) { + continue; + } + const stat = await fsp.stat(filePath); + runs.push({ + id: runId, + title: metadata.title, + createdAt: metadata.start.ts!, + modifiedAt: stat.mtime.toISOString(), + agentId: metadata.start.agentName, + ...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}), + }); + } + + return { runs }; + } + async delete(id: string): Promise { await fsp.unlink(runLogPath(id)); } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 80d7f7c9..e61b2309 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -152,3 +152,8 @@ export async function listRuns(cursor?: string): Promise('runsRepo'); return repo.list(cursor); } + +export async function listRunsByWorkDir(dir: string): Promise> { + const repo = container.resolve('runsRepo'); + return repo.listByWorkDir(dir); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d1543006..25ad6a74 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -350,6 +350,12 @@ const ipcSchemas = { }), res: ListRunsResponse, }, + 'runs:listByWorkDir': { + req: z.object({ + dir: z.string(), + }), + res: ListRunsResponse, + }, 'runs:delete': { req: z.object({ runId: z.string(),