Merge pull request #331 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2026-01-28 07:16:36 +05:30 committed by GitHub
commit f5c8267762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 167 additions and 31 deletions

View file

@ -282,6 +282,7 @@ interface ChatInputInnerProps {
isProcessing: boolean isProcessing: boolean
presetMessage?: string presetMessage?: string
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
runId?: string | null
} }
function ChatInputInner({ function ChatInputInner({
@ -289,6 +290,7 @@ function ChatInputInner({
isProcessing, isProcessing,
presetMessage, presetMessage,
onPresetMessageConsumed, onPresetMessageConsumed,
runId,
}: ChatInputInnerProps) { }: ChatInputInnerProps) {
const controller = usePromptInputController() const controller = usePromptInputController()
const message = controller.textInput.value const message = controller.textInput.value
@ -320,8 +322,9 @@ function ChatInputInner({
<div className="flex items-center gap-2 bg-background border border-border rounded-3xl shadow-xl px-4 py-2.5"> <div className="flex items-center gap-2 bg-background border border-border rounded-3xl shadow-xl px-4 py-2.5">
<PromptInputTextarea <PromptInputTextarea
placeholder="Type your message..." placeholder="Type your message..."
disabled={isProcessing}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoFocus
focusTrigger={runId}
className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none" className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none"
/> />
<Button <Button
@ -350,6 +353,7 @@ interface ChatInputWithMentionsProps {
isProcessing: boolean isProcessing: boolean
presetMessage?: string presetMessage?: string
onPresetMessageConsumed?: () => void onPresetMessageConsumed?: () => void
runId?: string | null
} }
function ChatInputWithMentions({ function ChatInputWithMentions({
@ -360,6 +364,7 @@ function ChatInputWithMentions({
isProcessing, isProcessing,
presetMessage, presetMessage,
onPresetMessageConsumed, onPresetMessageConsumed,
runId,
}: ChatInputWithMentionsProps) { }: ChatInputWithMentionsProps) {
return ( return (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}> <PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -368,6 +373,7 @@ function ChatInputWithMentions({
isProcessing={isProcessing} isProcessing={isProcessing}
presetMessage={presetMessage} presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed} onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
/> />
</PromptInputProvider> </PromptInputProvider>
) )
@ -376,6 +382,8 @@ function ChatInputWithMentions({
function App() { function App() {
// File browser state (for Knowledge section) // File browser state (for Knowledge section)
const [selectedPath, setSelectedPath] = useState<string | null>(null) const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [fileHistoryBack, setFileHistoryBack] = useState<string[]>([])
const [fileHistoryForward, setFileHistoryForward] = useState<string[]>([])
const [fileContent, setFileContent] = useState<string>('') const [fileContent, setFileContent] = useState<string>('')
const [editorContent, setEditorContent] = useState<string>('') const [editorContent, setEditorContent] = useState<string>('')
const [tree, setTree] = useState<TreeNode[]>([]) const [tree, setTree] = useState<TreeNode[]>([])
@ -404,6 +412,7 @@ function App() {
const [currentReasoning, setCurrentReasoning] = useState<string>('') const [currentReasoning, setCurrentReasoning] = useState<string>('')
const [, setModelUsage] = useState<LanguageModelUsage | null>(null) const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
const [runId, setRunId] = useState<string | null>(null) const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [agentId] = useState<string>('copilot') const [agentId] = useState<string>('copilot')
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined) const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
@ -426,6 +435,11 @@ function App() {
// Onboarding state // Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false) const [showOnboarding, setShowOnboarding] = useState(false)
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => {
runIdRef.current = runId
}, [runId])
// Load directory tree // Load directory tree
const loadDirectory = useCallback(async () => { const loadDirectory = useCallback(async () => {
try { try {
@ -722,15 +736,17 @@ function App() {
}, []) }, [])
// Listen to run events // Listen to run events
// Listen to run events - use ref to avoid stale closure issues
useEffect(() => { useEffect(() => {
const cleanup = window.ipc.on('runs:events', ((event: unknown) => { const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
handleRunEvent(event as RunEventType) handleRunEvent(event as RunEventType)
}) as (event: null) => void) }) as (event: null) => void)
return cleanup return cleanup
}, [runId]) }, [])
const handleRunEvent = (event: RunEventType) => { const handleRunEvent = (event: RunEventType) => {
if (event.runId !== runId) return // Use ref to get current runId to avoid stale closure issues
if (event.runId !== runIdRef.current) return
console.log('Run event:', event.type, event) console.log('Run event:', event.type, event)
@ -1043,6 +1059,7 @@ function App() {
setRunId(null) setRunId(null)
setMessage('') setMessage('')
setModelUsage(null) setModelUsage(null)
setIsProcessing(false)
setPendingPermissionRequests(new Map()) setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map()) setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
@ -1060,6 +1077,52 @@ function App() {
setIsGraphOpen(false) setIsGraphOpen(false)
}, []) }, [])
// File navigation with history tracking
const navigateToFile = useCallback((path: string | null) => {
if (path === selectedPath) return
// Push current path to back history (if we have one)
if (selectedPath) {
setFileHistoryBack(prev => [...prev, selectedPath])
}
// Clear forward history when navigating to a new file
setFileHistoryForward([])
setSelectedPath(path)
}, [selectedPath])
const navigateBack = useCallback(() => {
if (fileHistoryBack.length === 0) return
const newBack = [...fileHistoryBack]
const previousPath = newBack.pop()!
// Push current path to forward history
if (selectedPath) {
setFileHistoryForward(prev => [...prev, selectedPath])
}
setFileHistoryBack(newBack)
setSelectedPath(previousPath)
}, [fileHistoryBack, selectedPath])
const navigateForward = useCallback(() => {
if (fileHistoryForward.length === 0) return
const newForward = [...fileHistoryForward]
const nextPath = newForward.pop()!
// Push current path to back history
if (selectedPath) {
setFileHistoryBack(prev => [...prev, selectedPath])
}
setFileHistoryForward(newForward)
setSelectedPath(nextPath)
}, [fileHistoryForward, selectedPath])
const canNavigateBack = fileHistoryBack.length > 0
const canNavigateForward = fileHistoryForward.length > 0
// Handle image upload for the markdown editor // Handle image upload for the markdown editor
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => { const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
try { try {
@ -1113,7 +1176,7 @@ function App() {
const toggleExpand = (path: string, kind: 'file' | 'dir') => { const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') { if (kind === 'file') {
setSelectedPath(path) navigateToFile(path)
setIsGraphOpen(false) setIsGraphOpen(false)
return return
} }
@ -1297,9 +1360,9 @@ function App() {
const openWikiLink = useCallback(async (wikiPath: string) => { const openWikiLink = useCallback(async (wikiPath: string) => {
const resolvedPath = await ensureWikiFile(wikiPath) const resolvedPath = await ensureWikiFile(wikiPath)
if (resolvedPath) { if (resolvedPath) {
setSelectedPath(resolvedPath) navigateToFile(resolvedPath)
} }
}, [ensureWikiFile, setSelectedPath]) }, [ensureWikiFile, navigateToFile])
const wikiLinkConfig = React.useMemo(() => ({ const wikiLinkConfig = React.useMemo(() => ({
files: knowledgeFiles, files: knowledgeFiles,
@ -1601,7 +1664,7 @@ function App() {
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null} error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => { onSelectNode={(path) => {
setIsGraphOpen(false) setIsGraphOpen(false)
setSelectedPath(path) navigateToFile(path)
}} }}
/> />
</div> </div>
@ -1614,6 +1677,10 @@ function App() {
placeholder="Start writing..." placeholder="Start writing..."
wikiLinks={wikiLinkConfig} wikiLinks={wikiLinkConfig}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
onNavigateBack={navigateBack}
onNavigateForward={navigateForward}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/> />
</div> </div>
) : ( ) : (
@ -1715,6 +1782,7 @@ function App() {
isProcessing={isProcessing} isProcessing={isProcessing}
presetMessage={presetMessage} presetMessage={presetMessage}
onPresetMessageConsumed={() => setPresetMessage(undefined)} onPresetMessageConsumed={() => setPresetMessage(undefined)}
runId={runId}
/> />
</div> </div>
</div> </div>

View file

@ -904,13 +904,18 @@ export const PromptInputBody = ({
export type PromptInputTextareaProps = ComponentProps< export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea typeof InputGroupTextarea
>; > & {
autoFocus?: boolean;
focusTrigger?: unknown; // When this value changes, focus the textarea
};
export const PromptInputTextarea = ({ export const PromptInputTextarea = ({
onChange, onChange,
className, className,
placeholder = "What would you like to know?", placeholder = "What would you like to know?",
onKeyDown: externalOnKeyDown, onKeyDown: externalOnKeyDown,
autoFocus = false,
focusTrigger,
...props ...props
}: PromptInputTextareaProps) => { }: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController(); const controller = useOptionalPromptInputController();
@ -920,6 +925,17 @@ export const PromptInputTextarea = ({
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-focus the textarea when requested or when focusTrigger changes
useEffect(() => {
if (autoFocus || focusTrigger !== undefined) {
// Small delay to ensure the element is fully mounted and visible
const timer = setTimeout(() => {
textareaRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus, focusTrigger]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null); const highlightRef = useRef<HTMLDivElement>(null);

View file

@ -51,7 +51,7 @@ export function Suggestions({
return ( return (
<div className={cn( <div className={cn(
'flex gap-2', 'flex gap-2',
vertical ? 'flex-col items-start' : 'flex-wrap justify-center', vertical ? 'flex-col items-end' : 'flex-wrap justify-center',
className className
)}> )}>
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (

View file

@ -260,10 +260,16 @@ export function ChatSidebar({
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
}, [width]) }, [width])
// Auto-focus textarea when sidebar opens // Auto-focus textarea when sidebar opens or when conversation is cleared (new chat)
useEffect(() => { useEffect(() => {
textareaRef.current?.focus() // Focus when conversation is empty (new chat started)
}, []) if (conversation.length === 0) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
}, 50)
return () => clearTimeout(timer)
}
}, [conversation.length])
// Auto-populate with @currentfile when switching knowledge files // Auto-populate with @currentfile when switching knowledge files
useEffect(() => { useEffect(() => {
@ -584,9 +590,8 @@ export function ChatSidebar({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onScroll={syncHighlightScroll} onScroll={syncHighlightScroll}
placeholder="Ask anything..." placeholder="Ask anything..."
disabled={isProcessing}
rows={1} rows={1}
className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-6" className="relative z-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground resize-none max-h-32 min-h-6"
style={{ fieldSizing: 'content' } as React.CSSProperties} style={{ fieldSizing: 'content' } as React.CSSProperties}
/> />
</div> </div>

View file

@ -22,8 +22,8 @@ import {
MinusIcon, MinusIcon,
LinkIcon, LinkIcon,
CodeSquareIcon, CodeSquareIcon,
Undo2Icon, ChevronLeftIcon,
Redo2Icon, ChevronRightIcon,
ExternalLinkIcon, ExternalLinkIcon,
Trash2Icon, Trash2Icon,
ImageIcon, ImageIcon,
@ -33,9 +33,21 @@ interface EditorToolbarProps {
editor: Editor | null editor: Editor | null
onSelectionHighlight?: (range: { from: number; to: number } | null) => void onSelectionHighlight?: (range: { from: number; to: number } | null) => void
onImageUpload?: (file: File) => Promise<void> | void onImageUpload?: (file: File) => Promise<void> | void
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
} }
export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload }: EditorToolbarProps) { export function EditorToolbar({
editor,
onSelectionHighlight,
onImageUpload,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: EditorToolbarProps) {
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false) const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@ -105,24 +117,24 @@ export function EditorToolbar({ editor, onSelectionHighlight, onImageUpload }: E
return ( return (
<div className="editor-toolbar"> <div className="editor-toolbar">
{/* Undo / Redo */} {/* Back / Forward Navigation */}
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().undo().run()} onClick={onNavigateBack}
disabled={!editor.can().undo()} disabled={!canNavigateBack}
title="Undo (Ctrl+Z)" title="Go back"
> >
<Undo2Icon className="size-4" /> <ChevronLeftIcon className="size-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => editor.chain().focus().redo().run()} onClick={onNavigateForward}
disabled={!editor.can().redo()} disabled={!canNavigateForward}
title="Redo (Ctrl+Shift+Z)" title="Go forward"
> >
<Redo2Icon className="size-4" /> <ChevronRightIcon className="size-4" />
</Button> </Button>
<div className="separator" /> <div className="separator" />

View file

@ -30,6 +30,10 @@ interface MarkdownEditorProps {
placeholder?: string placeholder?: string
wikiLinks?: WikiLinkConfig wikiLinks?: WikiLinkConfig
onImageUpload?: (file: File) => Promise<string | null> onImageUpload?: (file: File) => Promise<string | null>
onNavigateBack?: () => void
onNavigateForward?: () => void
canNavigateBack?: boolean
canNavigateForward?: boolean
} }
type WikiLinkMatch = { type WikiLinkMatch = {
@ -78,6 +82,10 @@ export function MarkdownEditor({
placeholder = 'Start writing...', placeholder = 'Start writing...',
wikiLinks, wikiLinks,
onImageUpload, onImageUpload,
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const isInternalUpdate = useRef(false) const isInternalUpdate = useRef(false)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
@ -318,7 +326,15 @@ export function MarkdownEditor({
return ( return (
<div className="tiptap-editor" onKeyDown={handleKeyDown}> <div className="tiptap-editor" onKeyDown={handleKeyDown}>
<EditorToolbar editor={editor} onSelectionHighlight={setSelectionHighlight} onImageUpload={handleImageUploadWithPlaceholder} /> <EditorToolbar
editor={editor}
onSelectionHighlight={setSelectionHighlight}
onImageUpload={handleImageUploadWithPlaceholder}
onNavigateBack={onNavigateBack}
onNavigateForward={onNavigateForward}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
/>
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}> <div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{wikiLinks ? ( {wikiLinks ? (

View file

@ -137,7 +137,6 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent <DialogContent
className="!max-w-[900px] w-[900px] h-[600px] p-0 gap-0 overflow-hidden" className="!max-w-[900px] w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
showCloseButton={false}
> >
<div className="flex h-full"> <div className="flex h-full">
{/* Sidebar */} {/* Sidebar */}

View file

@ -62,7 +62,7 @@ const providerConfigs: ProviderConfig = {
}, },
scopes: [ scopes: [
'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive.readonly',
], ],
}, },

View file

@ -6,7 +6,6 @@ export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json"
const DEFAULT_ALLOW_LIST = [ const DEFAULT_ALLOW_LIST = [
"cat", "cat",
"curl",
"date", "date",
"echo", "echo",
"grep", "grep",

View file

@ -997,6 +997,12 @@ If new info contradicts existing:
## 9a: Meetings Create and Update Notes ## 9a: Meetings Create and Update Notes
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
**For new entities (meetings only):** **For new entities (meetings only):**
\`\`\`bash \`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'") executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
@ -1103,6 +1109,7 @@ If you discovered new name variants during resolution, add them to Aliases field
- Be concise: one line per activity entry - Be concise: one line per activity entry
- Note state changes with \`[Field → value]\` in activity - Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands - Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
--- ---

View file

@ -553,6 +553,12 @@ Before writing:
## 9a: Create and Update Notes ## 9a: Create and Update Notes
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
**For new entities:** **For new entities:**
\`\`\`bash \`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'") executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
@ -579,6 +585,7 @@ Add newly discovered name variants to Aliases field.
- Use YYYY-MM-DD format for dates - Use YYYY-MM-DD format for dates
- Be concise: one line per activity entry - Be concise: one line per activity entry
- Escape quotes properly in shell commands - Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
--- ---

View file

@ -906,6 +906,12 @@ If new info contradicts existing:
## 9a: Create and Update Notes ## 9a: Create and Update Notes
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
**For new entities (meetings and qualifying emails):** **For new entities (meetings and qualifying emails):**
\`\`\`bash \`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'") executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
@ -941,6 +947,7 @@ If you discovered new name variants during resolution, add them to Aliases field
- Be concise: one line per activity entry - Be concise: one line per activity entry
- Note state changes with \`[Field → value]\` in activity - Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands - Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
--- ---

View file

@ -11,7 +11,7 @@ const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const LOOKBACK_DAYS = 14; const LOOKBACK_DAYS = 14;
const REQUIRED_SCOPES = [ const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly' 'https://www.googleapis.com/auth/drive.readonly'
]; ];