mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-28 01:46:23 +02:00
add chat history
This commit is contained in:
parent
ff24494b77
commit
aa94b48d65
2 changed files with 238 additions and 17 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { workspace } from '@x/shared';
|
||||
import { RunEvent } from '@x/shared/src/runs.js';
|
||||
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
|
|
@ -48,6 +48,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
|||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
type ListRunsResponseType = z.infer<typeof ListRunsResponse>
|
||||
|
||||
interface TreeNode extends DirEntry {
|
||||
children?: TreeNode[]
|
||||
|
|
@ -116,6 +117,41 @@ const graphPalette = [
|
|||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
||||
// Parse attached files from message content and return clean message + file paths
|
||||
const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||
const match = content.match(attachedFilesRegex)
|
||||
|
||||
if (!match) {
|
||||
return { message: content, files: [] }
|
||||
}
|
||||
|
||||
// Extract file paths from the XML
|
||||
const filesXml = match[1]
|
||||
const filePathRegex = /<file path="([^"]+)">/g
|
||||
const files: string[] = []
|
||||
let fileMatch
|
||||
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
|
||||
files.push(fileMatch[1])
|
||||
}
|
||||
|
||||
// Remove the attached-files block
|
||||
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
|
||||
|
||||
// Also remove @mentions for the attached files (they're shown as pills)
|
||||
for (const filePath of files) {
|
||||
// Get the display name (last part of path without extension)
|
||||
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
|
||||
if (fileName) {
|
||||
// Remove @filename pattern (with optional trailing space)
|
||||
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
||||
cleanMessage = cleanMessage.replace(mentionRegex, '')
|
||||
}
|
||||
}
|
||||
|
||||
return { message: cleanMessage.trim(), files }
|
||||
}
|
||||
|
||||
const untitledBaseName = 'untitled'
|
||||
|
||||
const getHeadingTitle = (markdown: string) => {
|
||||
|
|
@ -348,6 +384,10 @@ function App() {
|
|||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [agentId] = useState<string>('copilot')
|
||||
|
||||
// Runs history state
|
||||
type RunListItem = { id: string; createdAt: string; agentId: string }
|
||||
const [runs, setRuns] = useState<RunListItem[]>([])
|
||||
|
||||
// Load directory tree
|
||||
const loadDirectory = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -481,6 +521,106 @@ function App() {
|
|||
saveFile()
|
||||
}, [debouncedContent, selectedPath])
|
||||
|
||||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
const allRuns: RunListItem[] = []
|
||||
let cursor: string | undefined = undefined
|
||||
|
||||
// Fetch all pages
|
||||
do {
|
||||
const result: ListRunsResponseType = await window.ipc.invoke('runs:list', { cursor })
|
||||
allRuns.push(...result.runs)
|
||||
cursor = result.nextCursor
|
||||
} while (cursor)
|
||||
|
||||
// Filter for copilot runs only
|
||||
const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot')
|
||||
setRuns(copilotRuns)
|
||||
} catch (err) {
|
||||
console.error('Failed to load runs:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load runs on mount
|
||||
useEffect(() => {
|
||||
loadRuns()
|
||||
}, [loadRuns])
|
||||
|
||||
// Load a specific run and populate conversation
|
||||
const loadRun = useCallback(async (id: string) => {
|
||||
try {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
|
||||
// Parse the log events into conversation items
|
||||
const items: ConversationItem[] = []
|
||||
const toolCallMap = new Map<string, ToolCall>()
|
||||
|
||||
for (const event of run.log) {
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
// Extract text content from message
|
||||
let textContent = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
textContent = msg.content
|
||||
.filter((part: { type: string }) => part.type === 'text')
|
||||
.map((part: { type: string; text?: string }) => part.text || '')
|
||||
.join('')
|
||||
}
|
||||
if (textContent) {
|
||||
items.push({
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: textContent,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation': {
|
||||
const toolCall: ToolCall = {
|
||||
id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`,
|
||||
name: event.toolName,
|
||||
input: normalizeToolInput(event.input),
|
||||
status: 'running',
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
items.push(toolCall)
|
||||
break
|
||||
}
|
||||
case 'tool-result': {
|
||||
const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existingTool) {
|
||||
existingTool.result = event.result
|
||||
existingTool.status = 'completed'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'llm-stream-event': {
|
||||
// We don't need to reconstruct streaming events for history
|
||||
// Reasoning is captured in the final message
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the conversation and runId
|
||||
setConversation(items)
|
||||
setRunId(id)
|
||||
setCurrentAssistantMessage('')
|
||||
setCurrentReasoning('')
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
console.error('Failed to load run:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen to run events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
||||
|
|
@ -664,6 +804,8 @@ function App() {
|
|||
})
|
||||
currentRunId = run.id
|
||||
setRunId(currentRunId)
|
||||
// Refresh runs list to include new run
|
||||
loadRuns()
|
||||
}
|
||||
|
||||
// Read mentioned file contents and format message with XML context
|
||||
|
|
@ -1011,14 +1153,32 @@ function App() {
|
|||
|
||||
const renderConversationItem = (item: ConversationItem) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{files.map((filePath, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"
|
||||
>
|
||||
@{wikiLabel(filePath)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
{item.role === 'assistant' ? (
|
||||
<MessageResponse>{item.content}</MessageResponse>
|
||||
) : (
|
||||
item.content
|
||||
)}
|
||||
<MessageResponse>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -1086,6 +1246,12 @@ function App() {
|
|||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
knowledgeActions={knowledgeActions}
|
||||
runs={runs}
|
||||
currentRunId={runId}
|
||||
tasksActions={{
|
||||
onNewChat: handleNewChat,
|
||||
onSelectRun: loadRun,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset className="overflow-hidden! min-h-0">
|
||||
{/* Header with sidebar triggers */}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Pencil,
|
||||
SquarePen,
|
||||
|
|
@ -27,7 +28,6 @@ import {
|
|||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
|
|
@ -70,12 +70,26 @@ type KnowledgeActions = {
|
|||
copyPath: (path: string) => void
|
||||
}
|
||||
|
||||
type RunListItem = {
|
||||
id: string
|
||||
createdAt: string
|
||||
agentId: string
|
||||
}
|
||||
|
||||
type TasksActions = {
|
||||
onNewChat: () => void
|
||||
onSelectRun: (runId: string) => void
|
||||
}
|
||||
|
||||
type SidebarContentPanelProps = {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
knowledgeActions: KnowledgeActions
|
||||
runs?: RunListItem[]
|
||||
currentRunId?: string | null
|
||||
tasksActions?: TasksActions
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTitles = {
|
||||
|
|
@ -89,6 +103,9 @@ export function SidebarContentPanel({
|
|||
expandedPaths,
|
||||
onSelectFile,
|
||||
knowledgeActions,
|
||||
runs = [],
|
||||
currentRunId,
|
||||
tasksActions,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection } = useSidebarSection()
|
||||
|
|
@ -111,7 +128,11 @@ export function SidebarContentPanel({
|
|||
/>
|
||||
)}
|
||||
{activeSection === "tasks" && (
|
||||
<TasksSection />
|
||||
<TasksSection
|
||||
runs={runs}
|
||||
currentRunId={currentRunId}
|
||||
actions={tasksActions}
|
||||
/>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
|
|
@ -404,16 +425,50 @@ function Tree({
|
|||
}
|
||||
|
||||
// Tasks Section
|
||||
function TasksSection() {
|
||||
function TasksSection({
|
||||
runs,
|
||||
currentRunId,
|
||||
actions,
|
||||
}: {
|
||||
runs: RunListItem[]
|
||||
currentRunId?: string | null
|
||||
actions?: TasksActions
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="px-2 py-2">
|
||||
<button className="flex items-center gap-2 text-sidebar-foreground hover:bg-sidebar-accent rounded-lg px-3 py-2 transition-colors w-full">
|
||||
<SquarePen className="size-4" />
|
||||
<span className="text-sm">New chat</span>
|
||||
</button>
|
||||
</div>
|
||||
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Sticky New Chat button - matches Knowledge section height */}
|
||||
<div className="sticky top-0 z-10 bg-sidebar border-b border-sidebar-border py-0.5">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={actions?.onNewChat} className="gap-2">
|
||||
<SquarePen className="size-4 shrink-0" />
|
||||
<span className="text-sm">New chat</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
<SidebarGroupContent className="flex-1 overflow-y-auto">
|
||||
{runs.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Chat history
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
{runs.map((run) => (
|
||||
<SidebarMenuItem key={run.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={currentRunId === run.id}
|
||||
onClick={() => actions?.onSelectRun(run.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<MessageSquare className="size-4 shrink-0" />
|
||||
<span className="truncate text-sm">{run.id}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue