chore: initial project setup

This commit is contained in:
ibelick
2026-02-05 08:27:27 +01:00
commit 0d39ce4d54
85 changed files with 17061 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"projectName": "webclaw",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "npm",
"git": true,
"install": true,
"addOnOptions": {},
"version": 1,
"framework": "react-cra",
"chosenAddOns": ["start", "eslint", "nitro"]
}
+10
View File
@@ -0,0 +1,10 @@
# WebClaw → Clawdbot Gateway connection
#
# The dashboard server connects to the Clawdbot Gateway via WebSocket.
# Keep secrets here (never in the browser).
CLAWDBOT_GATEWAY_URL=ws://127.0.0.1:18789
# Recommended auth method:
CLAWDBOT_GATEWAY_TOKEN=
# Alternative:
# CLAWDBOT_GATEWAY_PASSWORD=
+3
View File
@@ -0,0 +1,3 @@
eslint.config.js
prettier.config.js
vite.config.ts
+13
View File
@@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
todos.json
+3
View File
@@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
+11
View File
@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
+63
View File
@@ -0,0 +1,63 @@
# AGENTS
## Overview
WebClaw built with React + TanStack Router + Tailwind CSS v4.
## Commands
- `npm run dev` — Start development server
- `npm run build` — Build for production
- `npm run preview` — Preview production build
- `npm run test` — Run tests
- `npm run lint` — Run ESLint
- `npm run format` — Run Prettier
- `npm run check` — Format and lint fix
## Conventions
### Code Style
- **Functions**: Always use the `function` keyword. Avoid `const` for function definitions.
- **Types**: Always use `type T = { ... }`. Do not use `interface`.
- **File Naming**: Use `kebab-case` for all files (e.g., `chat-screen.tsx`, `use-session.ts`).
- NEVER use useEffect for anything that can be expressed as render logic
- MUST use cn utility (clsx + tailwind-merge) for class logic
### Routing & Structure
- Routes live in `src/routes` using TanStack file routing.
- Global styles and CSS variables live in `src/styles.css`.
- Local environment values go in `.env.local`.
### UI & Styling
- **Typography**: Never use font weights bolder than `font-medium`. Apply small negative tracking (`tracking-tight` or similar) on main titles.
- **Colors**:
- Use the custom Tailwind palette (e.g., `bg-primary-50`, `text-primary-900`).
- Never use arbitrary color values.
- Avoid `bg-white`, `bg-black`, `text-white`, `text-black`, and `outline-black`; use primary palette tokens instead.
- **Markdown Titles**: Avoid top margin on markdown headings.
- MUST use text-balance for headings and text-pretty for body/paragraphs
- MUST use tabular-nums for data
- SHOULD use truncate or line-clamp for dense UI
- NEVER modify letter-spacing (tracking-\*) unless explicitly requested
- MUST use a fixed z-index scale (no arbitrary z-\*)
- SHOULD use size-_ for square elements instead of w-_ + h-\*
- **Icons**:
- All icons should use `size={20}` and `strokeWidth={1.5}` consistently
- **React 19 Refs**: Use regular `function` components with direct ref passing instead of `React.forwardRef` (React 19 supports refs as regular props)
### Performance
- Avoid chat-wide rerenders while streaming: memoize large UI blocks and pass stable callbacks.
- Prefer passing derived data (maps/ids) instead of whole arrays when only lookups are needed.
- Keep prompt input state local to the composer when possible to avoid chat-wide rerenders on keystrokes.
- Memoize message rows with content-based equality and avoid passing freshly created objects that bust memoization.
- When scroll containers host frequently-updating content, memoize the scroll shell and portal the changing content to reduce root rerenders.
- Keep scroll position state inside scroll controls; avoid context state that forces scroll shells to rerender.
### Optimistic Updates
- For chat messages, write optimistic items directly into the history cache and reconcile when server history arrives (clientId/near-timestamp matching).
- For session rename/delete, optimistically update the sessions cache in mutation `onMutate`, rollback on error, then invalidate on success.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Julien Thibeaut
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# WebClaw
![Cover](./public/cover.webp)
Fast web client for OpenClaw
Currently in beta.
## Setup
Create `.env.local` with `CLAWDBOT_GATEWAY_URL` and either `CLAWDBOT_GATEWAY_TOKEN` (recommended) or `CLAWDBOT_GATEWAY_PASSWORD`. These map to your OpenClaw Gateway auth (`gateway.auth.token` or `gateway.auth.password`). Default URL is `ws://127.0.0.1:18789`. Docs: https://docs.openclaw.ai/gateway
```bash
npm install
npm run dev
```
+10
View File
@@ -0,0 +1,10 @@
// @ts-check
import { tanstackConfig } from '@tanstack/eslint-config'
export default [
...tanstackConfig,
{
ignores: ['eslint.config.js', 'prettier.config.js', 'vite.config.ts'],
},
]
+9945
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"name": "webclaw",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"marked": "^17.0.1",
"motion": "^12.29.2",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.21.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"use-stick-to-bottom": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2",
"ws": "^8.19.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}
+10
View File
@@ -0,0 +1,10 @@
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: 'all',
}
export default config
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80" fill="none">
<rect x="0.5" y="0.5" width="79" height="79" fill="oklch(0.25 0.003 80)" />
<path d="M56.343 44.1513C56.7813 43.2831 57.1357 42.581 57.408 41.6289C61.865 32.3874 62.4007 22.0378 60.0033 15.1717C59.0614 12.3089 56.9095 12.312 56.8172 14.6528C55.9163 20.6969 54.1958 24.5178 52.98 26.9262C49.9119 33.0037 45.1248 38.6396 40.4708 42.1544C32.919 47.8252 30.4248 43.4776 29.9327 38.7481C29.5892 34.7 30.1781 30.0994 31.2457 24.9825C31.8513 22.9009 29.949 21.9406 28.6338 23.664C27.147 25.7271 25.849 27.8855 24.8831 30.2116C21.9386 35.6315 20.1097 43.9829 20.8551 52.6203C21.4024 56.7713 22.0623 60.1929 22.7221 63.6144C23.5382 67.7022 26.2713 67.9925 29.8909 66.9397C40.7669 64.1786 50.8494 53.4948 54.7601 46.6301C55.474 45.8726 55.9123 45.0044 56.343 44.1513Z" fill="oklch(0.95 0.004 80)" />
</svg>

After

Width:  |  Height:  |  Size: 897 B

