Last modified (#624)

* order chats by recency
This commit is contained in:
arkml 2026-06-17 10:21:24 +05:30 committed by GitHub
parent 33c15cfbd9
commit ed8f6f7246
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 55 additions and 88 deletions

View file

@ -1042,7 +1042,7 @@ function App() {
}, [])
// Runs history state
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string; useCase?: string }
type RunListItem = { id: string; title?: string; createdAt: string; modifiedAt: string; agentId: string; useCase?: string }
const [runs, setRuns] = useState<RunListItem[]>([])
// Chat tab state
@ -2716,10 +2716,12 @@ function App() {
const inferredTitle = inferRunTitleFromMessage(titleSource)
setRuns((prev) => {
const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
const createdAt = newRunCreatedAt ?? new Date().toISOString()
return [{
id: currentRunId!,
title: inferredTitle,
createdAt: newRunCreatedAt ?? new Date().toISOString(),
createdAt,
modifiedAt: createdAt,
agentId,
}, ...withoutCurrent]
})
@ -5496,6 +5498,7 @@ function App() {
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
recentRuns={runs}
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
onOpenEmail={(threadId) => openEmailView(threadId)}
onOpenHome={() => void navigateToView({ type: 'home' })}
onNewChat={handleNewChatTab}

View file

@ -23,6 +23,7 @@ type Run = {
id: string
title?: string
createdAt: string
modifiedAt: string
agentId: string
}
@ -51,8 +52,8 @@ export function ChatHistoryView({
const sortedRuns = useMemo(() => {
return [...runs].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
const at = new Date(a.modifiedAt).getTime()
const bt = new Date(b.modifiedAt).getTime()
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at)
})
}, [runs])
@ -92,7 +93,7 @@ export function ChatHistoryView({
<div className="min-w-[480px]">
<div className="sticky top-0 z-10 flex items-center border-b border-border bg-background px-6 py-2 text-xs font-medium text-muted-foreground">
<div className="flex-1">Title</div>
<div className="w-32 shrink-0">Created</div>
<div className="w-32 shrink-0 text-right">Last modified</div>
</div>
{sortedRuns.length === 0 ? (
@ -122,8 +123,8 @@ export function ChatHistoryView({
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{run.title || '(Untitled chat)'}</span>
</div>
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.createdAt)}
<div className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(run.modifiedAt)}
</div>
</button>
</ContextMenuTrigger>

View file

@ -3,6 +3,7 @@
import * as React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ArrowUpRight,
Bot,
ChevronRight,
Code2,
@ -90,13 +91,6 @@ type KnowledgeActions = {
onOpenInNewTab?: (path: string) => void
}
function displayNoteName(node: TreeNode): string {
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
return node.name.slice(0, -3)
}
return node.name
}
function formatBillingPlanName(plan: string | null | undefined) {
if (!plan) return 'No plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
@ -172,8 +166,9 @@ type SidebarContentPanelProps = {
onOpenCode?: () => void
onOpenBgTasks?: () => void
onOpenAgent?: (slug: string) => void
recentRuns?: { id: string; title?: string; createdAt: string }[]
recentRuns?: { id: string; title?: string; createdAt: string; modifiedAt?: string }[]
onOpenRun?: (runId: string) => void
onOpenChatHistory?: () => void
onOpenEmail?: (threadId?: string) => void
onOpenHome?: () => void
onNewChat?: () => void
@ -414,15 +409,14 @@ function SyncStatusBar() {
export function SidebarContentPanel({
tree,
onSelectFile,
knowledgeActions,
bgTaskSummaries = [],
onOpenMeetings,
onOpenCode,
onOpenBgTasks,
onOpenAgent,
recentRuns = [],
onOpenRun,
onOpenChatHistory,
onOpenEmail,
onOpenHome,
onNewChat,
@ -448,7 +442,7 @@ export function SidebarContentPanel({
const [unreadEmailCount, setUnreadEmailCount] = useState(0)
const [emailThreads, setEmailThreads] = useState<SidebarEmailThread[]>([])
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
const [quickAccessExpanded, setQuickAccessExpanded] = useState(true)
const [chatsExpanded, setChatsExpanded] = useState(true)
// The Code section only makes sense with a coding agent available — same
// flag the chat composer's code chip uses (auto-on when Claude Code or
// Codex is installed + signed in; explicit toggle in settings wins).
@ -542,59 +536,16 @@ export function SidebarContentPanel({
.slice(0, 10)
}, [tree])
// Recents: most recently touched notes / agents / chats, interleaved by
// recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall.
type QuickAccessItem = {
key: string
label: string
recency: number
type: 'note' | 'agent' | 'chat'
onClick: () => void
}
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
const items: QuickAccessItem[] = []
for (const note of recentNotes.slice(0, 4)) {
items.push({
key: `note:${note.path}`,
label: displayNoteName(note),
recency: note.stat?.mtimeMs ?? 0,
type: 'note',
onClick: () => onSelectFile(note.path, 'file'),
})
}
const agentRecency = (t: TaskSummary) => {
const ts = t.lastRunAt ?? t.lastAttemptAt ?? t.createdAt
const ms = ts ? new Date(ts).getTime() : 0
// Chats: the 5 most recently modified chats, newest first.
const recentChats = React.useMemo(() => {
const chatRecency = (r: { createdAt: string; modifiedAt?: string }) => {
const ms = new Date(r.modifiedAt ?? r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) {
items.push({
key: `agent:${t.slug}`,
label: t.name,
recency: agentRecency(t),
type: 'agent',
onClick: () => onOpenAgent?.(t.slug),
})
}
const chatRecency = (r: { createdAt: string }) => {
const ms = new Date(r.createdAt).getTime()
return Number.isFinite(ms) ? ms : 0
}
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) {
items.push({
key: `chat:${r.id}`,
label: r.title || '(Untitled chat)',
recency: chatRecency(r),
type: 'chat',
onClick: () => onOpenRun?.(r.id),
})
}
return items.sort((a, b) => b.recency - a.recency).slice(0, 12)
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
return [...recentRuns]
.sort((a, b) => chatRecency(b) - chatRecency(a))
.slice(0, 10)
}, [recentRuns])
// Workspace count for the Workspaces sublabel — top-level dir children of
// knowledge/Workspace (matches WorkspaceView's root listing).
@ -921,38 +872,43 @@ export function SidebarContentPanel({
<div className="mx-3 border-t border-sidebar-border" />
{/* Recents */}
{/* Chats */}
<SidebarGroup className="flex flex-col">
<SidebarGroupContent>
<button
type="button"
onClick={() => setQuickAccessExpanded((v) => !v)}
onClick={() => setChatsExpanded((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
>
<ChevronRight className={cn('size-3 transition-transform', quickAccessExpanded && 'rotate-90')} />
<span className="flex-1 text-left">Recents</span>
<ChevronRight className={cn('size-3 transition-transform', chatsExpanded && 'rotate-90')} />
<span className="flex-1 text-left">Chats</span>
</button>
{quickAccessExpanded && (
quickAccessItems.length === 0 ? (
{chatsExpanded && (
recentChats.length === 0 ? (
<div className="px-4 pb-2 text-[11.5px] italic text-muted-foreground">
Recent notes and agents show up here.
Your recent chats show up here.
</div>
) : (
<SidebarMenu>
{quickAccessItems.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton onClick={item.onClick}>
{item.type === 'agent' ? (
<Bot className="size-4 shrink-0 text-muted-foreground" />
) : item.type === 'chat' ? (
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate">{item.label}</span>
{recentChats.map((chat) => (
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton onClick={() => onOpenRun?.(chat.id)}>
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{chat.title || '(Untitled chat)'}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{onOpenChatHistory && (
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => onOpenChatHistory()}
className="text-muted-foreground"
>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">View all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
)
)}

View file

@ -298,14 +298,17 @@ export class FSRunsRepo implements IRunsRepo {
for (const name of selected) {
const runId = name.slice(0, -'.jsonl'.length);
const metadata = await this.readRunMetadata(path.join(runsDir, name));
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 } : {}),
});

View file

@ -212,6 +212,10 @@ export const ListRunsResponse = z.object({
createdAt: true,
agentId: true,
useCase: true,
}).extend({
// Last-modified time of the run's log file (mtime), used to order the
// chat history by recent activity rather than creation time.
modifiedAt: z.iso.datetime(),
})),
nextCursor: z.string().optional(),
});