mirror of
https://github.com/ibelick/webclaw.git
synced 2026-08-14 00:57:51 +00:00
chore: initial project setup
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
@@ -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=
|
||||
@@ -0,0 +1,3 @@
|
||||
eslint.config.js
|
||||
prettier.config.js
|
||||
vite.config.ts
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
todos.json
|
||||
@@ -0,0 +1,3 @@
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
# WebClaw
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
Generated
+9945
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('prettier').Config} */
|
||||
const config = {
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
}
|
||||
|
||||
export default config
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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())
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user