+15
View File
@@ -0,0 +1,15 @@
{
"short_name": "WebClaw",
"name": "WebClaw",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+28
View File
@@ -0,0 +1,28 @@
export type WebClawIconBigProps = {
className?: string
}
export function WebClawIconBig({ className }: WebClawIconBigProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className={className}
>
<rect
width="80"
height="80"
fill="currentColor"
className="text-transparent"
/>
<path
d="M61.9624 45.5729C62.5862 44.3371 63.0907 43.3377 63.4782 41.9826C69.8221 28.8286 70.5847 14.0975 67.1723 4.32469C65.8317 0.249835 62.7688 0.254311 62.6374 3.58605C61.355 12.189 58.9061 17.6275 57.1756 21.0554C52.8087 29.7058 45.9949 37.7278 39.3706 42.7305C28.6217 50.8021 25.0716 44.614 24.3711 37.8823C23.8822 32.1203 24.7206 25.5721 26.24 18.2889C27.102 15.3261 24.3944 13.9592 22.5223 16.4122C20.4062 19.3487 18.5586 22.4209 17.1838 25.7318C12.9928 33.4462 10.3896 45.3332 11.4505 57.6273C12.2296 63.5357 13.1688 68.4057 14.1079 73.2757C15.2695 79.0941 19.1598 79.5073 24.3117 78.0088C39.7921 74.0789 54.143 58.872 59.7093 49.1011C60.7255 48.0229 61.3494 46.7872 61.9624 45.5729Z"
fill="currentColor"
className="text-primary-950"
/>
</svg>
)
}
+30
View File
@@ -0,0 +1,30 @@
export type WebClawIconProps = {
className?: string
}
export function WebClawIcon({ className }: WebClawIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
className={className}
>
<rect
x="0.5"
y="0.5"
width="79"
height="79"
fill="currentColor"
className="text-primary-950"
/>
<path
d="M56.343 44.1513C56.7813 43.2831 57.1357 42.581 57.408 41.6289C61.865 32.3874 62.4007 22.0378 60.0033 15.1717C59.0614 12.3089 56.9095 12.312 56.8172 14.6528C55.9163 20.6969 54.1958 24.5178 52.98 26.9262C49.9119 33.0037 45.1248 38.6396 40.4708 42.1544C32.919 47.8252 30.4248 43.4776 29.9327 38.7481C29.5892 34.7 30.1781 30.0994 31.2457 24.9825C31.8513 22.9009 29.949 21.9406 28.6338 23.664C27.147 25.7271 25.849 27.8855 24.8831 30.2116C21.9386 35.6315 20.1097 43.9829 20.8551 52.6203C21.4024 56.7713 22.0623 60.1929 22.7221 63.6144C23.5382 67.7022 26.2713 67.9925 29.8909 66.9397C40.7669 64.1786 50.8494 53.4948 54.7601 46.6301C55.474 45.8726 55.9123 45.0044 56.343 44.1513Z"
fill="currentColor"
className="text-primary-300"
/>
</svg>
)
}
@@ -0,0 +1,201 @@
'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { ScrollButton } from './scroll-button'
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
export type ChatContainerRootProps = {
children: React.ReactNode
className?: string
onUserScroll?: (scrollTop: number) => void
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerContentProps = {
children: React.ReactNode
className?: string
} & React.HTMLAttributes<HTMLDivElement>
export type ChatContainerScrollAnchorProps = {
className?: string
ref?: React.RefObject<HTMLDivElement | null>
} & React.HTMLAttributes<HTMLDivElement>
type ChatContainerShellProps = {
className?: string
viewportRef: React.Ref<HTMLDivElement>
scrollRef: React.RefObject<HTMLDivElement | null>
viewportProps: React.HTMLAttributes<HTMLDivElement>
}
function ChatContainerShell({
className,
viewportRef,
scrollRef,
viewportProps,
}: ChatContainerShellProps) {
return (
<ScrollAreaRoot
className={cn('relative flex flex-1 min-h-0 flex-col', className)}
>
<ScrollAreaViewport
className="relative"
ref={viewportRef}
{...viewportProps}
/>
<div className="relative mx-auto w-full max-w-full px-5 sm:max-w-[768px] sm:min-w-[400px] ">
<div className="pointer-events-none absolute bottom-10 right-10 z-50">
<ScrollButton scrollRef={scrollRef} />
</div>
</div>
<ScrollAreaScrollbar orientation="vertical">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
)
}
function areViewportPropsEqual(
prevProps: React.HTMLAttributes<HTMLDivElement>,
nextProps: React.HTMLAttributes<HTMLDivElement>,
): boolean {
if (prevProps === nextProps) return true
const prevKeys = Object.keys(prevProps)
const nextKeys = Object.keys(nextProps)
if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
if (
prevProps[key as keyof React.HTMLAttributes<HTMLDivElement>] !==
nextProps[key as keyof React.HTMLAttributes<HTMLDivElement>]
) {
return false
}
}
return true
}
function areShellPropsEqual(
prevProps: ChatContainerShellProps,
nextProps: ChatContainerShellProps,
): boolean {
if (prevProps.className !== nextProps.className) return false
if (prevProps.viewportRef !== nextProps.viewportRef) return false
if (prevProps.scrollRef !== nextProps.scrollRef) return false
if (
!areViewportPropsEqual(prevProps.viewportProps, nextProps.viewportProps)
) {
return false
}
return true
}
const MemoizedChatContainerShell = React.memo(
ChatContainerShell,
areShellPropsEqual,
)
type ChatContainerPortalProps = {
viewportNode: HTMLDivElement | null
children: React.ReactNode
}
function ChatContainerPortal({
viewportNode,
children,
}: ChatContainerPortalProps) {
if (!viewportNode) return null
return createPortal(
<div className="relative flex w-full flex-col">{children}</div>,
viewportNode,
)
}
function ChatContainerRoot({
children,
className,
onUserScroll,
...props
}: ChatContainerRootProps) {
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const [viewportNode, setViewportNode] = React.useState<HTMLDivElement | null>(
null,
)
const handleViewportRef = React.useCallback(function handleViewportRef(
node: HTMLDivElement | null,
) {
scrollRef.current = node
setViewportNode(node)
}, [])
// Handle scroll events
React.useLayoutEffect(() => {
const element = scrollRef.current
if (!element) return
const handleScroll = () => {
onUserScroll?.(element.scrollTop)
}
element.addEventListener('scroll', handleScroll)
return () => element.removeEventListener('scroll', handleScroll)
}, [onUserScroll])
return (
<>
<MemoizedChatContainerShell
className={className}
viewportRef={handleViewportRef}
scrollRef={scrollRef}
viewportProps={props}
/>
<ChatContainerPortal viewportNode={viewportNode}>
{children}
</ChatContainerPortal>
</>
)
}
const MemoizedChatContainerRoot = React.memo(ChatContainerRoot)
function ChatContainerContent({
children,
className,
...props
}: ChatContainerContentProps) {
return (
<div
className={cn('flex w-full flex-col min-h-full', className)}
{...props}
>
<div className="mx-auto w-full max-w-full px-5 sm:max-w-[768px] sm:min-w-[400px] flex flex-col flex-1 min-h-full">
<div className="flex flex-col space-y-6">{children}</div>
</div>
</div>
)
}
function ChatContainerScrollAnchor({
...props
}: ChatContainerScrollAnchorProps) {
return (
<div
className="h-px w-full shrink-0 scroll-mt-4 pt-6"
aria-hidden="true"
{...props}
/>
)
}
export {
MemoizedChatContainerRoot as ChatContainerRoot,
ChatContainerContent,
ChatContainerScrollAnchor,
}
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons'
import { createHighlighter } from 'shiki'
import type { BundledLanguage, Highlighter } from 'shiki'
import { useResolvedTheme } from '@/hooks/use-chat-settings'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { formatLanguageName, normalizeLanguage, resolveLanguage } from './utils'
type CodeBlockProps = {
content: string
ariaLabel?: string
language?: string
className?: string
}
let highlighterPromise: Promise<Highlighter> | null = null
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['vitesse-light', 'vitesse-dark'],
langs: ['text'],
})
}
return highlighterPromise
}
export function CodeBlock({
content,
ariaLabel,
language = 'text',
className,
}: CodeBlockProps) {
const resolvedTheme = useResolvedTheme()
const [copied, setCopied] = useState(false)
const [html, setHtml] = useState<string | null>(null)
const [resolvedLanguage, setResolvedLanguage] = useState('text')
const [headerBg, setHeaderBg] = useState<string | undefined>()
const fallback = useMemo(() => {
return content
}, [content])
const normalizedLanguage = normalizeLanguage(language || 'text')
const themeName = resolvedTheme === 'dark' ? 'vitesse-dark' : 'vitesse-light'
useEffect(() => {
let active = true
getHighlighter()
.then(async (highlighter) => {
let lang = resolveLanguage(normalizedLanguage)
if (lang !== 'text') {
try {
await highlighter.loadLanguage(lang as BundledLanguage)
} catch {
lang = 'text'
}
}
const highlighted = highlighter.codeToHtml(content, {
lang: lang as BundledLanguage,
theme: themeName,
})
if (active) {
setResolvedLanguage(lang)
setHtml(highlighted)
const theme = highlighter.getTheme(themeName)
setHeaderBg(theme.bg)
}
})
.catch(() => {
if (active) setHtml(null)
})
return () => {
active = false
}
}, [content, normalizedLanguage, themeName])
async function handleCopy() {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
} catch {
setCopied(false)
}
}
const isSingleLine = content.split('\n').length === 1
const displayLanguage = formatLanguageName(resolvedLanguage)
return (
<div
className={cn(
'group relative min-w-0 overflow-hidden rounded-lg border border-primary-200',
className,
)}
>
<div
className={cn('flex items-center justify-between px-3 pt-2')}
style={{ backgroundColor: headerBg }}
>
<span className="text-xs font-medium text-primary-500">
{displayLanguage}
</span>
<Button
variant="ghost"
aria-label={ariaLabel ?? 'Copy code'}
className="h-auto px-0 text-xs font-medium text-primary-500 hover:text-primary-800 hover:bg-transparent"
onClick={() => {
handleCopy().catch(() => {})
}}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
size={14}
strokeWidth={1.8}
/>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
{html ? (
<div
className={cn(
'text-sm text-primary-900 [&>pre]:overflow-x-auto',
isSingleLine
? '[&>pre]:whitespace-pre [&>pre]:px-3 [&>pre]:py-2'
: '[&>pre]:px-3 [&>pre]:py-3',
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre
className={cn(
'text-sm',
isSingleLine ? 'whitespace-pre px-3 py-2' : 'px-3 py-3',
)}
>
<code className="overflow-x-auto">{fallback}</code>
</pre>
)}
</div>
)
}
@@ -0,0 +1,50 @@
import { bundledLanguages } from 'shiki'
const LANGUAGE_ALIASES: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
tsx: 'tsx',
jsx: 'jsx',
typescriptreact: 'tsx',
javascriptreact: 'jsx',
react: 'jsx',
sh: 'bash',
shell: 'bash',
yml: 'yaml',
md: 'markdown',
txt: 'text',
}
export function normalizeLanguage(language: string): string {
const cleaned = language
.trim()
.toLowerCase()
.replace(/^language-/, '')
.replace(/^\[|\]$/g, '')
const token = cleaned.split(/[\s,]+/)[0] || 'text'
return LANGUAGE_ALIASES[token] ?? token
}
export function resolveLanguage(language: string): string {
const normalized = normalizeLanguage(language)
return normalized in bundledLanguages ? normalized : 'text'
}
export function formatLanguageName(language: string): string {
const names: Record<string, string> = {
bash: 'Bash',
python: 'Python',
javascript: 'JavaScript',
typescript: 'TypeScript',
tsx: 'TSX',
jsx: 'JSX',
json: 'JSON',
html: 'HTML',
css: 'CSS',
sql: 'SQL',
yaml: 'YAML',
markdown: 'Markdown',
text: 'Plain Text',
}
return names[language] || language.charAt(0).toUpperCase() + language.slice(1)
}
+195
View File
@@ -0,0 +1,195 @@
import { marked } from 'marked'
import { memo, useId, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './code-block'
import type { Components } from 'react-markdown'
import { cn } from '@/lib/utils'
export type MarkdownProps = {
children: string
id?: string
className?: string
components?: Partial<Components>
}
function parseMarkdownIntoBlocks(markdown: string): Array<string> {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
}
function extractLanguage(className?: string): string {
if (!className) return 'text'
const match = className.match(/language-(\w+)/)
return match ? match[1] : 'text'
}
const INITIAL_COMPONENTS: Partial<Components> = {
code: function CodeComponent({ className, children }) {
const isInline = !className?.includes('language-')
if (isInline) {
return (
<code className="rounded bg-primary-100 px-1.5 py-1 text-sm font-mono text-primary-900 border border-primary-200">
{children}
</code>
)
}
const language = extractLanguage(className)
return (
<CodeBlock
content={String(children ?? '')}
language={language}
className="w-full"
/>
)
},
pre: function PreComponent({ children }) {
return <>{children}</>
},
h1: function H1Component({ children }) {
return <h1 className="text-xl font-medium text-primary-950">{children}</h1>
},
h2: function H2Component({ children }) {
return <h2 className="text-lg font-medium text-primary-900">{children}</h2>
},
h3: function H3Component({ children }) {
return <h3 className="font-medium text-primary-900">{children}</h3>
},
p: function PComponent({ children }) {
return (
<p className="text-primary-950 text-pretty leading-relaxed">{children}</p>
)
},
ul: function UlComponent({ children }) {
return (
<ul className="ml-4 list-disc text-primary-950 marker:text-primary-400">
{children}
</ul>
)
},
ol: function OlComponent({ children }) {
return (
<ol className="ml-4 list-decimal text-primary-950 marker:text-primary-500">
{children}
</ol>
)
},
li: function LiComponent({ children }) {
return <li className="leading-relaxed">{children}</li>
},
a: function AComponent({ children, href }) {
return (
<a
href={href}
className="text-primary-950 underline decoration-primary-300 underline-offset-4 transition-colors hover:text-primary-950 hover:decoration-primary-500"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
},
blockquote: function BlockquoteComponent({ children }) {
return (
<blockquote className="border-l-2 border-primary-300 pl-4 text-primary-900 italic">
{children}
</blockquote>
)
},
strong: function StrongComponent({ children }) {
return <strong className="font-medium text-primary-950">{children}</strong>
},
em: function EmComponent({ children }) {
return <em className="italic text-primary-950">{children}</em>
},
hr: function HrComponent() {
return <hr className="my-3 border-primary-200" />
},
table: function TableComponent({ children }) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">{children}</table>
</div>
)
},
thead: function TheadComponent({ children }) {
return (
<thead className="border-b border-primary-200 bg-primary-50">
{children}
</thead>
)
},
tbody: function TbodyComponent({ children }) {
return <tbody className="divide-y divide-primary-100">{children}</tbody>
},
tr: function TrComponent({ children }) {
return (
<tr className="transition-colors hover:bg-primary-50/50">{children}</tr>
)
},
th: function ThComponent({ children }) {
return (
<th className="px-3 py-2 text-left font-medium text-primary-950">
{children}
</th>
)
},
td: function TdComponent({ children }) {
return <td className="px-3 py-2 text-primary-950">{children}</td>
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
components = INITIAL_COMPONENTS,
}: {
content: string
components?: Partial<Components>
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={components}
>
{content}
</ReactMarkdown>
)
},
function propsAreEqual(prevProps, nextProps) {
return prevProps.content === nextProps.content
},
)
MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock'
function MarkdownComponent({
children,
id,
className,
components = INITIAL_COMPONENTS,
}: MarkdownProps) {
const generatedId = useId()
const blockId = id ?? generatedId
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
return (
<div className={cn('flex flex-col gap-2', className)}>
{blocks.map((block, index) => (
<MemoizedMarkdownBlock
key={`${blockId}-block-${index}`}
content={block}
components={components}
/>
))}
</div>
)
}
const Markdown = memo(MarkdownComponent)
Markdown.displayName = 'Markdown'
export { Markdown }
+124
View File
@@ -0,0 +1,124 @@
import { Avatar } from '@base-ui/react/avatar'
import { Markdown } from './markdown'
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export type MessageProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
function Message({ children, className, ...props }: MessageProps) {
return (
<div className={cn('flex gap-3 w-full', className)} {...props}>
{children}
</div>
)
}
export type MessageAvatarProps = {
src: string
alt: string
fallback?: string
delayMs?: number
className?: string
}
function MessageAvatar({
src,
alt,
fallback,
delayMs,
className,
}: MessageAvatarProps) {
return (
<Avatar.Root className={cn('h-8 w-8 shrink-0', className)}>
<Avatar.Image src={src} alt={alt} />
{fallback && (
<Avatar.Fallback delay={delayMs}>{fallback}</Avatar.Fallback>
)}
</Avatar.Root>
)
}
export type MessageContentProps = {
children: React.ReactNode
markdown?: boolean
className?: string
} & React.ComponentProps<typeof Markdown> &
React.HTMLProps<HTMLDivElement>
function MessageContent({
children,
markdown = false,
className,
...props
}: MessageContentProps) {
const classNames = cn(
'rounded-[12px] break-words whitespace-normal min-w-0',
className,
)
return markdown ? (
<Markdown className={classNames} {...props}>
{children as string}
</Markdown>
) : (
<div className={classNames} {...props}>
{children}
</div>
)
}
export type MessageActionsProps = {
children: React.ReactNode
className?: string
} & React.HTMLProps<HTMLDivElement>
function MessageActions({
children,
className,
...props
}: MessageActionsProps) {
return (
<div
className={cn('text-primary-600 flex items-center gap-2', className)}
{...props}
>
{children}
</div>
)
}
export type MessageActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: 'top' | 'bottom' | 'left' | 'right'
} & React.ComponentProps<typeof TooltipRoot>
function MessageAction({
tooltip,
children,
className,
side = 'top',
...props
}: MessageActionProps) {
return (
<TooltipProvider>
<TooltipRoot {...props}>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
)
}
export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }
+273
View File
@@ -0,0 +1,273 @@
'use client'
import React, {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react'
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
type PromptInputContextType = {
isLoading: boolean
value: string
setValue: (value: string) => void
maxHeight: number | string
onSubmit?: () => void
disabled?: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
const PromptInputContext = createContext<PromptInputContextType>({
isLoading: false,
value: '',
setValue: () => {},
maxHeight: 240,
onSubmit: undefined,
disabled: false,
textareaRef: React.createRef<HTMLTextAreaElement>(),
})
let globalPromptTarget: HTMLTextAreaElement | null = null
let isGlobalListenerBound = false
function bindGlobalPromptListener() {
if (isGlobalListenerBound || typeof window === 'undefined') return
isGlobalListenerBound = true
window.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return
if (event.metaKey || event.ctrlKey || event.altKey) return
const target = event.target as HTMLElement | null
if (!target) return
const tag = target.tagName.toLowerCase()
if (
tag === 'input' ||
tag === 'textarea' ||
tag === 'select' ||
target.isContentEditable
) {
return
}
const isPrintable = event.key.length === 1
const isEditKey = event.key === 'Backspace'
if (!isPrintable && !isEditKey) return
if (!globalPromptTarget || globalPromptTarget.disabled) return
globalPromptTarget.focus()
})
}
function usePromptInput() {
return useContext(PromptInputContext)
}
export type PromptInputProps = {
isLoading?: boolean
value?: string
onValueChange?: (value: string) => void
maxHeight?: number | string
onSubmit?: () => void
children: React.ReactNode
className?: string
disabled?: boolean
} & React.ComponentProps<'div'>
function PromptInput({
className,
isLoading = false,
maxHeight = 240,
value,
onValueChange,
onSubmit,
children,
disabled = false,
onClick,
...props
}: PromptInputProps) {
const [internalValue, setInternalValue] = useState(value || '')
const textareaRef = useRef<HTMLTextAreaElement>(null)
bindGlobalPromptListener()
function handleChange(newValue: string) {
setInternalValue(newValue)
onValueChange?.(newValue)
}
function handleClick(e: React.MouseEvent<HTMLDivElement>) {
if (!disabled) textareaRef.current?.focus()
onClick?.(e)
}
return (
<TooltipProvider>
<PromptInputContext.Provider
value={{
isLoading,
value: value ?? internalValue,
setValue: onValueChange ?? handleChange,
maxHeight,
onSubmit,
disabled,
textareaRef,
}}
>
<div
onClick={handleClick}
className={cn(
'bg-surface cursor-text rounded-[22px] outline outline-ink/10 shadow-[0px_12px_32px_0px_rgba(0,0,0,0.05)] py-3 gap-3 flex flex-col',
disabled && 'cursor-not-allowed opacity-60',
className,
)}
{...props}
>
{children}
</div>
</PromptInputContext.Provider>
</TooltipProvider>
)
}
export type PromptInputTextareaProps = {
disableAutosize?: boolean
inputRef?: React.Ref<HTMLTextAreaElement>
} & React.ComponentProps<'textarea'>
function PromptInputTextarea({
className,
onKeyDown,
disableAutosize = false,
inputRef,
...props
}: PromptInputTextareaProps) {
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
usePromptInput()
function adjustHeight(el: HTMLTextAreaElement | null) {
if (!el || disableAutosize) return
el.style.height = 'auto'
if (typeof maxHeight === 'number') {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
}
function handleRef(el: HTMLTextAreaElement | null) {
textareaRef.current = el
if (typeof inputRef === 'function') {
inputRef(el)
} else if (inputRef && 'current' in inputRef) {
inputRef.current = el
}
if (el) {
globalPromptTarget = el
} else if (globalPromptTarget === el) {
globalPromptTarget = null
}
adjustHeight(el)
}
useLayoutEffect(() => {
if (!textareaRef.current || disableAutosize) return
const el = textareaRef.current
el.style.height = 'auto'
if (typeof maxHeight === 'number') {
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
} else {
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
}
}, [value, maxHeight, disableAutosize])
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
adjustHeight(e.target)
setValue(e.target.value)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit?.()
}
onKeyDown?.(e)
}
return (
<textarea
ref={handleRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className={cn(
'text-primary-950 min-h-[28px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 pl-4 pr-1 text-[15px] placeholder:text-primary-500',
className,
)}
rows={1}
readOnly={disabled}
aria-disabled={disabled}
{...props}
/>
)
}
export type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
function PromptInputActions({
children,
className,
...props
}: PromptInputActionsProps) {
return (
<div className={cn('flex items-center gap-2', className)} {...props}>
{children}
</div>
)
}
export type PromptInputActionProps = {
className?: string
tooltip: React.ReactNode
children: React.ReactNode
side?: 'top' | 'bottom' | 'left' | 'right'
} & React.ComponentProps<typeof TooltipRoot>
function PromptInputAction({
tooltip,
children,
className,
side = 'top',
...props
}: PromptInputActionProps) {
const { disabled } = usePromptInput()
return (
<TooltipRoot {...props}>
<TooltipTrigger
disabled={disabled}
onClick={(event) => event.stopPropagation()}
>
{children}
</TooltipTrigger>
<TooltipContent side={side} className={className}>
{tooltip}
</TooltipContent>
</TooltipRoot>
)
}
export {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputAction,
}
+102
View File
@@ -0,0 +1,102 @@
'use client'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import type { VariantProps } from 'class-variance-authority'
import type { buttonVariants } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type ScrollButtonProps = {
className?: string
scrollRef: React.RefObject<HTMLDivElement | null>
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
} & React.ButtonHTMLAttributes<HTMLButtonElement>
function ScrollButton({
className,
variant = 'outline',
scrollRef,
...props
}: ScrollButtonProps) {
const [isAtBottom, setIsAtBottom] = useState(true)
const [showButton, setShowButton] = useState(false)
const lastScrollTopRef = useRef(0)
const checkIsAtBottom = useCallback(() => {
const element = scrollRef.current
if (!element) return
const isBottom =
Math.abs(
element.scrollHeight - element.scrollTop - element.clientHeight,
) < 100
setIsAtBottom(isBottom)
}, [scrollRef])
useLayoutEffect(() => {
const element = scrollRef.current
if (!element) return
const handleScroll = () => {
lastScrollTopRef.current = element.scrollTop
checkIsAtBottom()
}
const observer = new MutationObserver(() => {
if (!element) return
if (element.scrollTop !== lastScrollTopRef.current) {
lastScrollTopRef.current = element.scrollTop
}
checkIsAtBottom()
})
checkIsAtBottom()
element.addEventListener('scroll', handleScroll)
observer.observe(element, { childList: true, subtree: true })
return () => {
element.removeEventListener('scroll', handleScroll)
observer.disconnect()
}
}, [checkIsAtBottom, scrollRef])
useLayoutEffect(() => {
if (isAtBottom) {
setShowButton(false)
return
}
const timer = window.setTimeout(() => {
setShowButton(true)
}, 200)
return () => window.clearTimeout(timer)
}, [isAtBottom])
return (
<Button
variant="secondary"
size="icon-sm"
className={cn(
'pointer-events-auto rounded-full shadow-md',
'transition-all duration-100 ease-in-out',
!isAtBottom && showButton
? 'translate-y-0 scale-100 opacity-100'
: 'pointer-events-none translate-y-4 scale-98 opacity-0',
className,
)}
onClick={() => {
const element = scrollRef.current
if (!element) return
element.scrollTop = element.scrollHeight
setIsAtBottom(true)
}}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} size={18} strokeWidth={1.8} />
</Button>
)
}
export { ScrollButton }
@@ -0,0 +1,39 @@
'use client'
import { cn } from '@/lib/utils'
export type TextShimmerProps = {
as?: string
duration?: number
spread?: number
children: React.ReactNode
} & React.HTMLAttributes<HTMLElement>
export function TextShimmer({
as = 'span',
className,
duration = 4,
spread = 20,
children,
...props
}: TextShimmerProps) {
const dynamicSpread = Math.min(Math.max(spread, 5), 45)
const Component = as as React.ElementType
return (
<Component
className={cn(
'bg-size-[200%_auto] bg-clip-text font-medium text-transparent',
'animate-[shimmer_4s_infinite_linear]',
className,
)}
style={{
backgroundImage: `linear-gradient(to right, var(--color-primary-600) ${50 - dynamicSpread}%, var(--color-primary-950) 50%, var(--color-primary-600) ${50 + dynamicSpread}%)`,
animationDuration: `${duration}s`,
}}
{...props}
>
{children}
</Component>
)
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import {
Collapsible,
CollapsibleTrigger,
CollapsiblePanel,
} from '@/components/ui/collapsible'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
export type ThinkingProps = {
content: string
}
function Thinking({ content }: ThinkingProps) {
return (
<div className="inline-flex flex-col">
<Collapsible>
<CollapsibleTrigger
render={
<Button
variant="ghost"
className="h-auto gap-1.5 px-1.5 py-0.5 -mx-2"
/>
}
>
<span className="text-sm font-medium text-primary-900">Thinking</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
size={14}
strokeWidth={1.5}
className="text-primary-900 transition-transform duration-150 group-data-panel-open:rotate-180"
/>
</CollapsibleTrigger>
<CollapsiblePanel>
<div className="pt-1 mb-3">
<p className="text-sm text-primary-700 whitespace-pre-wrap">
{content}
</p>
</div>
</CollapsiblePanel>
</Collapsible>
</div>
)
}
export { Thinking }
+137
View File
@@ -0,0 +1,137 @@
'use client'
import {
Collapsible,
CollapsibleTrigger,
CollapsiblePanel,
} from '@/components/ui/collapsible'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
export type ToolPart = {
type: string
state:
| 'input-streaming'
| 'input-available'
| 'output-available'
| 'output-error'
input?: Record<string, unknown>
output?: Record<string, unknown>
toolCallId?: string
errorText?: string
}
export type ToolProps = {
toolPart: ToolPart
defaultOpen?: boolean
}
function Tool({ toolPart, defaultOpen = false }: ToolProps) {
const { state, input, output, toolCallId } = toolPart
const formatValue = (value: unknown): unknown => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string') {
// Try to parse as JSON for pretty display
try {
const parsed = JSON.parse(value)
return parsed
} catch {
return value
}
}
return value
}
const renderValue = (value: unknown): React.ReactNode => {
const formatted = formatValue(value)
if (typeof formatted === 'object' && formatted !== null) {
return (
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed">
{JSON.stringify(formatted, null, 2)}
</pre>
)
}
return <span className="break-all">{String(formatted)}</span>
}
return (
<div className="inline-flex flex-col">
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger
render={
<Button
variant="ghost"
className="h-auto gap-1.5 px-1.5 py-0.5 -mx-2"
/>
}
>
<span className="text-sm font-medium text-primary-900">
{toolPart.type}
</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
size={14}
strokeWidth={1.5}
className="text-primary-900 transition-transform duration-150 group-data-panel-open:rotate-180"
/>
</CollapsibleTrigger>
<CollapsiblePanel className="mt-1">
<div className="space-y-2 bg-primary-100 p-2 border border-primary-200">
{input && Object.keys(input).length > 0 && (
<div className="border border-primary-200 bg-primary-50 p-3">
<h4 className="text-primary-600 mb-2 text-xs font-medium">
Input
</h4>
<div className="max-h-40 overflow-auto space-y-2 font-mono text-xs text-primary-800">
{Object.entries(input).map(([key, value]) => (
<div key={key} className="break-all">
<span className="text-primary-500">{key}:</span>{' '}
<span className="text-primary-700">
{renderValue(value)}
</span>
</div>
))}
</div>
</div>
)}
{output && (
<div className="border border-primary-200 bg-primary-50 p-3">
<h4 className="text-primary-600 mb-2 text-xs font-medium">
Output
</h4>
<div className="max-h-40 overflow-auto font-mono text-xs text-primary-800">
{renderValue(output)}
</div>
</div>
)}
{state === 'output-error' && toolPart.errorText && (
<div className="rounded-md bg-red-50 p-2">
<h4 className="mb-1 text-xs font-medium text-red-600">Error</h4>
<div className="text-xs text-red-700">{toolPart.errorText}</div>
</div>
)}
{state === 'input-streaming' && (
<div className="text-primary-500 text-xs">Processing...</div>
)}
{toolCallId && (
<div className="text-primary-400 text-xs">
<span className="font-mono tabular-nums">
ID: {toolCallId.slice(0, 16)}...
</span>
</div>
)}
</div>
</CollapsiblePanel>
</Collapsible>
</div>
)
}
export { Tool }
@@ -0,0 +1,29 @@
'use client'
import { TextShimmer } from './text-shimmer'
import { cn } from '@/lib/utils'
export type TypingIndicatorProps = {
className?: string
}
function TypingIndicator({ className }: TypingIndicatorProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
<div className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary-400 opacity-75" />
<span
className="relative inline-flex rounded-full h-1.5 w-1.5 bg-size-[200%_auto] animate-[shimmer_2s_infinite_linear]"
style={{
backgroundImage: `linear-gradient(to right, var(--color-primary-600) 0%, var(--color-primary-950) 50%, var(--color-primary-600) 100%)`,
}}
/>
</div>
<TextShimmer className="text-sm" duration={2}>
Generating...
</TextShimmer>
</div>
)
}
export { TypingIndicator }
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { AlertDialog } from '@base-ui/react/alert-dialog'
import { Button } from './button'
import { cn } from '@/lib/utils'
type AlertDialogRootProps = React.ComponentProps<typeof AlertDialog.Root>
function AlertDialogRoot({ children, ...props }: AlertDialogRootProps) {
return <AlertDialog.Root {...props}>{children}</AlertDialog.Root>
}
type AlertDialogTriggerProps = React.ComponentProps<typeof AlertDialog.Trigger>
function AlertDialogTrigger({ className, ...props }: AlertDialogTriggerProps) {
return <AlertDialog.Trigger className={cn(className)} {...props} />
}
type AlertDialogContentProps = {
className?: string
children: React.ReactNode
}
function AlertDialogContent({ className, children }: AlertDialogContentProps) {
return (
<AlertDialog.Portal>
<AlertDialog.Backdrop className="fixed inset-0 bg-primary-950/20 transition-all duration-150 data-[state=open]:opacity-100 data-[state=closed]:opacity-0" />
<AlertDialog.Popup
className={cn(
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'w-[min(400px,92vw)] rounded-xl border border-primary-200 bg-primary-50 p-0 shadow-xl',
'transition-all duration-150',
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
'data-[state=open]:scale-100 data-[state=closed]:scale-95',
className,
)}
>
{children}
</AlertDialog.Popup>
</AlertDialog.Portal>
)
}
type AlertDialogTitleProps = React.ComponentProps<typeof AlertDialog.Title>
function AlertDialogTitle({ className, ...props }: AlertDialogTitleProps) {
return (
<AlertDialog.Title
className={cn('text-lg font-medium text-primary-900', className)}
{...props}
/>
)
}
type AlertDialogDescriptionProps = React.ComponentProps<
typeof AlertDialog.Description
>
function AlertDialogDescription({
className,
...props
}: AlertDialogDescriptionProps) {
return (
<AlertDialog.Description
className={cn('text-sm text-primary-600', className)}
{...props}
/>
)
}
type AlertDialogCancelProps = React.ComponentProps<typeof AlertDialog.Close>
function AlertDialogCancel({ className, ...props }: AlertDialogCancelProps) {
return (
<AlertDialog.Close
render={<Button variant="outline" className={cn(className)} />}
{...props}
/>
)
}
type AlertDialogActionProps = React.ComponentProps<typeof AlertDialog.Close>
function AlertDialogAction({ className, ...props }: AlertDialogActionProps) {
return (
<AlertDialog.Close
render={<Button variant="destructive" className={cn(className)} />}
{...props}
/>
)
}
export {
AlertDialogRoot,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
import type * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-primary-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0] select-none duration-150',
{
defaultVariants: {
size: 'default',
variant: 'default',
},
variants: {
size: {
default: 'h-9 px-4',
sm: 'h-8 px-3',
lg: 'h-10 px-5',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-md': 'size-10',
'icon-xl': 'size-11 [&_svg]:size-5',
},
variant: {
default:
'bg-primary-950 text-primary-50 hover:bg-primary-900 shadow-sm outline outline-primary-900/10 shadow-2xs',
secondary:
'bg-primary-50 text-primary-950 hover:bg-primary-200 outline outline-primary-900/10 shadow-2xs',
outline:
'border-primary-200 bg-transparent text-primary-900 hover:bg-primary-50 shadow-2xs outline outline-primary-900/10',
ghost: 'text-primary-900 hover:bg-primary-200 hover:text-primary-950',
destructive: 'bg-red-600 text-primary-50 hover:bg-red-700 shadow-sm',
},
},
},
)
interface ButtonProps extends useRender.ComponentProps<'button'> {
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
}
function Button({ className, variant, size, render, ...props }: ButtonProps) {
const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>['type'] =
render ? undefined : 'button'
const defaultProps = {
className: cn(buttonVariants({ className, size, variant })),
'data-slot': 'button',
type: typeValue,
}
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(defaultProps, props),
render,
})
}
export { Button, buttonVariants }
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Collapsible(props: React.ComponentProps<typeof BaseCollapsible.Root>) {
return <BaseCollapsible.Root {...props} />
}
function CollapsibleTrigger({
className,
...props
}: React.ComponentProps<typeof BaseCollapsible.Trigger>) {
return (
<BaseCollapsible.Trigger
className={cn(
'group inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-left text-xs font-medium text-primary-500 transition-colors hover:bg-primary-100 hover:text-primary-700 data-panel-open:text-primary-700',
className,
)}
{...props}
/>
)
}
type CollapsiblePanelProps = React.ComponentProps<
typeof BaseCollapsible.Panel
> & {
contentClassName?: string
}
function CollapsiblePanel({
className,
contentClassName,
children,
...props
}: CollapsiblePanelProps) {
return (
<BaseCollapsible.Panel
className={cn(
'flex h-(--collapsible-panel-height) flex-col overflow-hidden text-sm transition-all duration-150 ease-out data-ending-style:h-0 data-starting-style:h-0 [&[hidden]:not([hidden="until-found"])]:hidden',
className,
)}
{...props}
>
<div className={cn('pt-1', contentClassName)}>{children}</div>
</BaseCollapsible.Panel>
)
}
export { Collapsible, CollapsibleTrigger, CollapsiblePanel }
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { Dialog } from '@base-ui/react/dialog'
import { Button } from './button'
import { cn } from '@/lib/utils'
type DialogRootProps = React.ComponentProps<typeof Dialog.Root>
function DialogRoot({ children, ...props }: DialogRootProps) {
return <Dialog.Root {...props}>{children}</Dialog.Root>
}
type DialogTriggerProps = React.ComponentProps<typeof Dialog.Trigger>
function DialogTrigger({ className, ...props }: DialogTriggerProps) {
return <Dialog.Trigger className={cn(className)} {...props} />
}
type DialogContentProps = {
className?: string
children: React.ReactNode
}
function DialogContent({ className, children }: DialogContentProps) {
return (
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 bg-ink/40 transition-all duration-150 data-[state=open]:opacity-100 data-[state=closed]:opacity-0 dark:bg-surface/40" />
<Dialog.Popup
className={cn(
'fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'w-[min(400px,92vw)] rounded-[20px] border border-primary-200 bg-primary-50 p-0 shadow-lg',
'transition-all duration-150',
'data-[state=open]:opacity-100 data-[state=closed]:opacity-0',
'data-[state=open]:scale-100 data-[state=closed]:scale-95',
className,
)}
>
{children}
</Dialog.Popup>
</Dialog.Portal>
)
}
type DialogTitleProps = React.ComponentProps<typeof Dialog.Title>
function DialogTitle({ className, ...props }: DialogTitleProps) {
return (
<Dialog.Title
className={cn('text-lg font-medium text-primary-900', className)}
{...props}
/>
)
}
type DialogDescriptionProps = React.ComponentProps<typeof Dialog.Description>
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
return (
<Dialog.Description
className={cn('text-sm text-primary-600', className)}
{...props}
/>
)
}
type DialogCloseProps = React.ComponentProps<typeof Dialog.Close> & {
render?: React.ReactElement
}
function DialogClose({ className, render, ...props }: DialogCloseProps) {
return (
<Dialog.Close
render={render || <Button variant="outline" className={cn(className)} />}
{...props}
/>
)
}
export {
DialogRoot,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { Menu } from '@base-ui/react/menu'
import { cn } from '@/lib/utils'
type MenuRootProps = React.ComponentProps<typeof Menu.Root>
function MenuRoot({ children, ...props }: MenuRootProps) {
return <Menu.Root {...props}>{children}</Menu.Root>
}
type MenuTriggerProps = React.ComponentProps<typeof Menu.Trigger>
function MenuTrigger({ className, ...props }: MenuTriggerProps) {
return <Menu.Trigger className={cn(className)} {...props} />
}
type MenuContentProps = {
className?: string
side?: 'top' | 'bottom' | 'left' | 'right'
align?: 'start' | 'center' | 'end'
children: React.ReactNode
}
function MenuContent({
className,
side = 'bottom',
align = 'end',
children,
}: MenuContentProps) {
return (
<Menu.Portal>
<Menu.Positioner side={side} align={align}>
<Menu.Popup
className={cn(
'min-w-[110px] rounded-lg bg-primary-50 p-1 text-sm text-primary-900 shadow-lg outline outline-primary-900/10',
className,
)}
>
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
)
}
type MenuItemProps = React.ComponentProps<typeof Menu.Item>
function MenuItem({ className, ...props }: MenuItemProps) {
return (
<Menu.Item
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-primary-900 hover:bg-primary-100 data-highlighted:bg-primary-100',
'select-none font-[450]',
className,
)}
{...props}
/>
)
}
export { MenuRoot, MenuTrigger, MenuContent, MenuItem }
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { ScrollArea } from '@base-ui/react/scroll-area'
import { cn } from '@/lib/utils'
type ScrollAreaRootProps = React.ComponentProps<typeof ScrollArea.Root>
function ScrollAreaRoot({ className, ...props }: ScrollAreaRootProps) {
return (
<ScrollArea.Root
className={cn('relative outline-none focus-visible:outline-none', className)}
{...props}
/>
)
}
type ScrollAreaViewportProps = React.ComponentProps<typeof ScrollArea.Viewport>
function ScrollAreaViewport({ className, ...props }: ScrollAreaViewportProps) {
return (
<ScrollArea.Viewport
className={cn('h-full w-full outline-none focus-visible:outline-none', className)}
{...props}
/>
)
}
type ScrollAreaScrollbarProps = React.ComponentProps<
typeof ScrollArea.Scrollbar
>
function ScrollAreaScrollbar({
className,
...props
}: ScrollAreaScrollbarProps) {
return (
<ScrollArea.Scrollbar
className={cn(
'flex w-2 touch-none select-none p-0.5 outline-none focus-visible:outline-none',
'opacity-0 transition-opacity duration-150',
'data-hovering:opacity-100 data-scrolling:opacity-100',
className,
)}
{...props}
/>
)
}
type ScrollAreaThumbProps = React.ComponentProps<typeof ScrollArea.Thumb>
function ScrollAreaThumb({ className, ...props }: ScrollAreaThumbProps) {
return (
<ScrollArea.Thumb
className={cn(
'flex-1 rounded-full bg-primary-300 outline-none focus-visible:outline-none',
className,
)}
{...props}
/>
)
}
type ScrollAreaCornerProps = React.ComponentProps<typeof ScrollArea.Corner>
function ScrollAreaCorner({ className, ...props }: ScrollAreaCornerProps) {
return (
<ScrollArea.Corner
className={cn('bg-primary-100 outline-none focus-visible:outline-none', className)}
{...props}
/>
)
}
export {
ScrollAreaRoot,
ScrollAreaViewport,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaCorner,
}
+27
View File
@@ -0,0 +1,27 @@
'use client'
import { Switch as SwitchPrimitive } from '@base-ui/react/switch'
import { cn } from '@/lib/utils'
function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
return (
<SwitchPrimitive.Root
className={cn(
'inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(5)] focus-visible:ring-2 focus-visible:ring-primary-950 focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary-900 data-unchecked:bg-primary-200 data-disabled:opacity-64 sm:[--thumb-size:--spacing(4)]',
className,
)}
data-slot="switch"
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-primary-50 shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]',
)}
data-slot="switch-thumb"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
import { cn } from '@/lib/utils'
type TabsVariant = 'default' | 'underline'
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
className={cn(
'flex flex-col gap-2 data-[orientation=vertical]:flex-row',
className,
)}
data-slot="tabs"
{...props}
/>
)
}
function TabsList({
variant = 'default',
className,
children,
...props
}: TabsPrimitive.List.Props & {
variant?: TabsVariant
}) {
return (
<TabsPrimitive.List
className={cn(
'relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-primary-600',
'data-[orientation=vertical]:flex-col',
variant === 'default'
? 'p-0.5 text-primary-600/80'
: 'data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1',
className,
)}
data-slot="tabs-list"
{...props}
>
{children}
<TabsPrimitive.Indicator
className={cn(
'-translate-y-(--active-tab-bottom) absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) transition-[width,translate] duration-200 ease-in-out',
variant === 'underline'
? 'data-[orientation=vertical]:-translate-x-px z-10 bg-primary-900 data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=horizontal]:translate-y-px'
: 'z-0 rounded-md bg-primary-200',
)}
data-slot="tab-indicator"
/>
</TabsPrimitive.List>
)
}
function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
className={cn(
'[&_svg]:-mx-0.5 relative z-10 flex h-8 shrink-0 grow cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium outline-none transition-[color,background-color,box-shadow] hover:text-primary-900 focus-visible:ring-2 focus-visible:ring-primary-400 data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-primary-900 data-disabled:opacity-64 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
className,
)}
data-slot="tabs-tab"
{...props}
/>
)
}
function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
className={cn('flex-1 outline-none', className)}
data-slot="tabs-content"
{...props}
/>
)
}
export {
Tabs,
TabsList,
TabsTab,
TabsTab as TabsTrigger,
TabsPanel,
TabsPanel as TabsContent,
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { Tooltip } from '@base-ui/react/tooltip'
import { cn } from '@/lib/utils'
type TooltipRootProps = React.ComponentProps<typeof Tooltip.Root>
function TooltipProvider({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delay={0} closeDelay={0} timeout={0}>
{children}
</Tooltip.Provider>
)
}
function TooltipRoot({ children, ...props }: TooltipRootProps) {
return <Tooltip.Root {...props}>{children}</Tooltip.Root>
}
type TooltipTriggerProps = React.ComponentProps<typeof Tooltip.Trigger>
function TooltipTrigger({ className, ...props }: TooltipTriggerProps) {
return <Tooltip.Trigger className={cn(className)} {...props} />
}
type TooltipContentProps = {
className?: string
side?: 'top' | 'bottom' | 'left' | 'right'
children: React.ReactNode
}
function TooltipContent({
className,
side = 'top',
children,
}: TooltipContentProps) {
return (
<Tooltip.Portal>
<Tooltip.Positioner side={side}>
<Tooltip.Popup
className={cn(
'rounded-md border border-primary-900 bg-primary-950 px-2 py-1 text-xs text-primary-50 shadow-sm',
className,
)}
>
{children}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
)
}
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent }
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useMemo, useState } from 'react'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeMode = 'system' | 'light' | 'dark'
export type ChatSettings = {
showToolMessages: boolean
showReasoningBlocks: boolean
theme: ThemeMode
}
type ChatSettingsState = {
settings: ChatSettings
updateSettings: (updates: Partial<ChatSettings>) => void
}
export const useChatSettingsStore = create<ChatSettingsState>()(
persist(
(set) => ({
settings: {
showToolMessages: true,
showReasoningBlocks: true,
theme: 'system',
},
updateSettings: (updates) =>
set((state) => ({
settings: { ...state.settings, ...updates },
})),
}),
{
name: 'chat-settings',
},
),
)
export function useChatSettings() {
const settings = useChatSettingsStore((state) => state.settings)
const updateSettings = useChatSettingsStore((state) => state.updateSettings)
return {
settings,
updateSettings,
}
}
export function useResolvedTheme() {
const theme = useChatSettingsStore((state) => state.settings.theme)
const [systemIsDark, setSystemIsDark] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
setSystemIsDark(media.matches)
function handleChange(event: MediaQueryListEvent) {
setSystemIsDark(event.matches)
}
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [])
return useMemo(() => {
if (theme === 'dark') return 'dark'
if (theme === 'light') return 'light'
return systemIsDark ? 'dark' : 'light'
}, [theme, systemIsDark])
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx'
export function cn(...inputs: Array<ClassValue>) {
return twMerge(clsx(inputs))
}
+12
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

