order recent chats first

This commit is contained in:
Arjun 2026-05-07 11:10:28 +05:30
parent a48887da61
commit 30c10b8db9
4 changed files with 117 additions and 75 deletions

View file

@ -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<RunListItem[]>([])
// 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)
}

View file

@ -124,6 +124,7 @@ type RunListItem = {
id: string
title?: string
createdAt: string
lastMessageAt?: string
agentId: string
}
@ -1595,9 +1596,9 @@ function TasksSection({
>
<div className="flex w-full items-center gap-2 min-w-0">
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
{run.createdAt ? (
{(run.lastMessageAt ?? run.createdAt) ? (
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatRunTime(run.createdAt)}
{formatRunTime(run.lastMessageAt ?? run.createdAt)}
</span>
) : null}
</div>

View file

@ -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<typeof LegacyStartEvent>;
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<typeof LegacyStartEvent> | 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<typeof RunEvent>[]): Promise<void> {
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<typeof ListRunsResponse>['runs'] = [];
for (const name of selected) {
const runId = name.slice(0, -'.jsonl'.length);
const loadedEntries = await Promise.all(files.map(async (name): Promise<RunListEntry | null> => {
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);
}
}
}

View file

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