Show previous chats related to the workspace in the workspace section.

This commit is contained in:
Arjun 2026-06-29 12:27:43 +05:30
parent 0887ee7fc0
commit 753e3448f0
6 changed files with 212 additions and 6 deletions

View file

@ -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 };

View file

@ -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 })}
/>
</div>
) : isKnowledgeViewOpen ? (

View file

@ -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<void>
// 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<string> {
})
}
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<WorkspaceChat[]>([])
const [chatsLoading, setChatsLoading] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(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
)
})}
</div>
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
{!isRoot && (
<Button
size="sm"
variant={chatsOpen ? 'secondary' : 'outline'}
onClick={() => setChatsOpen((v) => !v)}
>
<MessageSquare className="size-4" />
Chats{chats.length ? ` (${chats.length})` : ''}
</Button>
)}
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => actions.revealInFileManager(currentPath, true)}
>
<FolderOpen className="size-4" />
Open in {fileManagerName}
</Button>
{isRoot ? (
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="size-4" />
Add workspace
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="w-full">
<Button size="sm">
<Plus className="size-4" />
Add
</Button>
@ -422,6 +480,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
}}
/>
<div className="flex flex-1 overflow-hidden">
<div
className="relative flex-1 overflow-y-auto px-6 py-6"
onDragEnter={handleDragEnter}
@ -549,6 +608,45 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo
)}
</div>
{!isRoot && chatsOpen && (
<aside className="flex w-72 shrink-0 flex-col overflow-hidden border-l border-border bg-background">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
<span className="text-sm font-medium text-foreground">Chats</span>
{chatsLoading && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
</div>
<div className="flex-1 overflow-y-auto">
{!chatsLoading && chats.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-muted-foreground">
No chats use this workspace yet. Set a chat's work directory to this folder and it will appear here.
</div>
) : (
<div className="divide-y divide-border">
{chats.map((chat) => (
<button
key={chat.id}
type="button"
onClick={() => onOpenRun(chat.id)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/40"
>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-[13px] text-foreground">
{chat.title || '(Untitled chat)'}
</span>
<span className="text-[11px] text-muted-foreground">
{formatChatAt(chat.createdAt)}
</span>
</div>
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
</button>
))}
</div>
)}
</div>
</aside>
)}
</div>
<Dialog
open={addOpen}
onOpenChange={(open) => {

View file

@ -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<string | null> {
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<boolean> {
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<z.infer<typeof Run>>;
fetch(id: string): Promise<z.infer<typeof Run>>;
list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>>;
listByWorkDir(dir: string): Promise<z.infer<typeof ListRunsResponse>>;
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
delete(id: string): Promise<void>;
}
@ -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<z.infer<typeof ListRunsResponse>> {
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<typeof ListRunsResponse>['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<void> {
await fsp.unlink(runLogPath(id));
}

View file

@ -152,3 +152,8 @@ export async function listRuns(cursor?: string): Promise<z.infer<typeof ListRuns
const repo = container.resolve<IRunsRepo>('runsRepo');
return repo.list(cursor);
}
export async function listRunsByWorkDir(dir: string): Promise<z.infer<typeof ListRunsResponse>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
return repo.listByWorkDir(dir);
}

View file

@ -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(),