mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +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 * as React from 'react'
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { workspace } from '@x/shared';
|
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 type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
@ -48,6 +48,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
||||||
|
|
||||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||||
type RunEventType = z.infer<typeof RunEvent>
|
type RunEventType = z.infer<typeof RunEvent>
|
||||||
|
type ListRunsResponseType = z.infer<typeof ListRunsResponse>
|
||||||
|
|
||||||
interface TreeNode extends DirEntry {
|
interface TreeNode extends DirEntry {
|
||||||
children?: TreeNode[]
|
children?: TreeNode[]
|
||||||
|
|
@ -116,6 +117,41 @@ const graphPalette = [
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
Math.min(max, Math.max(min, value))
|
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 untitledBaseName = 'untitled'
|
||||||
|
|
||||||
const getHeadingTitle = (markdown: string) => {
|
const getHeadingTitle = (markdown: string) => {
|
||||||
|
|
@ -348,6 +384,10 @@ function App() {
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [agentId] = useState<string>('copilot')
|
const [agentId] = useState<string>('copilot')
|
||||||
|
|
||||||
|
// Runs history state
|
||||||
|
type RunListItem = { id: string; createdAt: string; agentId: string }
|
||||||
|
const [runs, setRuns] = useState<RunListItem[]>([])
|
||||||
|
|
||||||
// Load directory tree
|
// Load directory tree
|
||||||
const loadDirectory = useCallback(async () => {
|
const loadDirectory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -481,6 +521,106 @@ function App() {
|
||||||
saveFile()
|
saveFile()
|
||||||
}, [debouncedContent, selectedPath])
|
}, [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
|
// Listen to run events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
||||||
|
|
@ -664,6 +804,8 @@ function App() {
|
||||||
})
|
})
|
||||||
currentRunId = run.id
|
currentRunId = run.id
|
||||||
setRunId(currentRunId)
|
setRunId(currentRunId)
|
||||||
|
// Refresh runs list to include new run
|
||||||
|
loadRuns()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read mentioned file contents and format message with XML context
|
// Read mentioned file contents and format message with XML context
|
||||||
|
|
@ -1011,14 +1153,32 @@ function App() {
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem) => {
|
const renderConversationItem = (item: ConversationItem) => {
|
||||||
if (isChatMessage(item)) {
|
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 (
|
return (
|
||||||
<Message key={item.id} from={item.role}>
|
<Message key={item.id} from={item.role}>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
{item.role === 'assistant' ? (
|
<MessageResponse>{item.content}</MessageResponse>
|
||||||
<MessageResponse>{item.content}</MessageResponse>
|
|
||||||
) : (
|
|
||||||
item.content
|
|
||||||
)}
|
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
)
|
)
|
||||||
|
|
@ -1086,6 +1246,12 @@ function App() {
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
onSelectFile={toggleExpand}
|
onSelectFile={toggleExpand}
|
||||||
knowledgeActions={knowledgeActions}
|
knowledgeActions={knowledgeActions}
|
||||||
|
runs={runs}
|
||||||
|
currentRunId={runId}
|
||||||
|
tasksActions={{
|
||||||
|
onNewChat: handleNewChat,
|
||||||
|
onSelectRun: loadRun,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SidebarInset className="overflow-hidden! min-h-0">
|
<SidebarInset className="overflow-hidden! min-h-0">
|
||||||
{/* Header with sidebar triggers */}
|
{/* Header with sidebar triggers */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
MessageSquare,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
|
@ -27,7 +28,6 @@ import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
|
@ -70,12 +70,26 @@ type KnowledgeActions = {
|
||||||
copyPath: (path: string) => void
|
copyPath: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunListItem = {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasksActions = {
|
||||||
|
onNewChat: () => void
|
||||||
|
onSelectRun: (runId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
type SidebarContentPanelProps = {
|
type SidebarContentPanelProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
selectedPath: string | null
|
selectedPath: string | null
|
||||||
expandedPaths: Set<string>
|
expandedPaths: Set<string>
|
||||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||||
knowledgeActions: KnowledgeActions
|
knowledgeActions: KnowledgeActions
|
||||||
|
runs?: RunListItem[]
|
||||||
|
currentRunId?: string | null
|
||||||
|
tasksActions?: TasksActions
|
||||||
} & React.ComponentProps<typeof Sidebar>
|
} & React.ComponentProps<typeof Sidebar>
|
||||||
|
|
||||||
const sectionTitles = {
|
const sectionTitles = {
|
||||||
|
|
@ -89,6 +103,9 @@ export function SidebarContentPanel({
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
knowledgeActions,
|
knowledgeActions,
|
||||||
|
runs = [],
|
||||||
|
currentRunId,
|
||||||
|
tasksActions,
|
||||||
...props
|
...props
|
||||||
}: SidebarContentPanelProps) {
|
}: SidebarContentPanelProps) {
|
||||||
const { activeSection } = useSidebarSection()
|
const { activeSection } = useSidebarSection()
|
||||||
|
|
@ -111,7 +128,11 @@ export function SidebarContentPanel({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeSection === "tasks" && (
|
{activeSection === "tasks" && (
|
||||||
<TasksSection />
|
<TasksSection
|
||||||
|
runs={runs}
|
||||||
|
currentRunId={currentRunId}
|
||||||
|
actions={tasksActions}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|
@ -404,16 +425,50 @@ function Tree({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tasks Section
|
// Tasks Section
|
||||||
function TasksSection() {
|
function TasksSection({
|
||||||
|
runs,
|
||||||
|
currentRunId,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
runs: RunListItem[]
|
||||||
|
currentRunId?: string | null
|
||||||
|
actions?: TasksActions
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
|
||||||
<SidebarGroupContent>
|
{/* Sticky New Chat button - matches Knowledge section height */}
|
||||||
<div className="px-2 py-2">
|
<div className="sticky top-0 z-10 bg-sidebar border-b border-sidebar-border py-0.5">
|
||||||
<button className="flex items-center gap-2 text-sidebar-foreground hover:bg-sidebar-accent rounded-lg px-3 py-2 transition-colors w-full">
|
<SidebarMenu>
|
||||||
<SquarePen className="size-4" />
|
<SidebarMenuItem>
|
||||||
<span className="text-sm">New chat</span>
|
<SidebarMenuButton onClick={actions?.onNewChat} className="gap-2">
|
||||||
</button>
|
<SquarePen className="size-4 shrink-0" />
|
||||||
</div>
|
<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>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue