mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-18 20:15:20 +02:00
parent
33c15cfbd9
commit
ed8f6f7246
5 changed files with 55 additions and 88 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue