diff --git a/app/config/settings.json b/app/config/settings.json index 8b4bb82c..69220c15 100644 --- a/app/config/settings.json +++ b/app/config/settings.json @@ -19,7 +19,7 @@ "openai": "", "anthropic": "", "google": "", - "byteplus": "6aa60576-c6ef-4835-a77a-f7e51d0637ef" + "byteplus": "" }, "endpoints": { "remote_model_url": "", @@ -64,5 +64,11 @@ "browser": { "port": 7926, "startup_ui": false + }, + "api_keys_configured": { + "openai": false, + "anthropic": false, + "google": true, + "byteplus": true } } \ No newline at end of file diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json index 57a5f62b..2abb8a91 100644 --- a/app/ui_layer/browser/frontend/package-lock.json +++ b/app/ui_layer/browser/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "craftbot-frontend", "version": "0.1.0", "dependencies": { + "@tanstack/react-virtual": "^3.10.0", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -60,7 +61,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -223,23 +223,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -1326,6 +1326,33 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1372,9 +1399,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "license": "MIT", "dependencies": { "@types/ms": "*" @@ -1430,7 +1457,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1492,7 +1518,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -1679,7 +1704,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1775,9 +1799,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1830,7 +1854,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1856,9 +1879,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "dev": true, "funding": [ { @@ -2094,9 +2117,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", "dev": true, "license": "ISC" }, @@ -2169,7 +2192,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2497,9 +2519,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4211,7 +4233,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4224,7 +4245,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4721,7 +4741,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4892,7 +4911,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json index 84def27c..6c8e3d28 100644 --- a/app/ui_layer/browser/frontend/package.json +++ b/app/ui_layer/browser/frontend/package.json @@ -10,6 +10,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@tanstack/react-virtual": "^3.10.0", "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx index 4cac6cb1..701ac879 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/TopBar.tsx @@ -4,6 +4,7 @@ import { IconButton } from '../ui' import { useTheme } from '../../contexts/ThemeContext' import { useWebSocket } from '../../contexts/WebSocketContext' import { StatusIndicator } from '../ui/StatusIndicator' +import { useDerivedAgentStatus } from '../../hooks' import styles from './TopBar.module.css' // Simple Discord icon component since lucide-react doesn't have it @@ -18,7 +19,14 @@ function DiscordIcon() { export function TopBar() { const { theme, toggleTheme } = useTheme() - const { connected, status } = useWebSocket() + const { connected, actions, messages } = useWebSocket() + + // Derive agent status from actions and messages + const derivedStatus = useDerivedAgentStatus({ + actions, + messages, + connected, + }) return (
@@ -32,12 +40,12 @@ export function TopBar() {
- {connected ? status.message : 'Disconnected'} + {derivedStatus.message}
diff --git a/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx b/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx index 9844afc5..5b22fc7b 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/MarkdownContent.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { memo } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' @@ -9,7 +9,7 @@ interface MarkdownContentProps { className?: string } -export function MarkdownContent({ content, className = '' }: MarkdownContentProps) { +export const MarkdownContent = memo(function MarkdownContent({ content, className = '' }: MarkdownContentProps) { return (
@@ -17,4 +17,4 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
) -} +}) diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css index 634343e7..0be8b207 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css @@ -85,7 +85,7 @@ .dot_working, .dot_thinking, .dot_running { - background: var(#ff4f18, #ff9878); + background: #ff4f18; } .dot_error, diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 37c64425..b4808353 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -27,6 +27,8 @@ interface WebSocketState { onboardingStep: OnboardingStep | null onboardingError: string | null onboardingLoading: boolean + // Unread message tracking + lastSeenMessageId: string | null } interface WebSocketContextType extends WebSocketState { @@ -42,6 +44,17 @@ interface WebSocketContextType extends WebSocketState { submitOnboardingStep: (value: string | string[]) => void skipOnboardingStep: () => void goBackOnboardingStep: () => void + // Unread message tracking + markMessagesAsSeen: () => void +} + +// Initialize lastSeenMessageId from localStorage +const getInitialLastSeenMessageId = (): string | null => { + try { + return localStorage.getItem('lastSeenMessageId') + } catch { + return null + } } const defaultState: WebSocketState = { @@ -71,6 +84,8 @@ const defaultState: WebSocketState = { onboardingStep: null, onboardingError: null, onboardingLoading: false, + // Unread message tracking + lastSeenMessageId: getInitialLastSeenMessageId(), } const WebSocketContext = createContext(undefined) @@ -524,6 +539,24 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + // Mark all current messages as seen + const markMessagesAsSeen = useCallback(() => { + setState(prev => { + if (prev.messages.length > 0) { + const lastId = prev.messages[prev.messages.length - 1].messageId + if (lastId && lastId !== prev.lastSeenMessageId) { + try { + localStorage.setItem('lastSeenMessageId', lastId) + } catch { + // localStorage may be unavailable + } + return { ...prev, lastSeenMessageId: lastId } + } + } + return prev + }) + }, []) + return ( {children} diff --git a/app/ui_layer/browser/frontend/src/hooks/index.ts b/app/ui_layer/browser/frontend/src/hooks/index.ts index c3c436c4..8d9a983a 100644 --- a/app/ui_layer/browser/frontend/src/hooks/index.ts +++ b/app/ui_layer/browser/frontend/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useConfirmModal } from './useConfirmModal' export type { ConfirmModalState, ConfirmOptions } from './useConfirmModal' +export { useDerivedAgentStatus } from './useDerivedAgentStatus' diff --git a/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts new file mode 100644 index 00000000..052ebb10 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/hooks/useDerivedAgentStatus.ts @@ -0,0 +1,86 @@ +import { useMemo } from 'react' +import type { ActionItem, AgentState, AgentStatus, ChatMessage } from '../types' + +interface DerivedStatusOptions { + actions: ActionItem[] + messages: ChatMessage[] + connected: boolean +} + +/** + * Derives agent status from the actions array and messages. + * + * This is more robust than relying on separate status_update messages because: + * 1. Single source of truth - actions and messages arrays contain all state + * 2. Always in sync - computed status can never be stale + * 3. Shows meaningful info - displays actual task/action names + */ +export function useDerivedAgentStatus( + options: DerivedStatusOptions +): AgentStatus { + const { actions, messages, connected } = options + + return useMemo(() => { + // If not connected, show error state + if (!connected) { + return { + state: 'error' as AgentState, + message: 'Disconnected', + loading: false, + } + } + + // Find running tasks (top-level items) + const runningTasks = actions.filter( + a => a.itemType === 'task' && a.status === 'running' + ) + + // Find waiting tasks + const waitingTasks = actions.filter( + a => a.itemType === 'task' && a.status === 'waiting' + ) + + // Priority 1: If any task is waiting for user response + if (waitingTasks.length > 0) { + const taskName = waitingTasks[0].name + return { + state: 'waiting' as AgentState, + message: `Agent is waiting response on ${taskName}`, + loading: false, + } + } + + // Priority 2: If there are running tasks, list them + if (runningTasks.length > 0) { + const taskNames = runningTasks.map(t => t.name) + const message = taskNames.length === 1 + ? `Agent is working on ${taskNames[0]}` + : `Agent is working on ${taskNames.join(', ')}` + return { + state: 'working' as AgentState, + message, + loading: true, + } + } + + // Priority 3: If the last message is from user, agent is processing it + // (no running tasks yet means agent is still thinking/preparing) + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1] + if (lastMessage.style === 'user') { + return { + state: 'working' as AgentState, + message: 'Agent is working', + loading: true, + } + } + } + + // Default: Idle state + return { + state: 'idle' as AgentState, + message: 'Agent is idle', + loading: false, + } + }, [actions, messages, connected]) +} diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx new file mode 100644 index 00000000..10e954b7 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -0,0 +1,41 @@ +import React, { memo } from 'react' +import { MarkdownContent, AttachmentDisplay } from '../../components/ui' +import type { ChatMessage as ChatMessageType } from '../../types' +import styles from './ChatPage.module.css' + +interface ChatMessageProps { + message: ChatMessageType + onOpenFile: (path: string) => void + onOpenFolder: (path: string) => void +} + +export const ChatMessageItem = memo(function ChatMessageItem({ + message, + onOpenFile, + onOpenFolder +}: ChatMessageProps) { + return ( +
+
+
+ {message.sender} + + {new Date(message.timestamp * 1000).toLocaleTimeString()} + +
+
+ +
+
+ {message.attachments && message.attachments.length > 0 && ( +
+ +
+ )} +
+ ) +}, (prev, next) => prev.message.messageId === next.message.messageId) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css index 8bdda79d..18550bde 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css @@ -82,8 +82,10 @@ .messageWrapper { display: flex; flex-direction: column; + width: fit-content; max-width: 80%; gap: var(--space-2); + padding-bottom: var(--space-3); } .messageWrapper.userWrapper { diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 732cc416..bc92cf7d 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -1,7 +1,11 @@ import React, { useState, useRef, useEffect, useLayoutEffect, KeyboardEvent, useCallback, ChangeEvent, useMemo } from 'react' import { Send, Paperclip, X, Loader2, File, AlertCircle } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useLocation } from 'react-router-dom' import { useWebSocket } from '../../contexts/WebSocketContext' -import { Button, IconButton, StatusIndicator, MarkdownContent, AttachmentDisplay } from '../../components/ui' +import { Button, IconButton, StatusIndicator } from '../../components/ui' +import { useDerivedAgentStatus } from '../../hooks' +import { ChatMessageItem } from './ChatMessage' import styles from './ChatPage.module.css' // Pending attachment type @@ -31,14 +35,28 @@ const formatFileSize = (bytes: number): string => { } export function ChatPage() { - const { messages, actions, status, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder } = useWebSocket() + const { messages, actions, connected, sendMessage, cancelTask, cancellingTaskId, openFile, openFolder, lastSeenMessageId, markMessagesAsSeen } = useWebSocket() + + // Derive agent status from actions and messages + const status = useDerivedAgentStatus({ + actions, + messages, + connected, + }) const [input, setInput] = useState('') const [pendingAttachments, setPendingAttachments] = useState([]) const [attachmentError, setAttachmentError] = useState(null) - const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) + // Virtualization refs + const parentRef = useRef(null) + const hasScrolledRef = useRef(false) + const prevMessageCountRef = useRef(0) + const prevPathRef = useRef(null) + const wasNearBottomRef = useRef(true) + const location = useLocation() + // Resizable panel state const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) const [isResizing, setIsResizing] = useState(false) @@ -64,10 +82,88 @@ export function ChatPage() { return { valid: true, error: null } }, [pendingAttachments]) - // Auto-scroll to bottom when new messages arrive + // Setup virtualizer for efficient message rendering + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 100, + overscan: 5, + }) + + // Find first unread message index, returns -1 if no unread messages + const getFirstUnreadIndex = useCallback(() => { + if (!lastSeenMessageId) return -1 // No history, no unread tracking + const lastSeenIdx = messages.findIndex(m => m.messageId === lastSeenMessageId) + if (lastSeenIdx === -1) { + return 0 // ID not found (stale) - treat all as unread, start from beginning + } + if (lastSeenIdx === messages.length - 1) { + return -1 // Already at end, no unread + } + return lastSeenIdx + 1 // First unread is after last seen + }, [messages, lastSeenMessageId]) + + // Check if user is scrolled near the bottom + const isNearBottom = useCallback(() => { + const container = parentRef.current + if (!container) return true + const threshold = 100 // pixels from bottom + return container.scrollHeight - container.scrollTop - container.clientHeight < threshold + }, []) + + // Track scroll position continuously so we know where user was BEFORE new messages arrive + useEffect(() => { + const container = parentRef.current + if (!container) return + + const handleScroll = () => { + wasNearBottomRef.current = isNearBottom() + } + + container.addEventListener('scroll', handleScroll) + return () => container.removeEventListener('scroll', handleScroll) + }, [isNearBottom]) + + // Scroll to unread messages when entering chat page, smooth scroll for new messages only if near bottom + useEffect(() => { + if (messages.length === 0) return + + const isNavigatingToChat = prevPathRef.current !== null && prevPathRef.current !== '/' && location.pathname === '/' + const isFirstLoad = prevPathRef.current === null + const isNewMessage = messages.length > prevMessageCountRef.current + const shouldScrollToUnread = (isFirstLoad || isNavigatingToChat) && !hasScrolledRef.current + + prevPathRef.current = location.pathname + prevMessageCountRef.current = messages.length + + if (shouldScrollToUnread) { + hasScrolledRef.current = true + const firstUnreadIdx = getFirstUnreadIndex() + const hasUnreadMessages = firstUnreadIdx !== -1 + // Wait for virtualizer to measure elements before scrolling + setTimeout(() => { + if (hasUnreadMessages) { + // Scroll to first unread message at the top + virtualizer.scrollToIndex(firstUnreadIdx, { align: 'start', behavior: 'auto' }) + } else { + // All messages seen - scroll to bottom + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'auto' }) + } + markMessagesAsSeen() + }, 50) + } else if (isNewMessage && location.pathname === '/' && wasNearBottomRef.current) { + // Only auto-scroll if user WAS near the bottom before new message arrived + virtualizer.scrollToIndex(messages.length - 1, { align: 'end', behavior: 'smooth' }) + markMessagesAsSeen() + } + }, [messages.length, location.pathname, virtualizer, getFirstUnreadIndex, markMessagesAsSeen]) + + // Reset scroll flag when navigating away from chat useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + if (location.pathname !== '/') { + hasScrolledRef.current = false + } + }, [location.pathname]) // Auto-resize textarea based on content const adjustTextareaHeight = useCallback(() => { @@ -235,7 +331,7 @@ export function ChatPage() {
{/* Chat Panel - flexible width */}
-
+
{messages.length === 0 ? (
@@ -248,41 +344,44 @@ export function ChatPage() {

Send a message to begin interacting with CraftBot

) : ( - messages.map((msg, idx) => ( -
-
-
- {msg.sender} - - {new Date(msg.timestamp * 1000).toLocaleTimeString()} - -
-
- -
-
- {msg.attachments && msg.attachments.length > 0 && ( -
- + {virtualizer.getVirtualItems().map((virtualItem) => { + const message = messages[virtualItem.index] + return ( +
+
- )} -
- )) + ) + })} +
)} -
{/* Status bar */}
- - {connected ? status.message : 'Disconnected'} + + {status.message}
{/* Input area */} diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx index 40c28910..3d9dd9bb 100644 --- a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -19,6 +19,7 @@ import { } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { Badge, StatusIndicator } from '../../components/ui' +import { useDerivedAgentStatus } from '../../hooks' import type { MetricsTimePeriod } from '../../types' import styles from './DashboardPage.module.css' @@ -98,7 +99,14 @@ function getChartLabels(period: MetricsTimePeriod): { title: string; description } export function DashboardPage() { - const { status, actions, dashboardMetrics, filteredMetricsCache, requestFilteredMetrics } = useWebSocket() + const { connected, actions, messages, dashboardMetrics, filteredMetricsCache, requestFilteredMetrics } = useWebSocket() + + // Derive agent status from actions and messages + const status = useDerivedAgentStatus({ + actions, + messages, + connected, + }) // Time period state for each card const [taskPeriod, setTaskPeriod] = useState('total')