diff --git a/package.json b/package.json index c9caafa..39bb847 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@buf/googleapis_googleapis.bufbuild_es": "2.9.0-20251009205305-72c8614f3bd0.1", - "@buf/runmedev_runme.bufbuild_es": "2.9.0-20251009190022-068cc6f56f01.1", + "@buf/runmedev_runme.bufbuild_es": "2.9.0-20251120010649-1098d5833c44.1", "@bufbuild/protobuf": "2.9.0", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-node": "^2.1.0", @@ -88,5 +88,5 @@ "vite-plugin-compression": "^0.5.1", "vitest": "^2.1.9" }, - "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad" -} \ No newline at end of file + "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b" +} diff --git a/packages/react-components/src/App.tsx b/packages/react-components/src/App.tsx index 9fbeb8c..e63bb0b 100644 --- a/packages/react-components/src/App.tsx +++ b/packages/react-components/src/App.tsx @@ -1,14 +1,13 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' -import { WebAppConfig } from '@buf/runmedev_runme.bufbuild_es/agent/v1/webapp_pb' +import { InitialConfigState } from '@buf/runmedev_runme.bufbuild_es/agent/v1/webapp_pb' import { Theme } from '@radix-ui/themes' import '@radix-ui/themes/styles.css' import Actions from './components/Actions/Actions' -import Chat from './components/Chat/Chat' +import Chat, { ChatSequence } from './components/Chat/Chat' import FileViewer from './components/Files/Viewer' import Login from './components/Login/Login' -import NotFound from './components/NotFound' import Settings from './components/Settings/Settings' import { AgentClientProvider } from './contexts/AgentContext' import { CellProvider } from './contexts/CellContext' @@ -17,6 +16,7 @@ import { SettingsProvider } from './contexts/SettingsContext' import './index.css' import Layout from './layout' import { getAccessToken } from './token' +import { NotFound } from './components' export interface AppBranding { name: string @@ -25,64 +25,69 @@ export interface AppBranding { export interface AppProps { branding: AppBranding - initialState?: { - agentEndpoint?: string - requireAuth?: boolean - webApp?: WebAppConfig + initialState?: Partial< + Omit + > & { + webApp?: Partial< + Omit + > } } -function AppRouter({ branding }: { branding: AppBranding }) { +function AppRoutes({ branding }: { branding: AppBranding }) { + const actions = + const files = return ( - - - } - middle={} - right={} - /> - } - /> - } - middle={} - right={} - /> - } - /> - OIDC routes are exclusively handled by the server. - } - /> - } - /> - } />} - /> - } />} - /> - - + + } + middle={actions} + right={files} + /> + } + /> + } + middle={actions} + right={} + /> + } + /> + + } /> + + OIDC routes are exclusively handled by the server. + } + /> + } + /> + } />} + /> + } />} + /> + ) } -function App({ branding, initialState = {} }: AppProps) { +export function AppProviders({ branding, initialState = {} }: AppProps) { return ( <> {branding.name} @@ -96,13 +101,14 @@ function App({ branding, initialState = {} }: AppProps) { > - + @@ -112,4 +118,12 @@ function App({ branding, initialState = {} }: AppProps) { ) } +function App({ branding, initialState = {} }: AppProps) { + return ( + + + + ) +} + export default App diff --git a/packages/react-components/src/components/Actions/Actions.tsx b/packages/react-components/src/components/Actions/Actions.tsx index 76df43f..f52f808 100644 --- a/packages/react-components/src/components/Actions/Actions.tsx +++ b/packages/react-components/src/components/Actions/Actions.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { create } from '@bufbuild/protobuf' import { Box, Button, Card, ScrollArea, Text } from '@radix-ui/themes' // import '@runmedev/react-console/react-console-light.css' @@ -8,8 +7,8 @@ import { Box, Button, Card, ScrollArea, Text } from '@radix-ui/themes' import { parser_pb, useCell } from '../../contexts/CellContext' import { useOutput } from '../../contexts/OutputContext' import { useSettings } from '../../contexts/SettingsContext' -import { MimeType, RunmeMetadataKey } from '../../runme/client' -import CellConsole, { fontSettings } from './CellConsole' +import { RunmeMetadataKey } from '../../runme/client' +import { fontSettings } from './CellConsole' import Editor from './Editor' import { ErrorIcon, @@ -79,10 +78,10 @@ function Action({ cell }: { cell: parser_pb.Cell }) { }, [cell, pid, exitCode]) return ( -
+
-
-
+
+
- + { cell.value = v // only sync cell value on change saveState() @@ -131,7 +131,13 @@ function Action({ cell }: { cell: parser_pb.Cell }) { ) } -function Actions() { +function Actions({ + headline = 'Actions', + scrollToLatest = true, +}: { + headline?: string + scrollToLatest?: boolean +}) { const { useColumns, addCodeCell } = useCell() const { settings } = useSettings() const { actions } = useColumns() @@ -139,6 +145,9 @@ function Actions() { const actionsEndRef = useRef(null) useEffect(() => { + if (!scrollToLatest) { + return + } if (settings.webApp.invertedOrder) { actionsStartRef.current?.scrollIntoView({ behavior: 'smooth' }) return @@ -146,65 +155,17 @@ function Actions() { actionsEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [actions, settings.webApp.invertedOrder]) - const { registerRenderer, unregisterRenderer } = useOutput() - - // Register renderers for code cells - useEffect(() => { - registerRenderer(MimeType.StatefulRunmeTerminal, { - onCellUpdate: (cell: parser_pb.Cell) => { - if (cell.kind !== parser_pb.CellKind.CODE || cell.outputs.length > 0) { - return - } - - // it's basically shell, be prepared to render a terminal - cell.outputs = [ - create(parser_pb.CellOutputSchema, { - items: [ - create(parser_pb.CellOutputItemSchema, { - mime: MimeType.StatefulRunmeTerminal, - type: 'Buffer', - data: new Uint8Array(), // todo(sebastian): pass terminal settings - }), - ], - }), - ] - }, - component: ({ - cell, - onPid, - onExitCode, - }: { - cell: parser_pb.Cell - onPid: (pid: number | null) => void - onExitCode: (exitCode: number | null) => void - }) => { - return ( - - ) - }, - }) - - return () => { - unregisterRenderer(MimeType.StatefulRunmeTerminal) - } - }, [registerRenderer, unregisterRenderer]) - return ( -
+
- Actions + {headline}
{ const sequenceLabels = screen.getAllByTestId('sequence-label') expect(sequenceLabels).toHaveLength(2) }) - - it('registers terminal renderer for any code cell with empty outputs', () => { - // Capture the registered renderer - const registerCalls = mockUseOutput().registerRenderer.mock.calls - // Render to trigger useEffect registration - render() - - const callsAfterRender = mockUseOutput().registerRenderer.mock.calls - const call = (callsAfterRender.length ? callsAfterRender : registerCalls)[0] - expect(call).toBeDefined() - - const rendererConfig = call[1] - expect(rendererConfig).toBeDefined() - expect(typeof rendererConfig.onCellUpdate).toBe('function') - - const testCell: any = { - refId: 'c1', - kind: parser_pb.CellKind.CODE, - languageId: 'python', - outputs: [], - metadata: {}, - } - - rendererConfig.onCellUpdate(testCell) - - expect(Array.isArray(testCell.outputs)).toBe(true) - expect(testCell.outputs.length).toBe(1) - }) }) diff --git a/packages/react-components/src/components/Chat/Chat.tsx b/packages/react-components/src/components/Chat/Chat.tsx index 47c2784..764422f 100644 --- a/packages/react-components/src/components/Chat/Chat.tsx +++ b/packages/react-components/src/components/Chat/Chat.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import Markdown from 'react-markdown' import { Button, Flex, ScrollArea, Text, TextArea } from '@radix-ui/themes' @@ -6,6 +6,7 @@ import { Button, Flex, ScrollArea, Text, TextArea } from '@radix-ui/themes' import { TypingCell, parser_pb, useCell } from '../../contexts/CellContext' import { useSettings } from '../../contexts/SettingsContext' import { SubmitQuestionIcon } from '../Actions/icons' +import { Action } from '../Actions/Actions' type MessageProps = { cell: parser_pb.Cell @@ -13,32 +14,39 @@ type MessageProps = { const MessageContainer = ({ role, + kind, children, }: { role: parser_pb.CellRole + kind: parser_pb.CellKind children: React.ReactNode }) => { - const self = role === parser_pb.CellRole.USER ? 'self-end' : 'self-start' - - const messageStyle = { - backgroundColor: - role === parser_pb.CellRole.USER ? 'var(--accent-9)' : 'var(--gray-a5)', - color: - role === parser_pb.CellRole.USER - ? 'var(--accent-contrast)' - : 'var(--gray-background)', - border: - role === parser_pb.CellRole.USER - ? '1px solid var(--accent-7)' - : '1px solid var(--gray-1)', + const isUser = role === parser_pb.CellRole.USER + const isTool = kind === parser_pb.CellKind.TOOL + + // Distinct styling for tool calls - more subtle than regular messages + const containerClass = `${isUser ? 'self-end' : 'self-start'} max-w-[80%] break-words m-1 p-3` + + const baseStyle = { + backgroundColor: isUser ? 'var(--accent-9)' : 'var(--gray-a5)', + color: isUser ? 'var(--accent-contrast)' : 'var(--gray-background)', + border: isUser ? '1px solid var(--accent-7)' : '1px solid var(--gray-1)', borderRadius: 'var(--radius-3)', } + // Style tool calls with outline only - no background fill + const toolStyle = isTool + ? { + backgroundColor: 'transparent', + color: 'var(--gray-10)', + border: '1px solid var(--gray-7)', + borderRadius: 'var(--radius-3)', + fontSize: '0.875rem', + } + : baseStyle + return ( -
+
{children}
) @@ -46,7 +54,7 @@ const MessageContainer = ({ const UserMessage = ({ cell }: { cell: parser_pb.Cell }) => { return ( - + {cell.value} ) @@ -54,7 +62,7 @@ const UserMessage = ({ cell }: { cell: parser_pb.Cell }) => { const AssistantMessage = ({ cell }: { cell: parser_pb.Cell }) => { return ( - + { @@ -160,14 +168,19 @@ const Message = ({ cell, isRecentCodeCell, }: MessageProps & { isRecentCodeCell?: boolean }) => { - if (cell.kind === parser_pb.CellKind.CODE) { - return ( - - ) + switch (cell.kind) { + case parser_pb.CellKind.CODE: + return ( + + ) + case parser_pb.CellKind.TOOL: + return + default: + break } switch (cell.role) { @@ -180,13 +193,20 @@ const Message = ({ } } -const ChatMessages = () => { +const ChatMessages = ({ + scrollToLatest = true, +}: { + scrollToLatest?: boolean +}) => { const { useColumns, isTyping } = useCell() const { settings } = useSettings() const { chat } = useColumns() const messagesEndRef = useRef(null) useEffect(() => { + if (!scrollToLatest) { + return + } if (settings.webApp.invertedOrder) { return } @@ -223,7 +243,7 @@ const ChatMessages = () => { ) } -const ChatInput = () => { +const ChatInput = ({ placeholder }: { placeholder?: string }) => { const { sendUserCell, isInputDisabled } = useCell() const [userInput, setUserInput] = useState('') const inputRef = useRef(null) @@ -257,7 +277,7 @@ const ChatInput = () => { value={userInput} onChange={(e) => setUserInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Enter your question" + placeholder={placeholder || 'Enter your question'} size="3" className="flex-grow min-w-0" ref={inputRef} @@ -272,6 +292,220 @@ const ChatInput = () => { ) } +const getToolMessageLabeling = (cell: parser_pb.Cell) => { + const toolName = cell.value + const isActive = !cell.executionSummary?.success + + const progressIcon = isActive ? ( + + + + ) : ( + + + + ) + + let toolLabel = toolName + let toolIcon + switch (toolName) { + case 'code': + case 'shell': + toolLabel = 'Code Generation' + toolIcon = ( + + + + + ) + break + case 'file_search': + toolLabel = 'Searching' + toolIcon = ( + + + + + ) + break + default: + toolIcon = ( + + + + + ) + } + + return { progressIcon, toolIcon, toolLabel } +} + +const textDecoder = new TextDecoder() +const ToolMessage = ({ cell }: { cell: parser_pb.Cell }) => { + const downloadMessages = useMemo(() => { + return cell.outputs.map((output) => { + return output.items.map((i) => { + const blob = new Blob([textDecoder.decode(i.data)], { type: i.mime }) + const objectUrl = URL.createObjectURL(blob) + const downloadName = `Demo-${new Date().toISOString()}.md` + return ( + + Docs ready to download at{' '} + + {downloadName} + + + ) + }) + }) + }, [cell.outputs]) + const { progressIcon, toolIcon, toolLabel } = getToolMessageLabeling(cell) + return ( + <> + {downloadMessages} + +
+ {toolIcon} + {toolLabel} + {progressIcon} +
+
+ + ) +} + +export function ChatSequence() { + const { useColumns } = useCell() + const { all } = useColumns() + + const placeholder = + all.length > 0 ? "What's next?" : 'What problem brings you here today?' + return ( +
+ {/* */} + + + {/* */} +
+ ) +} + +const Messages = () => { + const { useColumns, isTyping } = useCell() + const { settings } = useSettings() + const { all } = useColumns() + + // const recentIndex = settings.webApp.invertedOrder ? 0 : all.length - 1 + + const typingJustification = 'justify-start' + + const typingCell = ( +
+ +
+ ) + + return ( +
+ {isTyping && settings.webApp.invertedOrder && typingCell} + {all.map((cell: parser_pb.Cell) => { + switch (cell.kind) { + case parser_pb.CellKind.CODE: + return + case parser_pb.CellKind.TOOL: + return + default: + break + } + switch (cell.role) { + case parser_pb.CellRole.USER: + return + case parser_pb.CellRole.ASSISTANT: + return + default: + return null + } + })} + {isTyping && !settings.webApp.invertedOrder && typingCell} +
+ ) +} + function Chat() { const { useColumns, runCodeCell } = useCell() const { settings } = useSettings() @@ -300,7 +534,7 @@ function Chat() { const layout = settings.webApp.invertedOrder ? 'flex-col' : 'flex-col-reverse' return ( -
+
How can I help you? @@ -324,3 +558,5 @@ function Chat() { } export default Chat + +export { TypingCell } diff --git a/packages/react-components/src/components/Files/Viewer.tsx b/packages/react-components/src/components/Files/Viewer.tsx index ca48c64..0b7e343 100644 --- a/packages/react-components/src/components/Files/Viewer.tsx +++ b/packages/react-components/src/components/Files/Viewer.tsx @@ -6,7 +6,13 @@ import { Box, Link, ScrollArea, Text } from '@radix-ui/themes' import { parser_pb, useCell } from '../../contexts/CellContext' -const FileViewer = () => { +const FileViewer = ({ + headline, + scrollToLatest = true, +}: { + headline?: string + scrollToLatest?: boolean +}) => { // The code below is using "destructuring" assignment to assign certain values from the // context object return by useCell to local variables. const { useColumns } = useCell() @@ -32,17 +38,26 @@ const FileViewer = () => { // TODO(jlewi): Why do we pass in chatCells as a dependency? // sebastian: because otherwise it won't rerender when the cell changes useEffect(() => { + if (!scrollToLatest) { + return + } scrollToBottom() - }, [oneCell]) + }, [oneCell, scrollToLatest]) const hasSearchResults = oneCell.docResults.length > 0 return ( -
- - Docs - - +
+ {headline && ( + + {headline} + + )} + {!hasSearchResults ? (
No search results yet
diff --git a/packages/react-components/src/components/Settings/Settings.tsx b/packages/react-components/src/components/Settings/Settings.tsx index b0449d2..0d5ec92 100644 --- a/packages/react-components/src/components/Settings/Settings.tsx +++ b/packages/react-components/src/components/Settings/Settings.tsx @@ -25,6 +25,7 @@ export default function Settings() { const handleSave = () => { updateSettings({ agentEndpoint: endpoint, + systemShell: settings.systemShell, webApp: { runner: runnerEndpoint, reconnect: settings.webApp.reconnect, diff --git a/packages/react-components/src/components/TopNavigation.tsx b/packages/react-components/src/components/TopNavigation.tsx index b373c78..b5bce26 100644 --- a/packages/react-components/src/components/TopNavigation.tsx +++ b/packages/react-components/src/components/TopNavigation.tsx @@ -61,12 +61,13 @@ const UserAvatar = () => ( ) -const TopNavigation = () => { +const TopNavigation = ({ actions }: { actions?: React.ReactNode[] }) => { const { resetSession, exportDocument } = useCell() const navigate = useNavigate() const location = useLocation() return ( <> + {actions?.map((button) => button)}