+240
View File
@@ -0,0 +1,240 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as NewRouteImport } from './routes/new'
import { Route as ConnectRouteImport } from './routes/connect'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey'
import { Route as ApiSessionsRouteImport } from './routes/api/sessions'
import { Route as ApiSendRouteImport } from './routes/api/send'
import { Route as ApiPingRouteImport } from './routes/api/ping'
import { Route as ApiPathsRouteImport } from './routes/api/paths'
import { Route as ApiHistoryRouteImport } from './routes/api/history'
const NewRoute = NewRouteImport.update({
id: '/new',
path: '/new',
getParentRoute: () => rootRouteImport,
} as any)
const ConnectRoute = ConnectRouteImport.update({
id: '/connect',
path: '/connect',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({
id: '/chat/$sessionKey',
path: '/chat/$sessionKey',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSessionsRoute = ApiSessionsRouteImport.update({
id: '/api/sessions',
path: '/api/sessions',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSendRoute = ApiSendRouteImport.update({
id: '/api/send',
path: '/api/send',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPingRoute = ApiPingRouteImport.update({
id: '/api/ping',
path: '/api/ping',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPathsRoute = ApiPathsRouteImport.update({
id: '/api/paths',
path: '/api/paths',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHistoryRoute = ApiHistoryRouteImport.update({
id: '/api/history',
path: '/api/history',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/connect': typeof ConnectRoute
'/new': typeof NewRoute
'/api/history': typeof ApiHistoryRoute
'/api/paths': typeof ApiPathsRoute
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/connect'
| '/new'
| '/api/history'
| '/api/paths'
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/chat/$sessionKey'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/connect'
| '/new'
| '/api/history'
| '/api/paths'
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/chat/$sessionKey'
id:
| '__root__'
| '/'
| '/connect'
| '/new'
| '/api/history'
| '/api/paths'
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/chat/$sessionKey'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConnectRoute: typeof ConnectRoute
NewRoute: typeof NewRoute
ApiHistoryRoute: typeof ApiHistoryRoute
ApiPathsRoute: typeof ApiPathsRoute
ApiPingRoute: typeof ApiPingRoute
ApiSendRoute: typeof ApiSendRoute
ApiSessionsRoute: typeof ApiSessionsRoute
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/new': {
id: '/new'
path: '/new'
fullPath: '/new'
preLoaderRoute: typeof NewRouteImport
parentRoute: typeof rootRouteImport
}
'/connect': {
id: '/connect'
path: '/connect'
fullPath: '/connect'
preLoaderRoute: typeof ConnectRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/chat/$sessionKey': {
id: '/chat/$sessionKey'
path: '/chat/$sessionKey'
fullPath: '/chat/$sessionKey'
preLoaderRoute: typeof ChatSessionKeyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/sessions': {
id: '/api/sessions'
path: '/api/sessions'
fullPath: '/api/sessions'
preLoaderRoute: typeof ApiSessionsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/send': {
id: '/api/send'
path: '/api/send'
fullPath: '/api/send'
preLoaderRoute: typeof ApiSendRouteImport
parentRoute: typeof rootRouteImport
}
'/api/ping': {
id: '/api/ping'
path: '/api/ping'
fullPath: '/api/ping'
preLoaderRoute: typeof ApiPingRouteImport
parentRoute: typeof rootRouteImport
}
'/api/paths': {
id: '/api/paths'
path: '/api/paths'
fullPath: '/api/paths'
preLoaderRoute: typeof ApiPathsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/history': {
id: '/api/history'
path: '/api/history'
fullPath: '/api/history'
preLoaderRoute: typeof ApiHistoryRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConnectRoute: ConnectRoute,
NewRoute: NewRoute,
ApiHistoryRoute: ApiHistoryRoute,
ApiPathsRoute: ApiPathsRoute,
ApiPingRoute: ApiPingRoute,
ApiSendRoute: ApiSendRoute,
ApiSessionsRoute: ApiSessionsRoute,
ChatSessionKeyRoute: ChatSessionKeyRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+17
View File
@@ -0,0 +1,17 @@
import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
// Create a new router instance
export const getRouter = () => {
const router = createRouter({
routeTree,
context: {},
scrollRestoration: true,
defaultPreloadStaleTime: 0,
})
return router
}
+114
View File
@@ -0,0 +1,114 @@
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import appCss from '../styles.css?url'
const themeScript = `
(() => {
try {
const stored = localStorage.getItem('chat-settings')
let theme = 'system'
if (stored) {
const parsed = JSON.parse(stored)
const storedTheme = parsed?.state?.settings?.theme
if (storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system') {
theme = storedTheme
}
}
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => {
root.classList.remove('light', 'dark', 'system')
root.classList.add(theme)
if (theme === 'system' && media.matches) {
root.classList.add('dark')
}
}
apply()
media.addEventListener('change', () => {
if (theme === 'system') apply()
})
} catch {}
})()
`
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'WebClaw',
},
{
name: 'description',
content: 'a fast web client for OpenClaw',
},
{
property: 'og:image',
content: '/cover.webp',
},
{
property: 'og:image:type',
content: 'image/webp',
},
{
name: 'twitter:card',
content: 'summary_large_image',
},
{
name: 'twitter:image',
content: '/cover.webp',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{
rel: 'icon',
type: 'image/svg+xml',
href: '/favicon.svg',
},
],
}),
shellComponent: RootDocument,
component: RootLayout,
})
const queryClient = new QueryClient()
function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<HeadContent />
</head>
<body>
<div className="root">{children}</div>
<Scripts />
</body>
</html>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayRpc } from '../../server/gateway'
type ChatHistoryResponse = {
sessionKey: string
sessionId?: string
messages: Array<any>
thinkingLevel?: string
}
type SessionsResolveResponse = {
ok?: boolean
key?: string
}
export const Route = createFileRoute('/api/history')({
server: {
handlers: {
GET: async ({ request }) => {
try {
const url = new URL(request.url)
const limit = Number(url.searchParams.get('limit') || '200')
const rawSessionKey = url.searchParams.get('sessionKey')?.trim()
const friendlyId = url.searchParams.get('friendlyId')?.trim()
let sessionKey =
rawSessionKey && rawSessionKey.length > 0 ? rawSessionKey : ''
if (!sessionKey && friendlyId) {
const resolved = await gatewayRpc<SessionsResolveResponse>(
'sessions.resolve',
{
key: friendlyId,
includeUnknown: true,
includeGlobal: true,
},
)
const resolvedKey =
typeof resolved.key === 'string' ? resolved.key.trim() : ''
if (resolvedKey.length === 0) {
return json({ error: 'session not found' }, { status: 404 })
}
sessionKey = resolvedKey
}
if (sessionKey.length === 0) {
sessionKey = 'main'
}
const payload = await gatewayRpc<ChatHistoryResponse>(
'chat.history',
{
sessionKey,
limit,
},
)
return json(payload)
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})
+35
View File
@@ -0,0 +1,35 @@
import os from 'node:os'
import path from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
function resolveSessionsDir() {
// Keep in sync with Clawdbot default layout:
// ~/.clawdbot/agents/<agentId>/sessions
const agentId = (process.env.CLAWDBOT_AGENT_ID || 'main').trim() || 'main'
const stateDir = (
process.env.CLAWDBOT_STATE_DIR || path.join(os.homedir(), '.clawdbot')
).trim()
return {
agentId,
stateDir,
sessionsDir: path.join(stateDir, 'agents', agentId, 'sessions'),
storePath: path.join(
stateDir,
'agents',
agentId,
'sessions',
'sessions.json',
),
}
}
export const Route = createFileRoute('/api/paths')({
server: {
handlers: {
GET: () => {
return json(resolveSessionsDir())
},
},
},
})
+12
View File
@@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/ping')({
server: {
handlers: {
GET: async () => {
return json({ ok: true })
},
},
},
})
+87
View File
@@ -0,0 +1,87 @@
import { randomUUID } from 'node:crypto'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayRpc } from '../../server/gateway'
type SessionsResolveResponse = {
ok?: boolean
key?: string
}
export const Route = createFileRoute('/api/send')({
server: {
handlers: {
POST: async ({ request }) => {
try {
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const rawSessionKey =
typeof body.sessionKey === 'string' ? body.sessionKey.trim() : ''
const friendlyId =
typeof body.friendlyId === 'string' ? body.friendlyId.trim() : ''
const message = String(body.message ?? '')
const thinking =
typeof body.thinking === 'string' ? body.thinking : undefined
if (!message.trim()) {
return json(
{ ok: false, error: 'message required' },
{ status: 400 },
)
}
let sessionKey = rawSessionKey.length > 0 ? rawSessionKey : ''
if (!sessionKey && friendlyId) {
const resolved = await gatewayRpc<SessionsResolveResponse>(
'sessions.resolve',
{
key: friendlyId,
includeUnknown: true,
includeGlobal: true,
},
)
const resolvedKey =
typeof resolved.key === 'string' ? resolved.key.trim() : ''
if (resolvedKey.length === 0) {
return json(
{ ok: false, error: 'session not found' },
{ status: 404 },
)
}
sessionKey = resolvedKey
}
if (sessionKey.length === 0) {
sessionKey = 'main'
}
const res = await gatewayRpc<{ runId: string }>('chat.send', {
sessionKey,
message,
thinking,
deliver: false,
timeoutMs: 120_000,
idempotencyKey:
typeof body.idempotencyKey === 'string'
? body.idempotencyKey
: randomUUID(),
})
return json({ ok: true, ...res, sessionKey })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})
+248
View File
@@ -0,0 +1,248 @@
import { randomUUID } from 'node:crypto'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayRpc } from '../../server/gateway'
type SessionsListGatewayResponse = {
sessions?: Array<Record<string, unknown>>
}
type SessionsListResponse = {
sessions: Array<Record<string, unknown>>
}
type SessionsPatchResponse = {
ok?: boolean
key?: string
path?: string
entry?: Record<string, unknown>
}
type SessionsResolveResponse = {
ok?: boolean
key?: string
}
function deriveFriendlyIdFromKey(key: unknown): string {
if (typeof key !== 'string' || key.trim().length === 0) return 'main'
const parts = key.split(':')
const tail = parts[parts.length - 1]
return tail && tail.trim().length > 0 ? tail.trim() : key
}
function normalizeSessions(
payload: SessionsListGatewayResponse,
): SessionsListResponse {
const sessions: Array<Record<string, unknown>> = Array.isArray(
payload.sessions,
)
? payload.sessions
: []
const normalized = sessions.map((session) => {
const rawKey = session.key
const key = typeof rawKey === 'string' ? rawKey : ''
const rawFriendly = session.friendlyId
const friendlyIdFromPayload =
typeof rawFriendly === 'string' ? rawFriendly.trim() : ''
const friendlyId =
friendlyIdFromPayload.length > 0
? friendlyIdFromPayload
: deriveFriendlyIdFromKey(key)
return {
...session,
key,
friendlyId,
}
})
return { sessions: normalized }
}
export const Route = createFileRoute('/api/sessions')({
server: {
handlers: {
GET: async () => {
try {
const payload = await gatewayRpc<SessionsListGatewayResponse>(
'sessions.list',
{
limit: 50,
includeLastMessage: true,
includeDerivedTitles: true,
},
)
return json(normalizeSessions(payload))
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
POST: async ({ request }) => {
try {
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const requestedLabel =
typeof body.label === 'string' ? body.label.trim() : ''
const label = requestedLabel || undefined
const friendlyId = randomUUID()
const params: Record<string, unknown> = { key: friendlyId }
if (label) params.label = label
const payload = await gatewayRpc<SessionsPatchResponse>(
'sessions.patch',
params,
)
const sessionKeyRaw = payload.key
const sessionKey =
typeof sessionKeyRaw === 'string' && sessionKeyRaw.trim().length > 0
? sessionKeyRaw.trim()
: ''
if (sessionKey.length === 0) {
throw new Error('gateway returned an invalid response')
}
// Register the friendly id so subsequent lookups resolve quickly.
await gatewayRpc<SessionsResolveResponse>('sessions.resolve', {
key: friendlyId,
includeUnknown: true,
includeGlobal: true,
}).catch(() => ({ ok: false }))
return json({
ok: true,
sessionKey,
friendlyId,
entry: payload.entry,
})
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
PATCH: async ({ request }) => {
try {
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const rawSessionKey =
typeof body.sessionKey === 'string' ? body.sessionKey.trim() : ''
const rawFriendlyId =
typeof body.friendlyId === 'string' ? body.friendlyId.trim() : ''
const label =
typeof body.label === 'string' ? body.label.trim() : undefined
let sessionKey = rawSessionKey
const friendlyId = rawFriendlyId
if (friendlyId) {
const resolved = await gatewayRpc<SessionsResolveResponse>(
'sessions.resolve',
{
key: friendlyId,
includeUnknown: true,
includeGlobal: true,
},
)
const resolvedKey =
typeof resolved.key === 'string' ? resolved.key.trim() : ''
if (resolvedKey.length > 0) sessionKey = resolvedKey
}
if (!sessionKey) {
return json(
{ ok: false, error: 'sessionKey required' },
{ status: 400 },
)
}
const params: Record<string, unknown> = { key: sessionKey }
if (label) params.label = label
const payload = await gatewayRpc<SessionsPatchResponse>(
'sessions.patch',
params,
)
return json({
ok: true,
sessionKey,
entry: payload.entry,
})
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
DELETE: async ({ request }) => {
try {
const url = new URL(request.url)
const rawSessionKey = url.searchParams.get('sessionKey') ?? ''
const rawFriendlyId = url.searchParams.get('friendlyId') ?? ''
let sessionKey = rawSessionKey.trim()
const friendlyId = rawFriendlyId.trim()
if (friendlyId) {
const resolved = await gatewayRpc<SessionsResolveResponse>(
'sessions.resolve',
{
key: friendlyId,
includeUnknown: true,
includeGlobal: true,
},
)
const resolvedKey =
typeof resolved.key === 'string' ? resolved.key.trim() : ''
if (resolvedKey.length > 0) sessionKey = resolvedKey
}
if (!sessionKey) {
return json(
{ ok: false, error: 'sessionKey required' },
{ status: 400 },
)
}
await gatewayRpc('sessions.delete', { key: sessionKey })
if (friendlyId && friendlyId !== sessionKey) {
await gatewayRpc('sessions.delete', { key: friendlyId }).catch(
() => ({}),
)
}
return json({ ok: true, sessionKey })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})
+59
View File
@@ -0,0 +1,59 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useCallback, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { ChatScreen } from '../../screens/chat/chat-screen'
import { moveHistoryMessages } from '../../screens/chat/chat-queries'
export const Route = createFileRoute('/chat/$sessionKey')({
component: ChatRoute,
})
function ChatRoute() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [forcedSession, setForcedSession] = useState<{
friendlyId: string
sessionKey: string
} | null>(null)
const params = Route.useParams()
const activeFriendlyId =
typeof params.sessionKey === 'string' ? params.sessionKey : 'main'
const isNewChat = activeFriendlyId === 'new'
const forcedSessionKey =
forcedSession?.friendlyId === activeFriendlyId
? forcedSession.sessionKey
: undefined
const handleSessionResolved = useCallback(
function handleSessionResolved(payload: {
friendlyId: string
sessionKey: string
}) {
moveHistoryMessages(
queryClient,
'new',
'new',
payload.friendlyId,
payload.sessionKey,
)
setForcedSession({
friendlyId: payload.friendlyId,
sessionKey: payload.sessionKey,
})
navigate({
to: '/chat/$sessionKey',
params: { sessionKey: payload.friendlyId },
replace: true,
})
},
[navigate, queryClient],
)
return (
<ChatScreen
activeFriendlyId={activeFriendlyId}
isNewChat={isNewChat}
forcedSessionKey={forcedSessionKey}
onSessionResolved={isNewChat ? handleSessionResolved : undefined}
/>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { createFileRoute } from '@tanstack/react-router'
import { CodeBlock } from '../components/prompt-kit/code-block'
export const Route = createFileRoute('/connect')({
component: ConnectRoute,
})
function ConnectRoute() {
return (
<div className="min-h-screen bg-primary-50 text-primary-900">
<div className="max-w-2xl mx-auto px-6 py-10 space-y-10">
<div className="space-y-3">
<h1 className="text-3xl font-medium tracking-[-0.02em] text-center mb-10">
Connect to WebClaw
</h1>
<p className="text-primary-700">
This client needs access to your OpenClaw gateway before you can
start chatting.
</p>
</div>
<div className="space-y-4 text-primary-700">
<p>
At the root of the project, create a new file named{' '}
<code className="inline-code">.env.local</code>.
</p>
<div className="space-y-3">
<p>Paste this into it:</p>
<CodeBlock
content={`CLAWDBOT_GATEWAY_URL=ws://127.0.0.1:18789\nCLAWDBOT_GATEWAY_TOKEN=YOUR_TOKEN_HERE`}
ariaLabel="Copy gateway token example"
language="bash"
/>
<p className="text-primary-600 text-sm">or:</p>
<CodeBlock
content="CLAWDBOT_GATEWAY_PASSWORD=YOUR_PASSWORD_HERE"
ariaLabel="Copy gateway password example"
language="bash"
/>
</div>
<p>
Environment variables are loaded at startup. Restart your dev
server:
</p>
<CodeBlock
content="npm run dev"
ariaLabel="Copy npm run dev"
language="bash"
/>
<p>Refresh the page after the restart and you should be connected.</p>
</div>
<div className="space-y-3 rounded-lg border border-primary-200 bg-primary-100 px-4 py-3 text-primary-700 text-sm">
<p className="text-primary-900 font-medium">
Where to find these values
</p>
<div className="space-y-3">
<p>
<code className="inline-code">CLAWDBOT_GATEWAY_URL</code>
<br />
Your OpenClaw gateway endpoint (default is
<code className="inline-code">ws://127.0.0.1:18789</code>).
</p>
<p>
<code className="inline-code">CLAWDBOT_GATEWAY_TOKEN</code>{' '}
(recommended)
<br />
Matches your Gateway token (
<code className="inline-code">gateway.auth.token</code> or
<code className="inline-code">OPENCLAW_GATEWAY_TOKEN</code>).
</p>
<p>
<code className="inline-code">CLAWDBOT_GATEWAY_PASSWORD</code>{' '}
(fallback)
<br />
Matches your Gateway password (
<code className="inline-code">gateway.auth.password</code>).
</p>
</div>
<p>
Gateway docs:{' '}
<a
className="text-primary-700 hover:text-primary-900 underline"
href="https://docs.openclaw.ai/gateway"
target="_blank"
rel="noreferrer"
>
https://docs.openclaw.ai/gateway
</a>
</p>
</div>
</div>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: IndexRoute,
})
function IndexRoute() {
const navigate = Route.useNavigate()
useEffect(() => {
navigate({
to: '/chat/$sessionKey',
params: { sessionKey: 'main' },
replace: true,
})
}, [navigate])
return (
<div className="h-screen flex items-center justify-center text-primary-600">
Loading
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/new')({
beforeLoad: function redirectToNewChat() {
throw redirect({
to: '/chat/$sessionKey',
params: { sessionKey: 'new' },
replace: true,
})
},
component: function NewChatRoute() {
return null
},
})
+203
View File
@@ -0,0 +1,203 @@
import { normalizeSessions, readError } from './utils'
import type { QueryClient } from '@tanstack/react-query'
import type {
GatewayMessage,
HistoryResponse,
SessionListResponse,
SessionMeta,
} from './types'
export const chatQueryKeys = {
sessions: ['chat', 'sessions'] as const,
history: function history(friendlyId: string, sessionKey: string) {
return ['chat', 'history', friendlyId, sessionKey] as const
},
} as const
export async function fetchSessions(): Promise<Array<SessionMeta>> {
const res = await fetch('/api/sessions')
if (!res.ok) throw new Error(await readError(res))
const data = (await res.json()) as SessionListResponse
return normalizeSessions(data.sessions)
}
export async function fetchHistory(payload: {
sessionKey: string
friendlyId: string
}): Promise<HistoryResponse> {
const query = new URLSearchParams({ limit: '200' })
if (payload.sessionKey) query.set('sessionKey', payload.sessionKey)
if (payload.friendlyId) query.set('friendlyId', payload.friendlyId)
const res = await fetch(`/api/history?${query.toString()}`)
if (!res.ok) throw new Error(await readError(res))
return (await res.json()) as HistoryResponse
}
export function updateHistoryMessages(
queryClient: QueryClient,
friendlyId: string,
sessionKey: string,
updater: (messages: Array<GatewayMessage>) => Array<GatewayMessage>,
) {
const queryKey = chatQueryKeys.history(friendlyId, sessionKey)
queryClient.setQueryData(queryKey, function update(data: unknown) {
const current = data as HistoryResponse | undefined
const messages = Array.isArray(current?.messages) ? current.messages : []
const nextMessages = updater(messages)
return {
sessionKey: current?.sessionKey ?? sessionKey,
sessionId: current?.sessionId,
messages: nextMessages,
}
})
}
export function appendHistoryMessage(
queryClient: QueryClient,
friendlyId: string,
sessionKey: string,
message: GatewayMessage,
) {
updateHistoryMessages(
queryClient,
friendlyId,
sessionKey,
function append(messages) {
return [...messages, message]
},
)
}
export function updateHistoryMessageByClientId(
queryClient: QueryClient,
friendlyId: string,
sessionKey: string,
clientId: string,
updater: (message: GatewayMessage) => GatewayMessage,
) {
const optimisticId = `opt-${clientId}`
updateHistoryMessages(
queryClient,
friendlyId,
sessionKey,
function update(messages) {
return messages.map((message) => {
if (
message.clientId === clientId ||
message.__optimisticId === clientId ||
message.__optimisticId === optimisticId
) {
return updater(message)
}
return message
})
},
)
}
export function removeHistoryMessageByClientId(
queryClient: QueryClient,
friendlyId: string,
sessionKey: string,
clientId: string,
optimisticId?: string,
) {
updateHistoryMessages(
queryClient,
friendlyId,
sessionKey,
function remove(messages) {
return messages.filter((message) => {
if (message.clientId === clientId) return false
if (message.__optimisticId === clientId) return false
if (optimisticId && message.__optimisticId === optimisticId)
return false
return true
})
},
)
}
export function clearHistoryMessages(
queryClient: QueryClient,
friendlyId: string,
sessionKey: string,
) {
const queryKey = chatQueryKeys.history(friendlyId, sessionKey)
queryClient.setQueryData(queryKey, {
sessionKey,
messages: [],
})
}
export function moveHistoryMessages(
queryClient: QueryClient,
fromFriendlyId: string,
fromSessionKey: string,
toFriendlyId: string,
toSessionKey: string,
) {
const fromKey = chatQueryKeys.history(fromFriendlyId, fromSessionKey)
const toKey = chatQueryKeys.history(toFriendlyId, toSessionKey)
const fromData = queryClient.getQueryData(fromKey) as
| HistoryResponse
| undefined
if (!fromData) return
const messages = Array.isArray(fromData.messages) ? fromData.messages : []
queryClient.setQueryData(toKey, {
sessionKey: toSessionKey,
sessionId: fromData.sessionId,
messages,
})
queryClient.removeQueries({ queryKey: fromKey, exact: true })
}
export function updateSessionLastMessage(
queryClient: QueryClient,
sessionKey: string,
friendlyId: string,
message: GatewayMessage,
) {
queryClient.setQueryData(
chatQueryKeys.sessions,
function update(messages: unknown) {
if (!Array.isArray(messages)) return messages
return (messages as Array<SessionMeta>).map((session) => {
if (session.key !== sessionKey && session.friendlyId !== friendlyId) {
return session
}
return {
...session,
lastMessage: message,
}
})
},
)
}
export function removeSessionFromCache(
queryClient: QueryClient,
sessionKey: string,
friendlyId: string,
) {
queryClient.setQueryData(
chatQueryKeys.sessions,
function update(messages: unknown) {
if (!Array.isArray(messages)) return messages
return (messages as Array<SessionMeta>).filter((session) => {
return session.key !== sessionKey && session.friendlyId !== friendlyId
})
},
)
queryClient.removeQueries({
queryKey: ['chat', 'history', friendlyId],
exact: false,
})
if (sessionKey && sessionKey !== friendlyId) {
queryClient.removeQueries({
queryKey: ['chat', 'history', sessionKey],
exact: false,
})
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { GatewayMessage } from './types'
type OptimisticMessagePayload = {
clientId: string
optimisticId: string
optimisticMessage: GatewayMessage
}
export function createOptimisticMessage(
body: string,
): OptimisticMessagePayload {
const clientId = crypto.randomUUID()
const optimisticId = `opt-${clientId}`
const timestamp = Date.now()
const optimisticMessage: GatewayMessage = {
role: 'user',
content: [{ type: 'text', text: body }],
__optimisticId: optimisticId,
clientId,
status: 'sending',
timestamp,
}
return { clientId, optimisticId, optimisticMessage }
}
+606
View File
@@ -0,0 +1,606 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
deriveFriendlyIdFromKey,
isMissingGatewayAuth,
readError,
textFromMessage,
} from './utils'
import { createOptimisticMessage } from './chat-screen-utils'
import {
chatQueryKeys,
appendHistoryMessage,
clearHistoryMessages,
removeHistoryMessageByClientId,
updateHistoryMessageByClientId,
updateSessionLastMessage,
} from './chat-queries'
import { chatUiQueryKey, getChatUiState, setChatUiState } from './chat-ui'
import { ChatSidebar } from './components/chat-sidebar'
import { ChatHeader } from './components/chat-header'
import { ChatMessageList } from './components/chat-message-list'
import { ChatComposer } from './components/chat-composer'
import {
consumePendingSend,
hasPendingGeneration,
hasPendingSend,
isRecentSession,
resetPendingSend,
setRecentSession,
setPendingGeneration,
stashPendingSend,
} from './pending-send'
import { useChatMeasurements } from './hooks/use-chat-measurements'
import { useChatHistory } from './hooks/use-chat-history'
import { useChatMobile } from './hooks/use-chat-mobile'
import { useChatSessions } from './hooks/use-chat-sessions'
import type { ChatComposerHelpers } from './components/chat-composer'
import type { HistoryResponse } from './types'
import { cn } from '@/lib/utils'
type ChatScreenProps = {
activeFriendlyId: string
isNewChat?: boolean
onSessionResolved?: (payload: {
sessionKey: string
friendlyId: string
}) => void
forcedSessionKey?: string
}
export function ChatScreen({
activeFriendlyId,
isNewChat = false,
onSessionResolved,
forcedSessionKey,
}: ChatScreenProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [sending, setSending] = useState(false)
const [creatingSession, setCreatingSession] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isRedirecting, setIsRedirecting] = useState(false)
const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } =
useChatMeasurements()
const [waitingForResponse, setWaitingForResponse] = useState(
() => hasPendingSend() || hasPendingGeneration(),
)
const [pinToTop, setPinToTop] = useState(
() => hasPendingSend() || hasPendingGeneration(),
)
const streamTimer = useRef<number | null>(null)
const streamIdleTimer = useRef<number | null>(null)
const lastAssistantSignature = useRef('')
const refreshHistoryRef = useRef<() => void>(() => {})
const pendingStartRef = useRef(false)
const { isMobile } = useChatMobile(queryClient)
const {
sessionsQuery,
sessions,
activeExists,
activeSessionKey,
activeTitle,
sessionsError,
} = useChatSessions({ activeFriendlyId, isNewChat, forcedSessionKey })
const {
historyQuery,
historyMessages,
displayMessages,
historyError,
resolvedSessionKey,
activeCanonicalKey,
sessionKeyForHistory,
} = useChatHistory({
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
isRedirecting,
activeExists,
sessionsReady: sessionsQuery.isSuccess,
queryClient,
})
const uiQuery = useQuery({
queryKey: chatUiQueryKey,
queryFn: function readUiState() {
return getChatUiState(queryClient)
},
initialData: function initialUiState() {
return getChatUiState(queryClient)
},
staleTime: Infinity,
})
const isSidebarCollapsed = uiQuery.data?.isSidebarCollapsed ?? false
const handleActiveSessionDelete = useCallback(() => {
setError(null)
setIsRedirecting(true)
navigate({ to: '/new', replace: true })
}, [navigate])
const streamStop = useCallback(() => {
if (streamTimer.current) {
window.clearInterval(streamTimer.current)
streamTimer.current = null
}
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
streamIdleTimer.current = null
}
}, [])
const streamFinish = useCallback(() => {
streamStop()
setPendingGeneration(false)
setWaitingForResponse(false)
}, [streamStop])
const streamStart = useCallback(() => {
if (!activeFriendlyId || isNewChat) return
if (streamTimer.current) window.clearInterval(streamTimer.current)
streamTimer.current = window.setInterval(() => {
refreshHistoryRef.current()
}, 350)
}, [activeFriendlyId, isNewChat])
const stableContentStyle = useMemo<React.CSSProperties>(() => ({}), [])
refreshHistoryRef.current = function refreshHistory() {
void historyQuery.refetch()
}
useEffect(() => {
if (isRedirecting) {
if (error) setError(null)
return
}
if (shouldRedirectToNew) {
if (error) setError(null)
return
}
if (sessionsQuery.isSuccess && !activeExists) {
if (error) setError(null)
return
}
const messageText = sessionsError ?? historyError
if (!messageText) {
if (error?.startsWith('Failed to load')) {
setError(null)
}
return
}
if (isMissingGatewayAuth(messageText)) {
navigate({ to: '/connect', replace: true })
}
const message = sessionsError
? `Failed to load sessions. ${sessionsError}`
: historyError
? `Failed to load history. ${historyError}`
: null
if (message) setError(message)
}, [error, historyError, isRedirecting, navigate, sessionsError])
const shouldRedirectToNew =
!isNewChat &&
!forcedSessionKey &&
!isRecentSession(activeFriendlyId) &&
sessionsQuery.isSuccess &&
sessions.length > 0 &&
!sessions.some((session) => session.friendlyId === activeFriendlyId) &&
!historyQuery.isFetching &&
!historyQuery.isSuccess
useEffect(() => {
if (!isRedirecting) return
if (isNewChat) {
setIsRedirecting(false)
return
}
if (!shouldRedirectToNew && sessionsQuery.isSuccess) {
setIsRedirecting(false)
}
}, [isNewChat, isRedirecting, sessionsQuery.isSuccess, shouldRedirectToNew])
useEffect(() => {
if (isNewChat) return
if (!sessionsQuery.isSuccess) return
if (sessions.length === 0) return
if (!shouldRedirectToNew) return
resetPendingSend()
clearHistoryMessages(queryClient, activeFriendlyId, sessionKeyForHistory)
navigate({ to: '/new', replace: true })
}, [
activeFriendlyId,
historyQuery.isFetching,
historyQuery.isSuccess,
isNewChat,
navigate,
queryClient,
sessionKeyForHistory,
sessions,
sessionsQuery.isSuccess,
shouldRedirectToNew,
])
const hideUi = shouldRedirectToNew || isRedirecting
useEffect(() => {
const latestMessage = historyMessages[historyMessages.length - 1]
if (!latestMessage || latestMessage.role !== 'assistant') return
const signature = `${historyMessages.length}:${textFromMessage(latestMessage).slice(-64)}`
if (signature !== lastAssistantSignature.current) {
lastAssistantSignature.current = signature
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
}
streamIdleTimer.current = window.setTimeout(() => {
streamFinish()
}, 4000)
}
}, [historyMessages, streamFinish])
useEffect(() => {
const resetKey = isNewChat ? 'new' : activeFriendlyId
if (!resetKey) return
if (pendingStartRef.current) {
pendingStartRef.current = false
return
}
if (hasPendingSend() || hasPendingGeneration()) {
setWaitingForResponse(true)
setPinToTop(true)
return
}
streamStop()
lastAssistantSignature.current = ''
setWaitingForResponse(false)
setPinToTop(false)
}, [activeFriendlyId, isNewChat, streamStop])
useLayoutEffect(() => {
if (isNewChat) return
const pending = consumePendingSend(
forcedSessionKey || resolvedSessionKey || activeSessionKey,
activeFriendlyId,
)
if (!pending) return
pendingStartRef.current = true
const historyKey = chatQueryKeys.history(
pending.friendlyId,
pending.sessionKey,
)
const cached = queryClient.getQueryData(historyKey) as
| HistoryResponse
| undefined
const cachedMessages = Array.isArray(cached?.messages)
? cached.messages
: []
const alreadyHasOptimistic = cachedMessages.some((message) => {
if (pending.optimisticMessage.clientId) {
if (message.clientId === pending.optimisticMessage.clientId) return true
if (message.__optimisticId === pending.optimisticMessage.clientId)
return true
}
if (pending.optimisticMessage.__optimisticId) {
if (message.__optimisticId === pending.optimisticMessage.__optimisticId)
return true
}
return false
})
if (!alreadyHasOptimistic) {
appendHistoryMessage(
queryClient,
pending.friendlyId,
pending.sessionKey,
pending.optimisticMessage,
)
}
setWaitingForResponse(true)
setPinToTop(true)
sendMessage(pending.sessionKey, pending.friendlyId, pending.message, true)
}, [
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
queryClient,
resolvedSessionKey,
])
function sendMessage(
sessionKey: string,
friendlyId: string,
body: string,
skipOptimistic = false,
) {
let optimisticClientId = ''
if (!skipOptimistic) {
const { clientId, optimisticMessage } = createOptimisticMessage(body)
optimisticClientId = clientId
appendHistoryMessage(
queryClient,
friendlyId,
sessionKey,
optimisticMessage,
)
updateSessionLastMessage(
queryClient,
sessionKey,
friendlyId,
optimisticMessage,
)
}
setPendingGeneration(true)
setSending(true)
setError(null)
setWaitingForResponse(true)
setPinToTop(true)
fetch('/api/send', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionKey,
friendlyId,
message: body,
thinking: 'low',
idempotencyKey: crypto.randomUUID(),
}),
})
.then(async (res) => {
if (!res.ok) throw new Error(await readError(res))
streamStart()
})
.catch((err) => {
const messageText = err instanceof Error ? err.message : String(err)
if (isMissingGatewayAuth(messageText)) {
navigate({ to: '/connect', replace: true })
return
}
if (optimisticClientId) {
updateHistoryMessageByClientId(
queryClient,
friendlyId,
sessionKey,
optimisticClientId,
function markFailed(message) {
return { ...message, status: 'error' }
},
)
}
setError(`Failed to send message. ${messageText}`)
setPendingGeneration(false)
setWaitingForResponse(false)
setPinToTop(false)
})
.finally(() => {
setSending(false)
})
}
const createSessionForMessage = useCallback(async () => {
setCreatingSession(true)
try {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
if (!res.ok) throw new Error(await readError(res))
const data = (await res.json()) as {
sessionKey?: string
friendlyId?: string
}
const sessionKey =
typeof data.sessionKey === 'string' ? data.sessionKey : ''
const friendlyId =
typeof data.friendlyId === 'string' && data.friendlyId.trim().length > 0
? data.friendlyId.trim()
: deriveFriendlyIdFromKey(sessionKey)
if (!sessionKey || !friendlyId) {
throw new Error('Invalid session response')
}
queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions })
return { sessionKey, friendlyId }
} finally {
setCreatingSession(false)
}
}, [queryClient])
const send = useCallback(
(body: string, helpers: ChatComposerHelpers) => {
if (body.length === 0) return
helpers.reset()
if (isNewChat) {
const { clientId, optimisticId, optimisticMessage } =
createOptimisticMessage(body)
appendHistoryMessage(queryClient, 'new', 'new', optimisticMessage)
setPendingGeneration(true)
setSending(true)
setWaitingForResponse(true)
setPinToTop(true)
createSessionForMessage()
.then(({ sessionKey, friendlyId }) => {
setRecentSession(friendlyId)
stashPendingSend({
sessionKey,
friendlyId,
message: body,
optimisticMessage,
})
if (onSessionResolved) {
onSessionResolved({ sessionKey, friendlyId })
return
}
navigate({
to: '/chat/$sessionKey',
params: { sessionKey: friendlyId },
replace: true,
})
})
.catch((err: unknown) => {
removeHistoryMessageByClientId(
queryClient,
'new',
'new',
clientId,
optimisticId,
)
helpers.setValue(body)
setError(
`Failed to create session. ${err instanceof Error ? err.message : String(err)}`,
)
setPendingGeneration(false)
setWaitingForResponse(false)
setPinToTop(false)
setSending(false)
})
return
}
const sessionKeyForSend =
forcedSessionKey || resolvedSessionKey || activeSessionKey
sendMessage(sessionKeyForSend, activeFriendlyId, body)
},
[
activeFriendlyId,
activeSessionKey,
createSessionForMessage,
forcedSessionKey,
isNewChat,
navigate,
onSessionResolved,
queryClient,
resolvedSessionKey,
],
)
const startNewChat = useCallback(() => {
setWaitingForResponse(false)
setPinToTop(false)
clearHistoryMessages(queryClient, 'new', 'new')
navigate({ to: '/new' })
if (isMobile) {
setChatUiState(queryClient, function collapse(state) {
return { ...state, isSidebarCollapsed: true }
})
}
}, [isMobile, navigate, queryClient])
const handleToggleSidebarCollapse = useCallback(() => {
setChatUiState(queryClient, function toggle(state) {
return { ...state, isSidebarCollapsed: !state.isSidebarCollapsed }
})
}, [queryClient])
const handleSelectSession = useCallback(() => {
if (!isMobile) return
setChatUiState(queryClient, function collapse(state) {
return { ...state, isSidebarCollapsed: true }
})
}, [isMobile, queryClient])
const handleOpenSidebar = useCallback(() => {
setChatUiState(queryClient, function open(state) {
return { ...state, isSidebarCollapsed: false }
})
}, [queryClient])
const historyLoading =
(historyQuery.isLoading && !historyQuery.data) || isRedirecting
const historyEmpty = !historyLoading && displayMessages.length === 0
const sidebar = (
<ChatSidebar
sessions={sessions}
activeFriendlyId={activeFriendlyId}
creatingSession={creatingSession}
onCreateSession={startNewChat}
isCollapsed={isMobile ? false : isSidebarCollapsed}
onToggleCollapse={handleToggleSidebarCollapse}
onSelectSession={handleSelectSession}
onActiveSessionDelete={handleActiveSessionDelete}
/>
)
return (
<div className="h-screen bg-surface text-primary-900">
<div
className={cn(
'h-full overflow-hidden',
isMobile ? 'relative' : 'grid grid-cols-[auto_1fr]',
)}
>
{hideUi ? null : isMobile ? (
<>
<div
className={cn(
'fixed inset-y-0 left-0 z-50 w-[300px] transition-transform duration-200',
isSidebarCollapsed ? '-translate-x-full' : 'translate-x-0',
)}
>
{sidebar}
</div>
</>
) : (
sidebar
)}
<main className="flex flex-col h-full min-h-0" ref={mainRef}>
<ChatHeader
activeTitle={activeTitle}
wrapperRef={headerRef}
showSidebarButton={isMobile}
onOpenSidebar={handleOpenSidebar}
/>
{error && !hideUi ? (
<div className="border-b border-primary-200 bg-primary-100 px-4 py-3 text-sm text-primary-800">
<div className="font-medium">{error}</div>
<div className="text-xs text-primary-700 mt-1">
Check that the dashboard server has access to the Clawdbot
Gateway and that{' '}
<code className="inline-code">CLAWDBOT_GATEWAY_TOKEN</code> (or{' '}
<code className="inline-code">CLAWDBOT_GATEWAY_PASSWORD</code>)
is set in your server environment.
</div>
</div>
) : null}
{hideUi ? null : (
<>
<ChatMessageList
messages={displayMessages}
loading={historyLoading}
empty={historyEmpty}
waitingForResponse={waitingForResponse}
sessionKey={activeCanonicalKey}
pinToTop={pinToTop}
pinGroupMinHeight={pinGroupMinHeight}
headerHeight={headerHeight}
contentStyle={stableContentStyle}
/>
<ChatComposer
onSubmit={send}
isLoading={sending}
disabled={sending}
wrapperRef={composerRef}
/>
</>
)}
</main>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import type { QueryClient } from '@tanstack/react-query'
export type ChatUiState = {
isSidebarCollapsed: boolean
}
const defaultChatUiState: ChatUiState = {
isSidebarCollapsed: false,
}
export const chatUiQueryKey = ['chat', 'ui'] as const
export function getChatUiState(queryClient: QueryClient): ChatUiState {
const cached = queryClient.getQueryData(chatUiQueryKey)
if (cached && typeof cached === 'object') {
return {
...defaultChatUiState,
...(cached as Partial<ChatUiState>),
}
}
return defaultChatUiState
}
export function setChatUiState(
queryClient: QueryClient,
updater: (state: ChatUiState) => ChatUiState,
) {
queryClient.setQueryData(chatUiQueryKey, function update(state: unknown) {
const current =
state && typeof state === 'object'
? {
...defaultChatUiState,
...(state as Partial<ChatUiState>),
}
: defaultChatUiState
return updater(current)
})
}
@@ -0,0 +1,97 @@
import { memo, useCallback, useRef, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowUp02Icon } from '@hugeicons/core-free-icons'
import type { Ref } from 'react'
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from '@/components/prompt-kit/prompt-input'
import { Button } from '@/components/ui/button'
type ChatComposerProps = {
onSubmit: (value: string, helpers: ChatComposerHelpers) => void
isLoading: boolean
disabled: boolean
wrapperRef?: Ref<HTMLDivElement>
}
type ChatComposerHelpers = {
reset: () => void
setValue: (value: string) => void
}
function ChatComposerComponent({
onSubmit,
isLoading,
disabled,
wrapperRef,
}: ChatComposerProps) {
const [value, setValue] = useState('')
const promptRef = useRef<HTMLTextAreaElement | null>(null)
const focusPrompt = useCallback(() => {
if (typeof window === 'undefined') return
window.requestAnimationFrame(() => {
promptRef.current?.focus()
})
}, [])
const reset = useCallback(() => {
setValue('')
focusPrompt()
}, [focusPrompt])
const setComposerValue = useCallback(
(nextValue: string) => {
setValue(nextValue)
focusPrompt()
},
[focusPrompt],
)
const handleSubmit = useCallback(() => {
if (disabled) return
const body = value.trim()
if (body.length === 0) return
onSubmit(body, { reset, setValue: setComposerValue })
focusPrompt()
}, [disabled, focusPrompt, onSubmit, reset, setComposerValue, value])
const submitDisabled = disabled || value.trim().length === 0
return (
<div
className="mx-auto w-full max-w-full px-5 sm:max-w-[768px] sm:min-w-[400px] relative pb-3"
ref={wrapperRef}
>
<PromptInput
value={value}
onValueChange={setValue}
onSubmit={handleSubmit}
isLoading={isLoading}
disabled={disabled}
>
<PromptInputTextarea
placeholder="Type a message…"
inputRef={promptRef}
/>
<PromptInputActions className="justify-end px-3">
<PromptInputAction tooltip="Send message">
<Button
onClick={handleSubmit}
disabled={submitDisabled}
size="icon-sm"
className="rounded-full"
aria-label="Send message"
>
<HugeiconsIcon icon={ArrowUp02Icon} size={18} strokeWidth={2} />
</Button>
</PromptInputAction>
</PromptInputActions>
</PromptInput>
</div>
)
}
const MemoizedChatComposer = memo(ChatComposerComponent)
export { MemoizedChatComposer as ChatComposer }
export type { ChatComposerHelpers }
@@ -0,0 +1,42 @@
import { memo } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Menu01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
type ChatHeaderProps = {
activeTitle: string
wrapperRef?: React.Ref<HTMLDivElement>
showSidebarButton?: boolean
onOpenSidebar?: () => void
}
function ChatHeaderComponent({
activeTitle,
wrapperRef,
showSidebarButton = false,
onOpenSidebar,
}: ChatHeaderProps) {
return (
<div
ref={wrapperRef}
className="border-b border-primary-200 px-4 h-12 flex items-center bg-surface"
>
{showSidebarButton ? (
<Button
size="icon-sm"
variant="ghost"
onClick={onOpenSidebar}
className="mr-2 text-primary-800 hover:bg-primary-100"
aria-label="Open sidebar"
>
<HugeiconsIcon icon={Menu01Icon} size={18} strokeWidth={1.6} />
</Button>
) : null}
<div className="text-sm font-medium truncate">{activeTitle}</div>
</div>
)
}
const MemoizedChatHeader = memo(ChatHeaderComponent)
export { MemoizedChatHeader as ChatHeader }
@@ -0,0 +1,234 @@
import { memo, useLayoutEffect, useMemo, useRef } from 'react'
import { getToolCallsFromMessage } from '../utils'
import { MessageItem } from './message-item'
import type { GatewayMessage } from '../types'
import {
ChatContainerContent,
ChatContainerRoot,
ChatContainerScrollAnchor,
} from '@/components/prompt-kit/chat-container'
import { TypingIndicator } from '@/components/prompt-kit/typing-indicator'
type ChatMessageListProps = {
messages: Array<GatewayMessage>
loading: boolean
empty: boolean
waitingForResponse: boolean
sessionKey?: string
pinToTop: boolean
pinGroupMinHeight: number
headerHeight: number
contentStyle?: React.CSSProperties
}
function ChatMessageListComponent({
messages,
loading,
empty,
waitingForResponse,
sessionKey,
pinToTop,
pinGroupMinHeight,
headerHeight,
contentStyle,
}: ChatMessageListProps) {
const anchorRef = useRef<HTMLDivElement | null>(null)
const lastUserRef = useRef<HTMLDivElement | null>(null)
const programmaticScroll = useRef(false)
const prevPinRef = useRef(pinToTop)
const prevUserIndexRef = useRef<number | undefined>(undefined)
// Filter out toolResult messages - they'll be displayed inside their associated tool calls
const displayMessages = useMemo(() => {
return messages.filter((msg) => msg.role !== 'toolResult')
}, [messages])
const toolResultsByCallId = useMemo(() => {
const map = new Map<string, GatewayMessage>()
for (const message of messages) {
if (message.role !== 'toolResult') continue
const toolCallId = message.toolCallId
if (typeof toolCallId === 'string' && toolCallId.trim().length > 0) {
map.set(toolCallId, message)
}
}
return map
}, [messages])
const lastAssistantIndex = displayMessages
.map((message, index) => ({ message, index }))
.filter(({ message }) => message.role !== 'user')
.map(({ index }) => index)
.pop()
const lastUserIndex = displayMessages
.map((message, index) => ({ message, index }))
.filter(({ message }) => message.role === 'user')
.map(({ index }) => index)
.pop()
const showTypingIndicator =
waitingForResponse &&
(typeof lastUserIndex !== 'number' ||
typeof lastAssistantIndex !== 'number' ||
lastAssistantIndex < lastUserIndex)
// Pin the last user+assistant group without adding bottom padding.
const groupStartIndex = typeof lastUserIndex === 'number' ? lastUserIndex : -1
const hasGroup = pinToTop && groupStartIndex >= 0
useLayoutEffect(() => {
if (loading) return
if (pinToTop) {
const shouldPin =
!prevPinRef.current || prevUserIndexRef.current !== lastUserIndex
prevPinRef.current = true
prevUserIndexRef.current = lastUserIndex
if (shouldPin && lastUserRef.current) {
programmaticScroll.current = true
lastUserRef.current.scrollIntoView({ behavior: 'auto', block: 'start' })
window.setTimeout(() => {
programmaticScroll.current = false
}, 0)
}
return
}
prevPinRef.current = false
prevUserIndexRef.current = lastUserIndex
if (anchorRef.current) {
programmaticScroll.current = true
anchorRef.current.scrollIntoView({ behavior: 'auto', block: 'end' })
window.setTimeout(() => {
programmaticScroll.current = false
}, 0)
}
}, [loading, displayMessages.length, sessionKey, pinToTop, lastUserIndex])
return (
// mt-2 is to fix the prompt-input cut off
<ChatContainerRoot className="flex-1 min-h-0 -mb-4">
<ChatContainerContent className="pt-6" style={contentStyle}>
{empty ? (
<div aria-hidden></div>
) : hasGroup ? (
<>
{displayMessages
.slice(0, groupStartIndex)
.map((chatMessage, index) => {
const messageKey =
chatMessage.__optimisticId || (chatMessage as any).id || index
const forceActionsVisible =
typeof lastAssistantIndex === 'number' &&
index === lastAssistantIndex
const hasToolCalls =
chatMessage.role === 'assistant' &&
getToolCallsFromMessage(chatMessage).length > 0
return (
<MessageItem
key={messageKey}
message={chatMessage}
toolResultsByCallId={
hasToolCalls ? toolResultsByCallId : undefined
}
forceActionsVisible={forceActionsVisible}
/>
)
})}
{/* // Keep the last exchange pinned without extra tail gap. // Account
for space-y-6 (24px) when pinning. */}
<div
className="flex flex-col space-y-6"
style={{ minHeight: `${Math.max(0, pinGroupMinHeight - 24)}px` }}
>
{displayMessages
.slice(groupStartIndex)
.map((chatMessage, index) => {
const realIndex = groupStartIndex + index
const messageKey =
chatMessage.__optimisticId ||
(chatMessage as any).id ||
realIndex
const forceActionsVisible =
typeof lastAssistantIndex === 'number' &&
realIndex === lastAssistantIndex
const wrapperRef =
realIndex === lastUserIndex ? lastUserRef : undefined
const wrapperClassName =
realIndex === lastUserIndex ? 'scroll-mt-0' : undefined
const wrapperScrollMarginTop =
realIndex === lastUserIndex ? headerHeight : undefined
const hasToolCalls =
chatMessage.role === 'assistant' &&
getToolCallsFromMessage(chatMessage).length > 0
return (
<MessageItem
key={messageKey}
message={chatMessage}
toolResultsByCallId={
hasToolCalls ? toolResultsByCallId : undefined
}
forceActionsVisible={forceActionsVisible}
wrapperRef={wrapperRef}
wrapperClassName={wrapperClassName}
wrapperScrollMarginTop={wrapperScrollMarginTop}
/>
)
})}
{showTypingIndicator ? (
<div className="py-2">
<TypingIndicator />
</div>
) : null}
</div>
</>
) : (
displayMessages.map((chatMessage, index) => {
const messageKey =
chatMessage.__optimisticId || (chatMessage as any).id || index
const forceActionsVisible =
typeof lastAssistantIndex === 'number' &&
index === lastAssistantIndex
const hasToolCalls =
chatMessage.role === 'assistant' &&
getToolCallsFromMessage(chatMessage).length > 0
return (
<MessageItem
key={messageKey}
message={chatMessage}
toolResultsByCallId={
hasToolCalls ? toolResultsByCallId : undefined
}
forceActionsVisible={forceActionsVisible}
/>
)
})
)}
<ChatContainerScrollAnchor
ref={anchorRef as React.RefObject<HTMLDivElement>}
/>
</ChatContainerContent>
</ChatContainerRoot>
)
}
function areChatMessageListEqual(
prev: ChatMessageListProps,
next: ChatMessageListProps,
) {
return (
prev.messages === next.messages &&
prev.loading === next.loading &&
prev.empty === next.empty &&
prev.waitingForResponse === next.waitingForResponse &&
prev.sessionKey === next.sessionKey &&
prev.pinToTop === next.pinToTop &&
prev.pinGroupMinHeight === next.pinGroupMinHeight &&
prev.headerHeight === next.headerHeight &&
prev.contentStyle === next.contentStyle
)
}
const MemoizedChatMessageList = memo(
ChatMessageListComponent,
areChatMessageListEqual,
)
export { MemoizedChatMessageList as ChatMessageList }
@@ -0,0 +1,336 @@
import { HugeiconsIcon } from '@hugeicons/react'
import {
PencilEdit02Icon,
Settings01Icon,
SidebarLeft01Icon,
} from '@hugeicons/core-free-icons'
import { AnimatePresence, motion } from 'motion/react'
import { memo, useState } from 'react'
import { SettingsDialog } from './settings-dialog'
import type { SessionMeta } from '../types'
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { SessionRenameDialog } from './sidebar/session-rename-dialog'
import { SessionDeleteDialog } from './sidebar/session-delete-dialog'
import { SidebarSessions } from './sidebar/sidebar-sessions'
import { cn } from '@/lib/utils'
import { useChatSettings } from '../hooks/use-chat-settings'
import { useDeleteSession } from '../hooks/use-delete-session'
import { useRenameSession } from '../hooks/use-rename-session'
import { Button, buttonVariants } from '@/components/ui/button'
import { Link } from '@tanstack/react-router'
import { WebClawIconBig } from '@/components/icons/webclaw-big'
type ChatSidebarProps = {
sessions: Array<SessionMeta>
activeFriendlyId: string
creatingSession: boolean
onCreateSession: () => void
isCollapsed: boolean
onToggleCollapse: () => void
onSelectSession?: () => void
onActiveSessionDelete?: () => void
}
function ChatSidebarComponent({
sessions,
activeFriendlyId,
creatingSession,
onCreateSession,
isCollapsed,
onToggleCollapse,
onSelectSession,
onActiveSessionDelete,
}: ChatSidebarProps) {
const {
settingsOpen,
setSettingsOpen,
pathsLoading,
pathsError,
paths,
handleOpenSettings,
closeSettings,
copySessionsDir,
copyStorePath,
} = useChatSettings()
const { deleteSession } = useDeleteSession()
const { renameSession } = useRenameSession()
const transition = {
duration: 0.15,
ease: isCollapsed ? 'easeIn' : 'easeOut',
} as const
const [renameDialogOpen, setRenameDialogOpen] = useState(false)
const [renameSessionKey, setRenameSessionKey] = useState<string | null>(null)
const [renameSessionTitle, setRenameSessionTitle] = useState('')
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteSessionKey, setDeleteSessionKey] = useState<string | null>(null)
const [deleteFriendlyId, setDeleteFriendlyId] = useState<string | null>(null)
const [deleteSessionTitle, setDeleteSessionTitle] = useState('')
function handleOpenRename(session: SessionMeta) {
setRenameSessionKey(session.key)
setRenameSessionTitle(
session.label || session.title || session.derivedTitle || '',
)
setRenameDialogOpen(true)
}
function handleSaveRename(newTitle: string) {
if (renameSessionKey) {
void renameSession(renameSessionKey, newTitle)
}
setRenameDialogOpen(false)
setRenameSessionKey(null)
}
function handleOpenDelete(session: SessionMeta) {
setDeleteSessionKey(session.key)
setDeleteFriendlyId(session.friendlyId)
setDeleteSessionTitle(
session.label ||
session.title ||
session.derivedTitle ||
session.friendlyId,
)
setDeleteDialogOpen(true)
}
function handleConfirmDelete() {
if (deleteSessionKey && deleteFriendlyId) {
const isActive = deleteFriendlyId === activeFriendlyId
if (isActive && onActiveSessionDelete) {
onActiveSessionDelete()
}
void deleteSession(deleteSessionKey, deleteFriendlyId, isActive)
}
setDeleteDialogOpen(false)
setDeleteSessionKey(null)
setDeleteFriendlyId(null)
}
const asideProps = {
className:
'border-r border-primary-200 h-full overflow-hidden bg-primary-100 flex flex-col',
}
return (
<motion.aside
initial={false}
animate={{ width: isCollapsed ? 48 : 300 }}
transition={transition}
className={asideProps.className}
>
<motion.div
layout
transition={{ layout: transition }}
className={cn('flex items-center h-12 px-2 justify-between')}
>
<AnimatePresence initial={false}>
{!isCollapsed ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
>
<Link
to="/new"
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'w-full pl-1.5 justify-start',
)}
>
<WebClawIconBig className="size-5 rounded-sm" />
WebClaw
</Link>
</motion.div>
) : null}
</AnimatePresence>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger
onClick={onToggleCollapse}
render={
<Button size="icon-sm" variant="ghost">
<HugeiconsIcon
icon={SidebarLeft01Icon}
size={20}
strokeWidth={1.5}
/>
</Button>
}
/>
<TooltipContent side="right">
{isCollapsed ? 'Open Sidebar' : 'Close Sidebar'}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
</motion.div>
<div className="px-2 mb-4">
<motion.div
layout
transition={{ layout: transition }}
className="w-full"
>
<Button
disabled={creatingSession}
variant="ghost"
size="sm"
onClick={onCreateSession}
onMouseUp={onSelectSession}
className="w-full pl-1.5 justify-start"
>
<HugeiconsIcon
icon={PencilEdit02Icon}
size={20}
strokeWidth={1.5}
className="min-w-5"
/>
<AnimatePresence initial={false} mode="wait">
{!isCollapsed && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
className="overflow-hidden whitespace-nowrap"
>
New Session
</motion.span>
)}
</AnimatePresence>
</Button>
</motion.div>
</div>
<div className="flex-1 min-h-0 relative overflow-hidden">
<AnimatePresence initial={false}>
{!isCollapsed && (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
className="absolute inset-0 pt-0 flex flex-col w-[300px] min-h-0"
>
<div className="flex-1 min-h-0">
<SidebarSessions
sessions={sessions}
activeFriendlyId={activeFriendlyId}
onSelect={onSelectSession}
onRename={handleOpenRename}
onDelete={handleOpenDelete}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="px-2 py-3 border-t border-primary-200 bg-primary-100">
<motion.div
layout
transition={{ layout: transition }}
className="w-full"
>
<Button
variant="ghost"
size="sm"
onClick={handleOpenSettings}
title={isCollapsed ? 'Settings' : undefined}
className="w-full justify-start pl-1.5"
>
<HugeiconsIcon
icon={Settings01Icon}
size={20}
strokeWidth={1.5}
className="min-w-5"
/>
<AnimatePresence initial={false} mode="wait">
{!isCollapsed && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={transition}
className="overflow-hidden whitespace-nowrap"
>
Settings
</motion.span>
)}
</AnimatePresence>
</Button>
</motion.div>
</div>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
pathsLoading={pathsLoading}
pathsError={pathsError}
paths={paths}
onClose={closeSettings}
onCopySessionsDir={copySessionsDir}
onCopyStorePath={copyStorePath}
/>
<SessionRenameDialog
open={renameDialogOpen}
onOpenChange={setRenameDialogOpen}
sessionTitle={renameSessionTitle}
onSave={handleSaveRename}
onCancel={() => setRenameDialogOpen(false)}
/>
<SessionDeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
sessionTitle={deleteSessionTitle}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteDialogOpen(false)}
/>
</motion.aside>
)
}
function areSessionsEqual(
prevSessions: Array<SessionMeta>,
nextSessions: Array<SessionMeta>,
): boolean {
if (prevSessions === nextSessions) return true
if (prevSessions.length !== nextSessions.length) return false
for (let i = 0; i < prevSessions.length; i += 1) {
const prev = prevSessions[i]
const next = nextSessions[i]
if (prev.key !== next.key) return false
if (prev.friendlyId !== next.friendlyId) return false
if (prev.label !== next.label) return false
if (prev.title !== next.title) return false
if (prev.derivedTitle !== next.derivedTitle) return false
if (prev.updatedAt !== next.updatedAt) return false
}
return true
}
function areSidebarPropsEqual(
prevProps: ChatSidebarProps,
nextProps: ChatSidebarProps,
): boolean {
if (prevProps.activeFriendlyId !== nextProps.activeFriendlyId) return false
if (prevProps.creatingSession !== nextProps.creatingSession) return false
if (prevProps.isCollapsed !== nextProps.isCollapsed) return false
if (!areSessionsEqual(prevProps.sessions, nextProps.sessions)) return false
return true
}
const MemoizedChatSidebar = memo(ChatSidebarComponent, areSidebarPropsEqual)
export { MemoizedChatSidebar as ChatSidebar }
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons'
import { MessageTimestamp } from './message-timestamp'
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
type MessageActionsBarProps = {
text: string
align: 'start' | 'end'
timestamp: number
forceVisible?: boolean
}
export function MessageActionsBar({
text,
align,
timestamp,
forceVisible = false,
}: MessageActionsBarProps) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1400)
} catch {
setCopied(false)
}
}
const positionClass = align === 'end' ? 'justify-end' : 'justify-start'
return (
<div
className={cn(
'flex items-center gap-2 text-xs text-primary-600 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 duration-100 ease-out',
forceVisible ? 'opacity-100' : 'opacity-0',
positionClass,
)}
>
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger
type="button"
onClick={() => {
handleCopy().catch(() => {})
}}
className="inline-flex items-center justify-center rounded border border-transparent bg-transparent p-1 text-primary-700 hover:text-primary-900 hover:bg-primary-100"
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
size={16}
strokeWidth={1.6}
/>
</TooltipTrigger>
<TooltipContent side="top">Copy</TooltipContent>
</TooltipRoot>
</TooltipProvider>
<MessageTimestamp timestamp={timestamp} />
</div>
)
}
@@ -0,0 +1,268 @@
import { memo } from 'react'
import {
getMessageTimestamp,
getToolCallsFromMessage,
textFromMessage,
} from '../utils'
import { MessageActionsBar } from './message-actions-bar'
import type { GatewayMessage, ToolCallContent } from '../types'
import type { ToolPart } from '@/components/prompt-kit/tool'
import { Message, MessageContent } from '@/components/prompt-kit/message'
import { Thinking } from '@/components/prompt-kit/thinking'
import { Tool } from '@/components/prompt-kit/tool'
import { useChatSettings } from '@/hooks/use-chat-settings'
import { cn } from '@/lib/utils'
type MessageItemProps = {
message: GatewayMessage
toolResultsByCallId?: Map<string, GatewayMessage>
forceActionsVisible?: boolean
wrapperRef?: React.RefObject<HTMLDivElement | null>
wrapperClassName?: string
wrapperScrollMarginTop?: number
}
function mapToolCallToToolPart(
toolCall: ToolCallContent,
resultMessage: GatewayMessage | undefined,
): ToolPart {
const hasResult = resultMessage !== undefined
const isError = resultMessage?.isError ?? false
let state: ToolPart['state']
if (!hasResult) {
state = 'input-available'
} else if (isError) {
state = 'output-error'
} else {
state = 'output-available'
}
// Extract error text from result message content
let errorText: string | undefined
if (isError && resultMessage?.content?.[0]?.type === 'text') {
errorText = resultMessage.content[0].text || 'Unknown error'
}
return {
type: toolCall.name || 'unknown',
state,
input: toolCall.arguments,
output: resultMessage?.details,
toolCallId: toolCall.id,
errorText,
}
}
function toolCallsSignature(message: GatewayMessage): string {
const toolCalls = getToolCallsFromMessage(message)
return toolCalls
.map((toolCall) => {
const id = toolCall.id ?? ''
const name = toolCall.name ?? ''
const partialJson = toolCall.partialJson ?? ''
const args = toolCall.arguments ? JSON.stringify(toolCall.arguments) : ''
return `${id}|${name}|${partialJson}|${args}`
})
.join('||')
}
function toolResultSignature(result: GatewayMessage | undefined): string {
if (!result) return 'missing'
const content = Array.isArray(result.content) ? result.content : []
const text = content
.map((part) => (part.type === 'text' ? String(part.text ?? '') : ''))
.join('')
.trim()
const details = result.details ? JSON.stringify(result.details) : ''
return `${result.toolCallId ?? ''}|${result.toolName ?? ''}|${result.isError ? '1' : '0'}|${text}|${details}`
}
function toolResultsSignature(
message: GatewayMessage,
toolResultsByCallId: Map<string, GatewayMessage> | undefined,
): string {
if (!toolResultsByCallId) return ''
const toolCalls = getToolCallsFromMessage(message)
if (toolCalls.length === 0) return ''
return toolCalls
.map((toolCall) => {
if (!toolCall.id) return 'missing'
return toolResultSignature(toolResultsByCallId.get(toolCall.id))
})
.join('||')
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
if (value < 1_000_000_000_000) return value * 1000
return value
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return parsed
}
return null
}
function rawTimestamp(message: GatewayMessage): number | null {
const candidates = [
(message as any).createdAt,
(message as any).created_at,
(message as any).timestamp,
(message as any).time,
(message as any).ts,
]
for (const candidate of candidates) {
const normalized = normalizeTimestamp(candidate)
if (normalized) return normalized
}
return null
}
function thinkingFromMessage(msg: GatewayMessage): string | null {
const parts = Array.isArray(msg.content) ? msg.content : []
const thinkingPart = parts.find((part) => part.type === 'thinking')
if (thinkingPart && 'thinking' in thinkingPart) {
return String(thinkingPart.thinking ?? '')
}
return null
}
function MessageItemComponent({
message,
toolResultsByCallId,
forceActionsVisible = false,
wrapperRef,
wrapperClassName,
wrapperScrollMarginTop,
}: MessageItemProps) {
const { settings } = useChatSettings()
const role = message.role || 'assistant'
const text = textFromMessage(message)
const thinking = thinkingFromMessage(message)
const isUser = role === 'user'
const timestamp = getMessageTimestamp(message)
// Get tool calls from this message (for assistant messages)
const toolCalls = role === 'assistant' ? getToolCallsFromMessage(message) : []
const hasToolCalls = toolCalls.length > 0
return (
<div
ref={wrapperRef}
style={
typeof wrapperScrollMarginTop === 'number'
? { scrollMarginTop: `${wrapperScrollMarginTop}px` }
: undefined
}
className={cn(
'group flex flex-col gap-1',
wrapperClassName,
isUser ? 'items-end' : 'items-start',
)}
>
{thinking && settings.showReasoningBlocks && (
<div className="w-full max-w-[900px]">
<Thinking content={thinking} />
</div>
)}
<Message className={cn(isUser ? 'flex-row-reverse' : '')}>
<MessageContent
markdown={!isUser}
className={cn(
'text-primary-900',
!isUser
? 'bg-transparent w-full'
: 'bg-primary-100 px-4 py-2.5 max-w-[85%]',
)}
>
{text}
</MessageContent>
</Message>
{/* Render tool calls with their results */}
{hasToolCalls && settings.showToolMessages && (
<div className="w-full max-w-[900px] mt-2 flex flex-col gap-3">
{toolCalls.map((toolCall) => {
const resultMessage = toolCall.id
? toolResultsByCallId?.get(toolCall.id)
: undefined
const toolPart = mapToolCallToToolPart(toolCall, resultMessage)
return (
<Tool
key={toolCall.id || toolCall.name}
toolPart={toolPart}
defaultOpen={false}
/>
)
})}
</div>
)}
{!hasToolCalls && (
<MessageActionsBar
text={text}
timestamp={timestamp}
align={isUser ? 'end' : 'start'}
forceVisible={forceActionsVisible}
/>
)}
</div>
)
}
function areMessagesEqual(
prevProps: MessageItemProps,
nextProps: MessageItemProps,
): boolean {
if (prevProps.forceActionsVisible !== nextProps.forceActionsVisible) {
return false
}
if (prevProps.wrapperClassName !== nextProps.wrapperClassName) return false
if (prevProps.wrapperRef !== nextProps.wrapperRef) return false
if (prevProps.wrapperScrollMarginTop !== nextProps.wrapperScrollMarginTop) {
return false
}
if (
(prevProps.message.role || 'assistant') !==
(nextProps.message.role || 'assistant')
) {
return false
}
if (
textFromMessage(prevProps.message) !== textFromMessage(nextProps.message)
) {
return false
}
if (
thinkingFromMessage(prevProps.message) !==
thinkingFromMessage(nextProps.message)
) {
return false
}
if (
toolCallsSignature(prevProps.message) !==
toolCallsSignature(nextProps.message)
) {
return false
}
if (
toolResultsSignature(prevProps.message, prevProps.toolResultsByCallId) !==
toolResultsSignature(nextProps.message, nextProps.toolResultsByCallId)
) {
return false
}
if (rawTimestamp(prevProps.message) !== rawTimestamp(nextProps.message)) {
return false
}
// No need to check settings here as the hook will cause a re-render
// and areMessagesEqual is for props only.
// However, memo components with hooks will re-render if the hook state changes.
return true
}
const MemoizedMessageItem = memo(MessageItemComponent, areMessagesEqual)
export { MemoizedMessageItem as MessageItem }
@@ -0,0 +1,62 @@
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@/components/ui/tooltip'
type MessageTimestampProps = {
timestamp: number
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatShort(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
if (isSameDay(date, now)) {
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
}).format(date)
}
function formatFull(timestamp: number): string {
const value = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date(timestamp))
return value.replace(' à ', ', ')
}
export function MessageTimestamp({ timestamp }: MessageTimestampProps) {
const shortLabel = formatShort(timestamp)
const fullLabel = formatFull(timestamp)
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger className="inline-flex items-center text-xs text-primary-600">
{shortLabel}
</TooltipTrigger>
<TooltipContent side="top">{fullLabel}</TooltipContent>
</TooltipRoot>
</TooltipProvider>
)
}
@@ -0,0 +1,204 @@
import { HugeiconsIcon } from '@hugeicons/react'
import {
Cancel01Icon,
ComputerIcon,
Moon01Icon,
Sun01Icon,
} from '@hugeicons/core-free-icons'
import type { PathsPayload } from '../types'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogRoot,
DialogTitle,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'
import { useChatSettings } from '@/hooks/use-chat-settings'
import type { ThemeMode } from '@/hooks/use-chat-settings'
import { Button } from '@/components/ui/button'
type SettingsSectionProps = {
title: string
children: React.ReactNode
}
function SettingsSection({ title, children }: SettingsSectionProps) {
return (
<div className="border-b border-primary-200 py-4 last:border-0">
<h3 className="mb-3 text-sm font-medium text-primary-900">{title}</h3>
<div className="space-y-3">{children}</div>
</div>
)
}
type SettingsRowProps = {
label: string
description?: string
children: React.ReactNode
}
function SettingsRow({ label, description, children }: SettingsRowProps) {
return (
<div className="flex items-center justify-between">
<div className="flex-1 select-none">
<div className="text-sm text-primary-800">{label}</div>
{description && (
<div className="text-xs text-primary-500">{description}</div>
)}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
)
}
type SettingsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
pathsLoading: boolean
pathsError: string | null
paths: PathsPayload | null
onClose: () => void
onCopySessionsDir: () => void
onCopyStorePath: () => void
}
export function SettingsDialog({
open,
onOpenChange,
onClose,
}: SettingsDialogProps) {
const { settings, updateSettings } = useChatSettings()
const themeOptions = [
{ value: 'system', label: 'System', icon: ComputerIcon },
{ value: 'light', label: 'Light', icon: Sun01Icon },
{ value: 'dark', label: 'Dark', icon: Moon01Icon },
] as const
function applyTheme(theme: ThemeMode) {
if (typeof document === 'undefined') return
const root = document.documentElement
const media = window.matchMedia('(prefers-color-scheme: dark)')
root.classList.remove('light', 'dark', 'system')
root.classList.add(theme)
if (theme === 'system' && media.matches) {
root.classList.add('dark')
}
}
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(480px,92vw)] max-h-[80vh] overflow-auto">
<div className="p-4">
<div className="flex items-start justify-between">
<div>
<DialogTitle className="mb-1">Settings</DialogTitle>
<DialogDescription className="hidden">
Configure WebClaw
</DialogDescription>
</div>
<DialogClose
render={
<Button
size="icon-sm"
variant="ghost"
className="text-primary-500 hover:bg-primary-100 hover:text-primary-700"
aria-label="Close"
>
<HugeiconsIcon
icon={Cancel01Icon}
size={20}
strokeWidth={1.5}
/>
</Button>
}
/>
</div>
<SettingsSection title="Connection">
<SettingsRow label="Status">
<span className="flex items-center gap-1.5 text-sm text-green-600">
<span className="size-2 rounded-full bg-green-500" />
Connected
</span>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Appearance">
<SettingsRow label="Theme">
<Tabs
value={settings.theme}
onValueChange={(value) => {
const theme = value as ThemeMode
applyTheme(theme)
updateSettings({ theme })
}}
>
<TabsList
variant="default"
className="gap-2 *:data-[slot=tab-indicator]:duration-0"
>
{themeOptions.map((option) => (
<TabsTab key={option.value} value={option.value}>
<HugeiconsIcon
icon={option.icon}
size={20}
strokeWidth={1.5}
/>
<span>{option.label}</span>
</TabsTab>
))}
</TabsList>
</Tabs>
</SettingsRow>
</SettingsSection>
<SettingsSection title="Chat">
<SettingsRow label="Show tool messages">
<Switch
checked={settings.showToolMessages}
onCheckedChange={(checked) =>
updateSettings({ showToolMessages: checked })
}
/>
</SettingsRow>
<SettingsRow label="Show reasoning blocks">
<Switch
checked={settings.showReasoningBlocks}
onCheckedChange={(checked) =>
updateSettings({ showReasoningBlocks: checked })
}
/>
</SettingsRow>
</SettingsSection>
<SettingsSection title="About">
<div className="text-sm text-primary-800">WebClaw (beta)</div>
<div className="flex gap-4 pt-2">
<a
href="https://github.com/ibelick/webclaw"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-900 hover:underline"
>
GitHub
</a>
<a
href="https://docs.openclaw.ai"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-900 hover:underline"
>
OpenClaw docs
</a>
</div>
</SettingsSection>
<div className="mt-6 flex justify-end">
<DialogClose onClick={onClose}>Close</DialogClose>
</div>
</div>
</DialogContent>
</DialogRoot>
)
}
@@ -0,0 +1,44 @@
'use client'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogRoot,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
type SessionDeleteDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
sessionTitle: string
onConfirm: () => void
onCancel: () => void
}
export function SessionDeleteDialog({
open,
onOpenChange,
sessionTitle,
onConfirm,
onCancel,
}: SessionDeleteDialogProps) {
return (
<AlertDialogRoot open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<div className="p-4">
<AlertDialogTitle className="mb-1">Delete Session</AlertDialogTitle>
<AlertDialogDescription className="mb-4">
Are you sure you want to delete "{sessionTitle}"? This action cannot
be undone.
</AlertDialogDescription>
<div className="flex justify-end gap-2">
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Delete</AlertDialogAction>
</div>
</div>
</AlertDialogContent>
</AlertDialogRoot>
)
}
@@ -0,0 +1,121 @@
'use client'
import { Link } from '@tanstack/react-router'
import { HugeiconsIcon } from '@hugeicons/react'
import {
MoreHorizontalIcon,
Pen01Icon,
Delete01Icon,
} from '@hugeicons/core-free-icons'
import { cn } from '@/lib/utils'
import {
MenuContent,
MenuItem,
MenuRoot,
MenuTrigger,
} from '@/components/ui/menu'
import { memo } from 'react'
import type { SessionMeta } from '../../types'
type SessionItemProps = {
session: SessionMeta
active: boolean
onSelect?: () => void
onRename: (session: SessionMeta) => void
onDelete: (session: SessionMeta) => void
}
function SessionItemComponent({
session,
active,
onSelect,
onRename,
onDelete,
}: SessionItemProps) {
const label =
session.label || session.title || session.derivedTitle || session.friendlyId
return (
<Link
to="/chat/$sessionKey"
params={{ sessionKey: session.friendlyId }}
onClick={onSelect}
className={cn(
'group inline-flex items-center justify-between',
'w-full text-left pl-1.5 pr-0.5 h-8 rounded-lg transition-colors duration-0',
'select-none',
active
? 'bg-primary-200 text-primary-950'
: 'bg-transparent text-primary-950 [&:hover:not(:has(button:hover))]:bg-primary-200',
)}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-[450] line-clamp-1">{label}</div>
</div>
<MenuRoot>
<MenuTrigger
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
}}
className={cn(
'ml-2 inline-flex size-7 items-center justify-center rounded-md text-primary-700',
'opacity-0 transition-opacity group-hover:opacity-100 hover:bg-primary-200',
'aria-expanded:opacity-100 aria-expanded:bg-primary-200',
)}
>
<HugeiconsIcon
icon={MoreHorizontalIcon}
size={20}
strokeWidth={1.5}
/>
</MenuTrigger>
<MenuContent side="bottom" align="end">
<MenuItem
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onRename(session)
}}
className="gap-2"
>
<HugeiconsIcon icon={Pen01Icon} size={20} strokeWidth={1.5} />{' '}
Rename
</MenuItem>
<MenuItem
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onDelete(session)
}}
className="text-red-700 gap-2 hover:bg-red-50/80 data-highlighted:bg-red-50/80"
>
<HugeiconsIcon icon={Delete01Icon} size={20} strokeWidth={1.5} />{' '}
Delete
</MenuItem>
</MenuContent>
</MenuRoot>
</Link>
)
}
function areSessionItemsEqual(prev: SessionItemProps, next: SessionItemProps) {
if (prev.active !== next.active) return false
if (prev.onSelect !== next.onSelect) return false
if (prev.onRename !== next.onRename) return false
if (prev.onDelete !== next.onDelete) return false
if (prev.session === next.session) return true
return (
prev.session.key === next.session.key &&
prev.session.friendlyId === next.session.friendlyId &&
prev.session.label === next.session.label &&
prev.session.title === next.session.title &&
prev.session.derivedTitle === next.session.derivedTitle &&
prev.session.updatedAt === next.session.updatedAt
)
}
const SessionItem = memo(SessionItemComponent, areSessionItemsEqual)
export { SessionItem }
@@ -0,0 +1,64 @@
'use client'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogRoot,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type SessionRenameDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
sessionTitle: string
onSave: (newTitle: string) => void
onCancel: () => void
}
export function SessionRenameDialog({
open,
onOpenChange,
sessionTitle,
onSave,
onCancel,
}: SessionRenameDialogProps) {
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogContent>
<div className="p-4">
<DialogTitle className="mb-1">Rename</DialogTitle>
<DialogDescription className="mb-4">
Enter a new name for this session.
</DialogDescription>
<input
type="text"
defaultValue={sessionTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onSave(e.currentTarget.value)
}
}}
className="w-full rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none focus:border-primary-400"
placeholder="Session name"
autoFocus
/>
<div className="mt-4 flex justify-end gap-2">
<DialogClose onClick={onCancel}>Cancel</DialogClose>
<Button
onClick={(e) => {
const input = e.currentTarget.parentElement
?.previousElementSibling as HTMLInputElement
onSave(input.value)
}}
>
Save
</Button>
</div>
</div>
</DialogContent>
</DialogRoot>
)
}
@@ -0,0 +1,90 @@
'use client'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowRight01Icon } from '@hugeicons/core-free-icons'
import {
Collapsible,
CollapsibleTrigger,
CollapsiblePanel,
} from '@/components/ui/collapsible'
import { SessionItem } from './session-item'
import type { SessionMeta } from '../../types'
import { memo } from 'react'
type SidebarSessionsProps = {
sessions: Array<SessionMeta>
activeFriendlyId: string
defaultOpen?: boolean
onSelect?: () => void
onRename: (session: SessionMeta) => void
onDelete: (session: SessionMeta) => void
}
export const SidebarSessions = memo(function SidebarSessions({
sessions,
activeFriendlyId,
defaultOpen = true,
onSelect,
onRename,
onDelete,
}: SidebarSessionsProps) {
return (
<Collapsible
className="flex flex-col flex-1 min-h-0 w-full px-2"
defaultOpen={defaultOpen}
>
<CollapsibleTrigger className="w-fit pl-1.5 shrink-0">
Sessions
<span className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3 transition-transform duration-150 group-data-panel-open:rotate-90"
/>
</span>
</CollapsibleTrigger>
<CollapsiblePanel
className="w-full flex-1 min-h-0 !h-full data-starting-style:!h-0 data-ending-style:!h-0"
contentClassName="flex-1 min-h-0"
>
<div className="h-full w-full overflow-y-auto">
<div className="flex flex-col gap-px">
{sessions.map((session) => (
<SessionItem
key={session.key}
session={session}
active={session.friendlyId === activeFriendlyId}
onSelect={onSelect}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
</div>
</CollapsiblePanel>
</Collapsible>
)
}, areSidebarSessionsEqual)
function areSidebarSessionsEqual(
prev: SidebarSessionsProps,
next: SidebarSessionsProps,
) {
if (prev.activeFriendlyId !== next.activeFriendlyId) return false
if (prev.defaultOpen !== next.defaultOpen) return false
if (prev.onSelect !== next.onSelect) return false
if (prev.onRename !== next.onRename) return false
if (prev.onDelete !== next.onDelete) return false
if (prev.sessions === next.sessions) return true
if (prev.sessions.length !== next.sessions.length) return false
for (let i = 0; i < prev.sessions.length; i += 1) {
const prevSession = prev.sessions[i]
const nextSession = next.sessions[i]
if (prevSession.key !== nextSession.key) return false
if (prevSession.friendlyId !== nextSession.friendlyId) return false
if (prevSession.label !== nextSession.label) return false
if (prevSession.title !== nextSession.title) return false
if (prevSession.derivedTitle !== nextSession.derivedTitle) return false
if (prevSession.updatedAt !== nextSession.updatedAt) return false
}
return true
}
+159
View File
@@ -0,0 +1,159 @@
import { useMemo, useRef } from 'react'
import { useQuery, type QueryClient } from '@tanstack/react-query'
import { chatQueryKeys, fetchHistory } from '../chat-queries'
import { getMessageTimestamp, textFromMessage } from '../utils'
import type { GatewayMessage, HistoryResponse } from '../types'
type UseChatHistoryInput = {
activeFriendlyId: string
activeSessionKey: string
forcedSessionKey?: string
isNewChat: boolean
isRedirecting: boolean
activeExists: boolean
sessionsReady: boolean
queryClient: QueryClient
}
export function useChatHistory({
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
isRedirecting,
activeExists,
sessionsReady,
queryClient,
}: UseChatHistoryInput) {
const sessionKeyForHistory =
forcedSessionKey || activeSessionKey || activeFriendlyId
const historyKey = chatQueryKeys.history(
activeFriendlyId,
sessionKeyForHistory,
)
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: async function fetchHistoryForSession() {
const cached = queryClient.getQueryData(historyKey) as
| HistoryResponse
| undefined
const optimisticMessages = Array.isArray(cached?.messages)
? cached.messages.filter((message) => {
if (message.status === 'sending') return true
if (message.__optimisticId) return true
return Boolean(message.clientId)
})
: []
const serverData = await fetchHistory({
sessionKey: sessionKeyForHistory,
friendlyId: activeFriendlyId,
})
if (!optimisticMessages.length) return serverData
const merged = mergeOptimisticHistoryMessages(
serverData.messages,
optimisticMessages,
)
return {
...serverData,
messages: merged,
}
},
enabled:
!isNewChat &&
Boolean(activeFriendlyId) &&
!isRedirecting &&
(!sessionsReady || activeExists),
placeholderData: function useCachedHistory(): HistoryResponse | undefined {
return queryClient.getQueryData(historyKey)
},
gcTime: 1000 * 60 * 10,
})
const stableHistorySignatureRef = useRef('')
const stableHistoryMessagesRef = useRef<Array<GatewayMessage>>([])
const historyMessages = useMemo(() => {
const messages = Array.isArray(historyQuery.data?.messages)
? historyQuery.data.messages
: []
const last = messages[messages.length - 1]
const lastId =
last && typeof (last as { id?: string }).id === 'string'
? (last as { id?: string }).id
: ''
const signature = `${messages.length}:${last?.role ?? ''}:${lastId}:${textFromMessage(last ?? { role: 'user', content: [] }).slice(-32)}`
if (signature === stableHistorySignatureRef.current) {
return stableHistoryMessagesRef.current
}
stableHistorySignatureRef.current = signature
stableHistoryMessagesRef.current = messages
return messages
}, [historyQuery.data?.messages])
const historyError =
historyQuery.error instanceof Error ? historyQuery.error.message : null
const resolvedSessionKey = useMemo(() => {
if (forcedSessionKey) return forcedSessionKey
const key = historyQuery.data?.sessionKey
if (typeof key === 'string' && key.trim().length > 0) return key.trim()
return activeSessionKey
}, [activeSessionKey, forcedSessionKey, historyQuery.data?.sessionKey])
const activeCanonicalKey = isNewChat
? 'new'
: resolvedSessionKey || activeFriendlyId
return {
historyQuery,
historyMessages,
displayMessages: historyMessages,
historyError,
resolvedSessionKey,
activeCanonicalKey,
sessionKeyForHistory,
}
}
function mergeOptimisticHistoryMessages(
serverMessages: Array<GatewayMessage>,
optimisticMessages: Array<GatewayMessage>,
): Array<GatewayMessage> {
if (!optimisticMessages.length) return serverMessages
const merged = [...serverMessages]
for (const optimisticMessage of optimisticMessages) {
const hasMatch = serverMessages.some((serverMessage) => {
if (
optimisticMessage.clientId &&
serverMessage.clientId &&
optimisticMessage.clientId === serverMessage.clientId
) {
return true
}
if (
optimisticMessage.__optimisticId &&
serverMessage.__optimisticId &&
optimisticMessage.__optimisticId === serverMessage.__optimisticId
) {
return true
}
if (optimisticMessage.role && serverMessage.role) {
if (optimisticMessage.role !== serverMessage.role) return false
}
const optimisticText = textFromMessage(optimisticMessage)
if (!optimisticText) return false
if (optimisticText !== textFromMessage(serverMessage)) return false
const optimisticTime = getMessageTimestamp(optimisticMessage)
const serverTime = getMessageTimestamp(serverMessage)
return Math.abs(optimisticTime - serverTime) <= 10000
})
if (!hasMatch) {
merged.push(optimisticMessage)
}
}
return merged
}
@@ -0,0 +1,58 @@
import { useLayoutEffect, useRef, useState } from 'react'
export type ChatMeasurements = {
headerRef: React.RefObject<HTMLDivElement | null>
composerRef: React.RefObject<HTMLDivElement | null>
mainRef: React.RefObject<HTMLDivElement | null>
pinGroupMinHeight: number
headerHeight: number
}
export function useChatMeasurements(): ChatMeasurements {
const headerRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLDivElement | null>(null)
const mainRef = useRef<HTMLDivElement | null>(null)
const [pinGroupMinHeight, setPinGroupMinHeight] = useState(0)
const [headerHeight, setHeaderHeight] = useState(0)
// Measure header/composer to keep pinned group exact.
useLayoutEffect(() => {
const headerEl = headerRef.current
const composerEl = composerRef.current
const mainEl = mainRef.current
if (!mainEl) return
const applySizes = () => {
const nextHeaderHeight = headerEl?.offsetHeight ?? 0
const composerHeight = composerEl?.offsetHeight ?? 0
const mainHeight = mainEl.clientHeight
mainEl.style.setProperty(
'--chat-header-height',
`${Math.max(0, nextHeaderHeight)}px`,
)
mainEl.style.setProperty(
'--chat-composer-height',
`${Math.max(0, composerHeight)}px`,
)
setHeaderHeight(nextHeaderHeight)
setPinGroupMinHeight(
Math.max(0, mainHeight - nextHeaderHeight - composerHeight),
)
}
applySizes()
const observer = new ResizeObserver(() => applySizes())
if (headerEl) observer.observe(headerEl)
if (composerEl) observer.observe(composerEl)
return () => observer.disconnect()
}, [])
return {
headerRef,
composerRef,
mainRef,
pinGroupMinHeight,
headerHeight,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { useLayoutEffect, useState } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { setChatUiState } from '../chat-ui'
export function useChatMobile(queryClient: QueryClient) {
const [isMobile, setIsMobile] = useState(false)
useLayoutEffect(() => {
const media = window.matchMedia('(max-width: 768px)')
const update = () => setIsMobile(media.matches)
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
useLayoutEffect(() => {
if (!isMobile) return
setChatUiState(queryClient, function collapse(state) {
return { ...state, isSidebarCollapsed: true }
})
}, [isMobile, queryClient])
return { isMobile }
}
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { chatQueryKeys, fetchSessions } from '../chat-queries'
import { isRecentSession } from '../pending-send'
import { filterSessionsWithTombstones } from '../session-tombstones'
type UseChatSessionsInput = {
activeFriendlyId: string
isNewChat: boolean
forcedSessionKey?: string
}
export function useChatSessions({
activeFriendlyId,
isNewChat,
forcedSessionKey,
}: UseChatSessionsInput) {
const sessionsQuery = useQuery({
queryKey: chatQueryKeys.sessions,
queryFn: fetchSessions,
refetchInterval: 30000,
})
const sessions = useMemo(() => {
const rawSessions = sessionsQuery.data ?? []
return filterSessionsWithTombstones(rawSessions)
}, [sessionsQuery.data])
const activeSession = useMemo(() => {
return sessions.find((session) => session.friendlyId === activeFriendlyId)
}, [sessions, activeFriendlyId])
const activeExists = useMemo(() => {
if (isNewChat) return true
if (forcedSessionKey) return true
if (isRecentSession(activeFriendlyId)) return true
return sessions.some((session) => session.friendlyId === activeFriendlyId)
}, [activeFriendlyId, forcedSessionKey, isNewChat, sessions])
const activeSessionKey = activeSession?.key ?? ''
const activeTitle = useMemo(() => {
if (activeSession) {
return (
activeSession.label ||
activeSession.title ||
activeSession.derivedTitle ||
activeSession.friendlyId
)
}
return activeFriendlyId
}, [activeFriendlyId, activeSession])
const sessionsError =
sessionsQuery.error instanceof Error ? sessionsQuery.error.message : null
return {
sessionsQuery,
sessions,
activeSession,
activeExists,
activeSessionKey,
activeTitle,
sessionsError,
}
}
@@ -0,0 +1,78 @@
import { useCallback, useState } from 'react'
import { readError } from '../utils'
import type { PathsPayload } from '../types'
export function useChatSettings() {
const [settingsOpen, setSettingsOpen] = useState(false)
const [pathsLoading, setPathsLoading] = useState(false)
const [pathsError, setPathsError] = useState<string | null>(null)
const [paths, setPaths] = useState<PathsPayload | null>(null)
const openSettings = useCallback(async () => {
setSettingsOpen(true)
setPathsError(null)
if (pathsLoading || paths) return
setPathsLoading(true)
try {
const res = await fetch('/api/paths')
if (!res.ok) throw new Error(await readError(res))
const data = (await res.json()) as {
agentId?: string
stateDir?: string
sessionsDir?: string
storePath?: string
}
setPaths({
agentId: String(data.agentId ?? 'main'),
stateDir: String(data.stateDir ?? ''),
sessionsDir: String(data.sessionsDir ?? ''),
storePath: String(data.storePath ?? ''),
})
} catch (err) {
setPathsError(err instanceof Error ? err.message : String(err))
} finally {
setPathsLoading(false)
}
}, [paths, pathsLoading])
const handleOpenSettings = useCallback(() => {
void openSettings()
}, [openSettings])
const closeSettings = useCallback(() => {
setSettingsOpen(false)
}, [])
const copySessionsDir = useCallback(() => {
if (!paths?.sessionsDir) return
try {
void navigator.clipboard.writeText(paths.sessionsDir)
} catch {
// ignore
}
}, [paths])
const copyStorePath = useCallback(() => {
if (!paths?.storePath) return
try {
void navigator.clipboard.writeText(paths.storePath)
} catch {
// ignore
}
}, [paths])
return {
settingsOpen,
setSettingsOpen,
pathsLoading,
pathsError,
paths,
handleOpenSettings,
closeSettings,
copySessionsDir,
copyStorePath,
}
}
@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
chatQueryKeys,
clearHistoryMessages,
removeSessionFromCache,
} from '../chat-queries'
import { clearPendingSendForSession, resetPendingSend } from '../pending-send'
import { clearSessionDeleted, markSessionDeleted } from '../session-tombstones'
import { readError } from '../utils'
export type DeleteSessionResult = {
deleteSession: (
sessionKey: string,
friendlyId: string,
isActive: boolean,
) => Promise<void>
deleting: boolean
error: string | null
}
export function useDeleteSession(): DeleteSessionResult {
const queryClient = useQueryClient()
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: async function deleteSessionRequest(payload: {
sessionKey: string
friendlyId: string
isActive: boolean
}) {
const query = new URLSearchParams()
if (payload.sessionKey) query.set('sessionKey', payload.sessionKey)
if (payload.friendlyId) query.set('friendlyId', payload.friendlyId)
const res = await fetch(`/api/sessions?${query.toString()}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(await readError(res))
return payload
},
onMutate: async function onMutate(payload) {
setError(null)
markSessionDeleted(payload.sessionKey || payload.friendlyId)
clearPendingSendForSession(payload.sessionKey, payload.friendlyId)
await queryClient.cancelQueries({ queryKey: chatQueryKeys.sessions })
const previousSessions = queryClient.getQueryData(chatQueryKeys.sessions)
removeSessionFromCache(
queryClient,
payload.sessionKey,
payload.friendlyId,
)
if (payload.isActive && (payload.sessionKey || payload.friendlyId)) {
clearHistoryMessages(
queryClient,
payload.friendlyId || payload.sessionKey,
payload.sessionKey || payload.friendlyId,
)
}
return { previousSessions, isActive: payload.isActive }
},
onError: function onError(err, _payload, context) {
if (context?.previousSessions) {
queryClient.setQueryData(
chatQueryKeys.sessions,
context.previousSessions,
)
}
clearSessionDeleted(_payload.sessionKey || _payload.friendlyId)
setError(err instanceof Error ? err.message : String(err))
},
onSuccess: function onSuccess(payload) {
if (payload.isActive) {
resetPendingSend()
}
queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions })
},
onSettled: function onSettled() {
setDeleting(false)
},
})
const deleteSession = useCallback(
async (sessionKey: string, friendlyId: string, isActive: boolean) => {
if (!sessionKey && !friendlyId) return
setDeleting(true)
await mutation.mutateAsync({ sessionKey, friendlyId, isActive })
},
[mutation],
)
return { deleteSession, deleting, error }
}
@@ -0,0 +1,86 @@
import { useCallback, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { chatQueryKeys } from '../chat-queries'
import { readError } from '../utils'
export type RenameSessionResult = {
renameSession: (sessionKey: string, newTitle: string) => Promise<void>
renaming: boolean
error: string | null
}
export function useRenameSession(): RenameSessionResult {
const queryClient = useQueryClient()
const [renaming, setRenaming] = useState(false)
const [error, setError] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: async function renameSessionRequest(payload: {
sessionKey: string
newTitle: string
}) {
const res = await fetch('/api/sessions', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionKey: payload.sessionKey,
label: payload.newTitle,
}),
})
if (!res.ok) throw new Error(await readError(res))
return payload
},
onMutate: async function onMutate(payload) {
setError(null)
await queryClient.cancelQueries({ queryKey: chatQueryKeys.sessions })
const previousSessions = queryClient.getQueryData(chatQueryKeys.sessions)
// Optimistically update the session title in cache
queryClient.setQueryData(
chatQueryKeys.sessions,
function update(sessions: unknown) {
if (!Array.isArray(sessions)) return sessions
return (
sessions as Array<{ key: string; label?: string; title?: string }>
).map((session) => {
if (session.key !== payload.sessionKey) return session
return {
...session,
label: payload.newTitle,
title: payload.newTitle,
}
})
},
)
return { previousSessions }
},
onError: function onError(err, _payload, context) {
if (context?.previousSessions) {
queryClient.setQueryData(
chatQueryKeys.sessions,
context.previousSessions,
)
}
setError(err instanceof Error ? err.message : String(err))
},
onSuccess: function onSuccess() {
// Invalidate to ensure we have the latest data
queryClient.invalidateQueries({ queryKey: chatQueryKeys.sessions })
},
onSettled: function onSettled() {
setRenaming(false)
},
})
const renameSession = useCallback(
async (sessionKey: string, newTitle: string) => {
if (!sessionKey || !newTitle.trim()) return
setRenaming(true)
await mutation.mutateAsync({ sessionKey, newTitle: newTitle.trim() })
},
[mutation],
)
return { renameSession, renaming, error }
}
+76
View File
@@ -0,0 +1,76 @@
import type { GatewayMessage } from './types'
export type PendingSendPayload = {
sessionKey: string
friendlyId: string
message: string
optimisticMessage: GatewayMessage
}
let pendingSend: PendingSendPayload | null = null
let pendingGeneration = false
let recentSession: { friendlyId: string; at: number } | null = null
export function stashPendingSend(payload: PendingSendPayload) {
pendingSend = payload
}
export function hasPendingSend() {
return pendingSend !== null
}
export function setPendingGeneration(value: boolean) {
pendingGeneration = value
}
export function hasPendingGeneration() {
return pendingGeneration
}
export function resetPendingSend() {
pendingSend = null
pendingGeneration = false
}
export function clearPendingSendForSession(
sessionKey: string,
friendlyId: string,
) {
if (!pendingSend) return
if (sessionKey && pendingSend.sessionKey === sessionKey) {
resetPendingSend()
return
}
if (friendlyId && pendingSend.friendlyId === friendlyId) {
resetPendingSend()
}
}
export function setRecentSession(friendlyId: string) {
recentSession = { friendlyId, at: Date.now() }
}
export function isRecentSession(friendlyId: string, maxAgeMs = 15000) {
if (!recentSession) return false
if (recentSession.friendlyId !== friendlyId) return false
if (Date.now() - recentSession.at > maxAgeMs) return false
return true
}
export function consumePendingSend(
sessionKey: string,
friendlyId?: string,
): PendingSendPayload | null {
if (!pendingSend) return null
if (sessionKey && pendingSend.sessionKey === sessionKey) {
const payload = pendingSend
pendingSend = null
return payload
}
if (friendlyId && pendingSend.friendlyId === friendlyId) {
const payload = pendingSend
pendingSend = null
return payload
}
return null
}
+47
View File
@@ -0,0 +1,47 @@
type Tombstone = {
id: string
expiresAt: number
}
const TOMBSTONE_TTL_MS = 8000
const tombstones = new Map<string, Tombstone>()
export function markSessionDeleted(id: string) {
if (!id) return
tombstones.set(id, { id, expiresAt: Date.now() + TOMBSTONE_TTL_MS })
}
export function clearSessionDeleted(id: string) {
if (!id) return
tombstones.delete(id)
}
export function filterSessionsWithTombstones<T extends { key: string; friendlyId: string }>(
sessions: Array<T>,
) {
if (tombstones.size === 0) return sessions
const now = Date.now()
let changed = false
const next = sessions.filter((session) => {
const keyTombstone = tombstones.get(session.key)
const friendlyTombstone = tombstones.get(session.friendlyId)
const isExpired =
(keyTombstone && keyTombstone.expiresAt <= now) ||
(friendlyTombstone && friendlyTombstone.expiresAt <= now)
if (isExpired) {
if (keyTombstone && keyTombstone.expiresAt <= now) {
tombstones.delete(session.key)
}
if (friendlyTombstone && friendlyTombstone.expiresAt <= now) {
tombstones.delete(session.friendlyId)
}
return true
}
if (keyTombstone || friendlyTombstone) {
changed = true
return false
}
return true
})
return changed ? next : sessions
}
+79
View File
@@ -0,0 +1,79 @@
export type ToolCallContent = {
type: 'toolCall'
id?: string
name?: string
arguments?: Record<string, unknown>
partialJson?: string
}
export type ToolResultContent = {
type: 'toolResult'
toolCallId?: string
toolName?: string
content?: Array<{ type?: string; text?: string }>
details?: Record<string, unknown>
isError?: boolean
}
export type TextContent = {
type: 'text'
text?: string
textSignature?: string
}
export type ThinkingContent = {
type: 'thinking'
thinking?: string
thinkingSignature?: string
}
export type MessageContent = TextContent | ToolCallContent | ThinkingContent
export type GatewayMessage = {
role?: string
content?: Array<MessageContent>
toolCallId?: string
toolName?: string
details?: Record<string, unknown>
isError?: boolean
timestamp?: number
[key: string]: unknown
__optimisticId?: string
}
export type SessionSummary = {
key?: string
label?: string
title?: string
derivedTitle?: string
updatedAt?: number
lastMessage?: GatewayMessage | null
friendlyId?: string
}
export type SessionListResponse = {
sessions?: Array<SessionSummary>
}
export type HistoryResponse = {
sessionKey: string
sessionId?: string
messages: Array<GatewayMessage>
}
export type SessionMeta = {
key: string
friendlyId: string
title?: string
derivedTitle?: string
label?: string
updatedAt?: number
lastMessage?: GatewayMessage | null
}
export type PathsPayload = {
agentId: string
stateDir: string
sessionsDir: string
storePath: string
}
+124
View File
@@ -0,0 +1,124 @@
import type {
GatewayMessage,
SessionMeta,
SessionSummary,
ToolCallContent,
} from './types'
export function deriveFriendlyIdFromKey(key: string | undefined): string {
if (!key) return 'main'
const trimmed = key.trim()
if (trimmed.length === 0) return 'main'
const parts = trimmed.split(':')
const tail = parts[parts.length - 1] ?? ''
const tailTrimmed = tail.trim()
return tailTrimmed.length > 0 ? tailTrimmed : trimmed
}
export function textFromMessage(msg: GatewayMessage): string {
const parts = Array.isArray(msg.content) ? msg.content : []
return parts
.map((part) => (part.type === 'text' ? String(part.text ?? '') : ''))
.join('')
.trim()
}
export function getToolCallsFromMessage(
msg: GatewayMessage,
): Array<ToolCallContent> {
const parts = Array.isArray(msg.content) ? msg.content : []
return parts.filter(
(part): part is ToolCallContent => part.type === 'toolCall',
)
}
export function findToolResultForCall(
toolCallId: string,
messages: Array<GatewayMessage>,
): GatewayMessage | undefined {
return messages.find(
(msg) => msg.role === 'toolResult' && msg.toolCallId === toolCallId,
)
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
if (value < 1_000_000_000_000) return value * 1000
return value
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return parsed
}
return null
}
export function getMessageTimestamp(message: GatewayMessage): number {
const candidates = [
(message as any).createdAt,
(message as any).created_at,
(message as any).timestamp,
(message as any).time,
(message as any).ts,
]
for (const candidate of candidates) {
const normalized = normalizeTimestamp(candidate)
if (normalized) return normalized
}
return Date.now()
}
export function normalizeSessions(
rows: Array<SessionSummary> | undefined,
): Array<SessionMeta> {
if (!Array.isArray(rows)) return []
return rows.map((session) => {
const key =
typeof session.key === 'string' && session.key.trim().length > 0
? session.key.trim()
: deriveFriendlyIdFromKey(session.friendlyId ?? session.key)
const friendlyIdCandidate =
typeof session.friendlyId === 'string' &&
session.friendlyId.trim().length > 0
? session.friendlyId.trim()
: deriveFriendlyIdFromKey(key)
return {
key,
friendlyId: friendlyIdCandidate,
title: typeof session.title === 'string' ? session.title : undefined,
derivedTitle:
typeof session.derivedTitle === 'string'
? session.derivedTitle
: undefined,
label: typeof session.label === 'string' ? session.label : undefined,
updatedAt:
typeof session.updatedAt === 'number' ? session.updatedAt : undefined,
lastMessage: session.lastMessage ?? null,
}
})
}
export async function readError(res: Response): Promise<string> {
try {
const data = await res.json()
if (data?.error) return String(data.error)
if (data?.message) return String(data.message)
return JSON.stringify(data)
} catch {
try {
return await res.text()
} catch {
return res.statusText || 'Request failed'
}
}
}
export const missingGatewayAuthMessage =
'Missing gateway auth. Set CLAWDBOT_GATEWAY_TOKEN (recommended) or CLAWDBOT_GATEWAY_PASSWORD in the server environment.'
export function isMissingGatewayAuth(message: string): boolean {
return message.includes(missingGatewayAuthMessage)
}
+166
View File
@@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
type GatewayFrame =
| { type: 'req'; id: string; method: string; params?: unknown }
| {
type: 'res'
id: string
ok: boolean
payload?: unknown
error?: { code: string; message: string; details?: unknown }
}
| { type: 'event'; event: string; payload?: unknown; seq?: number }
type ConnectParams = {
minProtocol: number
maxProtocol: number
client: {
id: string
displayName?: string
version: string
platform: string
mode: string
instanceId?: string
}
auth?: { token?: string; password?: string }
role?: 'operator' | 'node'
scopes?: Array<string>
}
function getGatewayConfig() {
const url = process.env.CLAWDBOT_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || ''
const password = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || ''
// For a minimal dashboard we require shared auth, otherwise we'd need a device identity signature.
if (!token && !password) {
throw new Error(
'Missing gateway auth. Set CLAWDBOT_GATEWAY_TOKEN (recommended) or CLAWDBOT_GATEWAY_PASSWORD in the server environment.',
)
}
return { url, token, password }
}
async function wsOpen(ws: WebSocket): Promise<void> {
if (ws.readyState === ws.OPEN) return
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
cleanup()
resolve()
}
const onError = (e: Event) => {
cleanup()
reject(new Error(`WebSocket error: ${String((e as any)?.message ?? e)}`))
}
const cleanup = () => {
ws.removeEventListener('open', onOpen)
ws.removeEventListener('error', onError)
}
ws.addEventListener('open', onOpen)
ws.addEventListener('error', onError)
})
}
async function wsClose(ws: WebSocket): Promise<void> {
if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) return
await new Promise<void>((resolve) => {
ws.addEventListener('close', () => resolve(), { once: true })
ws.close()
})
}
export async function gatewayRpc<TPayload = unknown>(
method: string,
params?: unknown,
): Promise<TPayload> {
const { url, token, password } = getGatewayConfig()
const ws = new WebSocket(url)
try {
await wsOpen(ws)
// 1) connect handshake (must be first request)
const connectId = randomUUID()
const connectParams: ConnectParams = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client',
displayName: 'webclaw',
version: 'dev',
platform: process.platform,
mode: 'ui',
instanceId: randomUUID(),
},
auth: {
token: token || undefined,
password: password || undefined,
},
role: 'operator',
scopes: ['operator.admin'],
}
const connectReq: GatewayFrame = {
type: 'req',
id: connectId,
method: 'connect',
params: connectParams,
}
const requestId = randomUUID()
const req: GatewayFrame = {
type: 'req',
id: requestId,
method,
params,
}
// Response waiters keyed by id
const waiters = new Map<
string,
{
resolve: (v: any) => void
reject: (e: Error) => void
}
>()
const waitForRes = (id: string) =>
new Promise<any>((resolve, reject) => {
waiters.set(id, { resolve, reject })
})
const onMessage = (evt: MessageEvent) => {
try {
const data = typeof evt.data === 'string' ? evt.data : ''
const parsed = JSON.parse(data) as GatewayFrame
if (parsed.type !== 'res') return
const w = waiters.get(parsed.id)
if (!w) return
waiters.delete(parsed.id)
if (parsed.ok) w.resolve(parsed.payload)
else w.reject(new Error(parsed.error?.message ?? 'gateway error'))
} catch {
// ignore parse errors
}
}
ws.addEventListener('message', onMessage)
ws.send(JSON.stringify(connectReq))
await waitForRes(connectId)
ws.send(JSON.stringify(req))
const payload = await waitForRes(requestId)
ws.removeEventListener('message', onMessage)
return payload as TPayload
} finally {
try {
await wsClose(ws)
} catch {
// ignore
}
}
}
+129
View File
@@ -0,0 +1,129 @@
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:opsz,wght@8..120,400..800&family=JetBrains+Mono:wght@400;500&display=swap');
@import 'tailwindcss';
.root {
isolation: isolate;
}
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-transparent: transparent;
--color-current: currentColor;
--color-surface: var(--color-white);
--color-ink: var(--color-black);
--color-primary-50: oklch(0.992 0.002 80);
--color-primary-100: oklch(0.9821 0 89.88);
--color-primary-200: oklch(0.9461 0 89.88);
--color-primary-300: oklch(0.95 0.004 80);
--color-primary-400: oklch(0.875 0.005 80);
--color-primary-500: oklch(0.785 0.006 80);
--color-primary-600: oklch(0.67 0.007 80);
--color-primary-700: oklch(0.56 0.006 80);
--color-primary-800: oklch(0.45 0.005 80);
--color-primary-900: oklch(0.35 0.004 80);
--color-primary-950: oklch(0.25 0.003 80);
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-serif: 'EB Garamond', serif;
--font-mono: "JetBrains Mono", monospace;
}
.dark {
color-scheme: dark;
--color-primary-50: oklch(0.1244 0 89.88);
--color-primary-100: oklch(0.1584 0 89.88);
--color-primary-200: oklch(0.2078 0 89.88);
--color-primary-300: oklch(0.3 0.006 80);
--color-primary-400: oklch(0.4 0.006 80);
--color-primary-500: oklch(0.52 0.006 80);
--color-primary-600: oklch(0.65 0.006 80);
--color-primary-700: oklch(0.73 0.006 80);
--color-primary-800: oklch(0.83 0.005 80);
--color-primary-900: oklch(0.895 0.004 80);
--color-primary-950: oklch(0.945 0.003 80);
--color-surface: var(--color-black);
--color-ink: var(--color-white);
}
.light {
color-scheme: light;
}
.system {
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
.system {
color-scheme: dark;
--color-primary-50: oklch(0.1244 0 89.88);
--color-primary-100: oklch(0.1584 0 89.88);
--color-primary-200: oklch(0.2078 0 89.88);
--color-primary-300: oklch(0.3 0.006 80);
--color-primary-400: oklch(0.4 0.006 80);
--color-primary-500: oklch(0.52 0.006 80);
--color-primary-600: oklch(0.65 0.006 80);
--color-primary-700: oklch(0.73 0.006 80);
--color-primary-800: oklch(0.83 0.005 80);
--color-primary-900: oklch(0.895 0.004 80);
--color-primary-950: oklch(0.945 0.003 80);
--color-surface: var(--color-black);
--color-ink: var(--color-white);
--color-surface-deep: oklch(0.18 0 0);
}
}
html,
body {
@apply m-0 font-sans;
letter-spacing: -0.15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
.font-sans {
letter-spacing: -0.15px;
}
.inline-code {
background: var(--color-primary-100);
border: 1px solid var(--color-primary-200);
border-radius: 0.5rem;
color: var(--color-primary-900);
font-family:
'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
font-size: 0.95em;
padding: 0.1rem 0.4rem;
white-space: nowrap;
}
.code-block .shiki {
background: transparent !important;
margin: 0;
}
.code-block .shiki code {
display: block;
font-size: 0.875rem;
line-height: 1.5;
}
code {
font-family:
'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
}
@keyframes shimmer {
0% {
background-position: 200% 50%;
}
100% {
background-position: -200% 50%;
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"include": [
"**/*.ts",
"**/*.tsx",
"eslint.config.js",
"prettier.config.js",
"vite.config.js"
],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { URL, fileURLToPath } from 'node:url'
// devtools removed
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// nitro plugin removed (tanstackStart handles server runtime)
import { defineConfig } from 'vite'
import viteTsConfigPaths from 'vite-tsconfig-paths'
const config = defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
plugins: [
// devtools(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config