feat: initial Honcho UI scaffold
React 19 + Vite 8 + TanStack Router SPA for browsing and chatting with a self-hosted Honcho instance. Configurable base URL stored in localStorage only. Dark/light theme, framer-motion animations, lucide-react icons.
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# fire-tools
|
||||||
|
.fire-tools-manifest.json
|
||||||
|
.fire-tools-delta.json
|
||||||
|
.fire-tools/
|
||||||
|
.pnpm-store/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Playwright MCP
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# TypeScript build info
|
||||||
|
*.tsbuildinfo
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"ignoredBuilds": [], "onlyBuiltDependencies": ["@biomejs/biome", "esbuild"]}
|
||||||
8
.pnpmfile.cjs
Normal file
8
.pnpmfile.cjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
function readPackage(pkg, context) {
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
module.exports = {
|
||||||
|
hooks: {
|
||||||
|
readPackage,
|
||||||
|
}
|
||||||
|
};
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
31
biome.json
Normal file
31
biome.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>honcho-ui-scaffold</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5265
openapi.json
Normal file
5265
openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "honcho-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "biome check src/",
|
||||||
|
"lint:fix": "biome check --write src/",
|
||||||
|
"format": "biome format --write src/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/dm-mono": "^5.2.7",
|
||||||
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"@tanstack/react-query": "^5.74.4",
|
||||||
|
"@tanstack/react-router": "^1.120.3",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.11.0",
|
||||||
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"zod": "^3.24.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@tanstack/router-plugin": "^1.120.3",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"openapi-typescript": "^7.8.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^3.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3299
pnpm-lock.yaml
generated
Normal file
3299
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
24
src/api/client.ts
Normal file
24
src/api/client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import createClient from "openapi-fetch";
|
||||||
|
import type { paths } from "./schema.d.ts";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export function createHonchoClient() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const baseUrl = config?.baseUrl ?? "http://localhost:8000";
|
||||||
|
const token = config?.token ?? "";
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createClient<paths>({ baseUrl, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const client = {
|
||||||
|
get current() {
|
||||||
|
return createHonchoClient();
|
||||||
|
},
|
||||||
|
};
|
||||||
331
src/api/queries.ts
Normal file
331
src/api/queries.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { client } from "./client";
|
||||||
|
|
||||||
|
// ─── Workspaces ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useWorkspaces(page = 1, pageSize = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workspaces", page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST("/v3/workspaces/list", {
|
||||||
|
params: { query: { page, page_size: pageSize } },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspace(workspaceId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workspace", workspaceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST("/v3/workspaces", {
|
||||||
|
body: { id: workspaceId, metadata: {} },
|
||||||
|
});
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueueStatus(workspaceId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["queue-status", workspaceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.GET(
|
||||||
|
"/v3/workspaces/{workspace_id}/queue/status",
|
||||||
|
{ params: { path: { workspace_id: workspaceId } } },
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["workspace-search", workspaceId, query],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/search",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId } },
|
||||||
|
body: { query, limit: 20 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Peers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peers", workspaceId, page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/list",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId }, query: { page, page_size: pageSize } },
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeer(workspaceId: string, peerId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peer", workspaceId, peerId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId } },
|
||||||
|
body: { id: peerId, metadata: {} },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeerRepresentation(workspaceId: string, peerId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peer-representation", workspaceId, peerId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||||
|
body: { max_conclusions: 20 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeerCard(workspaceId: string, peerId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peer-card", workspaceId, peerId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.GET(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeerContext(workspaceId: string, peerId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peer-context", workspaceId, peerId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.GET(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/context",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeerSessions(workspaceId: string, peerId: string, page = 1, pageSize = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["peer-sessions", workspaceId, peerId, page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/sessions",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { workspace_id: workspaceId, peer_id: peerId },
|
||||||
|
query: { page, page_size: pageSize },
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat(workspaceId: string, peerId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (message: string) => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/chat",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||||
|
body: { query: message, stream: false, reasoning_level: "low" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sessions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["sessions", workspaceId, page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/sessions/list",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { workspace_id: workspaceId },
|
||||||
|
query: { page, page_size: pageSize },
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionMessages(
|
||||||
|
workspaceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 50,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["session-messages", workspaceId, sessionId, page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/list",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { workspace_id: workspaceId, session_id: sessionId },
|
||||||
|
query: { page, page_size: pageSize },
|
||||||
|
},
|
||||||
|
body: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionSummaries(workspaceId: string, sessionId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["session-summaries", workspaceId, sessionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.GET(
|
||||||
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionContext(workspaceId: string, sessionId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["session-context", workspaceId, sessionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.GET(
|
||||||
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/context",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Conclusions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useConclusions(
|
||||||
|
workspaceId: string,
|
||||||
|
filters: Record<string, unknown> = {},
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["conclusions", workspaceId, filters, page, pageSize],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
path: { workspace_id: workspaceId },
|
||||||
|
query: { page, page_size: pageSize },
|
||||||
|
},
|
||||||
|
body: filters,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueryConclusions(
|
||||||
|
workspaceId: string,
|
||||||
|
query: string,
|
||||||
|
filters: Record<string, unknown> = {},
|
||||||
|
enabled = false,
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["conclusions-query", workspaceId, query, filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await client.current.POST(
|
||||||
|
"/v3/workspaces/{workspace_id}/conclusions/query",
|
||||||
|
{
|
||||||
|
params: { path: { workspace_id: workspaceId } },
|
||||||
|
body: { query, top_k: 10, ...filters },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw new Error(JSON.stringify(error));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||||
|
});
|
||||||
|
}
|
||||||
3324
src/api/schema.d.ts
vendored
Normal file
3324
src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
198
src/components/chat/ChatPage.tsx
Normal file
198
src/components/chat/ChatPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Send, Brain } from "lucide-react";
|
||||||
|
import { useChat } from "@/api/queries";
|
||||||
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||||
|
workspaceId: string;
|
||||||
|
peerId: string;
|
||||||
|
};
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chatMutation = useChat(workspaceId, peerId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed || chatMutation.isPending) return;
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
setMessages((prev) => [...prev, { role: "user", content: trimmed }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await chatMutation.mutateAsync(trimmed);
|
||||||
|
const responseText =
|
||||||
|
typeof result === "string"
|
||||||
|
? result
|
||||||
|
: typeof (result as { response?: unknown })?.response === "string"
|
||||||
|
? (result as { response: string }).response
|
||||||
|
: JSON.stringify(result);
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: responseText }]);
|
||||||
|
} catch (err) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen" style={{ background: "var(--bg)" }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-6 py-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-xs mb-1" style={{ color: "var(--text-3)" }}>
|
||||||
|
<Link
|
||||||
|
to="/workspaces/$workspaceId/peers/$peerId"
|
||||||
|
params={{ workspaceId, peerId } as never}
|
||||||
|
className="hover:underline font-mono"
|
||||||
|
>
|
||||||
|
{peerId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Chat</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h1 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
Memory-augmented chat
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
Honcho responds using accumulated context for{" "}
|
||||||
|
<span className="font-mono">{peerId}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-auto px-4 sm:px-6 py-4 space-y-4">
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex items-center justify-center h-full"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||||
|
style={{ background: "var(--accent-dim)", border: "1px solid var(--accent-border)" }}
|
||||||
|
>
|
||||||
|
<Brain className="w-6 h-6" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-2)" }}>
|
||||||
|
Start a conversation
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 max-w-xs" style={{ color: "var(--text-3)" }}>
|
||||||
|
Honcho will respond using accumulated memory context for this peer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
|
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-w-[80%] sm:max-w-[70%] rounded-2xl px-4 py-3 text-sm"
|
||||||
|
style={
|
||||||
|
msg.role === "user"
|
||||||
|
? { background: "var(--accent)", color: "#fff" }
|
||||||
|
: {
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{chatMutation.isPending && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-start"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl px-4 py-3 flex items-center gap-2"
|
||||||
|
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||||
|
Honcho is thinking...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-4 sm:px-6 py-4"
|
||||||
|
style={{ borderTop: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 max-w-3xl mx-auto">
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Message this peer... (Enter to send, Shift+Enter for newline)"
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 px-4 py-3 text-sm rounded-xl resize-none outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border-2)",
|
||||||
|
color: "var(--text-1)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||||
|
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || chatMutation.isPending}
|
||||||
|
className="px-4 rounded-xl self-end mb-0.5 py-3 text-sm font-medium transition-all flex items-center gap-2 disabled:opacity-40"
|
||||||
|
style={{ background: "var(--accent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
<span className="hidden sm:block">Send</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/conclusions/ConclusionBrowser.tsx
Normal file
230
src/components/conclusions/ConclusionBrowser.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Lightbulb, Search, X, Clock, ArrowLeft, Eye } from "lucide-react";
|
||||||
|
import { useConclusions, useQueryConclusions } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
|
type Conclusion = components["schemas"]["Conclusion"];
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 8 },
|
||||||
|
show: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { delay: i * 0.04, type: "spring" as const, stiffness: 300, damping: 25 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConclusionBrowser() {
|
||||||
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useConclusions(workspaceId, {}, page);
|
||||||
|
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
||||||
|
workspaceId,
|
||||||
|
activeSearch,
|
||||||
|
{},
|
||||||
|
Boolean(activeSearch),
|
||||||
|
);
|
||||||
|
|
||||||
|
const conclusions: Conclusion[] = (data as { items?: Conclusion[] } | undefined)?.items ?? [];
|
||||||
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
|
const displayedConclusions: Conclusion[] = activeSearch
|
||||||
|
? Array.isArray(searchResults) ? searchResults : []
|
||||||
|
: conclusions;
|
||||||
|
|
||||||
|
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveSearch(searchQuery.trim());
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId }}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
|
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
{workspaceId}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Lightbulb className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||||
|
Conclusions
|
||||||
|
</h1>
|
||||||
|
{total > 0 && !activeSearch && (
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.1)",
|
||||||
|
color: "#818cf8",
|
||||||
|
border: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "rgba(148,163,184,0.6)" }}>
|
||||||
|
Distilled memory observations about peers
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2 mb-6">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4"
|
||||||
|
style={{ color: "rgba(148,163,184,0.4)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Semantic search across conclusions..."
|
||||||
|
className="w-full rounded-xl pl-9 pr-4 py-2.5 text-sm font-mono outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.03)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
color: "#e4e4f0",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = "rgba(99,102,241,0.4)";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = "rgba(255,255,255,0.08)";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2.5 rounded-xl text-sm font-medium transition-all"
|
||||||
|
style={{ background: "#4f46e5", color: "#fff" }}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{activeSearch && (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
onClick={() => { setActiveSearch(""); setSearchQuery(""); }}
|
||||||
|
className="px-3 py-2.5 rounded-xl text-sm transition-all"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
color: "rgba(148,163,184,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{(isLoading || (activeSearch && searchLoading)) && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && !searchLoading && displayedConclusions.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={Lightbulb}
|
||||||
|
title={activeSearch ? "No results found" : "No conclusions yet"}
|
||||||
|
description={
|
||||||
|
activeSearch
|
||||||
|
? `No conclusions match "${activeSearch}"`
|
||||||
|
: "Conclusions are created when Honcho processes sessions."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayedConclusions.length > 0 && (
|
||||||
|
<>
|
||||||
|
{activeSearch && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-xs font-mono mb-3"
|
||||||
|
style={{ color: "rgba(148,163,184,0.4)" }}
|
||||||
|
>
|
||||||
|
{displayedConclusions.length} result{displayedConclusions.length !== 1 ? "s" : ""}{" "}
|
||||||
|
for “{activeSearch}”
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{displayedConclusions.map((c, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={c.id}
|
||||||
|
custom={i}
|
||||||
|
variants={itemVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="rounded-xl p-5"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm leading-relaxed whitespace-pre-wrap"
|
||||||
|
style={{ color: "#d4d4f5" }}
|
||||||
|
>
|
||||||
|
{c.content}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mt-4 pt-3"
|
||||||
|
style={{ borderTop: "1px solid rgba(255,255,255,0.05)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Eye className="w-3 h-3" style={{ color: "rgba(148,163,184,0.35)" }} strokeWidth={1.5} />
|
||||||
|
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||||
|
{c.observer_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{c.observed_id && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs" style={{ color: "rgba(148,163,184,0.2)" }}>→</span>
|
||||||
|
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||||
|
{c.observed_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{c.created_at && (
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.25)" }} strokeWidth={1.5} />
|
||||||
|
<span className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||||
|
{new Date(c.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!activeSearch && (
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/dashboard/Dashboard.tsx
Normal file
180
src/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Boxes, Activity, LayoutDashboard } from "lucide-react";
|
||||||
|
import { useWorkspaces, useQueueStatus } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
|
||||||
|
function QueueCard({ workspaceId }: { workspaceId: string }) {
|
||||||
|
const { data, isLoading } = useQueueStatus(workspaceId);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<PageLoader />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const pending = data.pending_work_units;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl p-5 theme-card">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h3 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>Queue Status</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: pending === 0 ? "rgba(52,211,153,0.1)" : "rgba(245,158,11,0.1)",
|
||||||
|
color: pending === 0 ? "#34d399" : "#f59e0b",
|
||||||
|
border: `1px solid ${pending === 0 ? "rgba(52,211,153,0.2)" : "rgba(245,158,11,0.2)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending === 0 ? "Idle" : "Active"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
||||||
|
<div key={key} className="flex justify-between text-xs">
|
||||||
|
<span className="capitalize" style={{ color: "var(--text-3)" }}>
|
||||||
|
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
{data[key]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [page] = useState(1);
|
||||||
|
const { data, isLoading, error } = useWorkspaces(page, 6);
|
||||||
|
|
||||||
|
const workspaces = (data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||||
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<LayoutDashboard className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
||||||
|
Overview of your Honcho instance
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stat row */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.05 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-3 gap-3"
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: "Workspaces", value: total, icon: Boxes },
|
||||||
|
].map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div key={stat.label} className="rounded-xl p-5 theme-card">
|
||||||
|
<Icon className="w-5 h-5 mb-3" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<div className="text-3xl font-semibold font-mono" style={{ color: "var(--text-1)" }}>
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-1" style={{ color: "var(--text-3)" }}>
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Workspace list */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="rounded-xl p-5 theme-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Boxes className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
Recent Workspaces
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/workspaces"
|
||||||
|
className="text-xs transition-colors"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaces.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>No workspaces found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<Link
|
||||||
|
key={ws.id}
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId: ws.id } as never}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg transition-all group"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs truncate"
|
||||||
|
style={{ color: "var(--accent-text)" }}
|
||||||
|
>
|
||||||
|
{ws.id}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Queue for first workspace */}
|
||||||
|
{workspaces[0] && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.15 }}
|
||||||
|
>
|
||||||
|
<QueueCard workspaceId={workspaces[0].id} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/layout/Sidebar.tsx
Normal file
131
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { LayoutDashboard, Boxes, Settings, Brain, ChevronRight, Sun, Moon } from "lucide-react";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
|
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||||
|
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
const config = loadConfig();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: -20, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className="w-14 sm:w-56 shrink-0 flex flex-col h-full"
|
||||||
|
style={{
|
||||||
|
background: "var(--sidebar-bg)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-3 sm:px-5 py-5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex items-center gap-2.5 justify-center sm:justify-start">
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||||
|
boxShadow: "0 0 16px rgba(99,102,241,0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Brain className="w-4 h-4 text-white" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<span className="font-semibold text-sm tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||||
|
Honcho UI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{config && (
|
||||||
|
<p
|
||||||
|
className="text-xs mt-2 truncate font-mono hidden sm:block"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
title={config.baseUrl}
|
||||||
|
>
|
||||||
|
{config.baseUrl.replace(/^https?:\/\//, "")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex-1 px-2 sm:px-3 py-3 space-y-0.5">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className="relative flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all group justify-center sm:justify-start"
|
||||||
|
style={{
|
||||||
|
color: isActive ? "var(--accent-text)" : "var(--text-2)",
|
||||||
|
background: isActive ? "var(--accent-dim)" : "transparent",
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="nav-indicator"
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
}}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
className="w-4 h-4 shrink-0 relative z-10"
|
||||||
|
strokeWidth={isActive ? 2 : 1.5}
|
||||||
|
/>
|
||||||
|
<span className="relative z-10 font-medium hidden sm:block">{item.label}</span>
|
||||||
|
{isActive && (
|
||||||
|
<ChevronRight
|
||||||
|
className="w-3 h-3 ml-auto relative z-10 opacity-60 hidden sm:block"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Theme toggle + footer */}
|
||||||
|
<div
|
||||||
|
className="px-3 sm:px-5 py-3 flex items-center justify-between"
|
||||||
|
style={{ borderTop: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-mono hidden sm:block" style={{ color: "var(--text-4)" }}>
|
||||||
|
API v3
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors mx-auto sm:mx-0"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-3)",
|
||||||
|
}}
|
||||||
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/components/peers/PeerDetail.tsx
Normal file
168
src/components/peers/PeerDetail.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { User, MessageCircle } from "lucide-react";
|
||||||
|
import { usePeer, usePeerCard, usePeerContext, usePeerRepresentation } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
|
|
||||||
|
type Tab = "context" | "card" | "representation" | "metadata";
|
||||||
|
|
||||||
|
export function PeerDetail() {
|
||||||
|
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||||
|
workspaceId: string;
|
||||||
|
peerId: string;
|
||||||
|
};
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState<Tab>("context");
|
||||||
|
|
||||||
|
const { data: peer, isLoading, error } = usePeer(workspaceId, peerId);
|
||||||
|
const { data: card, isLoading: cardLoading } = usePeerCard(workspaceId, peerId);
|
||||||
|
const { data: context, isLoading: contextLoading } = usePeerContext(workspaceId, peerId);
|
||||||
|
const { data: representation, isLoading: repLoading } = usePeerRepresentation(workspaceId, peerId);
|
||||||
|
|
||||||
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
|
{ id: "context", label: "Context" },
|
||||||
|
{ id: "card", label: "Card" },
|
||||||
|
{ id: "representation", label: "Representation" },
|
||||||
|
{ id: "metadata", label: "Metadata" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||||
|
<Link to="/workspaces" className="hover:underline">Workspaces</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
||||||
|
{workspaceId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/workspaces/$workspaceId/peers" params={{ workspaceId } as never} className="hover:underline">
|
||||||
|
Peers
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<User className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{peerId}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>Peer identity & memory</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/workspaces/$workspaceId/peers/$peerId/chat",
|
||||||
|
params: { workspaceId, peerId } as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="shrink-0 flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && peer && (
|
||||||
|
<>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div
|
||||||
|
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||||
|
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||||
|
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{tab === t.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="tab-active"
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<motion.div
|
||||||
|
key={tab}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="rounded-xl p-5 theme-card"
|
||||||
|
>
|
||||||
|
{tab === "context" && (
|
||||||
|
contextLoading ? <PageLoader /> : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Context</h2>
|
||||||
|
{typeof context === "string" ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>{context}</p>
|
||||||
|
) : (
|
||||||
|
<JsonViewer data={context} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{tab === "card" && (
|
||||||
|
cardLoading ? <PageLoader /> : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Card</h2>
|
||||||
|
{typeof card === "string" ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-2)" }}>{card}</p>
|
||||||
|
) : (
|
||||||
|
<JsonViewer data={card} maxHeight="400px" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{tab === "representation" && (
|
||||||
|
repLoading ? <PageLoader /> : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Memory Representation</h2>
|
||||||
|
{representation && typeof (representation as { representation?: unknown }).representation === "string" ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||||
|
{(representation as { representation: string }).representation}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<JsonViewer data={representation} maxHeight="400px" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{tab === "metadata" && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Peer Metadata</h2>
|
||||||
|
<JsonViewer data={peer.metadata} maxHeight="400px" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/peers/PeerList.tsx
Normal file
140
src/components/peers/PeerList.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
import { Users, ChevronRight, Clock, ArrowLeft } from "lucide-react";
|
||||||
|
import { usePeers } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
|
type Peer = components["schemas"]["Peer"];
|
||||||
|
|
||||||
|
const container: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||||
|
};
|
||||||
|
const item: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 10 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PeerList() {
|
||||||
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error } = usePeers(workspaceId, page);
|
||||||
|
|
||||||
|
const peers: Peer[] = (data as { items?: Peer[] } | undefined)?.items ?? [];
|
||||||
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId }}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
|
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
{workspaceId}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Users className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||||
|
Peers
|
||||||
|
</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.1)",
|
||||||
|
color: "#818cf8",
|
||||||
|
border: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||||
|
{workspaceId}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && peers.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No peers found"
|
||||||
|
description="No peers exist in this workspace."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && peers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
|
{peers.map((peer) => (
|
||||||
|
<motion.button
|
||||||
|
key={peer.id}
|
||||||
|
variants={item}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/workspaces/$workspaceId/peers/$peerId",
|
||||||
|
params: { workspaceId, peerId: peer.id } as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-left rounded-xl px-5 py-4 group"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
background: "rgba(99,102,241,0.06)",
|
||||||
|
borderColor: "rgba(99,102,241,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm font-medium truncate"
|
||||||
|
style={{ color: "#c7d2fe" }}
|
||||||
|
>
|
||||||
|
{peer.id}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||||
|
style={{ color: "#6366f1" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{peer.created_at && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
||||||
|
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||||
|
{new Date(peer.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/sessions/SessionDetail.tsx
Normal file
164
src/components/sessions/SessionDetail.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import { useSessionMessages, useSessionSummaries, useSessionContext } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { Badge } from "@/components/shared/Badge";
|
||||||
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
|
type Message = components["schemas"]["Message"];
|
||||||
|
type Tab = "messages" | "summaries" | "context";
|
||||||
|
|
||||||
|
export function SessionDetail() {
|
||||||
|
const { workspaceId, sessionId } = useParams({ strict: false }) as {
|
||||||
|
workspaceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
const [tab, setTab] = useState<Tab>("messages");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data: msgData, isLoading: msgsLoading } = useSessionMessages(workspaceId, sessionId, page);
|
||||||
|
const { data: summaries, isLoading: summariesLoading } = useSessionSummaries(workspaceId, sessionId);
|
||||||
|
const { data: context, isLoading: contextLoading } = useSessionContext(workspaceId, sessionId);
|
||||||
|
|
||||||
|
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||||
|
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
|
||||||
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
|
{ id: "messages", label: "Messages" },
|
||||||
|
{ id: "summaries", label: "Summaries" },
|
||||||
|
{ id: "context", label: "Context" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||||
|
<Link to="/workspaces/$workspaceId" params={{ workspaceId } as never} className="hover:underline font-mono">
|
||||||
|
{workspaceId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/workspaces/$workspaceId/sessions" params={{ workspaceId } as never} className="hover:underline">
|
||||||
|
Sessions
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MessageSquare className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{sessionId}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>Session detail</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div
|
||||||
|
className="flex gap-0.5 mb-4 p-1 rounded-xl"
|
||||||
|
style={{ background: "var(--bg-3)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
||||||
|
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{tab === t.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="session-tab-active"
|
||||||
|
className="absolute inset-0 rounded-lg"
|
||||||
|
style={{ background: "var(--bg-2)", border: "1px solid var(--border)" }}
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.35 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
key={tab}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="rounded-xl p-5 theme-card"
|
||||||
|
>
|
||||||
|
{tab === "messages" && (
|
||||||
|
msgsLoading ? <PageLoader /> : (
|
||||||
|
<div>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-3)" }}>No messages.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="pb-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||||
|
{msg.peer_id ?? "system"}
|
||||||
|
</Badge>
|
||||||
|
{msg.token_count != null && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
{msg.token_count} tokens
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msg.created_at && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-4)" }}>
|
||||||
|
{new Date(msg.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap leading-relaxed"
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "summaries" && (
|
||||||
|
summariesLoading ? <PageLoader /> : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Summaries</h2>
|
||||||
|
<JsonViewer data={summaries} maxHeight="500px" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "context" && (
|
||||||
|
contextLoading ? <PageLoader /> : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>Session Context</h2>
|
||||||
|
{typeof context === "string" ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed" style={{ color: "var(--text-2)" }}>
|
||||||
|
{context}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<JsonViewer data={context} maxHeight="500px" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/sessions/SessionList.tsx
Normal file
146
src/components/sessions/SessionList.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
import { MessageSquare, ChevronRight, Clock, CircleDot, ArrowLeft } from "lucide-react";
|
||||||
|
import { useSessions } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
|
type Session = components["schemas"]["Session"];
|
||||||
|
|
||||||
|
const container: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||||
|
};
|
||||||
|
const item: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 10 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 280, damping: 24 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SessionList() {
|
||||||
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error } = useSessions(workspaceId, page);
|
||||||
|
|
||||||
|
const sessions: Session[] = (data as { items?: Session[] } | undefined)?.items ?? [];
|
||||||
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/workspaces/$workspaceId"
|
||||||
|
params={{ workspaceId }}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
|
style={{ color: "rgba(148,163,184,0.5)" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
{workspaceId}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MessageSquare className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight" style={{ color: "#e4e4f0" }}>
|
||||||
|
Sessions
|
||||||
|
</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.1)",
|
||||||
|
color: "#818cf8",
|
||||||
|
border: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: "rgba(148,163,184,0.4)" }}>
|
||||||
|
{workspaceId}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && sessions.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={MessageSquare}
|
||||||
|
title="No sessions found"
|
||||||
|
description="No sessions exist in this workspace."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && sessions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<motion.button
|
||||||
|
key={session.id}
|
||||||
|
variants={item}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/workspaces/$workspaceId/sessions/$sessionId",
|
||||||
|
params: { workspaceId, sessionId: session.id } as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full text-left rounded-xl px-5 py-4 group"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
background: "rgba(99,102,241,0.06)",
|
||||||
|
borderColor: "rgba(99,102,241,0.2)",
|
||||||
|
x: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-sm font-medium truncate" style={{ color: "#c7d2fe" }}>
|
||||||
|
{session.id}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
|
{session.is_active && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<CircleDot className="w-3 h-3" style={{ color: "#34d399" }} strokeWidth={2} />
|
||||||
|
</motion.div>
|
||||||
|
<span className="text-xs" style={{ color: "#34d399" }}>Active</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||||
|
style={{ color: "#6366f1" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{session.created_at && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
<Clock className="w-3 h-3" style={{ color: "rgba(148,163,184,0.3)" }} strokeWidth={1.5} />
|
||||||
|
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.3)" }}>
|
||||||
|
{new Date(session.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/components/settings/SettingsForm.tsx
Normal file
229
src/components/settings/SettingsForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Wifi, WifiOff, Loader, CheckCircle, AlertCircle, Lock, LockOpen } from "lucide-react";
|
||||||
|
import {
|
||||||
|
configSchema,
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
checkConnection,
|
||||||
|
type Config,
|
||||||
|
type HealthStatus,
|
||||||
|
} from "@/lib/config";
|
||||||
|
|
||||||
|
interface SettingsFormProps {
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
ok: { icon: CheckCircle, color: "#34d399", label: "Connected" },
|
||||||
|
"auth-required": { icon: AlertCircle, color: "#f59e0b", label: "Auth required" },
|
||||||
|
unreachable: { icon: WifiOff, color: "#f87171", label: "Unreachable" },
|
||||||
|
checking: { icon: Loader, color: "#818cf8", label: "Checking..." },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||||
|
const existing = loadConfig();
|
||||||
|
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
|
||||||
|
const [token, setToken] = useState(existing?.token ?? "");
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setChecking(true);
|
||||||
|
setHealth({ status: "checking", message: "Connecting..." });
|
||||||
|
const result = await checkConnection(baseUrl, token || undefined);
|
||||||
|
setHealth(result);
|
||||||
|
setChecking(false);
|
||||||
|
|
||||||
|
// Auto-show token field if auth is required
|
||||||
|
if (result.status === "auth-required" && !token) {
|
||||||
|
document.getElementById("honcho-token")?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const result = configSchema.safeParse({ baseUrl, token });
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors: typeof errors = {};
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const key = issue.path[0] as keyof Config;
|
||||||
|
fieldErrors[key] = issue.message;
|
||||||
|
}
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
saveConfig(result.data);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaved(false);
|
||||||
|
onSaved?.();
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusIcon = health ? statusConfig[health.status].icon : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="rounded-2xl p-6 space-y-5"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-2)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Base URL */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
Honcho Base URL
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => { setBaseUrl(e.target.value); setHealth(null); }}
|
||||||
|
placeholder="http://localhost:8000"
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-mono rounded-xl outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border-2)",
|
||||||
|
color: "var(--text-1)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||||
|
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={checking || !baseUrl}
|
||||||
|
className="px-3 py-2 rounded-xl text-sm font-medium flex items-center gap-1.5 transition-all disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
color: "var(--accent-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checking ? (
|
||||||
|
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}>
|
||||||
|
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<Wifi className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:block">Test</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.baseUrl && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: "#f87171" }}>{errors.baseUrl}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs mt-1.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
URL of your self-hosted Honcho instance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health status */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{health && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-xl px-4 py-3 flex items-center gap-2.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: `1px solid ${statusConfig[health.status].color}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{StatusIcon && (
|
||||||
|
<StatusIcon
|
||||||
|
className="w-4 h-4 shrink-0"
|
||||||
|
style={{ color: statusConfig[health.status].color }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: statusConfig[health.status].color }}>
|
||||||
|
{statusConfig[health.status].label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
{health.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="honcho-token"
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium mb-1.5"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{token ? (
|
||||||
|
<Lock className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<LockOpen className="w-3.5 h-3.5" style={{ color: "var(--text-3)" }} strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
API Token
|
||||||
|
<span
|
||||||
|
className="ml-1 text-xs font-normal px-1.5 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
optional
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="honcho-token"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="eyJ... (required only if your instance has auth enabled)"
|
||||||
|
className="w-full px-3 py-2.5 text-sm rounded-xl font-mono resize-none outline-none transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border-2)",
|
||||||
|
color: "var(--text-1)",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
||||||
|
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
||||||
|
/>
|
||||||
|
{health?.status === "auth-required" && !token && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-xs mt-1"
|
||||||
|
style={{ color: "#f59e0b" }}
|
||||||
|
>
|
||||||
|
This instance requires an API token to proceed
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-2.5 px-4 rounded-xl text-sm font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: saved ? "#059669" : "var(--accent)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saved ? "✓ Saved" : "Save Connection"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/shared/Badge.tsx
Normal file
23
src/components/shared/Badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
interface BadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "default" | "green" | "yellow" | "red" | "blue";
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<string, React.CSSProperties> = {
|
||||||
|
default: { background: "var(--surface)", color: "var(--text-2)", border: "1px solid var(--border)" },
|
||||||
|
green: { background: "rgba(52,211,153,0.08)", color: "#34d399", border: "1px solid rgba(52,211,153,0.2)" },
|
||||||
|
yellow: { background: "rgba(245,158,11,0.08)", color: "#f59e0b", border: "1px solid rgba(245,158,11,0.2)" },
|
||||||
|
red: { background: "rgba(239,68,68,0.08)", color: "#f87171", border: "1px solid rgba(239,68,68,0.2)" },
|
||||||
|
blue: { background: "rgba(99,102,241,0.08)", color: "var(--accent-text)", border: "1px solid var(--accent-border)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ children, variant = "default" }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium font-mono"
|
||||||
|
style={variantStyles[variant]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/shared/Card.tsx
Normal file
28
src/components/shared/Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = "", onClick }: CardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
onClick={onClick}
|
||||||
|
whileHover={onClick ? { scale: 1.005, y: -1 } : undefined}
|
||||||
|
whileTap={onClick ? { scale: 0.998 } : undefined}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
cursor: onClick ? "pointer" : "default",
|
||||||
|
transition: "border-color 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/shared/EmptyState.tsx
Normal file
37
src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-center"
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center mb-4"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.08)",
|
||||||
|
border: "1px solid rgba(99,102,241,0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" style={{ color: "rgba(99,102,241,0.6)" }} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-zinc-300 font-medium text-sm">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-zinc-600 text-xs mt-1.5 max-w-xs leading-relaxed">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/shared/ErrorAlert.tsx
Normal file
24
src/components/shared/ErrorAlert.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
interface ErrorAlertProps {
|
||||||
|
error: Error | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorAlert({ error, message }: ErrorAlertProps) {
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl p-4 mb-4"
|
||||||
|
style={{
|
||||||
|
background: "rgba(239, 68, 68, 0.08)",
|
||||||
|
border: "1px solid rgba(239, 68, 68, 0.25)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium" style={{ color: "#f87171" }}>
|
||||||
|
{message ?? "An error occurred"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 font-mono" style={{ color: "rgba(248, 113, 113, 0.6)" }}>
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/shared/JsonViewer.tsx
Normal file
30
src/components/shared/JsonViewer.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
interface JsonViewerProps {
|
||||||
|
data: unknown;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonViewer({ data, maxHeight = "200px" }: JsonViewerProps) {
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>empty</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
typeof data === "object" && data !== null && Object.keys(data as object).length === 0;
|
||||||
|
if (isEmpty) {
|
||||||
|
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>{}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
className="text-xs rounded-xl p-3 overflow-auto font-mono"
|
||||||
|
style={{
|
||||||
|
maxHeight,
|
||||||
|
background: "var(--bg)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/shared/LoadingSpinner.tsx
Normal file
40
src/components/shared/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = { sm: 16, md: 24, lg: 40 };
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) {
|
||||||
|
const s = sizes[size];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
style={{
|
||||||
|
width: s,
|
||||||
|
height: s,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `2px solid rgba(99,102,241,0.15)`,
|
||||||
|
borderTopColor: "#6366f1",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
<motion.div
|
||||||
|
className="h-px w-24"
|
||||||
|
style={{ background: "linear-gradient(90deg, transparent, #6366f1, transparent)" }}
|
||||||
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/shared/Pagination.tsx
Normal file
41
src/components/shared/Pagination.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg disabled:opacity-30 transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono px-2" style={{ color: "var(--text-3)" }}>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg disabled:opacity-30 transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--surface)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/workspaces/WorkspaceDetail.tsx
Normal file
151
src/components/workspaces/WorkspaceDetail.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Boxes, Users, MessageSquare, Lightbulb, ArrowLeft, CircleDot } from "lucide-react";
|
||||||
|
import { useWorkspace, useQueueStatus } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ label: "Peers", icon: Users, to: "peers" as const, description: "Browse peer identities and memory" },
|
||||||
|
{ label: "Sessions", icon: MessageSquare, to: "sessions" as const, description: "View conversation sessions" },
|
||||||
|
{ label: "Conclusions", icon: Lightbulb, to: "conclusions" as const, description: "Browse memory conclusions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WorkspaceDetail() {
|
||||||
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
|
const { data: workspace, isLoading, error } = useWorkspace(workspaceId);
|
||||||
|
const { data: queue } = useQueueStatus(workspaceId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Link
|
||||||
|
to="/workspaces"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
Workspaces
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Boxes className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold font-mono break-all tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{workspaceId}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-2)" }}>Workspace overview</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && workspace && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Nav cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{sections.map((s, i) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={s.to}
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.07, type: "spring", stiffness: 300, damping: 25 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||||
|
params={{ workspaceId } as never}
|
||||||
|
className="block rounded-xl p-5 group transition-all theme-card"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="w-5 h-5 mb-3"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-medium mb-0.5"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-3)" }}>
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue status */}
|
||||||
|
{queue && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
className="rounded-xl p-5 theme-card"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
||||||
|
Queue Status
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{queue.pending_work_units > 0 ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<CircleDot className="w-3.5 h-3.5" style={{ color: "#f59e0b" }} strokeWidth={2} />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<CircleDot className="w-3.5 h-3.5" style={{ color: "#34d399" }} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: queue.pending_work_units > 0 ? "#f59e0b" : "#34d399" }}
|
||||||
|
>
|
||||||
|
{queue.pending_work_units === 0 ? "Idle" : `${queue.pending_work_units} pending`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{(["total_work_units", "completed_work_units", "in_progress_work_units", "pending_work_units"] as const).map((key) => (
|
||||||
|
<div key={key}>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-semibold font-mono"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
|
{queue[key]}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs capitalize mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
|
{key.replace(/_work_units$/, "").replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.35 }}
|
||||||
|
className="rounded-xl p-5 theme-card"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-medium mb-3" style={{ color: "var(--text-1)" }}>
|
||||||
|
Metadata
|
||||||
|
</h2>
|
||||||
|
<JsonViewer data={workspace.metadata} />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/workspaces/WorkspaceList.tsx
Normal file
140
src/components/workspaces/WorkspaceList.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
import { Boxes, ChevronRight, Clock } from "lucide-react";
|
||||||
|
import { useWorkspaces } from "@/api/queries";
|
||||||
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
|
||||||
|
type Workspace = components["schemas"]["Workspace"];
|
||||||
|
|
||||||
|
const container: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const item: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 12 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 25 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceList() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error } = useWorkspaces(page);
|
||||||
|
|
||||||
|
const workspaces: Workspace[] = (data as { items?: Workspace[] } | undefined)?.items ?? [];
|
||||||
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl mx-auto">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Boxes className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
||||||
|
<h1
|
||||||
|
className="text-xl font-semibold tracking-tight"
|
||||||
|
style={{ color: "#e4e4f0" }}
|
||||||
|
>
|
||||||
|
Workspaces
|
||||||
|
</h1>
|
||||||
|
{total > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.1)",
|
||||||
|
color: "#818cf8",
|
||||||
|
border: "1px solid rgba(99,102,241,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "rgba(148,163,184,0.6)" }}>
|
||||||
|
All workspaces in your Honcho instance
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
|
{isLoading && <PageLoader />}
|
||||||
|
|
||||||
|
{!isLoading && workspaces.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={Boxes}
|
||||||
|
title="No workspaces found"
|
||||||
|
description="No workspaces exist yet in this Honcho instance."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && workspaces.length > 0 && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<motion.button
|
||||||
|
key={ws.id}
|
||||||
|
variants={item}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: "/workspaces/$workspaceId",
|
||||||
|
params: { workspaceId: ws.id } as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full text-left rounded-xl px-5 py-4 group transition-all"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
background: "rgba(99,102,241,0.06)",
|
||||||
|
borderColor: "rgba(99,102,241,0.2)",
|
||||||
|
x: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm font-medium"
|
||||||
|
style={{ color: "#c7d2fe" }}
|
||||||
|
>
|
||||||
|
{ws.id}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||||
|
style={{ color: "#6366f1" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ws.created_at && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
<Clock
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{ color: "rgba(148,163,184,0.35)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<p className="text-xs font-mono" style={{ color: "rgba(148,163,184,0.35)" }}>
|
||||||
|
{new Date(ws.created_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/hooks/useTheme.ts
Normal file
16
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { type Theme, getStoredTheme, applyTheme } from "@/lib/theme";
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => getStoredTheme());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
setTheme((t) => (t === "dark" ? "light" : "dark"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { theme, toggle };
|
||||||
|
}
|
||||||
160
src/index.css
Normal file
160
src/index.css
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@fontsource/dm-mono/400.css";
|
||||||
|
@import "@fontsource/dm-mono/500.css";
|
||||||
|
@import "@fontsource/dm-sans/400.css";
|
||||||
|
@import "@fontsource/dm-sans/500.css";
|
||||||
|
@import "@fontsource/dm-sans/600.css";
|
||||||
|
|
||||||
|
/* ─── Theme tokens ─── */
|
||||||
|
:root,
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #0c0c10;
|
||||||
|
--bg-2: #111118;
|
||||||
|
--bg-3: #16161f;
|
||||||
|
--surface: rgba(255, 255, 255, 0.02);
|
||||||
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-2: rgba(255, 255, 255, 0.10);
|
||||||
|
--text-1: #e4e4f0;
|
||||||
|
--text-2: rgba(148, 163, 184, 0.75);
|
||||||
|
--text-3: rgba(148, 163, 184, 0.40);
|
||||||
|
--text-4: rgba(148, 163, 184, 0.25);
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-dim: rgba(99, 102, 241, 0.12);
|
||||||
|
--accent-border: rgba(99, 102, 241, 0.30);
|
||||||
|
--accent-text: #a5b4fc;
|
||||||
|
--sidebar-bg: linear-gradient(180deg, #111118 0%, #0e0e15 100%);
|
||||||
|
--grid-line: rgba(99, 102, 241, 0.03);
|
||||||
|
--glow: rgba(79, 70, 229, 0.08);
|
||||||
|
--scrollbar: rgba(99, 102, 241, 0.2);
|
||||||
|
--card-hover: rgba(99, 102, 241, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f8f8fc;
|
||||||
|
--bg-2: #ffffff;
|
||||||
|
--bg-3: #f0f0f8;
|
||||||
|
--surface: rgba(0, 0, 0, 0.02);
|
||||||
|
--border: rgba(0, 0, 0, 0.07);
|
||||||
|
--border-2: rgba(0, 0, 0, 0.12);
|
||||||
|
--text-1: #1a1a2e;
|
||||||
|
--text-2: rgba(30, 30, 60, 0.65);
|
||||||
|
--text-3: rgba(30, 30, 60, 0.40);
|
||||||
|
--text-4: rgba(30, 30, 60, 0.25);
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-dim: rgba(79, 70, 229, 0.08);
|
||||||
|
--accent-border: rgba(79, 70, 229, 0.25);
|
||||||
|
--accent-text: #4f46e5;
|
||||||
|
--sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f4f4fc 100%);
|
||||||
|
--grid-line: rgba(79, 70, 229, 0.04);
|
||||||
|
--glow: rgba(79, 70, 229, 0.06);
|
||||||
|
--scrollbar: rgba(79, 70, 229, 0.2);
|
||||||
|
--card-hover: rgba(79, 70, 229, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Base ─── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "DM Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-1);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Grid background overlay ─── */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Glow ─── */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: -20%;
|
||||||
|
left: -10%;
|
||||||
|
width: 60%;
|
||||||
|
height: 70%;
|
||||||
|
background: radial-gradient(ellipse at top left, var(--glow) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ─── */
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Focus ─── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Utility classes using theme tokens ─── */
|
||||||
|
.theme-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
color: var(--text-1);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive container ─── */
|
||||||
|
.page-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/lib/config.ts
Normal file
60
src/lib/config.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const CONFIG_KEY = "honcho-ui:config";
|
||||||
|
|
||||||
|
export const configSchema = z.object({
|
||||||
|
baseUrl: z.string().url("Must be a valid URL"),
|
||||||
|
token: z.string().optional().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
|
export function loadConfig(): Config | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return configSchema.parse(parsed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(config: Config): void {
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearConfig(): void {
|
||||||
|
localStorage.removeItem(CONFIG_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
||||||
|
|
||||||
|
export async function checkConnection(baseUrl: string, token?: string): Promise<{
|
||||||
|
status: HealthStatus;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/v3/workspaces/list`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) return { status: "ok", message: "Connected successfully" };
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
return { status: "auth-required", message: "Authentication required — provide an API token" };
|
||||||
|
}
|
||||||
|
return { status: "unreachable", message: `Server returned ${res.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
if (msg.includes("AbortError") || msg.includes("timeout")) {
|
||||||
|
return { status: "unreachable", message: "Connection timed out" };
|
||||||
|
}
|
||||||
|
return { status: "unreachable", message: `Cannot reach server: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/lib/theme.ts
Normal file
14
src/lib/theme.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const THEME_KEY = "honcho-ui:theme";
|
||||||
|
|
||||||
|
export type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
export function getStoredTheme(): Theme {
|
||||||
|
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
|
||||||
|
if (stored === "dark" || stored === "light") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
|
}
|
||||||
38
src/main.tsx
Normal file
38
src/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
scrollRestoration: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw new Error("Missing #root element");
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
260
src/routeTree.gen.ts
Normal file
260
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/* 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 WorkspacesRouteImport } from './routes/workspaces'
|
||||||
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as WorkspacesWorkspaceIdRouteImport } from './routes/workspaces_.$workspaceId'
|
||||||
|
import { Route as WorkspacesWorkspaceIdSessionsRouteImport } from './routes/workspaces_.$workspaceId_.sessions'
|
||||||
|
import { Route as WorkspacesWorkspaceIdPeersRouteImport } from './routes/workspaces_.$workspaceId_.peers'
|
||||||
|
import { Route as WorkspacesWorkspaceIdConclusionsRouteImport } from './routes/workspaces_.$workspaceId_.conclusions'
|
||||||
|
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRouteImport } from './routes/workspaces_.$workspaceId_.sessions_.$sessionId'
|
||||||
|
import { Route as WorkspacesWorkspaceIdPeersPeerIdRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId'
|
||||||
|
import { Route as WorkspacesWorkspaceIdPeersPeerIdChatRouteImport } from './routes/workspaces_.$workspaceId_.peers_.$peerId_.chat'
|
||||||
|
|
||||||
|
const WorkspacesRoute = WorkspacesRouteImport.update({
|
||||||
|
id: '/workspaces',
|
||||||
|
path: '/workspaces',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdRoute = WorkspacesWorkspaceIdRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId',
|
||||||
|
path: '/workspaces/$workspaceId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdSessionsRoute =
|
||||||
|
WorkspacesWorkspaceIdSessionsRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/sessions',
|
||||||
|
path: '/workspaces/$workspaceId/sessions',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdPeersRoute =
|
||||||
|
WorkspacesWorkspaceIdPeersRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/peers',
|
||||||
|
path: '/workspaces/$workspaceId/peers',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdConclusionsRoute =
|
||||||
|
WorkspacesWorkspaceIdConclusionsRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/conclusions',
|
||||||
|
path: '/workspaces/$workspaceId/conclusions',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdSessionsSessionIdRoute =
|
||||||
|
WorkspacesWorkspaceIdSessionsSessionIdRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/sessions_/$sessionId',
|
||||||
|
path: '/workspaces/$workspaceId/sessions/$sessionId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdPeersPeerIdRoute =
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/peers_/$peerId',
|
||||||
|
path: '/workspaces/$workspaceId/peers/$peerId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WorkspacesWorkspaceIdPeersPeerIdChatRoute =
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdChatRouteImport.update({
|
||||||
|
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat',
|
||||||
|
path: '/workspaces/$workspaceId/peers/$peerId/chat',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
|
'/workspaces': typeof WorkspacesRoute
|
||||||
|
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||||
|
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
|
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
|
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
|
'/workspaces': typeof WorkspacesRoute
|
||||||
|
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||||
|
'/workspaces/$workspaceId/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
|
'/workspaces/$workspaceId/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
|
'/workspaces/$workspaceId/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
|
'/workspaces/$workspaceId/sessions/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
|
'/workspaces/$workspaceId/peers/$peerId/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/settings': typeof SettingsRoute
|
||||||
|
'/workspaces': typeof WorkspacesRoute
|
||||||
|
'/workspaces_/$workspaceId': typeof WorkspacesWorkspaceIdRoute
|
||||||
|
'/workspaces_/$workspaceId_/conclusions': typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
|
'/workspaces_/$workspaceId_/peers': typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
|
'/workspaces_/$workspaceId_/sessions': typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
'/workspaces_/$workspaceId_/peers_/$peerId': typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
|
'/workspaces_/$workspaceId_/sessions_/$sessionId': typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
|
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/settings'
|
||||||
|
| '/workspaces'
|
||||||
|
| '/workspaces/$workspaceId'
|
||||||
|
| '/workspaces/$workspaceId/conclusions'
|
||||||
|
| '/workspaces/$workspaceId/peers'
|
||||||
|
| '/workspaces/$workspaceId/sessions'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/settings'
|
||||||
|
| '/workspaces'
|
||||||
|
| '/workspaces/$workspaceId'
|
||||||
|
| '/workspaces/$workspaceId/conclusions'
|
||||||
|
| '/workspaces/$workspaceId/peers'
|
||||||
|
| '/workspaces/$workspaceId/sessions'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId'
|
||||||
|
| '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
|
| '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/settings'
|
||||||
|
| '/workspaces'
|
||||||
|
| '/workspaces_/$workspaceId'
|
||||||
|
| '/workspaces_/$workspaceId_/conclusions'
|
||||||
|
| '/workspaces_/$workspaceId_/peers'
|
||||||
|
| '/workspaces_/$workspaceId_/sessions'
|
||||||
|
| '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||||
|
| '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||||
|
| '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
SettingsRoute: typeof SettingsRoute
|
||||||
|
WorkspacesRoute: typeof WorkspacesRoute
|
||||||
|
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute
|
||||||
|
WorkspacesWorkspaceIdConclusionsRoute: typeof WorkspacesWorkspaceIdConclusionsRoute
|
||||||
|
WorkspacesWorkspaceIdPeersRoute: typeof WorkspacesWorkspaceIdPeersRoute
|
||||||
|
WorkspacesWorkspaceIdSessionsRoute: typeof WorkspacesWorkspaceIdSessionsRoute
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRoute
|
||||||
|
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/workspaces': {
|
||||||
|
id: '/workspaces'
|
||||||
|
path: '/workspaces'
|
||||||
|
fullPath: '/workspaces'
|
||||||
|
preLoaderRoute: typeof WorkspacesRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/settings': {
|
||||||
|
id: '/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/settings'
|
||||||
|
preLoaderRoute: typeof SettingsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId': {
|
||||||
|
id: '/workspaces_/$workspaceId'
|
||||||
|
path: '/workspaces/$workspaceId'
|
||||||
|
fullPath: '/workspaces/$workspaceId'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/sessions': {
|
||||||
|
id: '/workspaces_/$workspaceId_/sessions'
|
||||||
|
path: '/workspaces/$workspaceId/sessions'
|
||||||
|
fullPath: '/workspaces/$workspaceId/sessions'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/peers': {
|
||||||
|
id: '/workspaces_/$workspaceId_/peers'
|
||||||
|
path: '/workspaces/$workspaceId/peers'
|
||||||
|
fullPath: '/workspaces/$workspaceId/peers'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/conclusions': {
|
||||||
|
id: '/workspaces_/$workspaceId_/conclusions'
|
||||||
|
path: '/workspaces/$workspaceId/conclusions'
|
||||||
|
fullPath: '/workspaces/$workspaceId/conclusions'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdConclusionsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/sessions_/$sessionId': {
|
||||||
|
id: '/workspaces_/$workspaceId_/sessions_/$sessionId'
|
||||||
|
path: '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
|
fullPath: '/workspaces/$workspaceId/sessions/$sessionId'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/peers_/$peerId': {
|
||||||
|
id: '/workspaces_/$workspaceId_/peers_/$peerId'
|
||||||
|
path: '/workspaces/$workspaceId/peers/$peerId'
|
||||||
|
fullPath: '/workspaces/$workspaceId/peers/$peerId'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/workspaces_/$workspaceId_/peers_/$peerId_/chat': {
|
||||||
|
id: '/workspaces_/$workspaceId_/peers_/$peerId_/chat'
|
||||||
|
path: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
fullPath: '/workspaces/$workspaceId/peers/$peerId/chat'
|
||||||
|
preLoaderRoute: typeof WorkspacesWorkspaceIdPeersPeerIdChatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
SettingsRoute: SettingsRoute,
|
||||||
|
WorkspacesRoute: WorkspacesRoute,
|
||||||
|
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRoute,
|
||||||
|
WorkspacesWorkspaceIdConclusionsRoute: WorkspacesWorkspaceIdConclusionsRoute,
|
||||||
|
WorkspacesWorkspaceIdPeersRoute: WorkspacesWorkspaceIdPeersRoute,
|
||||||
|
WorkspacesWorkspaceIdSessionsRoute: WorkspacesWorkspaceIdSessionsRoute,
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdRoute: WorkspacesWorkspaceIdPeersPeerIdRoute,
|
||||||
|
WorkspacesWorkspaceIdSessionsSessionIdRoute:
|
||||||
|
WorkspacesWorkspaceIdSessionsSessionIdRoute,
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute:
|
||||||
|
WorkspacesWorkspaceIdPeersPeerIdChatRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
46
src/routes/__root.tsx
Normal file
46
src/routes/__root.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
import { Sidebar } from "@/components/layout/Sidebar";
|
||||||
|
import { applyTheme, getStoredTheme } from "@/lib/theme";
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
|
const config = loadConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
const isSettings = router.state.location.pathname === "/settings";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(getStoredTheme());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config && !isSettings) {
|
||||||
|
router.navigate({ to: "/settings" as never });
|
||||||
|
}
|
||||||
|
}, [config, isSettings, router]);
|
||||||
|
|
||||||
|
if (isSettings) {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-screen w-full overflow-hidden"
|
||||||
|
style={{ background: "var(--bg)", position: "relative", zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<Sidebar />
|
||||||
|
<main
|
||||||
|
className="flex-1 overflow-auto"
|
||||||
|
style={{ position: "relative", zIndex: 1 }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
});
|
||||||
6
src/routes/index.tsx
Normal file
6
src/routes/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: Dashboard,
|
||||||
|
});
|
||||||
48
src/routes/settings.tsx
Normal file
48
src/routes/settings.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Brain } from "lucide-react";
|
||||||
|
import { SettingsForm } from "@/components/settings/SettingsForm";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/settings")({
|
||||||
|
component: SettingsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center p-4"
|
||||||
|
style={{ background: "var(--bg)" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 260, damping: 24 }}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
>
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||||
|
boxShadow: "0 0 32px rgba(99,102,241,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Brain className="w-7 h-7 text-white" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight" style={{ color: "var(--text-1)" }}>
|
||||||
|
Honcho UI
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
||||||
|
Connect to your self-hosted Honcho instance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SettingsForm onSaved={() => navigate({ to: "/" as never })} />
|
||||||
|
<p className="text-xs text-center mt-4" style={{ color: "var(--text-4)" }}>
|
||||||
|
Connection details are stored locally in your browser only
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/routes/workspaces.tsx
Normal file
6
src/routes/workspaces.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { WorkspaceList } from "@/components/workspaces/WorkspaceList";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces")({
|
||||||
|
component: WorkspaceList,
|
||||||
|
});
|
||||||
6
src/routes/workspaces_.$workspaceId.tsx
Normal file
6
src/routes/workspaces_.$workspaceId.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { WorkspaceDetail } from "@/components/workspaces/WorkspaceDetail";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId")({
|
||||||
|
component: WorkspaceDetail,
|
||||||
|
});
|
||||||
6
src/routes/workspaces_.$workspaceId_.conclusions.tsx
Normal file
6
src/routes/workspaces_.$workspaceId_.conclusions.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { ConclusionBrowser } from "@/components/conclusions/ConclusionBrowser";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/conclusions")({
|
||||||
|
component: ConclusionBrowser,
|
||||||
|
});
|
||||||
6
src/routes/workspaces_.$workspaceId_.peers.tsx
Normal file
6
src/routes/workspaces_.$workspaceId_.peers.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { PeerList } from "@/components/peers/PeerList";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers")({
|
||||||
|
component: PeerList,
|
||||||
|
});
|
||||||
6
src/routes/workspaces_.$workspaceId_.peers_.$peerId.tsx
Normal file
6
src/routes/workspaces_.$workspaceId_.peers_.$peerId.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { PeerDetail } from "@/components/peers/PeerDetail";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId")({
|
||||||
|
component: PeerDetail,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { ChatPage } from "@/components/chat/ChatPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/peers_/$peerId_/chat")({
|
||||||
|
component: ChatPage,
|
||||||
|
});
|
||||||
6
src/routes/workspaces_.$workspaceId_.sessions.tsx
Normal file
6
src/routes/workspaces_.$workspaceId_.sessions.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SessionList } from "@/components/sessions/SessionList";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/sessions")({
|
||||||
|
component: SessionList,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SessionDetail } from "@/components/sessions/SessionDetail";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/workspaces_/$workspaceId_/sessions_/$sessionId")({
|
||||||
|
component: SessionDetail,
|
||||||
|
});
|
||||||
35
tsconfig.app.json
Normal file
35
tsconfig.app.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Strict */
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
26
vite.config.ts
Normal file
26
vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tanstackRouter({ autoCodeSplitting: true }), react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api-proxy": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/api-proxy/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user