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:
Offending Commit
2026-04-24 21:30:48 -05:00
commit 8eff34b3c6
53 changed files with 15366 additions and 0 deletions

37
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
{"ignoredBuilds": [], "onlyBuiltDependencies": ["@biomejs/biome", "esbuild"]}

8
.pnpmfile.cjs Normal file
View File

@@ -0,0 +1,8 @@
function readPackage(pkg, context) {
return pkg;
}
module.exports = {
hooks: {
readPackage,
}
};

73
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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 &ldquo;{activeSearch}&rdquo;
</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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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)" }}>&#123;&#125;</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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { WorkspaceList } from "@/components/workspaces/WorkspaceList";
export const Route = createFileRoute("/workspaces")({
component: WorkspaceList,
});

View 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,
});

View 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,
});

View 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,
});

View 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,
});

View File

@@ -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,
});

View 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,
});

View File

@@ -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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View 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/, ""),
},
},
},
});