mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
order recent chats first
This commit is contained in:
parent
a48887da61
commit
30c10b8db9
4 changed files with 117 additions and 75 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue