diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 8ad3382b..39cc9c82 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -387,7 +387,7 @@ function App() { const [agentId] = useState('copilot') // Runs history state - type RunListItem = { id: string; createdAt: string; agentId: string } + type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } const [runs, setRuns] = useState([]) // Pending requests state diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index c55050ee..8820dfed 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -72,6 +72,7 @@ type KnowledgeActions = { type RunListItem = { id: string + title?: string createdAt: string agentId: string } @@ -462,7 +463,7 @@ function TasksSection({ className="gap-2" > - {run.id} + {run.title || '(Untitled chat)'} ))} diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 0bfcac45..20dd6389 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -3,7 +3,9 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j import { WorkDir } from "../config/config.js"; import path from "path"; import fsp from "fs/promises"; -import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse } from "@x/shared/dist/runs.js"; +import fs from "fs"; +import readline from "readline"; +import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js"; export interface IRunsRepo { create(options: z.infer): Promise>; @@ -24,6 +26,96 @@ export class FSRunsRepo implements IRunsRepo { fsp.mkdir(path.join(WorkDir, 'runs'), { recursive: true }); } + private extractTitle(events: z.infer[]): string | undefined { + for (const event of events) { + if (event.type === 'message') { + const messageEvent = event as z.infer; + if (messageEvent.message.role === 'user') { + const content = messageEvent.message.content; + if (typeof content === 'string' && content.trim()) { + // Truncate to 100 chars for display + const truncated = content.trim(); + return truncated.length > 100 ? truncated.substring(0, 100) : truncated; + } + } + } + } + return undefined; + } + + /** + * Read file line-by-line using streams, stopping early once we have + * the start event and title (or determine there's no title). + */ + private async readRunMetadata(filePath: string): Promise<{ + start: z.infer; + title: string | undefined; + } | null> { + return new Promise((resolve) => { + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let start: z.infer | null = null; + let title: string | undefined; + let lineIndex = 0; + + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + + try { + if (lineIndex === 0) { + // First line should be the start event + start = StartEvent.parse(JSON.parse(trimmed)); + } else { + // Subsequent lines - look for first user message or assistant response + const event = RunEvent.parse(JSON.parse(trimmed)); + if (event.type === 'message') { + const msg = event.message; + if (msg.role === 'user') { + // Found first user message - use as title + const content = msg.content; + if (typeof content === 'string' && content.trim()) { + const truncated = content.trim(); + title = truncated.length > 100 ? truncated.substring(0, 100) : truncated; + } + // Stop reading + rl.close(); + stream.destroy(); + return; + } else if (msg.role === 'assistant') { + // Assistant responded before any user message - no title + rl.close(); + stream.destroy(); + return; + } + } + } + lineIndex++; + } catch { + // Skip malformed lines + } + }); + + rl.on('close', () => { + if (start) { + resolve({ start, title }); + } else { + resolve(null); + } + }); + + rl.on('error', () => { + resolve(null); + }); + + stream.on('error', () => { + rl.close(); + resolve(null); + }); + }); + } + async appendEvents(runId: string, events: z.infer[]): Promise { await fsp.appendFile( path.join(WorkDir, 'runs', `${runId}.jsonl`), @@ -58,8 +150,10 @@ export class FSRunsRepo implements IRunsRepo { if (events.length === 0 || events[0].type !== 'start') { throw new Error('Corrupt run data'); } + const title = this.extractTitle(events); return { id, + title, createdAt: events[0].ts!, agentId: events[0].agentName, log: events, @@ -103,21 +197,16 @@ export class FSRunsRepo implements IRunsRepo { for (const name of selected) { const runId = name.slice(0, -'.jsonl'.length); - try { - const contents = await fsp.readFile(path.join(runsDir, name), 'utf8'); - const firstLine = contents.split('\n').find(line => line.trim() !== ''); - if (!firstLine) { - continue; - } - const start = StartEvent.parse(JSON.parse(firstLine)); - runs.push({ - id: runId, - createdAt: start.ts!, - agentId: start.agentName, - }); - } catch { + const metadata = await this.readRunMetadata(path.join(runsDir, name)); + if (!metadata) { continue; } + runs.push({ + id: runId, + title: metadata.title, + createdAt: metadata.start.ts!, + agentId: metadata.start.agentName, + }); } const hasMore = startIndex + PAGE_SIZE < files.length; diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 802ad651..429d827b 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -110,6 +110,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({ export const Run = z.object({ id: z.string(), + title: z.string().optional(), createdAt: z.iso.datetime(), agentId: z.string(), log: z.array(RunEvent), @@ -118,6 +119,7 @@ export const Run = z.object({ export const ListRunsResponse = z.object({ runs: z.array(Run.pick({ id: true, + title: true, createdAt: true, agentId: true, })),