diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index c2e35cb2..56559042 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { useCallback, useEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; -import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; +import { RunEvent, ListRunsResponse, monotonicIdToIsoTimestamp } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; @@ -138,6 +138,18 @@ const MACOS_TRAFFIC_LIGHTS_RESERVED_PX = 16 + 12 * 3 + 8 * 2 const TITLEBAR_BUTTON_PX = 32 const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_HEADER_GAP_PX = 8 + +type RunListItem = { + id: string + title?: string + createdAt: string + lastMessageAt?: string + agentId: string +} + +function upsertRunWithLatestActivity(runs: RunListItem[], nextRun: RunListItem): RunListItem[] { + return [nextRun, ...runs.filter((run) => run.id !== nextRun.id)] +} const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 @@ -909,7 +921,6 @@ function App() { }, []) // Runs history state - type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } const [runs, setRuns] = useState([]) // Chat tab state @@ -1983,16 +1994,21 @@ function App() { case 'message': { const msg = event.message - if (msg.role === 'user' && typeof msg.content === 'string') { - const inferredTitle = inferRunTitleFromMessage(msg.content) - if (inferredTitle) { - setRuns(prev => prev.map(run => ( - run.id === event.runId && !run.title - ? { ...run, title: inferredTitle } - : run - ))) - } - } + const lastMessageAt = event.ts ?? monotonicIdToIsoTimestamp(event.messageId) ?? new Date().toISOString() + setRuns((prev) => { + const existingRun = prev.find((run) => run.id === event.runId) + if (!existingRun) return prev + + const inferredTitle = msg.role === 'user' && typeof msg.content === 'string' + ? inferRunTitleFromMessage(msg.content) + : undefined + + return upsertRunWithLatestActivity(prev, { + ...existingRun, + title: existingRun.title ?? inferredTitle, + lastMessageAt, + }) + }) if (!isActiveRun) { if (msg.role === 'assistant') { clearStreamingBuffer(event.runId) @@ -2272,8 +2288,8 @@ function App() { try { let currentRunId = runId - let isNewRun = false let newRunCreatedAt: string | null = null + let persistedMessageId: string | null = null if (!currentRunId) { const selected = selectedModelByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { @@ -2290,7 +2306,6 @@ function App() { ? { ...tab, runId: currentRunId } : tab ))) - isNewRun = true } let titleSource = userMessage @@ -2341,7 +2356,7 @@ function App() { // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. const attachmentPayload = contentParts as unknown as string const middlePaneContext = await buildMiddlePaneContext() - await window.ipc.invoke('runs:createMessage', { + const result = await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: attachmentPayload, voiceInput: pendingVoiceInputRef.current || undefined, @@ -2349,6 +2364,7 @@ function App() { searchEnabled: searchEnabled || undefined, middlePaneContext, }) + persistedMessageId = result.messageId analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, @@ -2356,7 +2372,7 @@ function App() { }) } else { const middlePaneContext = await buildMiddlePaneContext() - await window.ipc.invoke('runs:createMessage', { + const result = await window.ipc.invoke('runs:createMessage', { runId: currentRunId, message: userMessage, voiceInput: pendingVoiceInputRef.current || undefined, @@ -2364,6 +2380,7 @@ function App() { searchEnabled: searchEnabled || undefined, middlePaneContext, }) + persistedMessageId = result.messageId analytics.chatMessageSent({ voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, @@ -2373,18 +2390,18 @@ function App() { pendingVoiceInputRef.current = false - if (isNewRun) { - const inferredTitle = inferRunTitleFromMessage(titleSource) - setRuns((prev) => { - const withoutCurrent = prev.filter((run) => run.id !== currentRunId) - return [{ - id: currentRunId!, - title: inferredTitle, - createdAt: newRunCreatedAt ?? new Date().toISOString(), - agentId, - }, ...withoutCurrent] + const inferredTitle = inferRunTitleFromMessage(titleSource) + const lastMessageAt = monotonicIdToIsoTimestamp(persistedMessageId ?? '') ?? new Date().toISOString() + setRuns((prev) => { + const existingRun = prev.find((run) => run.id === currentRunId) + return upsertRunWithLatestActivity(prev, { + id: currentRunId!, + title: existingRun?.title ?? inferredTitle, + createdAt: existingRun?.createdAt ?? newRunCreatedAt ?? new Date().toISOString(), + lastMessageAt, + agentId: existingRun?.agentId ?? agentId, }) - } + }) } catch (error) { console.error('Failed to send message:', error) } diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index dc49307c..bad24dae 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -124,6 +124,7 @@ type RunListItem = { id: string title?: string createdAt: string + lastMessageAt?: string agentId: string } @@ -1595,9 +1596,9 @@ function TasksSection({ >
{run.title || '(Untitled chat)'} - {run.createdAt ? ( + {(run.lastMessageAt ?? run.createdAt) ? ( - {formatRunTime(run.createdAt)} + {formatRunTime(run.lastMessageAt ?? run.createdAt)} ) : null}
diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index bbc148fd..95d47636 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -5,7 +5,7 @@ import path from "path"; import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; -import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js"; +import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase, monotonicIdToIsoTimestamp } from "@x/shared/dist/runs.js"; import { getDefaultModelAndProvider } from "../models/defaults.js"; /** @@ -92,8 +92,8 @@ export class FSRunsRepo implements IRunsRepo { } /** - * Read file line-by-line using streams, stopping early once we have - * the start event and title (or determine there's no title). + * Read file line-by-line using streams to capture the start event, first + * user-message title, and the latest message timestamp for list sorting. * * Parses the start event with `LegacyStartEvent` so runs written before * `model`/`provider` were required still surface in the list view. @@ -101,6 +101,8 @@ export class FSRunsRepo implements IRunsRepo { private async readRunMetadata(filePath: string): Promise<{ start: z.infer; title: string | undefined; + lastMessageAt: string | undefined; + lastMessageSortKey: string; } | null> { return new Promise((resolve) => { const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); @@ -108,6 +110,8 @@ export class FSRunsRepo implements IRunsRepo { let start: z.infer | null = null; let title: string | undefined; + let lastMessageAt: string | undefined; + let lastMessageSortKey = path.basename(filePath, '.jsonl'); let lineIndex = 0; rl.on('line', (line) => { @@ -117,13 +121,14 @@ export class FSRunsRepo implements IRunsRepo { try { if (lineIndex === 0) { start = LegacyStartEvent.parse(JSON.parse(trimmed)); + lastMessageSortKey = start.runId; } else { - // Subsequent lines - look for first user message or assistant response + // Subsequent lines - keep the first user message as the title and + // track the latest message for chat-history ordering. const event = ReadRunEvent.parse(JSON.parse(trimmed)); if (event.type === 'message') { const msg = event.message; - if (msg.role === 'user') { - // Found first user message - use as title + if (msg.role === 'user' && title === undefined) { const content = msg.content; let textContent: string | undefined; if (typeof content === 'string') { @@ -140,16 +145,9 @@ export class FSRunsRepo implements IRunsRepo { title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } } - // 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; } + lastMessageAt = event.ts ?? monotonicIdToIsoTimestamp(event.messageId); + lastMessageSortKey = event.messageId; } } lineIndex++; @@ -160,7 +158,7 @@ export class FSRunsRepo implements IRunsRepo { rl.on('close', () => { if (start) { - resolve({ start, title }); + resolve({ start, title, lastMessageAt, lastMessageSortKey }); } else { resolve(null); } @@ -178,9 +176,10 @@ export class FSRunsRepo implements IRunsRepo { } async appendEvents(runId: string, events: z.infer[]): Promise { + const appendedAt = new Date().toISOString(); await fsp.appendFile( path.join(WorkDir, 'runs', `${runId}.jsonl`), - events.map(event => JSON.stringify(event)).join("\n") + "\n" + events.map(event => JSON.stringify(event.ts ? event : { ...event, ts: appendedAt })).join("\n") + "\n" ); } @@ -264,40 +263,55 @@ export class FSRunsRepo implements IRunsRepo { throw err; } - files.sort((a, b) => b.localeCompare(a)); + type RunListEntry = { + fileName: string; + sortKey: string; + run: { + id: string; + title?: string; + createdAt: string; + lastMessageAt?: string; + agentId: string; + }; + }; - const cursorFile = cursor; - let startIndex = 0; - if (cursorFile) { - const exact = files.indexOf(cursorFile); - if (exact >= 0) { - startIndex = exact + 1; - } else { - const firstOlder = files.findIndex(name => name.localeCompare(cursorFile) < 0); - startIndex = firstOlder === -1 ? files.length : firstOlder; - } - } - - const selected = files.slice(startIndex, startIndex + PAGE_SIZE); - const runs: z.infer['runs'] = []; - - for (const name of selected) { - const runId = name.slice(0, -'.jsonl'.length); + const loadedEntries = await Promise.all(files.map(async (name): Promise => { const metadata = await this.readRunMetadata(path.join(runsDir, name)); if (!metadata) { - continue; + return null; } - runs.push({ - id: runId, - title: metadata.title, - createdAt: metadata.start.ts!, - agentId: metadata.start.agentName, - }); - } + return { + fileName: name, + sortKey: metadata.lastMessageSortKey, + run: { + id: name.slice(0, -'.jsonl'.length), + title: metadata.title, + createdAt: metadata.start.ts!, + lastMessageAt: metadata.lastMessageAt, + agentId: metadata.start.agentName, + }, + }; + })); - const hasMore = startIndex + PAGE_SIZE < files.length; + const runEntries: RunListEntry[] = loadedEntries.filter((entry): entry is RunListEntry => entry !== null); + + runEntries.sort((a, b) => { + const byActivity = b.sortKey.localeCompare(a.sortKey); + if (byActivity !== 0) return byActivity; + return b.fileName.localeCompare(a.fileName); + }); + + const cursorFile = cursor; + const startIndex = cursorFile + ? Math.max(0, runEntries.findIndex(entry => entry.fileName === cursorFile) + 1) + : 0; + + const selected = runEntries.slice(startIndex, startIndex + PAGE_SIZE); + const runs = selected.map(({ run }) => run); + + const hasMore = startIndex + PAGE_SIZE < runEntries.length; const nextCursor = hasMore && selected.length > 0 - ? selected[selected.length - 1] + ? selected[selected.length - 1].fileName : undefined; return { @@ -310,4 +324,4 @@ export class FSRunsRepo implements IRunsRepo { const filePath = path.join(WorkDir, 'runs', `${id}.jsonl`); await fsp.unlink(filePath); } -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index ea93c8a3..e2aa48fb 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -49,6 +49,14 @@ export const MessageEvent = BaseRunEvent.extend({ message: Message, }); +const MONOTONIC_ID_TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})Z(?:-|$)/; + +export function monotonicIdToIsoTimestamp(id: string): string | undefined { + const match = MONOTONIC_ID_TIMESTAMP_RE.exec(id); + if (!match) return undefined; + return `${match[1]}:${match[2]}:${match[3]}Z`; +} + export const ToolInvocationEvent = BaseRunEvent.extend({ type: z.literal("tool-invocation"), toolCallId: z.string().optional(), @@ -138,6 +146,7 @@ export const Run = z.object({ id: z.string(), title: z.string().optional(), createdAt: z.iso.datetime(), + lastMessageAt: z.iso.datetime().optional(), agentId: z.string(), model: z.string(), provider: z.string(), @@ -151,6 +160,7 @@ export const ListRunsResponse = z.object({ id: true, title: true, createdAt: true, + lastMessageAt: true, agentId: true, })), nextCursor: z.string().optional(),