mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
implement the new navigation design
Rebuild the sidebar, home, and chat surfaces per the navigation design: Recents (capped at 5), single clickable previews, section separators, and the chat page help items and discovery carousel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6587a67b7
commit
fbd0791d0c
6 changed files with 1467 additions and 1052 deletions
|
|
@ -5,10 +5,12 @@ 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';
|
||||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
|
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
|
import { ChatHeader } from './components/chat-header';
|
||||||
|
import { ChatEmptyState } from './components/chat-empty-state';
|
||||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||||
|
|
@ -28,12 +30,12 @@ import { EmailView } from '@/components/email-view';
|
||||||
import { WorkspaceView } from '@/components/workspace-view';
|
import { WorkspaceView } from '@/components/workspace-view';
|
||||||
import { KnowledgeView } from '@/components/knowledge-view';
|
import { KnowledgeView } from '@/components/knowledge-view';
|
||||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||||
|
import { HomeView } from '@/components/home-view';
|
||||||
import { MeetingsView } from '@/components/meetings-view';
|
import { MeetingsView } from '@/components/meetings-view';
|
||||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
ConversationEmptyState,
|
|
||||||
ConversationScrollButton,
|
ConversationScrollButton,
|
||||||
} from '@/components/ai-elements/conversation';
|
} from '@/components/ai-elements/conversation';
|
||||||
import {
|
import {
|
||||||
|
|
@ -55,7 +57,6 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
import { TerminalOutput } from '@/components/terminal-output';
|
import { TerminalOutput } from '@/components/terminal-output';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
|
|
@ -191,6 +192,7 @@ const WORKSPACE_TAB_PATH = '__rowboat_workspace__'
|
||||||
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
const WORKSPACE_ROOT = 'knowledge/Workspace'
|
||||||
const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
|
const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
|
||||||
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
|
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
|
||||||
|
const HOME_TAB_PATH = '__rowboat_home__'
|
||||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||||
|
|
||||||
const clampNumber = (value: number, min: number, max: number) =>
|
const clampNumber = (value: number, min: number, max: number) =>
|
||||||
|
|
@ -327,6 +329,7 @@ const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH
|
||||||
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
|
const isWorkspaceTabPath = (path: string) => path === WORKSPACE_TAB_PATH
|
||||||
const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
|
const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PATH
|
||||||
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
|
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
|
||||||
|
const isHomeTabPath = (path: string) => path === HOME_TAB_PATH
|
||||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||||
|
|
||||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||||
|
|
@ -580,6 +583,7 @@ type ViewState =
|
||||||
| { type: 'workspace'; path?: string }
|
| { type: 'workspace'; path?: string }
|
||||||
| { type: 'knowledge-view' }
|
| { type: 'knowledge-view' }
|
||||||
| { type: 'chat-history' }
|
| { type: 'chat-history' }
|
||||||
|
| { type: 'home' }
|
||||||
|
|
||||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type !== b.type) return false
|
if (a.type !== b.type) return false
|
||||||
|
|
@ -638,6 +642,8 @@ function parseDeepLink(input: string): ViewState | null {
|
||||||
return { type: 'knowledge-view' }
|
return { type: 'knowledge-view' }
|
||||||
case 'chat-history':
|
case 'chat-history':
|
||||||
return { type: 'chat-history' }
|
return { type: 'chat-history' }
|
||||||
|
case 'home':
|
||||||
|
return { type: 'home' }
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -751,6 +757,7 @@ function App() {
|
||||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
||||||
|
const [isHomeOpen, setIsHomeOpen] = useState(false)
|
||||||
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||||
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||||
const [expandedFrom, setExpandedFrom] = useState<{
|
const [expandedFrom, setExpandedFrom] = useState<{
|
||||||
|
|
@ -1009,13 +1016,13 @@ function App() {
|
||||||
|
|
||||||
// Chat tab state
|
// Chat tab state
|
||||||
const [chatTabs, setChatTabs] = useState<ChatTab[]>([{ id: 'default-chat-tab', runId: null }])
|
const [chatTabs, setChatTabs] = useState<ChatTab[]>([{ id: 'default-chat-tab', runId: null }])
|
||||||
|
const chatTabsRef = useRef(chatTabs)
|
||||||
|
chatTabsRef.current = chatTabs
|
||||||
const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
|
const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
|
||||||
const [chatViewStateByTab, setChatViewStateByTab] = useState<Record<string, ChatTabViewState>>({
|
const [chatViewStateByTab, setChatViewStateByTab] = useState<Record<string, ChatTabViewState>>({
|
||||||
'default-chat-tab': createEmptyChatTabViewState(),
|
'default-chat-tab': createEmptyChatTabViewState(),
|
||||||
})
|
})
|
||||||
const chatViewStateByTabRef = useRef(chatViewStateByTab)
|
const chatViewStateByTabRef = useRef(chatViewStateByTab)
|
||||||
const chatTabIdCounterRef = useRef(0)
|
|
||||||
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
|
|
||||||
const chatDraftsRef = useRef(new Map<string, string>())
|
const chatDraftsRef = useRef(new Map<string, string>())
|
||||||
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
||||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||||
|
|
@ -1110,6 +1117,7 @@ function App() {
|
||||||
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
|
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
|
||||||
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
|
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
|
||||||
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
|
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
|
||||||
|
if (isHomeTabPath(tab.path)) return 'Home'
|
||||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||||
|
|
@ -1757,6 +1765,7 @@ function App() {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lastAttemptAt?: string
|
lastAttemptAt?: string
|
||||||
lastRunAt?: string
|
lastRunAt?: string
|
||||||
|
lastRunError?: string
|
||||||
}>>([])
|
}>>([])
|
||||||
const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState<string | null>(null)
|
const [bgTaskInitialSlug, setBgTaskInitialSlug] = useState<string | null>(null)
|
||||||
const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0)
|
const [bgTaskSlugVersion, setBgTaskSlugVersion] = useState(0)
|
||||||
|
|
@ -1771,6 +1780,7 @@ function App() {
|
||||||
createdAt: it.createdAt,
|
createdAt: it.createdAt,
|
||||||
lastAttemptAt: it.lastAttemptAt,
|
lastAttemptAt: it.lastAttemptAt,
|
||||||
lastRunAt: it.lastRunAt,
|
lastRunAt: it.lastRunAt,
|
||||||
|
lastRunError: it.lastRunError,
|
||||||
})))
|
})))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load bg-task summaries:', err)
|
console.error('Failed to load bg-task summaries:', err)
|
||||||
|
|
@ -2699,25 +2709,6 @@ function App() {
|
||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openChatInNewTab = useCallback((targetRunId: string) => {
|
|
||||||
cancelRecordingIfActive()
|
|
||||||
const existingTab = chatTabs.find(t => t.runId === targetRunId)
|
|
||||||
if (existingTab) {
|
|
||||||
// Cancel stale in-flight loads from previously focused tabs.
|
|
||||||
loadRunRequestIdRef.current += 1
|
|
||||||
setActiveChatTabId(existingTab.id)
|
|
||||||
const restored = restoreChatTabState(existingTab.id, existingTab.runId)
|
|
||||||
if (processingRunIdsRef.current.has(targetRunId) || !restored) {
|
|
||||||
loadRun(targetRunId)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id = newChatTabId()
|
|
||||||
setChatTabs(prev => [...prev, { id, runId: targetRunId }])
|
|
||||||
setActiveChatTabId(id)
|
|
||||||
loadRun(targetRunId)
|
|
||||||
}, [chatTabs, loadRun, restoreChatTabState, cancelRecordingIfActive])
|
|
||||||
|
|
||||||
const switchChatTab = useCallback((tabId: string) => {
|
const switchChatTab = useCallback((tabId: string) => {
|
||||||
const tab = chatTabs.find(t => t.id === tabId)
|
const tab = chatTabs.find(t => t.id === tabId)
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
|
|
@ -2862,7 +2853,7 @@ function App() {
|
||||||
setActiveFileTabId(existingTab.id)
|
setActiveFileTabId(existingTab.id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2871,7 +2862,7 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(path)
|
setSelectedPath(path)
|
||||||
}, [fileTabs, dismissBrowserOverlay])
|
}, [fileTabs, dismissBrowserOverlay])
|
||||||
|
|
||||||
|
|
@ -2890,14 +2881,14 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isLiveNotesTabPath(tab.path)) {
|
if (isLiveNotesTabPath(tab.path)) {
|
||||||
|
|
@ -2910,6 +2901,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2923,6 +2915,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsBgTasksOpen(true)
|
setIsBgTasksOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2937,6 +2930,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isEmailTabPath(tab.path)) {
|
if (isEmailTabPath(tab.path)) {
|
||||||
|
|
@ -2949,6 +2943,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2962,6 +2957,7 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsWorkspaceOpen(true)
|
setIsWorkspaceOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2975,6 +2971,7 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsKnowledgeViewOpen(true)
|
setIsKnowledgeViewOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2988,18 +2985,26 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(true)
|
setIsChatHistoryOpen(true); setIsHomeOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isHomeTabPath(tab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(tab.path)
|
setSelectedPath(tab.path)
|
||||||
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
}, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay])
|
||||||
|
|
||||||
const closeFileTab = useCallback((tabId: string) => {
|
const closeFileTab = useCallback((tabId: string) => {
|
||||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||||
removeEditorCacheForPath(closingTab.path)
|
removeEditorCacheForPath(closingTab.path)
|
||||||
initialContentByPathRef.current.delete(closingTab.path)
|
initialContentByPathRef.current.delete(closingTab.path)
|
||||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||||
|
|
@ -3022,7 +3027,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const idx = prev.findIndex(t => t.id === tabId)
|
const idx = prev.findIndex(t => t.id === tabId)
|
||||||
|
|
@ -3036,12 +3041,12 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
} else if (isMeetingsTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3053,6 +3058,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
} else if (isLiveNotesTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3063,6 +3069,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
} else if (isBgTasksTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
|
@ -3075,6 +3082,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
} else if (isEmailTabPath(newActiveTab.path)) {
|
} else if (isEmailTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3085,6 +3093,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
} else if (isWorkspaceTabPath(newActiveTab.path)) {
|
} else if (isWorkspaceTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
|
@ -3096,6 +3105,7 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsWorkspaceOpen(true)
|
setIsWorkspaceOpen(true)
|
||||||
} else if (isKnowledgeViewTabPath(newActiveTab.path)) {
|
} else if (isKnowledgeViewTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
|
@ -3107,6 +3117,7 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsKnowledgeViewOpen(true)
|
setIsKnowledgeViewOpen(true)
|
||||||
} else if (isChatHistoryTabPath(newActiveTab.path)) {
|
} else if (isChatHistoryTabPath(newActiveTab.path)) {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
|
|
@ -3118,11 +3129,17 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(true)
|
setIsChatHistoryOpen(true); setIsHomeOpen(false)
|
||||||
} else {
|
} else if (isHomeTabPath(newActiveTab.path)) {
|
||||||
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(true)
|
||||||
|
} else {
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(newActiveTab.path)
|
setSelectedPath(newActiveTab.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3138,22 +3155,13 @@ function App() {
|
||||||
}, [activeFileTabId, fileTabs, removeEditorCacheForPath])
|
}, [activeFileTabId, fileTabs, removeEditorCacheForPath])
|
||||||
|
|
||||||
const handleNewChatTab = useCallback(() => {
|
const handleNewChatTab = useCallback(() => {
|
||||||
// If there's already an empty "New chat" tab, switch to it
|
// Single-chat model: reset the one conversation in place instead of
|
||||||
const emptyTab = chatTabs.find(t => !t.runId)
|
// opening a new tab.
|
||||||
if (emptyTab) {
|
setChatTabs([{ id: activeChatTabIdRef.current, runId: null }])
|
||||||
if (emptyTab.id !== activeChatTabId) {
|
|
||||||
setActiveChatTabId(emptyTab.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create a new tab
|
|
||||||
const id = newChatTabId()
|
|
||||||
setChatTabs(prev => [...prev, { id, runId: null }])
|
|
||||||
setActiveChatTabId(id)
|
|
||||||
}
|
|
||||||
dismissBrowserOverlay()
|
dismissBrowserOverlay()
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
// Left-pane "new chat" should always open full chat view.
|
// Left-pane "new chat" should always open full chat view.
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
|
||||||
setExpandedFrom({
|
setExpandedFrom({
|
||||||
path: selectedPath,
|
path: selectedPath,
|
||||||
graph: isGraphOpen,
|
graph: isGraphOpen,
|
||||||
|
|
@ -3170,23 +3178,14 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
}, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen])
|
}, [dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen])
|
||||||
|
|
||||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
// Sidebar variant: reset the chat in place without leaving file/graph context.
|
||||||
const handleNewChatTabInSidebar = useCallback(() => {
|
const handleNewChatTabInSidebar = useCallback(() => {
|
||||||
const emptyTab = chatTabs.find(t => !t.runId)
|
setChatTabs([{ id: activeChatTabIdRef.current, runId: null }])
|
||||||
if (emptyTab) {
|
|
||||||
if (emptyTab.id !== activeChatTabId) {
|
|
||||||
setActiveChatTabId(emptyTab.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const id = newChatTabId()
|
|
||||||
setChatTabs(prev => [...prev, { id, runId: null }])
|
|
||||||
setActiveChatTabId(id)
|
|
||||||
}
|
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
}, [chatTabs, activeChatTabId, handleNewChat])
|
}, [handleNewChat])
|
||||||
|
|
||||||
// Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab,
|
// Palette → sidebar submission. Opens the sidebar (if closed), forces a fresh chat tab,
|
||||||
// queues the message; the pending-submit effect (below) flushes it once state has settled
|
// queues the message; the pending-submit effect (below) flushes it once state has settled
|
||||||
|
|
@ -3303,7 +3302,7 @@ function App() {
|
||||||
|
|
||||||
const handleOpenFullScreenChat = useCallback(() => {
|
const handleOpenFullScreenChat = useCallback(() => {
|
||||||
// Remember where we came from so the close button can return
|
// Remember where we came from so the close button can return
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) {
|
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) {
|
||||||
setExpandedFrom({
|
setExpandedFrom({
|
||||||
path: selectedPath,
|
path: selectedPath,
|
||||||
graph: isGraphOpen,
|
graph: isGraphOpen,
|
||||||
|
|
@ -3319,7 +3318,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback(() => {
|
||||||
|
|
@ -3327,11 +3326,11 @@ function App() {
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
} else if (expandedFrom.suggestedTopics) {
|
} else if (expandedFrom.suggestedTopics) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
} else if (expandedFrom.meetings) {
|
} else if (expandedFrom.meetings) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
|
@ -3363,7 +3362,7 @@ function App() {
|
||||||
} else if (expandedFrom.path) {
|
} else if (expandedFrom.path) {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
|
|
@ -3380,10 +3379,11 @@ function App() {
|
||||||
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
||||||
if (isKnowledgeViewOpen) return { type: 'knowledge-view' }
|
if (isKnowledgeViewOpen) return { type: 'knowledge-view' }
|
||||||
if (isChatHistoryOpen) return { type: 'chat-history' }
|
if (isChatHistoryOpen) return { type: 'chat-history' }
|
||||||
|
if (isHomeOpen) return { type: 'home' }
|
||||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, workspaceInitialPath, runId])
|
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -3517,7 +3517,18 @@ function App() {
|
||||||
setActiveFileTabId(id)
|
setActiveFileTabId(id)
|
||||||
}, [fileTabs])
|
}, [fileTabs])
|
||||||
|
|
||||||
const openEmailView = useCallback(() => {
|
const ensureHomeFileTab = useCallback(() => {
|
||||||
|
const existing = fileTabs.find((tab) => isHomeTabPath(tab.path))
|
||||||
|
if (existing) {
|
||||||
|
setActiveFileTabId(existing.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = newFileTabId()
|
||||||
|
setFileTabs((prev) => [...prev, { id, path: HOME_TAB_PATH }])
|
||||||
|
setActiveFileTabId(id)
|
||||||
|
}, [fileTabs])
|
||||||
|
|
||||||
|
const openEmailView = useCallback((threadId?: string) => {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
|
|
@ -3527,10 +3538,16 @@ function App() {
|
||||||
setIsBgTasksOpen(false)
|
setIsBgTasksOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setIsEmailOpen(true)
|
setIsEmailOpen(true)
|
||||||
|
if (threadId) {
|
||||||
|
setEmailInitialThreadId(threadId)
|
||||||
|
setEmailThreadIdVersion((v) => v + 1)
|
||||||
|
}
|
||||||
ensureEmailFileTab()
|
ensureEmailFileTab()
|
||||||
}, [ensureEmailFileTab])
|
}, [ensureEmailFileTab])
|
||||||
|
|
||||||
|
|
@ -3539,7 +3556,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
|
|
@ -3558,6 +3575,8 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
|
|
@ -3573,7 +3592,7 @@ function App() {
|
||||||
// visible in the middle pane.
|
// visible in the middle pane.
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||||
// Only exit chat-only maximize, because that would hide the selected file.
|
// Only exit chat-only maximize, because that would hide the selected file.
|
||||||
|
|
@ -3588,7 +3607,7 @@ function App() {
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
ensureGraphFileTab()
|
ensureGraphFileTab()
|
||||||
|
|
@ -3601,7 +3620,7 @@ function App() {
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
setIsBrowserOpen(false)
|
setIsBrowserOpen(false)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(view.name)
|
setSelectedBackgroundTask(view.name)
|
||||||
|
|
@ -3614,7 +3633,7 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(true)
|
setIsSuggestedTopicsOpen(true)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
ensureSuggestedTopicsFileTab()
|
ensureSuggestedTopicsFileTab()
|
||||||
return
|
return
|
||||||
case 'meetings':
|
case 'meetings':
|
||||||
|
|
@ -3632,6 +3651,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
ensureMeetingsFileTab()
|
ensureMeetingsFileTab()
|
||||||
return
|
return
|
||||||
case 'live-notes':
|
case 'live-notes':
|
||||||
|
|
@ -3648,6 +3668,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setIsLiveNotesOpen(true)
|
setIsLiveNotesOpen(true)
|
||||||
ensureLiveNotesFileTab()
|
ensureLiveNotesFileTab()
|
||||||
return
|
return
|
||||||
|
|
@ -3666,6 +3687,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
ensureEmailFileTab()
|
ensureEmailFileTab()
|
||||||
return
|
return
|
||||||
case 'workspace':
|
case 'workspace':
|
||||||
|
|
@ -3683,6 +3705,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(true)
|
setIsWorkspaceOpen(true)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
setWorkspaceInitialPath(view.path ?? null)
|
setWorkspaceInitialPath(view.path ?? null)
|
||||||
ensureWorkspaceFileTab()
|
ensureWorkspaceFileTab()
|
||||||
return
|
return
|
||||||
|
|
@ -3701,6 +3724,7 @@ function App() {
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(true)
|
setIsKnowledgeViewOpen(true)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(false)
|
||||||
ensureKnowledgeViewFileTab()
|
ensureKnowledgeViewFileTab()
|
||||||
return
|
return
|
||||||
case 'chat-history':
|
case 'chat-history':
|
||||||
|
|
@ -3717,9 +3741,27 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(false)
|
setIsKnowledgeViewOpen(false)
|
||||||
setIsChatHistoryOpen(true)
|
setIsChatHistoryOpen(true); setIsHomeOpen(false)
|
||||||
ensureChatHistoryFileTab()
|
ensureChatHistoryFileTab()
|
||||||
return
|
return
|
||||||
|
case 'home':
|
||||||
|
setSelectedPath(null)
|
||||||
|
setIsGraphOpen(false)
|
||||||
|
setIsBrowserOpen(false)
|
||||||
|
setExpandedFrom(null)
|
||||||
|
setIsRightPaneMaximized(false)
|
||||||
|
setSelectedBackgroundTask(null)
|
||||||
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
setIsMeetingsOpen(false)
|
||||||
|
setIsLiveNotesOpen(false)
|
||||||
|
setIsBgTasksOpen(false)
|
||||||
|
setIsEmailOpen(false)
|
||||||
|
setIsWorkspaceOpen(false)
|
||||||
|
setIsKnowledgeViewOpen(false)
|
||||||
|
setIsChatHistoryOpen(false)
|
||||||
|
setIsHomeOpen(true)
|
||||||
|
ensureHomeFileTab()
|
||||||
|
return
|
||||||
case 'chat':
|
case 'chat':
|
||||||
setSelectedPath(null)
|
setSelectedPath(null)
|
||||||
setIsGraphOpen(false)
|
setIsGraphOpen(false)
|
||||||
|
|
@ -3728,15 +3770,27 @@ function App() {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
if (view.runId) {
|
if (view.runId) {
|
||||||
await loadRun(view.runId)
|
const targetRunId = view.runId
|
||||||
|
// Bind the loaded run to a chat tab so its title (derived from
|
||||||
|
// tab.runId) updates. Reuse an existing tab for this run if one is
|
||||||
|
// open, otherwise rebind the active tab.
|
||||||
|
const existingTab = chatTabsRef.current.find((tab) => tab.runId === targetRunId)
|
||||||
|
if (existingTab) {
|
||||||
|
setActiveChatTabId(existingTab.id)
|
||||||
|
} else {
|
||||||
|
setChatTabs((prev) => prev.map((tab) => (
|
||||||
|
tab.id === activeChatTabIdRef.current ? { ...tab, runId: targetRunId } : tab
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
await loadRun(targetRunId)
|
||||||
} else {
|
} else {
|
||||||
handleNewChat()
|
handleNewChat()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||||
|
|
||||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||||
const current = currentViewState
|
const current = currentViewState
|
||||||
|
|
@ -4058,7 +4112,7 @@ function App() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask && !isBrowserOpen
|
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||||
|
|
@ -4143,11 +4197,11 @@ function App() {
|
||||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
const mod = e.metaKey || e.ctrlKey
|
const mod = e.metaKey || e.ctrlKey
|
||||||
if (!mod) return
|
if (!mod) return
|
||||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && isChatSidebarOpen)
|
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && isChatSidebarOpen)
|
||||||
const targetPane: ShortcutPane = rightPaneAvailable
|
const targetPane: ShortcutPane = rightPaneAvailable
|
||||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||||
: 'left'
|
: 'left'
|
||||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen)
|
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen)
|
||||||
const selectedKnowledgePath = isGraphOpen
|
const selectedKnowledgePath = isGraphOpen
|
||||||
? GRAPH_TAB_PATH
|
? GRAPH_TAB_PATH
|
||||||
: isSuggestedTopicsOpen
|
: isSuggestedTopicsOpen
|
||||||
|
|
@ -4166,6 +4220,8 @@ function App() {
|
||||||
? KNOWLEDGE_VIEW_TAB_PATH
|
? KNOWLEDGE_VIEW_TAB_PATH
|
||||||
: isChatHistoryOpen
|
: isChatHistoryOpen
|
||||||
? CHAT_HISTORY_TAB_PATH
|
? CHAT_HISTORY_TAB_PATH
|
||||||
|
: isHomeOpen
|
||||||
|
? HOME_TAB_PATH
|
||||||
: selectedPath
|
: selectedPath
|
||||||
const targetFileTabId = activeFileTabId ?? (
|
const targetFileTabId = activeFileTabId ?? (
|
||||||
selectedKnowledgePath
|
selectedKnowledgePath
|
||||||
|
|
@ -5006,11 +5062,10 @@ function App() {
|
||||||
if (tabId === activeChatTabId) return activeChatTabState
|
if (tabId === activeChatTabId) return activeChatTabState
|
||||||
return chatViewStateByTab[tabId] ?? emptyChatTabState
|
return chatViewStateByTab[tabId] ?? emptyChatTabState
|
||||||
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
|
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
|
||||||
const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage
|
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
const openMarkdownTabs = React.useMemo(() => {
|
const openMarkdownTabs = React.useMemo(() => {
|
||||||
|
|
@ -5027,7 +5082,7 @@ function App() {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
<SidebarSectionProvider defaultSection="tasks" onSectionChange={(section) => {
|
||||||
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen) {
|
if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen) {
|
||||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
|
@ -5040,97 +5095,31 @@ function App() {
|
||||||
>
|
>
|
||||||
<SidebarContentPanel
|
<SidebarContentPanel
|
||||||
tree={tree}
|
tree={tree}
|
||||||
selectedPath={selectedPath}
|
|
||||||
onSelectFile={toggleExpand}
|
onSelectFile={toggleExpand}
|
||||||
knowledgeActions={knowledgeActions}
|
knowledgeActions={knowledgeActions}
|
||||||
runs={runs}
|
|
||||||
currentRunId={runId}
|
|
||||||
processingRunIds={processingRunIds}
|
|
||||||
tasksActions={{
|
|
||||||
onNewChat: handleNewChatTab,
|
|
||||||
onSelectRun: (runIdToLoad) => {
|
|
||||||
cancelRecordingIfActive()
|
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
|
|
||||||
setIsChatSidebarOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If already open in a chat tab, switch to it
|
|
||||||
const existingTab = chatTabs.find(t => t.runId === runIdToLoad)
|
|
||||||
if (existingTab) {
|
|
||||||
switchChatTab(existingTab.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
|
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
|
||||||
loadRun(runIdToLoad)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outside two-pane mode, navigate to chat.
|
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
|
||||||
void navigateToView({ type: 'chat', runId: runIdToLoad })
|
|
||||||
},
|
|
||||||
onOpenInNewTab: (targetRunId) => {
|
|
||||||
openChatInNewTab(targetRunId)
|
|
||||||
},
|
|
||||||
onDeleteRun: async (runIdToDelete) => {
|
|
||||||
try {
|
|
||||||
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
|
|
||||||
// Close any chat tab showing the deleted run
|
|
||||||
const tabForRun = chatTabs.find(t => t.runId === runIdToDelete)
|
|
||||||
if (tabForRun) {
|
|
||||||
if (chatTabs.length > 1) {
|
|
||||||
closeChatTab(tabForRun.id)
|
|
||||||
} else {
|
|
||||||
// Only one tab, reset it to new chat
|
|
||||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
|
|
||||||
handleNewChat()
|
|
||||||
} else {
|
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (runId === runIdToDelete) {
|
|
||||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isBrowserOpen) {
|
|
||||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
|
||||||
handleNewChat()
|
|
||||||
} else {
|
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await loadRuns()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete run:', err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelectBackgroundTask: (taskName) => {
|
|
||||||
void navigateToView({ type: 'task', name: taskName })
|
|
||||||
},
|
|
||||||
onOpenChatHistoryView: () => {
|
|
||||||
void navigateToView({ type: 'chat-history' })
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
bgTaskSummaries={bgTaskSummaries}
|
bgTaskSummaries={bgTaskSummaries}
|
||||||
onOpenBgTask={(slug) => {
|
activeNav={
|
||||||
setBgTaskInitialSlug(slug)
|
isHomeOpen ? 'home'
|
||||||
setBgTaskSlugVersion((v) => v + 1)
|
: isEmailOpen ? 'email'
|
||||||
openBgTasksView()
|
: isMeetingsOpen ? 'meetings'
|
||||||
}}
|
: (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge'
|
||||||
|
: isBgTasksOpen ? 'agents'
|
||||||
|
: isWorkspaceOpen ? 'workspaces'
|
||||||
|
: null
|
||||||
|
}
|
||||||
onOpenMeetings={openMeetingsView}
|
onOpenMeetings={openMeetingsView}
|
||||||
|
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
recentRuns={runs}
|
||||||
|
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
|
onOpenEmail={(threadId) => openEmailView(threadId)}
|
||||||
|
onOpenHome={() => void navigateToView({ type: 'home' })}
|
||||||
|
onNewChat={handleNewChatTab}
|
||||||
|
onToggleBrowser={handleToggleBrowser}
|
||||||
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
meetingRecordingState={meetingTranscription.state}
|
meetingRecordingState={meetingTranscription.state}
|
||||||
recordingMeetingSource={recordingMeetingSource}
|
recordingMeetingSource={recordingMeetingSource}
|
||||||
onToggleMeetingRecording={() => { void handleToggleMeeting() }}
|
onToggleMeetingRecording={() => { void handleToggleMeeting() }}
|
||||||
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
|
||||||
onOpenEmail={(threadId) => {
|
|
||||||
setEmailInitialThreadId(threadId ?? null)
|
|
||||||
setEmailThreadIdVersion((v) => v + 1)
|
|
||||||
openEmailView()
|
|
||||||
}}
|
|
||||||
onOpenHome={navigateToFullScreenChat}
|
|
||||||
onNewChat={handleNewChatTab}
|
|
||||||
onOpenSearch={() => setIsSearchOpen(true)}
|
|
||||||
onToggleBrowser={handleToggleBrowser}
|
|
||||||
/>
|
/>
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -5150,7 +5139,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && fileTabs.length >= 1 ? (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -5158,7 +5147,19 @@ function App() {
|
||||||
getTabId={(t) => t.id}
|
getTabId={(t) => t.id}
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||||
|
/>
|
||||||
|
) : isFullScreenChat ? (
|
||||||
|
<ChatHeader
|
||||||
|
activeTitle={(() => {
|
||||||
|
const activeTab = chatTabs.find((t) => t.id === activeChatTabId)
|
||||||
|
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||||
|
})()}
|
||||||
|
onNewChatTab={handleNewChatTab}
|
||||||
|
recentRuns={runs}
|
||||||
|
activeRunId={runId}
|
||||||
|
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|
@ -5211,19 +5212,19 @@ function App() {
|
||||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
|
{!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNewChatTab}
|
onClick={handleNewChatTab}
|
||||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
|
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
|
||||||
aria-label="New chat tab"
|
aria-label="New chat"
|
||||||
>
|
>
|
||||||
<SquarePen className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isBrowserOpen && expandedFrom && (
|
||||||
|
|
@ -5241,7 +5242,7 @@ function App() {
|
||||||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen) && (
|
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -5265,6 +5266,24 @@ function App() {
|
||||||
onClose={handleCloseBrowser}
|
onClose={handleCloseBrowser}
|
||||||
forceHidden={isSearchOpen || showMeetingPermissions}
|
forceHidden={isSearchOpen || showMeetingPermissions}
|
||||||
/>
|
/>
|
||||||
|
) : isHomeOpen ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
|
<HomeView
|
||||||
|
tree={tree}
|
||||||
|
runs={runs}
|
||||||
|
bgTaskSummaries={bgTaskSummaries}
|
||||||
|
onOpenEmail={() => openEmailView()}
|
||||||
|
onOpenMeetings={openMeetingsView}
|
||||||
|
onOpenAgents={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
|
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
|
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
|
||||||
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
|
onRunBrowserTask={handleToggleBrowser}
|
||||||
|
onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : isSuggestedTopicsOpen ? (
|
) : isSuggestedTopicsOpen ? (
|
||||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||||
<SuggestedTopicsView
|
<SuggestedTopicsView
|
||||||
|
|
@ -5350,7 +5369,6 @@ function App() {
|
||||||
currentRunId={runId}
|
currentRunId={runId}
|
||||||
processingRunIds={processingRunIds}
|
processingRunIds={processingRunIds}
|
||||||
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
onOpenInNewTab={(rid) => openChatInNewTab(rid)}
|
|
||||||
onDeleteRun={async (rid) => {
|
onDeleteRun={async (rid) => {
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke('runs:delete', { runId: rid })
|
await window.ipc.invoke('runs:delete', { runId: rid })
|
||||||
|
|
@ -5555,7 +5573,24 @@ function App() {
|
||||||
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{chatTabs.map((tab) => {
|
{(activeChatTabState.conversation.length === 0 && !activeChatTabState.currentAssistantMessage) ? (
|
||||||
|
<HomeView
|
||||||
|
tree={tree}
|
||||||
|
runs={runs}
|
||||||
|
bgTaskSummaries={bgTaskSummaries}
|
||||||
|
onOpenEmail={() => openEmailView()}
|
||||||
|
onOpenMeetings={openMeetingsView}
|
||||||
|
onOpenAgents={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||||
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
|
onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
|
onTakeMeetingNotes={() => { void handleToggleMeeting() }}
|
||||||
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
|
onRunBrowserTask={handleToggleBrowser}
|
||||||
|
onStartResearch={() => submitFromPalette('Do deep, extreme research on a topic and build me a local website that summarizes the findings. Ask me what topic to research.', null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
chatTabs.map((tab) => {
|
||||||
const isActive = tab.id === activeChatTabId
|
const isActive = tab.id === activeChatTabId
|
||||||
const tabState = getChatTabStateForRender(tab.id)
|
const tabState = getChatTabStateForRender(tab.id)
|
||||||
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
|
||||||
|
|
@ -5581,11 +5616,13 @@ function App() {
|
||||||
>
|
>
|
||||||
<ConversationContent className={tabConversationContentClassName}>
|
<ConversationContent className={tabConversationContentClassName}>
|
||||||
{!tabHasConversation ? (
|
{!tabHasConversation ? (
|
||||||
<ConversationEmptyState className="h-auto">
|
<ChatEmptyState
|
||||||
<div className="text-2xl font-semibold tracking-tight text-foreground/80 sm:text-3xl md:text-4xl">
|
wide
|
||||||
What are we working on?
|
recentRuns={runs}
|
||||||
</div>
|
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||||
</ConversationEmptyState>
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
|
onPickPrompt={setPresetMessage}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
|
|
@ -5657,15 +5694,13 @@ function App() {
|
||||||
</Conversation>
|
</Conversation>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
<div className="rowboat-composer-dock sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
||||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||||
<div className="mx-auto w-full max-w-4xl px-4">
|
<div className="mx-auto w-full max-w-4xl px-4">
|
||||||
{!hasConversation && (
|
|
||||||
<Suggestions onSelect={setPresetMessage} className="mb-3 justify-center" />
|
|
||||||
)}
|
|
||||||
{chatTabs.map((tab) => {
|
{chatTabs.map((tab) => {
|
||||||
const isActive = tab.id === activeChatTabId
|
const isActive = tab.id === activeChatTabId
|
||||||
const tabState = getChatTabStateForRender(tab.id)
|
const tabState = getChatTabStateForRender(tab.id)
|
||||||
|
|
@ -5729,10 +5764,18 @@ function App() {
|
||||||
chatTabs={chatTabs}
|
chatTabs={chatTabs}
|
||||||
activeChatTabId={activeChatTabId}
|
activeChatTabId={activeChatTabId}
|
||||||
getChatTabTitle={getChatTabTitle}
|
getChatTabTitle={getChatTabTitle}
|
||||||
isChatTabProcessing={isChatTabProcessing}
|
|
||||||
onSwitchChatTab={switchChatTab}
|
|
||||||
onCloseChatTab={closeChatTab}
|
|
||||||
onNewChatTab={handleNewChatTabInSidebar}
|
onNewChatTab={handleNewChatTabInSidebar}
|
||||||
|
recentRuns={runs}
|
||||||
|
onSelectRun={(rid) => {
|
||||||
|
const existingTab = chatTabs.find((t) => t.runId === rid)
|
||||||
|
if (existingTab) {
|
||||||
|
switchChatTab(existingTab.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChatTabs((prev) => prev.map((t) => (t.id === activeChatTabId ? { ...t, runId: rid } : t)))
|
||||||
|
loadRun(rid)
|
||||||
|
}}
|
||||||
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
onOpenFullScreen={toggleRightPaneMaximize}
|
onOpenFullScreen={toggleRightPaneMaximize}
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
currentAssistantMessage={currentAssistantMessage}
|
currentAssistantMessage={currentAssistantMessage}
|
||||||
|
|
|
||||||
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal file
106
apps/x/apps/renderer/src/components/chat-empty-state.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { ArrowUpRight, Bot, Mail, MessageSquare, NotebookPen, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
|
|
||||||
|
export interface ChatEmptyStateRun {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatEmptyStateProps {
|
||||||
|
recentRuns?: ChatEmptyStateRun[]
|
||||||
|
onSelectRun?: (runId: string) => void
|
||||||
|
onOpenChatHistory?: () => void
|
||||||
|
/** Fill the composer with a starter prompt (does not submit). */
|
||||||
|
onPickPrompt: (prompt: string) => void
|
||||||
|
/** Use a wider column — for the full-screen chat where the narrow column looks cramped. */
|
||||||
|
wide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTED_ACTIONS: { icon: typeof Mail; title: string; sub: string; prompt: string }[] = [
|
||||||
|
{ icon: Mail, title: 'Draft a reply', sub: 'to an email', prompt: "Let's draft a reply to [name]'s email" },
|
||||||
|
{ icon: Bot, title: 'Set up a background agent', sub: 'that summarizes my inbox', prompt: 'Set up a background agent that summarizes my inbox each morning' },
|
||||||
|
{ icon: NotebookPen, title: 'Take notes', sub: 'while I work', prompt: 'Take notes while I work' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty-state body for the chat surface: greeting, recent chats, and starter
|
||||||
|
* action cards. Shown in both the side-pane copilot and full-screen chat.
|
||||||
|
*/
|
||||||
|
export function ChatEmptyState({
|
||||||
|
recentRuns = [],
|
||||||
|
onSelectRun,
|
||||||
|
onOpenChatHistory,
|
||||||
|
onPickPrompt,
|
||||||
|
wide = false,
|
||||||
|
}: ChatEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('mx-auto flex w-full flex-col gap-6 px-2 py-6', wide ? 'max-w-2xl' : 'max-w-md')}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-border bg-background text-foreground">
|
||||||
|
<Sparkles className="size-[17px]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold tracking-tight">How can I help?</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Ask anything, or pick up where you left off.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentRuns.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<span className="flex-1">Recent chats</span>
|
||||||
|
{onOpenChatHistory && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenChatHistory}
|
||||||
|
className="inline-flex items-center gap-0.5 text-[11px] font-medium normal-case tracking-normal text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{recentRuns.slice(0, 4).map((run) => (
|
||||||
|
<button
|
||||||
|
key={run.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectRun?.(run.id)}
|
||||||
|
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-left hover:bg-accent"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px]">{run.title || '(Untitled chat)'}</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">{formatRelativeTime(run.createdAt)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="px-1 pb-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{recentRuns.length > 0 ? 'Or start fresh' : 'Get started'}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{SUGGESTED_ACTIONS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.title}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPickPrompt(action.prompt)}
|
||||||
|
className="flex items-start gap-2.5 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<action.icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[12.8px] font-medium">{action.title}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{action.sub}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal file
114
apps/x/apps/renderer/src/components/chat-header.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { ArrowUpRight, ChevronDown, MessageSquare, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { formatRelativeTime } from '@/lib/relative-time'
|
||||||
|
|
||||||
|
export interface ChatHeaderRecentRun {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatHeaderProps {
|
||||||
|
activeTitle: string
|
||||||
|
onNewChatTab: () => void
|
||||||
|
recentRuns?: ChatHeaderRecentRun[]
|
||||||
|
activeRunId?: string | null
|
||||||
|
onSelectRun?: (runId: string) => void
|
||||||
|
onOpenChatHistory?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header controls for the copilot/chat surface: the active-chat title with a
|
||||||
|
* recent-chats history dropdown, plus the new-chat button. Rendered identically
|
||||||
|
* whether the chat lives in the side pane (ChatSidebar) or full screen (App
|
||||||
|
* content header). There is a single chat conversation at a time — switching
|
||||||
|
* between chats happens through the history dropdown.
|
||||||
|
*/
|
||||||
|
export function ChatHeader({
|
||||||
|
activeTitle,
|
||||||
|
onNewChatTab,
|
||||||
|
recentRuns = [],
|
||||||
|
activeRunId,
|
||||||
|
onSelectRun,
|
||||||
|
onOpenChatHistory,
|
||||||
|
}: ChatHeaderProps) {
|
||||||
|
const hasHistory = recentRuns.length > 0 || Boolean(onOpenChatHistory)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasHistory ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-2 rounded-md px-3 text-sm font-medium text-foreground outline-none hover:bg-accent/60"
|
||||||
|
aria-label="Chat history"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{activeTitle}</span>
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
|
{recentRuns.length > 0 && (
|
||||||
|
<DropdownMenuLabel className="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Recent
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
)}
|
||||||
|
{recentRuns.slice(0, 6).map((run) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={run.id}
|
||||||
|
onClick={() => onSelectRun?.(run.id)}
|
||||||
|
className={cn('gap-2', activeRunId === run.id && 'bg-accent')}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{run.title || '(Untitled chat)'}</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{formatRelativeTime(run.createdAt)}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{onOpenChatHistory && (
|
||||||
|
<>
|
||||||
|
{recentRuns.length > 0 && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuItem onClick={onOpenChatHistory} className="gap-2 text-primary">
|
||||||
|
<ArrowUpRight className="size-4" />
|
||||||
|
View all chats
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2 px-3 text-sm font-medium text-foreground">
|
||||||
|
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{activeTitle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onNewChatTab}
|
||||||
|
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="New chat"
|
||||||
|
>
|
||||||
|
<Plus className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
|
import { Maximize2, Minimize2 } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { ChatHeader } from '@/components/chat-header'
|
||||||
|
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
ConversationEmptyState,
|
|
||||||
ConversationScrollButton,
|
ConversationScrollButton,
|
||||||
} from '@/components/ai-elements/conversation'
|
} from '@/components/ai-elements/conversation'
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,13 +23,12 @@ import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-c
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
import { TerminalOutput } from '@/components/terminal-output'
|
import { TerminalOutput } from '@/components/terminal-output'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
|
||||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { defaultRemarkPlugins } from 'streamdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
import { type ChatTab } from '@/components/tab-bar'
|
||||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
|
|
@ -120,10 +120,10 @@ interface ChatSidebarProps {
|
||||||
chatTabs: ChatTab[]
|
chatTabs: ChatTab[]
|
||||||
activeChatTabId: string
|
activeChatTabId: string
|
||||||
getChatTabTitle: (tab: ChatTab) => string
|
getChatTabTitle: (tab: ChatTab) => string
|
||||||
isChatTabProcessing: (tab: ChatTab) => boolean
|
|
||||||
onSwitchChatTab: (tabId: string) => void
|
|
||||||
onCloseChatTab: (tabId: string) => void
|
|
||||||
onNewChatTab: () => void
|
onNewChatTab: () => void
|
||||||
|
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||||
|
onSelectRun?: (runId: string) => void
|
||||||
|
onOpenChatHistory?: () => void
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
|
|
@ -175,10 +175,10 @@ export function ChatSidebar({
|
||||||
chatTabs,
|
chatTabs,
|
||||||
activeChatTabId,
|
activeChatTabId,
|
||||||
getChatTabTitle,
|
getChatTabTitle,
|
||||||
isChatTabProcessing,
|
|
||||||
onSwitchChatTab,
|
|
||||||
onCloseChatTab,
|
|
||||||
onNewChatTab,
|
onNewChatTab,
|
||||||
|
recentRuns = [],
|
||||||
|
onSelectRun,
|
||||||
|
onOpenChatHistory,
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
|
|
@ -327,7 +327,6 @@ export function ChatSidebar({
|
||||||
if (tabId === activeChatTabId) return activeTabState
|
if (tabId === activeChatTabId) return activeTabState
|
||||||
return chatTabStates[tabId] ?? emptyTabState
|
return chatTabStates[tabId] ?? emptyTabState
|
||||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||||
const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
|
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
|
|
@ -499,28 +498,17 @@ export function ChatSidebar({
|
||||||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabBar
|
<ChatHeader
|
||||||
tabs={chatTabs}
|
activeTitle={(() => {
|
||||||
activeTabId={activeChatTabId}
|
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||||
getTabTitle={getChatTabTitle}
|
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||||
getTabId={(tab) => tab.id}
|
})()}
|
||||||
isProcessing={isChatTabProcessing}
|
onNewChatTab={onNewChatTab}
|
||||||
onSwitchTab={onSwitchChatTab}
|
recentRuns={recentRuns}
|
||||||
onCloseTab={onCloseChatTab}
|
activeRunId={runId}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onNewChatTab}
|
|
||||||
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<SquarePen className="size-5" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{onOpenFullScreen && (
|
{onOpenFullScreen && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -565,11 +553,19 @@ export function ChatSidebar({
|
||||||
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
anchorRequestKey={viewportAnchors[tab.id]?.requestKey}
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
>
|
>
|
||||||
<ConversationContent className={tabHasConversation ? 'mx-auto w-full max-w-4xl px-3 pb-28' : 'mx-auto w-full max-w-4xl min-h-full items-center justify-center px-3 pb-0'}>
|
<ConversationContent className={cn(
|
||||||
|
'mx-auto w-full max-w-4xl px-3',
|
||||||
|
tabHasConversation ? 'pb-28' : 'pb-0',
|
||||||
|
!tabHasConversation && isMaximized && 'min-h-full items-center justify-center',
|
||||||
|
)}>
|
||||||
{!tabHasConversation ? (
|
{!tabHasConversation ? (
|
||||||
<ConversationEmptyState className="h-auto">
|
<ChatEmptyState
|
||||||
<div className="text-sm text-muted-foreground">Ask anything...</div>
|
wide={isMaximized}
|
||||||
</ConversationEmptyState>
|
recentRuns={recentRuns}
|
||||||
|
onSelectRun={onSelectRun}
|
||||||
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
|
onPickPrompt={setLocalPresetMessage}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
|
|
@ -647,9 +643,6 @@ export function ChatSidebar({
|
||||||
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
<div className="sticky bottom-0 z-10 bg-background pb-12 pt-0 shadow-lg">
|
||||||
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 -top-6 h-6 bg-linear-to-t from-background to-transparent" />
|
||||||
<div className="mx-auto w-full max-w-4xl px-3">
|
<div className="mx-auto w-full max-w-4xl px-3">
|
||||||
{!hasConversation && (
|
|
||||||
<Suggestions onSelect={setLocalPresetMessage} className="mb-3 justify-center" />
|
|
||||||
)}
|
|
||||||
{chatTabs.map((tab) => {
|
{chatTabs.map((tab) => {
|
||||||
const isActive = tab.id === activeChatTabId
|
const isActive = tab.id === activeChatTabId
|
||||||
const tabState = getTabState(tab.id)
|
const tabState = getTabState(tab.id)
|
||||||
|
|
|
||||||
503
apps/x/apps/renderer/src/components/home-view.tsx
Normal file
503
apps/x/apps/renderer/src/components/home-view.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ArrowRight, Bot, Calendar, Clock, FileText, Globe, Mail, MessageSquare, Mic, Plus, Telescope, Video } from 'lucide-react'
|
||||||
|
|
||||||
|
import { VoiceNoteButton } from '@/components/sidebar-content'
|
||||||
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
|
|
||||||
|
interface TreeNode {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
kind: 'file' | 'dir'
|
||||||
|
children?: TreeNode[]
|
||||||
|
stat?: { size: number; mtimeMs: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunItem = { id: string; title?: string; createdAt: string }
|
||||||
|
type TaskItem = { slug: string; name: string; active: boolean; lastRunAt?: string; lastAttemptAt?: string }
|
||||||
|
|
||||||
|
type HomeViewProps = {
|
||||||
|
tree: TreeNode[]
|
||||||
|
runs: RunItem[]
|
||||||
|
bgTaskSummaries: TaskItem[]
|
||||||
|
onOpenEmail: () => void
|
||||||
|
onOpenMeetings: () => void
|
||||||
|
onOpenAgents: () => void
|
||||||
|
onOpenAgent: (slug: string) => void
|
||||||
|
onOpenNote: (path: string) => void
|
||||||
|
onOpenRun: (runId: string) => void
|
||||||
|
onTakeMeetingNotes: () => void
|
||||||
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
|
onRunBrowserTask?: () => void
|
||||||
|
onStartResearch?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalEvent = {
|
||||||
|
id: string
|
||||||
|
summary: string
|
||||||
|
start: Date
|
||||||
|
end: Date | null
|
||||||
|
isAllDay: boolean
|
||||||
|
conferenceLink: string | null
|
||||||
|
rawStart: { dateTime?: string; date?: string } | undefined
|
||||||
|
rawEnd: { dateTime?: string; date?: string } | undefined
|
||||||
|
location: string | null
|
||||||
|
htmlLink: string | null
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawCalEvent = {
|
||||||
|
id?: string
|
||||||
|
summary?: string
|
||||||
|
start?: { dateTime?: string; date?: string }
|
||||||
|
end?: { dateTime?: string; date?: string }
|
||||||
|
location?: string
|
||||||
|
htmlLink?: string
|
||||||
|
status?: string
|
||||||
|
attendees?: Array<{ self?: boolean; responseStatus?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailThread = { threadId: string; subject: string; from: string }
|
||||||
|
|
||||||
|
function greeting(): string {
|
||||||
|
const h = new Date().getHours()
|
||||||
|
if (h < 12) return 'Good morning'
|
||||||
|
if (h < 18) return 'Good afternoon'
|
||||||
|
return 'Good evening'
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayLabel(): string {
|
||||||
|
return new Date().toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOfDay(d: Date): string {
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeFromNow(start: Date): string {
|
||||||
|
const ms = start.getTime() - Date.now()
|
||||||
|
if (ms <= 0) return 'now'
|
||||||
|
const min = Math.round(ms / 60000)
|
||||||
|
if (min < 60) return `in ${min}m`
|
||||||
|
const hr = Math.round(min / 60)
|
||||||
|
if (hr < 24) return `in ${hr}h`
|
||||||
|
return start.toLocaleDateString([], { weekday: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeAgo(iso?: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
if (Number.isNaN(t)) return ''
|
||||||
|
const min = Math.round((Date.now() - t) / 60000)
|
||||||
|
if (min < 1) return 'just now'
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hr = Math.round(min / 60)
|
||||||
|
if (hr < 24) return `${hr}h ago`
|
||||||
|
const d = Math.round(hr / 24)
|
||||||
|
return `${d}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllDay(s: string): Date | null {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||||
|
if (!m) return null
|
||||||
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCalEvent(raw: RawCalEvent, sourcePath: string): CalEvent | null {
|
||||||
|
if (raw.status === 'cancelled') return null
|
||||||
|
const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined'
|
||||||
|
if (declined) return null
|
||||||
|
const timed = raw.start?.dateTime
|
||||||
|
const allDay = raw.start?.date
|
||||||
|
const isAllDay = !timed && Boolean(allDay)
|
||||||
|
let start: Date | null = null
|
||||||
|
let end: Date | null = null
|
||||||
|
if (timed) {
|
||||||
|
start = new Date(timed)
|
||||||
|
end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null
|
||||||
|
} else if (allDay) {
|
||||||
|
start = parseAllDay(allDay)
|
||||||
|
end = raw.end?.date ? parseAllDay(raw.end.date) : null
|
||||||
|
}
|
||||||
|
if (!start || Number.isNaN(start.getTime())) return null
|
||||||
|
return {
|
||||||
|
id: raw.id ?? sourcePath,
|
||||||
|
summary: raw.summary?.trim() || '(No title)',
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
isAllDay,
|
||||||
|
conferenceLink: extractConferenceLink(raw as unknown as Record<string, unknown>) ?? null,
|
||||||
|
rawStart: raw.start,
|
||||||
|
rawEnd: raw.end,
|
||||||
|
location: raw.location?.trim() || null,
|
||||||
|
htmlLink: raw.htmlLink ?? null,
|
||||||
|
source: sourcePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteLabel(node: TreeNode): string {
|
||||||
|
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) return node.name.slice(0, -3)
|
||||||
|
return node.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD = 'rounded-xl border border-border bg-card p-4'
|
||||||
|
|
||||||
|
export function HomeView({
|
||||||
|
tree,
|
||||||
|
runs,
|
||||||
|
bgTaskSummaries,
|
||||||
|
onOpenEmail,
|
||||||
|
onOpenMeetings,
|
||||||
|
onOpenAgents,
|
||||||
|
onOpenAgent,
|
||||||
|
onOpenNote,
|
||||||
|
onOpenRun,
|
||||||
|
onTakeMeetingNotes,
|
||||||
|
onVoiceNoteCreated,
|
||||||
|
onRunBrowserTask,
|
||||||
|
onStartResearch,
|
||||||
|
}: HomeViewProps) {
|
||||||
|
const [events, setEvents] = useState<CalEvent[]>([])
|
||||||
|
const [emails, setEmails] = useState<EmailThread[]>([])
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const exists = await window.ipc.invoke('workspace:exists', { path: 'calendar_sync' })
|
||||||
|
if (!exists.exists) { setEvents([]); return }
|
||||||
|
const entries = await window.ipc.invoke('workspace:readdir', {
|
||||||
|
path: 'calendar_sync',
|
||||||
|
opts: { recursive: false, includeHidden: false, includeStats: false },
|
||||||
|
})
|
||||||
|
const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json'))
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
jsonEntries.map(async (entry): Promise<CalEvent | null> => {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: entry.path, encoding: 'utf8' })
|
||||||
|
return normalizeCalEvent(JSON.parse(result.data) as RawCalEvent, entry.path)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const out: CalEvent[] = []
|
||||||
|
for (const r of settled) if (r.status === 'fulfilled' && r.value) out.push(r.value)
|
||||||
|
out.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||||
|
setEvents(out)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Home: failed to load events', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadEmails = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('gmail:getImportant', { limit: 25 })
|
||||||
|
setEmails(
|
||||||
|
result.threads
|
||||||
|
.filter((t) => t.unread === true)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((t) => ({ threadId: t.threadId, subject: t.subject ?? '(No subject)', from: t.from ?? '' })),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Home: failed to load emails', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void loadEvents(); void loadEmails() }, [loadEvents, loadEmails])
|
||||||
|
|
||||||
|
// Upcoming (not-yet-ended) events, soonest first.
|
||||||
|
const upcoming = useMemo(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
return events.filter((e) => {
|
||||||
|
const end = e.end ?? (e.isAllDay ? new Date(e.start.getTime() + 864e5) : e.start)
|
||||||
|
return end.getTime() > now
|
||||||
|
})
|
||||||
|
}, [events])
|
||||||
|
|
||||||
|
const nextEvent = upcoming[0]
|
||||||
|
|
||||||
|
const todaysEvents = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return upcoming.filter((e) =>
|
||||||
|
e.start.getFullYear() === now.getFullYear() &&
|
||||||
|
e.start.getMonth() === now.getMonth() &&
|
||||||
|
e.start.getDate() === now.getDate(),
|
||||||
|
)
|
||||||
|
}, [upcoming])
|
||||||
|
|
||||||
|
const activeAgents = useMemo(() => bgTaskSummaries.filter((t) => t.active), [bgTaskSummaries])
|
||||||
|
const recentAgent = useMemo(() => {
|
||||||
|
const t = (s?: string) => (s ? new Date(s).getTime() || 0 : 0)
|
||||||
|
return [...bgTaskSummaries].sort((a, b) =>
|
||||||
|
Math.max(t(b.lastRunAt), t(b.lastAttemptAt)) - Math.max(t(a.lastRunAt), t(a.lastAttemptAt)),
|
||||||
|
)[0]
|
||||||
|
}, [bgTaskSummaries])
|
||||||
|
|
||||||
|
const recentNotes = useMemo<TreeNode[]>(() => {
|
||||||
|
const out: TreeNode[] = []
|
||||||
|
const walk = (nodes: TreeNode[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue
|
||||||
|
if (n.kind === 'file') out.push(n)
|
||||||
|
else if (n.children?.length) walk(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree)
|
||||||
|
return out
|
||||||
|
.filter((n) => n.stat?.mtimeMs)
|
||||||
|
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
||||||
|
.slice(0, 2)
|
||||||
|
}, [tree])
|
||||||
|
|
||||||
|
const recentActivity = useMemo(() => {
|
||||||
|
const items: Array<{ key: string; icon: 'note' | 'chat'; label: string; kind: string; when: number; open: () => void }> = []
|
||||||
|
for (const n of recentNotes) {
|
||||||
|
items.push({ key: `n:${n.path}`, icon: 'note', label: noteLabel(n), kind: 'note', when: n.stat?.mtimeMs ?? 0, open: () => onOpenNote(n.path) })
|
||||||
|
}
|
||||||
|
for (const r of runs.slice(0, 4)) {
|
||||||
|
items.push({ key: `r:${r.id}`, icon: 'chat', label: r.title || '(Untitled chat)', kind: 'chat', when: new Date(r.createdAt).getTime() || 0, open: () => onOpenRun(r.id) })
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => b.when - a.when).slice(0, 4)
|
||||||
|
}, [recentNotes, runs, onOpenNote, onOpenRun])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden bg-muted/30">
|
||||||
|
<div className="flex-1 overflow-y-auto px-9 py-7">
|
||||||
|
<div className="mx-auto flex max-w-[760px] flex-col gap-[18px]">
|
||||||
|
|
||||||
|
{/* Greeting */}
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h1 className="text-[28px] font-semibold tracking-tight">{greeting()}</h1>
|
||||||
|
<span className="text-sm text-muted-foreground">{todayLabel()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Up-next hero */}
|
||||||
|
{nextEvent && (
|
||||||
|
<div className="flex items-center gap-[18px] rounded-xl bg-foreground p-[18px] text-background">
|
||||||
|
<div className="flex size-[52px] shrink-0 items-center justify-center rounded-xl bg-background/10">
|
||||||
|
<Mic className="size-[22px]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 text-[11px] uppercase tracking-wide text-background/55">
|
||||||
|
Up next · {nextEvent.isAllDay ? 'today' : relativeFromNow(nextEvent.start)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-0.5 truncate text-[17px] font-medium">{nextEvent.summary}</div>
|
||||||
|
<div className="truncate text-[13px] text-background/70">
|
||||||
|
{nextEvent.isAllDay ? 'All day' : `${timeOfDay(nextEvent.start)}${nextEvent.end ? ` – ${timeOfDay(nextEvent.end)}` : ''}`}
|
||||||
|
{nextEvent.location ? ` · ${nextEvent.location}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTakeMeetingNotes}
|
||||||
|
className="rounded-md bg-background px-3.5 py-2 text-[13px] font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Take notes
|
||||||
|
</button>
|
||||||
|
{nextEvent.conferenceLink && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.open(nextEvent.conferenceLink!, '_blank')}
|
||||||
|
className="rounded-md border border-background/20 px-3 py-2 text-background"
|
||||||
|
aria-label="Join meeting"
|
||||||
|
>
|
||||||
|
<Video className="size-[13px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inbox + Background agents */}
|
||||||
|
<div className="grid grid-cols-2 gap-[18px]">
|
||||||
|
<div className={CARD}>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Mail className="size-[15px]" />
|
||||||
|
<span className="text-sm font-medium">Inbox</span>
|
||||||
|
{emails.length > 0 && (
|
||||||
|
<span className="rounded-lg bg-destructive px-1.5 py-px text-[10.5px] font-semibold uppercase tracking-wide text-white">
|
||||||
|
{emails.length} new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onOpenEmail} className="text-xs text-primary hover:underline">Open →</button>
|
||||||
|
</div>
|
||||||
|
{emails.length === 0 ? (
|
||||||
|
<div className="py-1 text-[12.5px] text-muted-foreground">No unread important email.</div>
|
||||||
|
) : emails.map((e, i) => (
|
||||||
|
<button
|
||||||
|
key={e.threadId}
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenEmail}
|
||||||
|
className={`flex w-full gap-2.5 py-[7px] text-left text-[12.5px] ${i ? 'border-t border-border' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="w-[92px] shrink-0 truncate text-muted-foreground">{formatFrom(e.from)}</span>
|
||||||
|
<span className="flex-1 truncate">{e.subject}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={CARD}>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Bot className="size-[15px]" />
|
||||||
|
<span className="text-sm font-medium">Background agents</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="text-xs text-muted-foreground">{activeAgents.length} active</span>
|
||||||
|
</div>
|
||||||
|
{recentAgent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenAgent(recentAgent.slug)}
|
||||||
|
className="flex w-full items-center gap-2.5 py-[7px] text-left text-[13px]"
|
||||||
|
>
|
||||||
|
<span className={`size-2 shrink-0 rounded-full ${recentAgent.active ? 'bg-emerald-500' : 'bg-muted-foreground'}`} />
|
||||||
|
<span className="flex-1 truncate font-medium">{recentAgent.name}</span>
|
||||||
|
<span className="text-[11.5px] text-muted-foreground">{relativeAgo(recentAgent.lastRunAt) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="py-1 text-[12.5px] text-muted-foreground">No agents yet.</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenAgents}
|
||||||
|
className="mt-3.5 flex items-center gap-2 border-t border-border pt-3 text-[12.5px] text-primary"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Create an agent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today's schedule */}
|
||||||
|
<div className={CARD}>
|
||||||
|
<div className="mb-3.5 flex items-center gap-2">
|
||||||
|
<Calendar className="size-[14px]" />
|
||||||
|
<span className="text-sm font-medium">Today's schedule</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={onOpenMeetings} className="text-xs text-primary hover:underline">All meetings →</button>
|
||||||
|
</div>
|
||||||
|
{todaysEvents.length === 0 ? (
|
||||||
|
<div className="py-1 text-[13px] italic text-muted-foreground">No more events today.</div>
|
||||||
|
) : todaysEvents.map((e, i) => (
|
||||||
|
<div key={e.id} className={`flex items-center gap-3.5 py-2 text-[13px] ${i ? 'border-t border-border' : ''}`}>
|
||||||
|
<span className="w-[90px] shrink-0 font-mono text-[11.5px] text-muted-foreground">
|
||||||
|
{e.isAllDay ? 'All day' : `${timeOfDay(e.start)}${e.end ? ` – ${timeOfDay(e.end)}` : ''}`}
|
||||||
|
</span>
|
||||||
|
<span className={`size-2 shrink-0 rounded-full ${i === 0 ? 'bg-emerald-500' : 'bg-border'}`} />
|
||||||
|
<span className="flex-1 truncate font-medium">{e.summary}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent activity */}
|
||||||
|
{recentActivity.length > 0 && (
|
||||||
|
<div className={CARD}>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="size-[14px]" />
|
||||||
|
<span className="text-sm font-medium">Recent activity</span>
|
||||||
|
</div>
|
||||||
|
{recentActivity.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.key}
|
||||||
|
type="button"
|
||||||
|
onClick={a.open}
|
||||||
|
className={`flex w-full items-center gap-3 py-2 text-left text-[13px] ${i ? 'border-t border-border' : ''}`}
|
||||||
|
>
|
||||||
|
{a.icon === 'note' ? <FileText className="size-[13px] shrink-0 text-muted-foreground" /> : <MessageSquare className="size-[13px] shrink-0 text-muted-foreground" />}
|
||||||
|
<span className="flex-1 truncate">{a.label}</span>
|
||||||
|
<span className="w-[60px] text-right text-[11px] text-muted-foreground">{a.kind}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discovery carousel */}
|
||||||
|
<DiscoveryCarousel
|
||||||
|
tips={[
|
||||||
|
{
|
||||||
|
icon: Mic,
|
||||||
|
title: 'Try a voice note',
|
||||||
|
desc: 'capture a thought out loud; the agent transcribes it and files it into Knowledge.',
|
||||||
|
action: <VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
title: 'Run a browser task',
|
||||||
|
desc: 'let the agent drive a real browser to look things up and get things done.',
|
||||||
|
onAction: onRunBrowserTask,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Telescope,
|
||||||
|
title: 'Do extreme research',
|
||||||
|
desc: 'Rowboat digs deep and builds you a local website with the findings.',
|
||||||
|
onAction: onStartResearch,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveryTip = {
|
||||||
|
icon: typeof Mic
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
/** Provide one of: an action node (e.g. VoiceNoteButton) or an onAction handler. */
|
||||||
|
action?: React.ReactNode
|
||||||
|
onAction?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscoveryCarousel({ tips }: { tips: DiscoveryTip[] }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || tips.length <= 1) return
|
||||||
|
const id = setInterval(() => setIndex((i) => (i + 1) % tips.length), 7000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [paused, tips.length])
|
||||||
|
|
||||||
|
const safeIndex = index % tips.length
|
||||||
|
const tip = tips[safeIndex]
|
||||||
|
const Icon = tip.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${CARD} flex items-center gap-3.5 border-dashed bg-transparent`}
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card">
|
||||||
|
<Icon className="size-[15px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-[13.5px] leading-snug">
|
||||||
|
<span className="font-medium">{tip.title}</span>
|
||||||
|
<span className="text-muted-foreground"> — {tip.desc}</span>
|
||||||
|
</div>
|
||||||
|
{tip.action ?? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => tip.onAction?.()}
|
||||||
|
aria-label={tip.title}
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-[15px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="ml-1 flex shrink-0 items-center gap-1">
|
||||||
|
{tips.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
aria-label={`Show tip ${i + 1}`}
|
||||||
|
className={`size-1.5 rounded-full transition-colors ${i === safeIndex ? 'bg-foreground' : 'bg-border hover:bg-muted-foreground/40'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFrom(from: string): string {
|
||||||
|
const m = /^\s*"?([^"<]+?)"?\s*<.+>\s*$/.exec(from)
|
||||||
|
return (m ? m[1] : from).trim()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue