feat: restructure as pnpm monorepo with Tauri desktop shell

- Migrate to packages/web + packages/desktop workspace layout via git mv
- Add Tauri v2 desktop shell with @tauri-apps/plugin-http for CORS bypass
- Configure Turborepo with package-level dependsOn build graph
- Add semantic-release with exec plugin for GHA output and disabled PR comments
- Fix http:default capability scope to allow all HTTP/HTTPS origins
- Add Vite Tauri integration (clearScreen, TAURI_DEV_HOST, target, envPrefix)
- Add semantic-release.yml and release.yml GitHub Actions workflows
- Fix all Biome lint errors (noArrayIndexKey, noNonNullAssertion, button types)
This commit is contained in:
Offending Commit
2026-04-24 16:52:40 -05:00
parent 9a74182f97
commit 92c4dfd3dd
152 changed files with 14088 additions and 4774 deletions

View File

@@ -0,0 +1,15 @@
{
"name": "@openconcho/desktop",
"private": true,
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
},
"dependencies": {
"@openconcho/web": "workspace:*"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}

5578
packages/desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
[package]
name = "openconcho"
version = "0.1.0"
edition = "2021"
[lib]
name = "openconcho_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-http = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the OpenConcho desktop window",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "http:default",
"allow": [
{ "url": "http://**" },
{ "url": "https://**" }
]
},
"shell:allow-open"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,8 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,6 @@
// Prevents the Windows console window from appearing in release builds
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
openconcho_lib::run();
}

View File

@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2.json",
"productName": "OpenConcho",
"identifier": "com.offendingcommit.openconcho",
"build": {
"frontendDist": "../../web/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "pnpm --filter @openconcho/web dev"
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"app": {
"windows": [
{
"title": "OpenConcho",
"width": 1280,
"height": 800,
"minWidth": 800,
"minHeight": 600
}
],
"security": {
"csp": null
}
}
}

14
packages/web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!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</title>
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5265
packages/web/openapi.json Normal file

File diff suppressed because it is too large Load Diff

60
packages/web/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "@openconcho/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit -p tsconfig.app.json",
"preview": "vite preview",
"lint": "biome check src/",
"lint:fix": "biome check --write src/",
"test": "vitest run --passWithNoTests",
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-http": "^2",
"@tauri-apps/plugin-shell": "^2",
"@fontsource/dm-mono": "^5.2.7",
"@fontsource/dm-sans": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.74.4",
"@tanstack/react-router": "^1.120.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.11.0",
"luxon": "^3.7.2",
"openapi-fetch": "^0.13.5",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"zod": "^3.24.3"
},
"devDependencies": {
"@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/luxon": "^3.7.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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

View File

@@ -0,0 +1,25 @@
import { loadConfig } from "@/lib/config";
import { httpFetch } from "@/lib/http";
import createClient from "openapi-fetch";
import type { paths } from "./schema.d.ts";
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, fetch: httpFetch });
}
export const client = {
get current() {
return createHonchoClient();
},
};

View File

@@ -0,0 +1,36 @@
export const QK = {
workspaces: (page: number, size: number) => ["workspaces", page, size] as const,
workspace: (id: string) => ["workspace", id] as const,
queueStatus: (wsId: string) => ["queue-status", wsId] as const,
workspaceSearch: (wsId: string, q: string) => ["workspace-search", wsId, q] as const,
peers: (wsId: string, page: number, size: number) => ["peers", wsId, page, size] as const,
peer: (wsId: string, pId: string) => ["peer", wsId, pId] as const,
peerRepresentation: (wsId: string, pId: string, target?: string) =>
["peer-representation", wsId, pId, target] as const,
peerCard: (wsId: string, pId: string) => ["peer-card", wsId, pId] as const,
peerContext: (wsId: string, pId: string) => ["peer-context", wsId, pId] as const,
peerSessions: (wsId: string, pId: string, page: number, size: number) =>
["peer-sessions", wsId, pId, page, size] as const,
sessions: (wsId: string, page: number, size: number) => ["sessions", wsId, page, size] as const,
session: (wsId: string, sId: string) => ["session", wsId, sId] as const,
sessionMessages: (wsId: string, sId: string, page: number, size: number) =>
["session-messages", wsId, sId, page, size] as const,
sessionSummaries: (wsId: string, sId: string) => ["session-summaries", wsId, sId] as const,
sessionContext: (wsId: string, sId: string) => ["session-context", wsId, sId] as const,
sessionPeers: (wsId: string, sId: string) => ["session-peers", wsId, sId] as const,
peerConfig: (wsId: string, sId: string, pId: string) => ["peer-config", wsId, sId, pId] as const,
conclusions: (
wsId: string,
filters: Record<string, unknown>,
page: number,
size: number,
reverse?: boolean,
) => ["conclusions", wsId, filters, page, size, reverse] as const,
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
["conclusions-query", wsId, q, filters] as const,
webhooks: (wsId: string) => ["webhooks", wsId] as const,
};

View File

@@ -0,0 +1,765 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { client } from "./client";
import { QK } from "./keys";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function err(e: unknown): never {
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
}
// ─── Workspaces ──────────────────────────────────────────────────────────────
export function useWorkspaces(page = 1, pageSize = 20) {
return useQuery({
queryKey: QK.workspaces(page, pageSize),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces/list", {
params: { query: { page, page_size: pageSize } },
body: {},
});
return data ?? err(error);
},
});
}
export function useWorkspace(workspaceId: string) {
return useQuery({
queryKey: QK.workspace(workspaceId),
queryFn: async () => {
const { data, error } = await client.current.POST("/v3/workspaces", {
body: { id: workspaceId, metadata: {} },
});
return data ?? err(error);
},
enabled: Boolean(workspaceId),
});
}
export function useUpdateWorkspace(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
const { data, error } = await client.current.PUT("/v3/workspaces/{workspace_id}", {
params: { path: { workspace_id: workspaceId } },
body,
});
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workspace", workspaceId] });
qc.invalidateQueries({ queryKey: ["workspaces"] });
},
});
}
export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (workspaceId: string) => {
const { error } = await client.current.DELETE("/v3/workspaces/{workspace_id}", {
params: { path: { workspace_id: workspaceId } },
});
if (error) err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["workspaces"] });
},
});
}
export function useScheduleDream(workspaceId: string) {
return useMutation({
mutationFn: async (body: {
observer: string;
observed?: string | null;
dream_type: "omni";
session_id?: string | null;
}) => {
const { error } = await client.current.POST("/v3/workspaces/{workspace_id}/schedule_dream", {
params: { path: { workspace_id: workspaceId } },
body,
});
if (error) err(error);
},
});
}
export function useQueueStatus(workspaceId: string) {
return useQuery({
queryKey: QK.queueStatus(workspaceId),
queryFn: async () => {
const { data, error } = await client.current.GET(
"/v3/workspaces/{workspace_id}/queue/status",
{ params: { path: { workspace_id: workspaceId } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId),
refetchInterval: 10_000,
});
}
export function useSearchWorkspace(workspaceId: string, query: string, enabled = false) {
return useQuery({
queryKey: QK.workspaceSearch(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 },
});
return data ?? err(error);
},
enabled: enabled && Boolean(workspaceId) && Boolean(query),
});
}
// ─── Peers ────────────────────────────────────────────────────────────────────
export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: QK.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: {},
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId),
});
}
export function usePeer(workspaceId: string, peerId: string) {
return useQuery({
queryKey: QK.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: {} },
});
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
}
export function useUpdatePeer(workspaceId: string, peerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/peers/{peer_id}",
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } }, body },
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["peer", workspaceId, peerId] });
qc.invalidateQueries({ queryKey: ["peers", workspaceId] });
},
});
}
export function usePeerRepresentation(workspaceId: string, peerId: string, target?: string) {
return useQuery({
queryKey: QK.peerRepresentation(workspaceId, peerId, target),
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, ...(target ? { target } : {}) },
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
}
export function usePeerCard(workspaceId: string, peerId: string) {
return useQuery({
queryKey: QK.peerCard(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 } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
}
export function useSetPeerCard(workspaceId: string, peerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (peerCard: string[]) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
{
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { peer_card: peerCard },
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.peerCard(workspaceId, peerId) });
},
});
}
export function usePeerContext(workspaceId: string, peerId: string) {
return useQuery({
queryKey: QK.peerContext(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 } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
}
export function usePeerSessions(workspaceId: string, peerId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: QK.peerSessions(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: {},
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(peerId),
});
}
export function useSearchPeer(workspaceId: string, peerId: string) {
return useMutation({
mutationFn: async (query: string) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/peers/{peer_id}/search",
{
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
body: { query, limit: 20 },
},
);
return data ?? err(error);
},
});
}
export function useChat(workspaceId: string, peerId: string) {
const qc = 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" },
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
},
});
}
// ─── Sessions ─────────────────────────────────────────────────────────────────
export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
return useQuery({
queryKey: QK.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: {},
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId),
});
}
export function useUpdateSession(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } }, body },
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
},
});
}
export function useDeleteSession(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
const { error } = await client.current.DELETE(
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
);
if (error) err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
},
});
}
export function useCloneSession(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/clone",
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
},
});
}
export function useSearchSession(workspaceId: string, sessionId: string) {
return useMutation({
mutationFn: async (query: string) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/search",
{
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
body: { query, limit: 20 },
},
);
return data ?? err(error);
},
});
}
export function useSessionMessages(
workspaceId: string,
sessionId: string,
page = 1,
pageSize = 50,
) {
return useQuery({
queryKey: QK.sessionMessages(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: {},
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
}
export function useCreateMessages(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (
messages: Array<{ content: string; peer_id: string; metadata?: Record<string, unknown> }>,
) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages",
{
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
body: { messages },
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
},
});
}
export function useUpdateMessage(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
messageId,
body,
}: {
messageId: string;
body: { metadata?: Record<string, unknown> };
}) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/messages/{message_id}",
{
params: {
path: {
workspace_id: workspaceId,
session_id: sessionId,
message_id: messageId,
},
},
body,
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
},
});
}
// ─── Session ↔ Peer membership ────────────────────────────────────────────────
export function useSessionPeers(workspaceId: string, sessionId: string) {
return useQuery({
queryKey: QK.sessionPeers(workspaceId, sessionId),
queryFn: async () => {
const { data, error } = await client.current.GET(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
}
type SessionPeerConfigMap = Record<
string,
{ observe_me?: boolean | null; observe_others?: boolean | null }
>;
export function useAddPeersToSession(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (peers: SessionPeerConfigMap) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
{
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
body: peers,
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
},
});
}
export function useSetSessionPeers(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (peers: SessionPeerConfigMap) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
{
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
body: peers,
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
},
});
}
export function useRemovePeersFromSession(workspaceId: string, sessionId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (peerIds: string[]) => {
const { error } = await client.current.DELETE(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
{
params: { path: { workspace_id: workspaceId, session_id: sessionId } },
body: peerIds,
},
);
if (error) err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
qc.invalidateQueries({ queryKey: ["peer-sessions", workspaceId] });
},
});
}
export function usePeerConfig(workspaceId: string, sessionId: string, peerId: string) {
return useQuery({
queryKey: QK.peerConfig(workspaceId, sessionId, peerId),
queryFn: async () => {
const { data, error } = await client.current.GET(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config",
{
params: {
path: { workspace_id: workspaceId, session_id: sessionId, peer_id: peerId },
},
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(sessionId) && Boolean(peerId),
});
}
export function useSetPeerConfig(workspaceId: string, sessionId: string, peerId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (config: Record<string, unknown>) => {
const { data, error } = await client.current.PUT(
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config",
{
params: {
path: { workspace_id: workspaceId, session_id: sessionId, peer_id: peerId },
},
body: config,
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.peerConfig(workspaceId, sessionId, peerId) });
},
});
}
// ─── Session summaries & context ──────────────────────────────────────────────
export function useSessionSummaries(workspaceId: string, sessionId: string) {
return useQuery({
queryKey: QK.sessionSummaries(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 } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
}
export function useSessionContext(workspaceId: string, sessionId: string) {
return useQuery({
queryKey: QK.sessionContext(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 } } },
);
return data ?? err(error);
},
enabled: Boolean(workspaceId) && Boolean(sessionId),
});
}
// ─── Conclusions ──────────────────────────────────────────────────────────────
export function useConclusions(
workspaceId: string,
filters: Record<string, unknown> = {},
page = 1,
pageSize = 20,
reverse = false,
) {
return useQuery({
queryKey: QK.conclusions(workspaceId, filters, page, pageSize, reverse),
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, reverse },
},
body: filters,
},
);
return data ?? err(error);
},
enabled: Boolean(workspaceId),
});
}
export function useQueryConclusions(
workspaceId: string,
query: string,
filters: Record<string, unknown> = {},
enabled = false,
) {
return useQuery({
queryKey: QK.conclusionsQuery(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 },
},
);
return data ?? err(error);
},
enabled: enabled && Boolean(workspaceId) && Boolean(query),
});
}
export function useCreateConclusion(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (conclusion: {
observer_id: string;
observed_id: string;
content: string;
session_id?: string | null;
}) => {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/conclusions",
{
params: { path: { workspace_id: workspaceId } },
body: { conclusions: [conclusion] },
},
);
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
},
});
}
export function useDeleteConclusion(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (conclusionId: string) => {
const { error } = await client.current.DELETE(
"/v3/workspaces/{workspace_id}/conclusions/{conclusion_id}",
{
params: {
path: { workspace_id: workspaceId, conclusion_id: conclusionId },
},
},
);
if (error) err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
qc.invalidateQueries({ queryKey: ["conclusions-query", workspaceId] });
},
});
}
// ─── Webhooks ─────────────────────────────────────────────────────────────────
export function useWebhooks(workspaceId: string) {
return useQuery({
queryKey: QK.webhooks(workspaceId),
queryFn: async () => {
const { data, error } = await client.current.GET("/v3/workspaces/{workspace_id}/webhooks", {
params: { path: { workspace_id: workspaceId } },
});
return data ?? err(error);
},
enabled: Boolean(workspaceId),
});
}
export function useCreateWebhook(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (url: string) => {
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/webhooks", {
params: { path: { workspace_id: workspaceId } },
body: { url },
});
return data ?? err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
},
});
}
export function useDeleteWebhook(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (endpointId: string) => {
const { error } = await client.current.DELETE(
"/v3/workspaces/{workspace_id}/webhooks/{endpoint_id}",
{
params: {
path: { workspace_id: workspaceId, endpoint_id: endpointId },
},
},
);
if (error) err(error);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
},
});
}
export function useTestWebhook(workspaceId: string) {
return useMutation({
mutationFn: async () => {
const { data, error } = await client.current.GET(
"/v3/workspaces/{workspace_id}/webhooks/test",
{ params: { path: { workspace_id: workspaceId } } },
);
return data ?? err(error);
},
});
}
// ─── Keys ─────────────────────────────────────────────────────────────────────
export function useCreateKey() {
return useMutation({
mutationFn: async () => {
const { data, error } = await client.current.POST("/v3/keys", {});
return data ?? err(error);
},
});
}

3324
packages/web/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import { useChat } from "@/api/queries";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/input";
import { SectionHeading } from "@/components/ui/typography";
import { Link, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { Brain, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
interface Message {
id: string;
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(() => {
if (messages.length > 0) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
async function handleSend() {
const trimmed = input.trim();
if (!trimmed || chatMutation.isPending) return;
setInput("");
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: "user", content: trimmed }]);
try {
const result = await chatMutation.mutateAsync(trimmed);
const responseText =
(result as { content?: string | null }).content ??
(typeof result === "string" ? result : JSON.stringify(result));
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: "assistant", content: responseText },
]);
} catch (err) {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
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} />
<SectionHeading as="h1" className="mb-0">
Memory-augmented chat
</SectionHeading>
</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) => (
<motion.div
key={msg.id}
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 resize-none"
/>
<Button
variant="primary"
onClick={handleSend}
disabled={!input.trim() || chatMutation.isPending}
className="self-end mb-0.5"
>
<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,419 @@
import {
useConclusions,
useCreateConclusion,
useDeleteConclusion,
useQueryConclusions,
} from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { FormModal } from "@/components/shared/FormModal";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { TimestampChip } from "@/components/shared/TimestampChip";
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Body, Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { Link, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { z } from "zod";
type Conclusion = components["schemas"]["Conclusion"];
const createSchema = z.object({
observer_id: z.string().min(1, "Observer peer ID is required"),
observed_id: z.string().min(1, "Observed peer ID is required"),
content: z.string().min(1, "Content is required"),
session_id: z.string().optional(),
});
const SORT_OPTIONS = [
{ value: "created_at", label: "Date" },
{ value: "observer_id", label: "Observer" },
{ value: "observed_id", label: "Observed" },
];
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 [sortField, setSortField] = useState("created_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [searchQuery, setSearchQuery] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// created_at uses server-side reverse; other fields use client-side sort
const serverReverse = sortField === "created_at" && sortDir === "asc";
const { data, isLoading, error } = useConclusions(workspaceId, {}, page, 20, serverReverse);
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
workspaceId,
activeSearch,
{},
Boolean(activeSearch),
);
const createConclusion = useCreateConclusion(workspaceId);
const deleteConclusion = useDeleteConclusion(workspaceId);
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 sortedConclusions = useMemo(() => {
if (sortField === "created_at") return conclusions; // server handles this
return [...conclusions].sort((a, b) => {
const cmp =
sortField === "observer_id"
? a.observer_id.localeCompare(b.observer_id)
: (a.observed_id ?? "").localeCompare(b.observed_id ?? "");
return sortDir === "asc" ? cmp : -cmp;
});
}, [conclusions, sortField, sortDir]);
const displayedConclusions: Conclusion[] = activeSearch
? Array.isArray(searchResults)
? searchResults
: []
: sortedConclusions;
function handleSort(field: string, dir: SortDir) {
setSortField(field);
setSortDir(dir);
setPage(1);
}
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
setActiveSearch(searchQuery.trim());
setPage(1);
}
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
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} />
{workspaceId}
</Link>
<div className="flex items-center gap-2 mb-1">
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<PageTitle>Conclusions</PageTitle>
{total > 0 && !activeSearch && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{total}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{!activeSearch && (
<SortControl
options={SORT_OPTIONS}
field={sortField}
dir={sortDir}
onChange={handleSort}
/>
)}
<Button variant="accent" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
New
</Button>
</div>
</div>
<Muted className="mt-0.5">Distilled memory observations about peers</Muted>
</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: "var(--text-4)" }}
strokeWidth={1.5}
/>
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search across conclusions..."
className="rounded-xl pl-9 pr-4 py-2.5 font-mono"
/>
</div>
<Button type="submit" variant="primary" className="rounded-xl">
Search
</Button>
<AnimatePresence>
{activeSearch && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Button
type="button"
variant="surface"
onClick={() => {
setActiveSearch("");
setSearchQuery("");
}}
className="rounded-xl"
>
<X className="w-4 h-4" strokeWidth={1.5} />
</Button>
</motion.div>
)}
</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: "var(--text-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="group rounded-xl p-5"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div className="flex items-start justify-between gap-3">
<Body className="whitespace-pre-wrap flex-1">{c.content}</Body>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteTarget(c.id)}
className="opacity-0 group-hover:opacity-100 flex-shrink-0"
aria-label="Delete conclusion"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
<div
className="flex items-center gap-3 mt-4 pt-3"
style={{ borderTop: "1px solid var(--border)" }}
>
<div className="flex items-center gap-1.5">
<Eye className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<MonoCaption>{c.observer_id}</MonoCaption>
</div>
{c.observed_id && (
<div className="flex items-center gap-1">
<Caption></Caption>
<MonoCaption>{c.observed_id}</MonoCaption>
</div>
)}
{c.session_id && (
<Link
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
params={{ workspaceId, sessionId: c.session_id } as never}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="flex items-center gap-1 text-xs font-mono hover:underline"
style={{ color: "var(--accent-text)" }}
>
{c.session_id}
</Link>
)}
{c.created_at && (
<div className="ml-auto">
<TimestampChip
value={c.created_at.replace("T", " ").replace(/\.\d+Z?$/, "")}
/>
</div>
)}
</div>
</motion.div>
))}
</div>
{!activeSearch && (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
)}
</>
)}
<CreateConclusionModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onSubmit={async (values) => {
await createConclusion.mutateAsync(values);
setCreateOpen(false);
}}
loading={createConclusion.isPending}
error={createConclusion.error?.message}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title="Delete conclusion"
description="This conclusion will be permanently removed."
confirmLabel="Delete"
onConfirm={async () => {
if (deleteTarget) await deleteConclusion.mutateAsync(deleteTarget);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
loading={deleteConclusion.isPending}
/>
</div>
);
}
function CreateConclusionModal({
open,
onClose,
onSubmit,
loading,
error,
}: {
open: boolean;
onClose: () => void;
onSubmit: (v: {
observer_id: string;
observed_id: string;
content: string;
session_id?: string | null;
}) => Promise<void>;
loading: boolean;
error?: string;
}) {
const [fields, setFields] = useState({
observer_id: "",
observed_id: "",
content: "",
session_id: "",
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const set =
(k: keyof typeof fields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setFields((f) => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
const result = createSchema.safeParse(fields);
if (!result.success) {
const errs: Record<string, string> = {};
for (const issue of result.error.errors) errs[issue.path[0] as string] = issue.message;
setValidationErrors(errs);
return;
}
setValidationErrors({});
await onSubmit({
...result.data,
session_id: result.data.session_id ?? null,
});
setFields({ observer_id: "", observed_id: "", content: "", session_id: "" });
};
return (
<FormModal open={open} title="New conclusion" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{(["observer_id", "observed_id"] as const).map((field) => (
<div key={field}>
<Label className="mb-1">
{field === "observer_id" ? "Observer peer ID" : "Observed peer ID"}{" "}
<span style={{ color: COLOR.destructive }}>*</span>
</Label>
<Input value={fields[field]} onChange={set(field)} placeholder="peer_id" />
{validationErrors[field] && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{validationErrors[field]}
</p>
)}
</div>
))}
<div>
<Label className="mb-1">
Content <span style={{ color: COLOR.destructive }}>*</span>
</Label>
<Textarea
value={fields.content}
onChange={set("content")}
rows={4}
placeholder="The conclusion content…"
className="resize-y"
/>
{validationErrors.content && (
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
{validationErrors.content}
</p>
)}
</div>
<div>
<Label className="mb-1">
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
</Label>
<Input value={fields.session_id} onChange={set("session_id")} placeholder="session_id" />
</div>
{error && (
<p className="text-xs" style={{ color: COLOR.destructive }}>
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="surface" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="accent" size="sm" disabled={loading}>
{loading ? "Creating..." : "Create"}
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,266 @@
import { useQueueStatus, useWorkspaces } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { formatCount } from "@/lib/utils";
import { Link } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Activity, Boxes, ChevronRight, CircleDot, LayoutDashboard } from "lucide-react";
import { useState } from "react";
type QueueStatus = components["schemas"]["QueueStatus"];
// ─── Per-workspace queue row ─────────────────────────────────────────────────
function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
const { data, isLoading } = useQueueStatus(workspaceId);
const pending = data?.pending_work_units ?? 0;
const active = data?.in_progress_work_units ?? 0;
const done = data?.completed_work_units ?? 0;
const total = data?.total_work_units ?? 0;
const isActive = active > 0 || pending > 0;
return (
<tr
style={{
borderTop: "1px solid var(--border)",
background: isActive ? COLOR.warningDim : undefined,
}}
>
<td className="py-2 px-4">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="flex items-center gap-2 group"
>
<span
className="font-mono text-xs truncate max-w-[200px] group-hover:underline"
style={{ color: "var(--accent-text)" }}
>
{workspaceId}
</span>
<ChevronRight
className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={2}
/>
</Link>
</td>
<td className="py-2 px-4 text-right">
{isLoading ? (
<span className="text-xs font-mono" style={{ color: "var(--text-4)" }}>
</span>
) : (
<div className="flex items-center justify-end gap-1.5">
{isActive ? (
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
>
<CircleDot className="w-3 h-3" style={{ color: COLOR.warning }} strokeWidth={2} />
</motion.div>
) : (
<CircleDot className="w-3 h-3" style={{ color: COLOR.success }} strokeWidth={2} />
)}
<span
className="text-xs font-medium"
style={{ color: isActive ? COLOR.warning : COLOR.success }}
>
{isActive ? `${formatCount(pending + active)} pending` : "Idle"}
</span>
</div>
)}
</td>
{(
[
{ val: total, color: "var(--text-2)" },
{ val: done, color: COLOR.success },
{ val: active, color: COLOR.warning },
{ val: pending, color: "var(--text-3)" },
] as Array<{ val: number; color: string }>
).map(({ val, color }, i) => (
<td
// biome-ignore lint/suspicious/noArrayIndexKey: static positional columns
key={i}
className="py-2 px-4 text-right font-mono text-xs"
style={{ color: isLoading ? "var(--text-4)" : color }}
>
{isLoading ? "—" : formatCount(val)}
</td>
))}
</tr>
);
}
// ─── Aggregate banner ─────────────────────────────────────────────────────────
// Each workspace row already called useQueueStatus — TanStack Query deduplicates
// the fetches so calling the same hooks here just reads from cache.
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
const statuses = workspaces.map((ws) => {
const { data } = useQueueStatus(ws.id);
return data as QueueStatus | undefined;
});
const totalPending = statuses.reduce((s, d) => s + (d?.pending_work_units ?? 0), 0);
const totalActive = statuses.reduce((s, d) => s + (d?.in_progress_work_units ?? 0), 0);
const totalDone = statuses.reduce((s, d) => s + (d?.completed_work_units ?? 0), 0);
const allLoaded = statuses.every((d) => d !== undefined);
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(
[
{ label: "Workspaces", value: workspaces.length, color: "var(--text-1)", always: true },
{ label: "Total done", value: totalDone, color: COLOR.success, always: false },
{ label: "Active", value: totalActive, color: COLOR.warning, always: false },
{
label: "Pending",
value: totalPending,
color: totalPending > 0 ? COLOR.warning : "var(--text-3)",
always: false,
},
] as Array<{ label: string; value: number; color: string; always: boolean }>
).map(({ label, value, color, always }) => (
<div key={label} className="rounded-xl p-4 theme-card">
<div
className="text-2xl font-semibold font-mono"
style={{ color: allLoaded || always ? color : "var(--text-4)" }}
>
{allLoaded || always ? formatCount(value) : "—"}
</div>
<div className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
{label}
</div>
</div>
))}
</div>
);
}
// ─── Main dashboard ───────────────────────────────────────────────────────────
export function Dashboard() {
const [page] = useState(1);
const { data, isLoading, error } = useWorkspaces(page, 50);
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 page-container--xl">
<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}
/>
<PageTitle>Dashboard</PageTitle>
{total > 0 && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{total} workspace{total !== 1 ? "s" : ""}
</span>
)}
</div>
<Body className="leading-none">Overview of your Honcho instance</Body>
</motion.div>
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{!isLoading && workspaces.length > 0 && (
<div className="space-y-4">
{/* Aggregate stat row */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
<GlobalQueueBanner workspaces={workspaces} />
</motion.div>
{/* Per-workspace queue table */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
className="rounded-xl theme-card overflow-hidden"
>
<div
className="flex items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
<SectionHeading className="mb-0">Queue Status</SectionHeading>
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
all workspaces · updates every 10s
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ background: "var(--bg-3)" }}>
{["Workspace", "Status", "Total", "Done", "Active", "Pending"].map((h) => (
<th
key={h}
className={`py-2 px-4 font-medium text-left ${h !== "Workspace" && h !== "Status" ? "text-right" : ""}`}
style={{ color: "var(--text-3)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{workspaces.map((ws) => (
<WorkspaceQueueRow key={ws.id} workspaceId={ws.id} />
))}
</tbody>
</table>
</div>
</motion.div>
{total > workspaces.length && (
<p className="text-xs text-center" style={{ color: "var(--text-4)" }}>
Showing {workspaces.length} of {total} workspaces.{" "}
<Link
to="/workspaces"
className="hover:underline"
style={{ color: "var(--accent-text)" }}
>
View all
</Link>
</p>
)}
</div>
)}
{!isLoading && workspaces.length === 0 && (
<div className="rounded-xl p-10 text-center theme-card">
<Boxes
className="w-8 h-8 mx-auto mb-3"
style={{ color: "var(--text-4)" }}
strokeWidth={1}
/>
<Muted>No workspaces found.</Muted>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useTheme } from "@/hooks/useTheme";
import { loadConfig } from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { Boxes, Brain, ChevronRight, LayoutDashboard, Moon, Settings, Sun } from "lucide-react";
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 ${COLOR.accentGlow}`,
}}
>
<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
type="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,423 @@
import {
usePeer,
usePeerCard,
usePeerContext,
usePeerRepresentation,
useSearchPeer,
useSetPeerCard,
} from "@/api/queries";
import { Badge } from "@/components/shared/Badge";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { MarkdownRenderer } from "@/components/shared/MarkdownRenderer";
import { PeerCardViewer } from "@/components/shared/PeerCardViewer";
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import {
Body,
Caption,
MonoCaption,
Muted,
PageTitle,
SectionHeading,
} from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import {
ChevronDown,
Eye,
EyeOff,
MessageCircle,
Save,
Search,
User,
Users,
X,
} from "lucide-react";
import { useState } from "react";
export function PeerDetail() {
const { workspaceId, peerId } = useParams({ strict: false }) as {
workspaceId: string;
peerId: string;
};
const navigate = useNavigate();
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 [repTarget, setRepTarget] = useState("");
const [repTargetInput, setRepTargetInput] = useState("");
const { data: representation, isLoading: repLoading } = usePeerRepresentation(
workspaceId,
peerId,
repTarget || undefined,
);
const setPeerCard = useSetPeerCard(workspaceId, peerId);
const searchPeer = useSearchPeer(workspaceId, peerId);
const [cardDraft, setCardDraft] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [metaExpanded, setMetaExpanded] = useState(false);
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
?.configuration?.observe_me;
const cardLines: string[] = Array.isArray((card as { peer_card?: unknown })?.peer_card)
? (card as { peer_card: string[] }).peer_card
: typeof card === "string"
? [card]
: [];
return (
<div className="page-container page-container--xl">
<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} />
<PageTitle className="font-mono break-all">{peerId}</PageTitle>
{observeMe !== undefined && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono"
style={{
background: observeMe ? COLOR.accentSubtle : COLOR.cardBaseBg,
color: observeMe ? COLOR.accentText : COLOR.dimText,
border: `1px solid ${observeMe ? COLOR.accentBorder : COLOR.cardBaseBorder}`,
}}
>
{observeMe ? (
<Eye className="w-3 h-3" strokeWidth={2} />
) : (
<EyeOff className="w-3 h-3" strokeWidth={2} />
)}
{observeMe ? "observed" : "not observed"}
</span>
)}
</div>
<Body className="leading-none">Peer identity &amp; memory</Body>
</div>
<Button
variant="primary"
onClick={() =>
navigate({
to: "/workspaces/$workspaceId/peers/$peerId/chat",
params: { workspaceId, peerId } as never,
})
}
className="shrink-0 rounded-xl"
>
<MessageCircle className="w-4 h-4" strokeWidth={1.5} />
Chat
</Button>
</div>
</motion.div>
<div className="mt-6 space-y-4">
<ErrorAlert error={error instanceof Error ? error : null} />
{isLoading && <PageLoader />}
{!isLoading && peer && (
<>
{/* Search — prominent, always visible */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="rounded-xl p-5 theme-card"
>
<SectionHeading className="flex items-center gap-1.5 mb-3">
<Search className="w-3.5 h-3.5" strokeWidth={2} />
Search peer messages
</SectionHeading>
<form
onSubmit={(e) => {
e.preventDefault();
if (searchQuery.trim()) searchPeer.mutate(searchQuery.trim());
}}
className="flex gap-2 mb-4"
>
<Input
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Semantic search across this peer's messages…"
className="flex-1 text-sm"
/>
<Button type="submit" variant="accent" disabled={searchPeer.isPending}>
{searchPeer.isPending ? "…" : "Search"}
</Button>
</form>
<AnimatePresence>
{searchPeer.data && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3 overflow-hidden"
>
{(
searchPeer.data as Array<{
id: string;
content: string;
peer_id?: string;
created_at?: string;
}>
).length === 0 ? (
<Muted>No results.</Muted>
) : (
(
searchPeer.data as Array<{
id: string;
content: string;
peer_id?: string;
created_at?: string;
}>
).map((r) => (
<div
key={r.id}
className="py-3 px-4 rounded-lg"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
}}
>
<div className="flex items-center gap-2 mb-1.5">
<Badge variant="blue">{r.peer_id ?? peerId}</Badge>
{r.created_at && (
<Caption>{new Date(r.created_at).toLocaleString()}</Caption>
)}
</div>
<Body className="whitespace-pre-wrap">{r.content}</Body>
</div>
))
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Card + Representation — side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Peer Card */}
<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-3">
<SectionHeading className="mb-0">Peer Card</SectionHeading>
{!cardLoading &&
(cardDraft === null ? (
<Button
variant="accent"
size="sm"
onClick={() => setCardDraft(cardLines.join("\n"))}
>
Edit
</Button>
) : (
<div className="flex gap-1.5">
<Button
variant="accent"
size="sm"
onClick={() => {
setPeerCard.mutate(cardDraft.split("\n").filter(Boolean));
setCardDraft(null);
}}
disabled={setPeerCard.isPending}
>
<Save className="w-3 h-3" strokeWidth={2} />
Save
</Button>
<Button variant="surface" size="sm" onClick={() => setCardDraft(null)}>
<X className="w-3 h-3" strokeWidth={2} />
</Button>
</div>
))}
</div>
{cardLoading ? (
<PageLoader />
) : (
<AnimatePresence mode="wait">
{cardDraft !== null ? (
<motion.div key="edit" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<Textarea
value={cardDraft}
onChange={(e) => setCardDraft(e.target.value)}
rows={8}
className="font-mono resize-y"
style={{ minHeight: "8rem" }}
/>
</motion.div>
) : (
<motion.div key="view" initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{cardLines.length > 0 ? (
<PeerCardViewer lines={cardLines} />
) : (
<Muted>No card data yet.</Muted>
)}
</motion.div>
)}
</AnimatePresence>
)}
</motion.div>
{/* Representation */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
className="rounded-xl p-5 theme-card"
>
<div className="flex items-center justify-between mb-3 gap-3">
<SectionHeading className="mb-0 flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" strokeWidth={2} />
{repTarget ? (
<>
<MonoCaption as="span">{peerId}</MonoCaption>
<span className="opacity-50"></span>
<MonoCaption as="span">{repTarget}</MonoCaption>
</>
) : (
"Memory Representation"
)}
</SectionHeading>
<form
onSubmit={(e) => {
e.preventDefault();
setRepTarget(repTargetInput.trim());
}}
className="flex items-center gap-1.5 shrink-0"
>
<Input
value={repTargetInput}
onChange={(e) => setRepTargetInput(e.target.value)}
placeholder="view as peer…"
className="text-xs font-mono h-7 w-36 rounded-lg"
/>
<Button type="submit" variant="surface" size="sm" className="h-7 px-2 text-xs">
{repTarget ? "Update" : "Scope"}
</Button>
{repTarget && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setRepTarget("");
setRepTargetInput("");
}}
>
<X className="w-3 h-3" strokeWidth={2} />
</Button>
)}
</form>
</div>
{repLoading ? (
<PageLoader />
) : representation &&
typeof (representation as { representation?: unknown }).representation ===
"string" ? (
<MarkdownRenderer
content={(representation as { representation: string }).representation}
workspaceId={workspaceId}
/>
) : (
<JsonViewer data={representation} maxHeight="320px" />
)}
</motion.div>
</div>
{/* Context — full width */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="rounded-xl p-5 theme-card"
>
<SectionHeading>Peer Context</SectionHeading>
{contextLoading ? (
<PageLoader />
) : typeof context === "string" ? (
<Body className="whitespace-pre-wrap">{context}</Body>
) : (
<JsonViewer data={context} />
)}
</motion.div>
{/* Metadata — collapsible */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="rounded-xl theme-card overflow-hidden"
>
<button
type="button"
onClick={() => setMetaExpanded((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4"
style={{ color: "var(--text-3)" }}
>
<SectionHeading className="mb-0">Metadata</SectionHeading>
<motion.div
animate={{ rotate: metaExpanded ? 0 : -90 }}
transition={{ duration: 0.15 }}
>
<ChevronDown
className="w-4 h-4"
strokeWidth={2}
style={{ color: COLOR.dimText }}
/>
</motion.div>
</button>
<AnimatePresence initial={false}>
{metaExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5">
<JsonViewer data={peer.metadata} maxHeight="300px" />
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,329 @@
import { usePeers } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, PageTitle } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { type Variants, motion } from "framer-motion";
import { ArrowLeft, ChevronRight, Clock, Eye, Users } from "lucide-react";
import { useMemo, useState } from "react";
type Peer = components["schemas"]["Peer"];
type KindStyle = { bg: string; text: string; border: string };
const KIND_STYLES: Record<string, KindStyle> = {
agent: { bg: COLOR.warningDim, text: COLOR.warning, border: COLOR.warningBorder },
discord: { bg: "rgba(14,165,233,0.08)", text: "#38bdf8", border: "rgba(14,165,233,0.2)" },
ai: { bg: COLOR.accentDim, text: COLOR.accentText, border: COLOR.accentBorder },
};
function peerKind(id: string): (KindStyle & { label: string }) | null {
if (id.startsWith("agent-")) return { label: "agent", ...KIND_STYLES.agent };
if (id.startsWith("discord-")) return { label: "discord", ...KIND_STYLES.discord };
if (["claude", "hermes", "codex"].includes(id)) return { label: "ai", ...KIND_STYLES.ai };
return null;
}
const SORT_OPTIONS = [
{ value: "created_at", label: "Newest" },
{ value: "id", label: "ID" },
];
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 [sortField, setSortField] = useState("created_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [expandedMeta, setExpandedMeta] = useState<Set<string>>(new Set());
const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
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;
const availableLabels = useMemo(() => {
const labels = new Set<string>();
for (const peer of peers) {
const kind = peerKind(peer.id);
if (kind) labels.add(kind.label);
}
return labels;
}, [peers]);
const sorted = useMemo(() => {
return [...peers].sort((a, b) => {
let cmp = 0;
if (sortField === "created_at") {
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
} else if (sortField === "id") {
cmp = a.id.localeCompare(b.id);
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [peers, sortField, sortDir]);
const filtered = useMemo(() => {
if (activeFilters.size === 0) return sorted;
return sorted.filter((peer) => {
const kind = peerKind(peer.id);
return kind ? activeFilters.has(kind.label) : false;
});
}, [sorted, activeFilters]);
function toggleFilter(label: string) {
setActiveFilters((prev) => {
const next = new Set(prev);
next.has(label) ? next.delete(label) : next.add(label);
return next;
});
}
function handleSort(field: string, dir: SortDir) {
setSortField(field);
setSortDir(dir);
}
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: COLOR.dimText }}
>
<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: COLOR.accent }} strokeWidth={1.5} />
<PageTitle>Peers</PageTitle>
{total > 0 && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{total}
</span>
)}
<div className="ml-auto">
<SortControl
options={SORT_OPTIONS}
field={sortField}
dir={sortDir}
onChange={handleSort}
/>
</div>
</div>
<MonoCaption className="mt-0.5" as="p">
{workspaceId}
</MonoCaption>
</motion.div>
{availableLabels.size > 0 && (
<div className="flex items-center gap-2 flex-wrap mb-4">
{[...availableLabels].map((label) => {
const style = KIND_STYLES[label];
const active = activeFilters.has(label);
return (
<button
key={label}
type="button"
onClick={() => toggleFilter(label)}
className="text-xs font-mono px-2 py-1 rounded transition-opacity hover:opacity-90"
style={{
background: active ? style.bg : "transparent",
color: active ? style.text : "var(--text-4)",
border: `1px solid ${active ? style.border : "var(--border)"}`,
}}
>
{label}
</button>
);
})}
{activeFilters.size > 0 && (
<button
type="button"
onClick={() => setActiveFilters(new Set())}
className="text-xs font-mono px-2 py-1 rounded transition-opacity hover:opacity-80"
style={{ color: "var(--text-4)" }}
>
clear
</button>
)}
</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 && filtered.length === 0 && (
<EmptyState
icon={Users}
title="No peers match"
description="No peers match the selected filters."
/>
)}
{!isLoading && filtered.length > 0 && (
<>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 sm:grid-cols-2 gap-2"
>
{filtered.map((peer) => {
const kind = peerKind(peer.id);
const metaKeys = Object.keys(peer.metadata ?? {});
const hasMeta = metaKeys.length > 0;
const metaOpen = expandedMeta.has(peer.id);
function toggleMeta(e: React.MouseEvent) {
e.stopPropagation();
setExpandedMeta((prev) => {
const next = new Set(prev);
next.has(peer.id) ? next.delete(peer.id) : next.add(peer.id);
return next;
});
}
return (
<motion.div
key={peer.id}
variants={item}
className="rounded-xl overflow-hidden group"
style={{
background: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
whileHover={{
background: COLOR.accentDimHover,
borderColor: COLOR.accentBorder,
}}
>
<button
type="button"
onClick={() =>
navigate({
to: "/workspaces/$workspaceId/peers/$peerId",
params: { workspaceId, peerId: peer.id } as never,
})
}
className="text-left w-full px-5 py-4"
>
<div className="flex items-center justify-between mb-1">
<span
className="font-mono text-sm font-medium truncate"
style={{ color: COLOR.accentSoft }}
>
{peer.id}
</span>
<ChevronRight
className="w-4 h-4 shrink-0 ml-2 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: COLOR.accent }}
strokeWidth={1.5}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{kind && (
<span
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: kind.bg,
color: kind.text,
border: `1px solid ${kind.border}`,
}}
>
{kind.label}
</span>
)}
{(peer.configuration as { observe_me?: boolean } | null)?.observe_me && (
<div className="flex items-center gap-1">
<Eye
className="w-3 h-3"
style={{ color: COLOR.accentText }}
strokeWidth={1.5}
/>
<span className="text-xs" style={{ color: COLOR.accentText }}>
observed
</span>
</div>
)}
{peer.created_at && (
<div className="flex items-center gap-1">
<Clock
className="w-3 h-3"
style={{ color: COLOR.dimIcon }}
strokeWidth={1.5}
/>
<MonoCaption>{new Date(peer.created_at).toLocaleString()}</MonoCaption>
</div>
)}
</div>
</button>
{hasMeta && (
<>
<button
type="button"
onClick={toggleMeta}
className="w-full flex items-center gap-1.5 px-5 py-1.5 text-xs font-mono transition-opacity hover:opacity-80"
style={{
borderTop: `1px solid ${COLOR.cardBaseBorder}`,
color: COLOR.dimText,
}}
>
<ChevronRight
className="w-3 h-3 transition-transform duration-150"
style={{ transform: metaOpen ? "rotate(90deg)" : "rotate(0deg)" }}
strokeWidth={2}
/>
{metaKeys.length} metadata key{metaKeys.length !== 1 ? "s" : ""}
</button>
{metaOpen && (
<div className="px-4 pb-4">
<JsonViewer data={peer.metadata} maxHeight="200px" />
</div>
)}
</>
)}
</motion.div>
);
})}
</motion.div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,465 @@
import {
useAddPeersToSession,
useCloneSession,
useDeleteSession,
usePeers,
useRemovePeersFromSession,
useSearchSession,
useSessionContext,
useSessionMessages,
useSessionPeers,
useSessionSummaries,
} from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { Badge } from "@/components/shared/Badge";
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
import { JsonViewer } from "@/components/shared/JsonViewer";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Body,
Caption,
MonoCaption,
Muted,
PageTitle,
SectionHeading,
} from "@/components/ui/typography";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion } from "framer-motion";
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
import { useState } from "react";
type Message = components["schemas"]["Message"];
type SessionSummaries = components["schemas"]["SessionSummaries"];
type Summary = components["schemas"]["Summary"];
type Tab = "messages" | "summaries" | "context" | "peers";
export function SessionDetail() {
const { workspaceId, sessionId } = useParams({ strict: false }) as {
workspaceId: string;
sessionId: string;
};
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>("messages");
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [searchActive, setSearchActive] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
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 { data: sessionPeers, isLoading: peersLoading } = useSessionPeers(workspaceId, sessionId);
const { data: allPeers } = usePeers(workspaceId, 1, 100);
const deleteSession = useDeleteSession(workspaceId);
const cloneSession = useCloneSession(workspaceId);
const searchSession = useSearchSession(workspaceId, sessionId);
const removePeers = useRemovePeersFromSession(workspaceId, sessionId);
const addPeers = useAddPeersToSession(workspaceId, sessionId);
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
const sessionPeerItems =
(sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined)?.items ?? [];
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
const availablePeers = (
(allPeers as { items?: Array<{ id: string }> } | undefined)?.items ?? []
).filter((p) => !memberPeerIds.has(p.id));
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "messages", label: "Messages" },
{ id: "summaries", label: "Summaries" },
{ id: "context", label: "Context" },
{ id: "peers", label: "Peers" },
];
const handleDelete = async () => {
await deleteSession.mutateAsync(sessionId);
navigate({
to: "/workspaces/$workspaceId/sessions" as never,
params: { workspaceId } as never,
});
};
const handleClone = async () => {
const cloned = await cloneSession.mutateAsync(sessionId);
if ((cloned as { id?: string })?.id) {
navigate({
to: "/workspaces/$workspaceId/sessions/$sessionId" as never,
params: { workspaceId, sessionId: (cloned as { id: string }).id } as never,
});
}
};
return (
<div className="page-container page-container--wide">
<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-start justify-between gap-4 mb-1">
<div className="flex items-center gap-2 min-w-0">
<MessageSquare
className="w-5 h-5 flex-shrink-0"
style={{ color: "var(--accent)" }}
strokeWidth={1.5}
/>
<PageTitle className="font-mono break-all">{sessionId}</PageTitle>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant={searchActive ? "accent" : "surface"}
size="icon"
onClick={() => setSearchActive((v) => !v)}
aria-label="Search session"
>
<Search className="w-3.5 h-3.5" strokeWidth={2} />
</Button>
<Button
variant="surface"
size="icon"
onClick={handleClone}
disabled={cloneSession.isPending}
aria-label="Clone session"
>
<Copy className="w-3.5 h-3.5" strokeWidth={2} />
</Button>
<Button
variant="destructive"
size="icon"
onClick={() => setConfirmDelete(true)}
aria-label="Delete session"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
</Button>
</div>
</div>
<Body className="leading-none">Session detail</Body>
</motion.div>
{/* Inline search bar */}
<AnimatePresence>
{searchActive && (
<motion.div
initial={{ opacity: 0, height: 0, marginTop: 0 }}
animate={{ opacity: 1, height: "auto", marginTop: 16 }}
exit={{ opacity: 0, height: 0, marginTop: 0 }}
className="overflow-hidden"
>
<form
onSubmit={(e) => {
e.preventDefault();
if (searchQuery.trim()) searchSession.mutate(searchQuery.trim());
}}
className="flex gap-2"
>
<Input
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search within this session…"
className="flex-1"
/>
<Button type="submit" variant="accent" disabled={searchSession.isPending}>
{searchSession.isPending ? "…" : "Search"}
</Button>
</form>
{searchSession.data && (
<div className="mt-3 rounded-xl p-4 theme-card space-y-2">
{(searchSession.data as Array<{ id: string; content: string; peer_id?: string }>)
.length === 0 ? (
<Muted>No results.</Muted>
) : (
(
searchSession.data as Array<{ id: string; content: string; peer_id?: string }>
).map((r) => (
<div
key={r.id}
className="text-sm py-2"
style={{ borderBottom: "1px solid var(--border)", color: "var(--text-2)" }}
>
{r.peer_id && <Badge variant="blue">{r.peer_id}</Badge>}
<p className="mt-1 whitespace-pre-wrap">{r.content}</p>
</div>
))
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
<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}
type="button"
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 ? (
<Muted>No messages.</Muted>
) : (
<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 && <Caption>{msg.token_count} tokens</Caption>}
{msg.created_at && (
<Caption>{new Date(msg.created_at).toLocaleString()}</Caption>
)}
</div>
<Body className="whitespace-pre-wrap">{msg.content}</Body>
</div>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</div>
))}
{tab === "summaries" &&
(summariesLoading ? <PageLoader /> : <SummariesDisplay summaries={summaries} />)}
{tab === "context" &&
(contextLoading ? (
<PageLoader />
) : (
<>
<SectionHeading>Session Context</SectionHeading>
{typeof context === "string" ? (
<Body className="whitespace-pre-wrap">{context}</Body>
) : (
<JsonViewer data={context} maxHeight="500px" />
)}
</>
))}
{tab === "peers" &&
(peersLoading ? (
<PageLoader />
) : (
<SessionPeersTab
members={sessionPeerItems}
available={availablePeers}
onRemove={(id) => removePeers.mutate([id])}
onAdd={(id) => addPeers.mutate({ [id]: {} })}
removing={removePeers.isPending}
adding={addPeers.isPending}
/>
))}
</motion.div>
</div>
<ConfirmDialog
open={confirmDelete}
title="Delete session"
description={`Permanently delete session "${sessionId}"? This cannot be undone.`}
confirmLabel="Delete session"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
loading={deleteSession.isPending}
/>
</div>
);
}
function SessionPeersTab({
members,
available,
onRemove,
onAdd,
removing,
adding,
}: {
members: Array<{ id?: string; peer_id?: string }> | undefined;
available: Array<{ id: string }>;
onRemove: (id: string) => void;
onAdd: (id: string) => void;
removing: boolean;
adding: boolean;
}) {
const list = members ?? [];
return (
<div className="space-y-4">
<div>
<SectionHeading className="mb-2">
<Users className="w-3.5 h-3.5 inline mr-1.5" strokeWidth={2} />
Session members ({list.length})
</SectionHeading>
{list.length === 0 ? (
<Muted>No peers in this session.</Muted>
) : (
<div className="space-y-1">
{list.map((p) => {
const id = p.id ?? p.peer_id ?? "";
return (
<div
key={id}
className="flex items-center justify-between py-1.5 px-3 rounded-lg"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
<span className="text-xs font-mono" style={{ color: "var(--accent-text)" }}>
{id}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(id)}
disabled={removing}
aria-label={`Remove ${id}`}
>
<X className="w-3 h-3" strokeWidth={2} />
</Button>
</div>
);
})}
</div>
)}
</div>
{available.length > 0 && (
<div>
<SectionHeading className="mb-2">Add peer</SectionHeading>
<div className="space-y-1 max-h-48 overflow-auto">
{available.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onAdd(p.id)}
disabled={adding}
className="w-full text-left py-1.5 px-3 rounded-lg text-xs font-mono transition-all disabled:opacity-40"
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
color: "var(--text-3)",
}}
>
+ {p.id}
</button>
))}
</div>
</div>
)}
</div>
);
}
function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
return (
<div
className="rounded-xl p-4"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<AlignLeft className="w-3.5 h-3.5" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<span className="text-xs font-medium" style={{ color: "var(--text-2)" }}>
{label}
</span>
</div>
<div className="flex items-center gap-3">
{summary.token_count != null && <MonoCaption>{summary.token_count} tok</MonoCaption>}
{summary.created_at && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
<MonoCaption>{new Date(summary.created_at).toLocaleString()}</MonoCaption>
</div>
)}
</div>
</div>
<Body className="whitespace-pre-wrap">{summary.content}</Body>
</div>
);
}
function SummariesDisplay({ summaries }: { summaries: unknown }) {
const data = summaries as SessionSummaries | null | undefined;
if (!data || (!data.short_summary && !data.long_summary)) {
return (
<>
<SectionHeading>Session Summaries</SectionHeading>
<Caption as="p">No summaries available yet.</Caption>
</>
);
}
return (
<>
<SectionHeading>Session Summaries</SectionHeading>
<div className="space-y-3">
{data.short_summary && <SummaryCard label="Short summary" summary={data.short_summary} />}
{data.long_summary && <SummaryCard label="Long summary" summary={data.long_summary} />}
</div>
</>
);
}

View File

@@ -0,0 +1,204 @@
import { useSessions } from "@/api/queries";
import type { components } from "@/api/schema.d.ts";
import { EmptyState } from "@/components/shared/EmptyState";
import { ErrorAlert } from "@/components/shared/ErrorAlert";
import { PageLoader } from "@/components/shared/LoadingSpinner";
import { Pagination } from "@/components/shared/Pagination";
import { SortControl, type SortDir } from "@/components/shared/SortControl";
import { MonoCaption, PageTitle } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { type Variants, motion } from "framer-motion";
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
import { useMemo, useState } from "react";
type Session = components["schemas"]["Session"];
const SORT_OPTIONS = [
{ value: "created_at", label: "Newest" },
{ value: "active", label: "Active" },
{ value: "id", label: "ID" },
];
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 [sortField, setSortField] = useState("created_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
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;
const sorted = useMemo(() => {
return [...sessions].sort((a, b) => {
let cmp = 0;
if (sortField === "created_at") {
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
} else if (sortField === "active") {
// active sessions first (true > false)
cmp = Number(a.is_active) - Number(b.is_active);
} else if (sortField === "id") {
cmp = a.id.localeCompare(b.id);
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [sessions, sortField, sortDir]);
function handleSort(field: string, dir: SortDir) {
setSortField(field);
setSortDir(dir);
}
return (
<div className="page-container">
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
<Link
to="/workspaces/$workspaceId"
params={{ workspaceId } as never}
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
style={{ color: COLOR.dimText }}
>
<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: COLOR.accent }} strokeWidth={1.5} />
<PageTitle>Sessions</PageTitle>
{total > 0 && (
<span
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{total}
</span>
)}
<div className="ml-auto">
<SortControl
options={SORT_OPTIONS}
field={sortField}
dir={sortDir}
onChange={handleSort}
/>
</div>
</div>
<MonoCaption className="mt-0.5" as="p">
{workspaceId}
</MonoCaption>
</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 && sorted.length > 0 && (
<>
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
{sorted.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: COLOR.cardBaseBg,
border: `1px solid ${COLOR.cardBaseBorder}`,
}}
whileHover={{
background: COLOR.accentDimHover,
borderColor: COLOR.accentBorder,
x: 2,
}}
>
<div className="flex items-center justify-between">
<span
className="font-mono text-sm font-medium truncate"
style={{ color: COLOR.accentSoft }}
>
{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: Number.POSITIVE_INFINITY }}
>
<CircleDot
className="w-3 h-3"
style={{ color: COLOR.success }}
strokeWidth={2}
/>
</motion.div>
<span className="text-xs" style={{ color: COLOR.success }}>
Active
</span>
</div>
)}
<ChevronRight
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
style={{ color: COLOR.accent }}
strokeWidth={1.5}
/>
</div>
</div>
<div className="flex items-center gap-2 mt-2">
{session.created_at && (
<div className="flex items-center gap-1.5">
<Clock
className="w-3 h-3"
style={{ color: COLOR.dimIcon }}
strokeWidth={1.5}
/>
<MonoCaption>{new Date(session.created_at).toLocaleString()}</MonoCaption>
</div>
)}
{(session.metadata as Record<string, string> | null)?.source && (
<span
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: COLOR.accentDim,
border: `1px solid ${COLOR.accentBorderStrong}`,
color: COLOR.dimText,
}}
>
{(session.metadata as Record<string, string>).source}
</span>
)}
</div>
</motion.button>
))}
</motion.div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { Button } from "@/components/ui/button";
import { Input, Textarea } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Muted } from "@/components/ui/typography";
import {
type Config,
type HealthStatus,
checkConnection,
configSchema,
loadConfig,
saveConfig,
} from "@/lib/config";
import { COLOR } from "@/lib/constants";
import { AnimatePresence, motion } from "framer-motion";
import { AlertCircle, CheckCircle, Loader, Lock, LockOpen, Wifi, WifiOff } from "lucide-react";
import { useState } from "react";
interface SettingsFormProps {
onSaved?: () => void;
}
const statusConfig = {
ok: { icon: CheckCircle, color: COLOR.success, label: "Connected" },
"auth-required": { icon: AlertCircle, color: COLOR.warning, label: "Auth required" },
unreachable: { icon: WifiOff, color: COLOR.destructive, label: "Unreachable" },
checking: { icon: Loader, color: COLOR.accentText, 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);
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="mb-1.5 text-sm">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 font-mono rounded-xl"
/>
<Button
type="button"
variant="accent"
onClick={handleTest}
disabled={checking || !baseUrl}
className="rounded-xl"
>
{checking ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Number.POSITIVE_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: COLOR.destructive }}>
{errors.baseUrl}
</p>
)}
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted>
</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>
<Muted className="text-xs mt-0.5">{health.message}</Muted>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Token */}
<div>
<Label htmlFor="honcho-token" className="flex items-center gap-1.5 mb-1.5 text-sm">
{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="font-mono rounded-xl"
/>
{health?.status === "auth-required" && !token && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs mt-1"
style={{ color: COLOR.warning }}
>
This instance requires an API token to proceed
</motion.p>
)}
</div>
<Button
type="submit"
variant="primary"
className="w-full py-2.5 px-4 rounded-xl"
style={saved ? { background: "#059669" } : undefined}
>
{saved ? "✓ Saved" : "Save Connection"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,43 @@
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,70 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { COLOR } from "@/lib/constants";
import { AlertTriangle } from "lucide-react";
interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
danger?: boolean;
loading?: boolean;
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "Confirm",
onConfirm,
onCancel,
danger = true,
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
<DialogContent className="max-w-sm">
<div className="flex items-start gap-3 mb-4">
{danger && (
<div
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5"
style={{ background: COLOR.destructiveDim }}
>
<AlertTriangle
className="w-4 h-4"
style={{ color: COLOR.destructive }}
strokeWidth={2}
/>
</div>
)}
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-1">{description}</DialogDescription>
</div>
</div>
<DialogFooter>
<Button variant="surface" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button
variant={danger ? "destructive" : "accent"}
size="sm"
onClick={onConfirm}
disabled={loading}
>
{loading ? "..." : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
import { Body, Caption } from "@/components/ui/typography";
import { COLOR } from "@/lib/constants";
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: COLOR.accentSubtle,
border: `1px solid ${COLOR.accentBorderStrong}`,
}}
>
<Icon className="w-5 h-5" style={{ color: COLOR.accentMuted }} strokeWidth={1.5} />
</div>
)}
<Body className="font-medium">{title}</Body>
{description && <Caption className="mt-1.5 max-w-xs leading-relaxed">{description}</Caption>}
{action && <div className="mt-4">{action}</div>}
</motion.div>
);
}

View File

@@ -0,0 +1,26 @@
import { COLOR } from "@/lib/constants";
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: COLOR.destructiveDim,
border: `1px solid ${COLOR.destructiveBorderStrong}`,
}}
>
<p className="text-sm font-medium" style={{ color: COLOR.destructive }}>
{message ?? "An error occurred"}
</p>
<p className="text-xs mt-1 font-mono" style={{ color: COLOR.destructiveMuted }}>
{error.message}
</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface FormModalProps {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
}
export function FormModal({
open,
title,
onClose,
children,
maxWidth = "max-w-md",
}: FormModalProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className={cn("p-0", maxWidth)}>
<DialogHeader className="px-5 py-4 mb-0">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="px-5 pb-5">{children}</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,103 @@
import { Check, Pencil, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
interface InlineEditorProps {
value: string;
onSave: (value: string) => void;
loading?: boolean;
placeholder?: string;
className?: string;
}
export function InlineEditor({
value,
onSave,
loading = false,
placeholder = "Click to edit",
className = "",
}: InlineEditorProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
setDraft(value);
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing, value]);
const commit = () => {
if (draft.trim() && draft !== value) onSave(draft.trim());
setEditing(false);
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
if (!editing) {
return (
<button
type="button"
onClick={() => setEditing(true)}
className={`group flex items-center gap-1.5 text-left transition-colors ${className}`}
style={{ color: "var(--text-1)" }}
>
<span>{value || <span style={{ color: "var(--text-4)" }}>{placeholder}</span>}</span>
<Pencil
className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
style={{ color: "var(--text-4)" }}
strokeWidth={2}
/>
</button>
);
}
return (
<div className="flex items-center gap-1">
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") cancel();
}}
onBlur={commit}
disabled={loading}
className="text-sm px-2 py-0.5 rounded-md flex-1 min-w-0"
style={{
background: "var(--surface)",
border: "1px solid var(--accent-border)",
color: "var(--text-1)",
outline: "none",
}}
/>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
commit();
}}
className="p-1 rounded"
style={{ color: "var(--accent-text)" }}
>
<Check className="w-3.5 h-3.5" strokeWidth={2.5} />
</button>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
cancel();
}}
className="p-1 rounded"
style={{ color: "var(--text-4)" }}
>
<X className="w-3.5 h-3.5" strokeWidth={2.5} />
</button>
</div>
);
}

View File

@@ -0,0 +1,38 @@
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,41 @@
import { COLOR } from "@/lib/constants";
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: Number.POSITIVE_INFINITY, ease: "linear" }}
style={{
width: s,
height: s,
borderRadius: "50%",
border: `2px solid ${COLOR.accentSpinnerTrack}`,
borderTopColor: COLOR.accent,
}}
/>
);
}
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, ${COLOR.accent}, transparent)` }}
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
/>
</div>
);
}

View File

@@ -0,0 +1,549 @@
import { TimestampChip } from "@/components/shared/TimestampChip";
import { COLOR } from "@/lib/constants";
import { Link } from "@tanstack/react-router";
import { DateTime } from "luxon";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── Types ────────────────────────────────────────────────────────────────────
type Confidence = "high" | "medium" | "low";
interface PatternBlock {
confidence: Confidence;
description: string;
type: string;
sources: string[];
}
interface ContradictionBlock {
description: string;
conflictingStatements: string[];
}
interface ContentSection {
heading: string | null;
rawBody: string;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const TIMESTAMP_LINE_RE = /^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s*(.*)/s;
const CONFIDENCE_STYLE: Record<Confidence, { bg: string; text: string; border: string }> = {
high: { bg: COLOR.destructiveDim, text: COLOR.destructive, border: COLOR.destructiveBorder },
medium: { bg: COLOR.warningDim, text: COLOR.warning, border: COLOR.warningBorder },
low: { bg: COLOR.successDim, text: COLOR.success, border: COLOR.successBorder },
};
const CONFIDENCE_ORDER: Record<Confidence, number> = { high: 0, medium: 1, low: 2 };
// 10+ alphanumeric/_/- chars in brackets that are NOT a timestamp
const CITATION_RE = /\[([A-Za-z0-9_-]{10,})\]/g;
// ─── Preprocessor ─────────────────────────────────────────────────────────────
function preprocessContent(content: string): string {
return content
.replace(/^ {3}/gm, "")
.replace(/^(- .+)\n(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\])/gm, "$1\n\n$2");
}
// ─── Section splitter ─────────────────────────────────────────────────────────
function splitIntoSections(content: string): ContentSection[] {
const result: ContentSection[] = [];
const parts = content.split(/^(## .+)$/m);
if (parts[0].trim()) {
result.push({ heading: null, rawBody: parts[0] });
}
for (let i = 1; i < parts.length; i += 2) {
result.push({
heading: parts[i].replace(/^## /, "").trim(),
rawBody: parts[i + 1] ?? "",
});
}
return result;
}
// ─── Block parsers ────────────────────────────────────────────────────────────
function parsePatternBlocks(sectionBody: string): PatternBlock[] {
const blocks = sectionBody.split(/\n\n+/);
const result: PatternBlock[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const firstLine = (lines[0] ?? "").trim();
const patternMatch = /\*\*Pattern\*\* \[(high|medium|low)\]: (.+)/i.exec(firstLine);
if (!patternMatch) continue;
const confidence = patternMatch[1].toLowerCase() as Confidence;
const description = patternMatch[2].trim();
let type = "";
const sources: string[] = [];
let inSources = false;
for (const line of lines.slice(1)) {
const t = line.trim();
if (!t) continue;
const typeMatch = /\*\*Type\*\*: (.+)/.exec(t);
if (typeMatch) {
type = typeMatch[1].trim();
continue;
}
if (/\*\*Sources\*\*:/.test(t)) {
inSources = true;
continue;
}
if (inSources && t.startsWith("- ")) {
sources.push(t.slice(2).trim());
}
}
result.push({ confidence, description, type, sources });
}
return result.sort((a, b) => CONFIDENCE_ORDER[a.confidence] - CONFIDENCE_ORDER[b.confidence]);
}
function parseContradictionBlocks(sectionBody: string): ContradictionBlock[] {
const blocks = sectionBody.split(/\n\n+/);
const result: ContradictionBlock[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const firstLine = (lines[0] ?? "").trim();
const descMatch = /\*\*CONTRADICTION\*\*: (.+)/i.exec(firstLine);
if (!descMatch) continue;
const description = descMatch[1].trim();
const conflictingStatements: string[] = [];
let inStatements = false;
for (const line of lines.slice(1)) {
const t = line.trim();
if (!t) continue;
if (/\*\*Conflicting statements?\*\*:/.test(t)) {
inStatements = true;
continue;
}
if (inStatements && t.startsWith("- ")) {
conflictingStatements.push(t.slice(2).trim());
}
}
result.push({ description, conflictingStatements });
}
return result;
}
// ─── Inline citation renderer ─────────────────────────────────────────────────
function renderWithCitations(text: string, workspaceId?: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
CITATION_RE.lastIndex = 0;
let match = CITATION_RE.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const id = match[1];
const label = `${id.slice(0, 8)}`;
const chipStyle = {
background: COLOR.accentDim,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
};
if (workspaceId) {
parts.push(
<Link
key={`${id}-${match.index}`}
to="/workspaces/$workspaceId/sessions/$sessionId"
params={{ workspaceId, sessionId: id } as never}
className="font-mono text-xs px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity"
style={chipStyle}
>
{label}
</Link>,
);
} else {
parts.push(
<span
key={`${id}-${match.index}`}
className="font-mono text-xs px-1.5 py-0.5 rounded"
style={chipStyle}
>
{label}
</span>,
);
}
lastIndex = match.index + match[0].length;
match = CITATION_RE.exec(text);
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
// ─── Section renderers ────────────────────────────────────────────────────────
function PatternCard({ block }: { block: PatternBlock }) {
const cs = CONFIDENCE_STYLE[block.confidence];
return (
<div
className="rounded-lg p-4 mb-3"
style={{ background: COLOR.cardBaseBg, border: `1px solid ${COLOR.cardBaseBorder}` }}
>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span
className="text-xs font-mono px-2 py-0.5 rounded-full uppercase font-semibold tracking-wide"
style={{ background: cs.bg, color: cs.text, border: `1px solid ${cs.border}` }}
>
{block.confidence}
</span>
{block.type && (
<span
className="text-xs font-mono px-2 py-0.5 rounded"
style={{
background: COLOR.accentSubtle,
color: COLOR.accentText,
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{block.type}
</span>
)}
</div>
<p className="text-sm leading-relaxed mb-0" style={{ color: "var(--text-2)" }}>
{block.description}
</p>
{block.sources.length > 0 && (
<div
className="mt-3 pt-3 space-y-1"
style={{ borderTop: `1px solid ${COLOR.cardBaseBorder}` }}
>
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-3)" }}>
Sources
</p>
{block.sources.map((s) => {
const isOverflow = /^\.\.\. and \d+ more$/.test(s);
return (
<div key={s} className="flex items-start gap-1.5">
{!isOverflow && (
<span className="mt-1 shrink-0 text-xs" style={{ color: COLOR.accent }}>
</span>
)}
<span
className={isOverflow ? "text-xs italic pl-3" : "text-xs leading-relaxed"}
style={{
color: isOverflow ? "var(--text-4)" : "var(--text-3)",
}}
>
{s}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
function ContradictionCard({
block,
workspaceId,
}: {
block: ContradictionBlock;
workspaceId?: string;
}) {
return (
<div
className="rounded-lg p-4 mb-3"
style={{
background: COLOR.destructiveDim,
border: `1px solid ${COLOR.destructiveBorder}`,
}}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs font-mono px-2 py-0.5 rounded-full uppercase font-semibold tracking-wide"
style={{
background: "rgba(239,68,68,0.12)",
color: COLOR.destructive,
border: `1px solid ${COLOR.destructiveBorder}`,
}}
>
Contradiction
</span>
</div>
<p className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
{renderWithCitations(block.description, workspaceId)}
</p>
{block.conflictingStatements.length > 0 && (
<div
className="mt-3 pt-3 space-y-2"
style={{ borderTop: `1px solid ${COLOR.destructiveBorder}` }}
>
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-3)" }}>
Conflicting statements
</p>
{block.conflictingStatements.map((s, i) => (
<div
key={s}
className="flex items-start gap-2 rounded px-3 py-2"
style={{
background: i === 0 ? "rgba(239,68,68,0.06)" : "rgba(248,113,113,0.04)",
border: `1px solid ${COLOR.destructiveBorder}`,
}}
>
<span
className="text-xs font-mono shrink-0 mt-0.5"
style={{ color: COLOR.destructiveMuted }}
>
{i === 0 ? "A" : "B"}
</span>
<span className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
{s}
</span>
</div>
))}
</div>
)}
</div>
);
}
// ─── Standard markdown pipeline ───────────────────────────────────────────────
function flattenChildren(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) return children.map(flattenChildren).join("");
if (children && typeof children === "object" && "props" in (children as object)) {
return flattenChildren((children as { props: { children?: React.ReactNode } }).props.children);
}
return "";
}
function Paragraph({ children }: { children?: React.ReactNode }) {
const text = flattenChildren(children);
const lines = text.split("\n").filter(Boolean);
// All lines are timestamps → sorted chip list
if (lines.length > 0 && lines.every((l) => TIMESTAMP_LINE_RE.test(l))) {
const sorted = [...lines].sort((a, b) => {
const ta = DateTime.fromFormat(TIMESTAMP_LINE_RE.exec(a)?.[1] ?? "", "yyyy-MM-dd HH:mm:ss", {
zone: "utc",
});
const tb = DateTime.fromFormat(TIMESTAMP_LINE_RE.exec(b)?.[1] ?? "", "yyyy-MM-dd HH:mm:ss", {
zone: "utc",
});
return tb.toMillis() - ta.toMillis();
});
return (
<div className="space-y-0.5 my-2">
{sorted.map((line) => {
const m = TIMESTAMP_LINE_RE.exec(line);
return (
<div
key={line}
className="flex items-start gap-3 py-1 px-1 rounded-sm"
style={{ borderBottom: "1px solid var(--border)" }}
>
<TimestampChip value={m?.[1] ?? ""} className="mt-0.5" />
<span
className="text-sm leading-relaxed flex-1 min-w-0"
style={{ color: "var(--text-2)" }}
>
{m?.[2]}
</span>
</div>
);
})}
</div>
);
}
// First line is timestamp + trailing label(s) → deductive entry header
const firstMatch = lines.length > 1 ? TIMESTAMP_LINE_RE.exec(lines[0]) : null;
if (firstMatch) {
return (
<div className="mt-3 mb-1 pb-1" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="flex items-start gap-3">
<TimestampChip value={firstMatch[1]} className="mt-0.5 shrink-0" />
<span className="text-sm leading-relaxed" style={{ color: "var(--text-2)" }}>
{firstMatch[2]}
</span>
</div>
{lines.slice(1).map((l) => (
<p key={l} className="text-xs mt-1 font-medium" style={{ color: "var(--text-3)" }}>
{l}
</p>
))}
</div>
);
}
return (
<p className="text-sm leading-relaxed mb-3" style={{ color: "var(--text-2)" }}>
{children}
</p>
);
}
const SECTION_H2_CLASS = "text-sm font-semibold mt-4 mb-3 pb-1 uppercase tracking-wider";
const SECTION_H2_STYLE = { color: "var(--accent-text)", borderBottom: "1px solid var(--border)" };
const COMPONENTS: Components = {
h1: ({ children }) => (
<h1
className="text-base font-semibold mt-4 mb-2 pb-1"
style={{ color: "var(--text-1)", borderBottom: "1px solid var(--border)" }}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-sm font-medium mt-3 mb-1.5" style={{ color: "var(--text-1)" }}>
{children}
</h3>
),
p: Paragraph,
ul: ({ children }) => (
<ul className="text-sm space-y-1 mb-3 pl-4 list-disc" style={{ color: "var(--text-2)" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="text-sm space-y-1 mb-3 pl-4 list-decimal" style={{ color: "var(--text-2)" }}>
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<pre
className="text-xs font-mono rounded-lg p-3 overflow-x-auto my-3"
style={{
background: "var(--bg-3)",
border: "1px solid var(--border)",
color: "var(--text-2)",
}}
>
<code>{children}</code>
</pre>
);
}
return (
<code
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: "var(--bg-3)",
color: "var(--accent-text)",
border: "1px solid var(--border)",
}}
>
{children}
</code>
);
},
blockquote: ({ children }) => (
<blockquote
className="text-sm pl-3 my-3 italic"
style={{ borderLeft: "3px solid var(--accent-border)", color: "var(--text-3)" }}
>
{children}
</blockquote>
),
hr: () => (
<hr style={{ border: "none", borderTop: "1px solid var(--border)" }} className="my-4" />
),
strong: ({ children }) => (
<strong className="font-semibold" style={{ color: "var(--text-1)" }}>
{children}
</strong>
),
};
// ─── Export ───────────────────────────────────────────────────────────────────
interface Props {
content: string;
workspaceId?: string;
}
export function MarkdownRenderer({ content, workspaceId }: Props) {
const sections = splitIntoSections(content);
return (
<div>
{sections.map((section) => {
const sectionKey = `${section.heading ?? ""}-${section.rawBody.slice(0, 30)}`;
if (section.heading === "Inductive Observations") {
const blocks = parsePatternBlocks(section.rawBody);
return (
<div key={sectionKey}>
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
Inductive Observations
</h2>
{blocks.map((b) => (
<PatternCard
key={`${b.confidence}-${b.type}-${b.description.slice(0, 20)}`}
block={b}
/>
))}
</div>
);
}
if (section.heading === "Contradictions") {
const blocks = parseContradictionBlocks(section.rawBody);
return (
<div key={sectionKey}>
<h2 className={SECTION_H2_CLASS} style={SECTION_H2_STYLE}>
Contradictions
</h2>
{blocks.map((b) => (
<ContradictionCard
key={b.description.slice(0, 40)}
block={b}
workspaceId={workspaceId}
/>
))}
</div>
);
}
const sectionContent = section.heading
? `## ${section.heading}\n${section.rawBody}`
: section.rawBody;
return (
<ReactMarkdown key={sectionKey} remarkPlugins={[remarkGfm]} components={COMPONENTS}>
{preprocessContent(sectionContent)}
</ReactMarkdown>
);
})}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Button } from "@/components/ui/button";
import { MonoCaption } from "@/components/ui/typography";
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
variant="surface"
size="sm"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
Previous
</Button>
<MonoCaption className="px-2">
{page} / {totalPages}
</MonoCaption>
<Button
variant="surface"
size="sm"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
Next
</Button>
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { COLOR } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
interface Props {
lines: string[];
}
// ALL_CAPS_WORD: — no lowercase letters in key
const CAPS_RE = /^([A-Z][A-Z0-9_]+):\s*([\s\S]*)/;
// Title Case Word: — starts capital, must contain at least one lowercase
const TITLE_RE = /^([A-Z][a-zA-Z0-9][a-zA-Z0-9 ]*):\s*([\s\S]*)/;
type ParsedLine =
| { kind: "fact"; text: string }
| { kind: "caps"; key: string; value: string }
| { kind: "title"; key: string; value: string };
const PALETTE: Array<{ bg: string; text: string; border: string; dot: string }> = [
{ bg: "rgba(52,211,153,0.08)", text: "#34d399", border: "rgba(52,211,153,0.25)", dot: "#34d399" },
{ bg: "rgba(245,158,11,0.08)", text: "#f59e0b", border: "rgba(245,158,11,0.25)", dot: "#f59e0b" },
{ bg: "rgba(14,165,233,0.08)", text: "#38bdf8", border: "rgba(14,165,233,0.25)", dot: "#38bdf8" },
{ bg: "rgba(236,72,153,0.08)", text: "#f472b6", border: "rgba(236,72,153,0.25)", dot: "#f472b6" },
{ bg: "rgba(168,85,247,0.08)", text: "#c084fc", border: "rgba(168,85,247,0.25)", dot: "#c084fc" },
{ bg: "rgba(239,68,68,0.08)", text: "#f87171", border: "rgba(239,68,68,0.25)", dot: "#f87171" },
{ bg: "rgba(34,197,94,0.08)", text: "#4ade80", border: "rgba(34,197,94,0.25)", dot: "#4ade80" },
{ bg: "rgba(251,146,60,0.08)", text: "#fb923c", border: "rgba(251,146,60,0.25)", dot: "#fb923c" },
];
function hashPalette(word: string): number {
let h = 5381;
for (let i = 0; i < word.length; i++) h = ((h * 33) ^ word.charCodeAt(i)) >>> 0;
return h % PALETTE.length;
}
function toLabel(key: string): string {
const s = key.toLowerCase().replace(/_/g, " ");
return s.charAt(0).toUpperCase() + s.slice(1);
}
function parseLine(line: string): ParsedLine {
const caps = CAPS_RE.exec(line);
if (caps) return { kind: "caps", key: caps[1], value: caps[2].trim() };
const title = TITLE_RE.exec(line);
if (title && /[a-z]/.test(title[1])) {
return { kind: "title", key: title[1], value: title[2].trim() };
}
return { kind: "fact", text: line };
}
interface CapsGroup {
key: string;
items: string[];
}
interface Parsed {
titlePairs: Array<{ key: string; value: string }>;
facts: string[];
capsGroups: CapsGroup[];
}
function parse(lines: string[]): Parsed {
const titlePairs: Array<{ key: string; value: string }> = [];
const facts: string[] = [];
const capsMap = new Map<string, string[]>();
const capsOrder: string[] = [];
for (const line of lines) {
const p = parseLine(line);
if (p.kind === "title") {
titlePairs.push({ key: p.key, value: p.value });
} else if (p.kind === "caps") {
if (!capsMap.has(p.key)) {
capsMap.set(p.key, []);
capsOrder.push(p.key);
}
capsMap.get(p.key)?.push(p.value);
} else {
facts.push(p.text);
}
}
return {
titlePairs,
facts,
capsGroups: capsOrder.map((k) => ({ key: k, items: capsMap.get(k) ?? [] })),
};
}
// ─── Metadata table (Title Case: pairs) ──────────────────────────────────────
function MetadataCard({ pairs }: { pairs: Array<{ key: string; value: string }> }) {
if (pairs.length === 0) return null;
return (
<div className="rounded-lg overflow-hidden" style={{ border: "1px solid var(--border-2)" }}>
<dl className="divide-y" style={{ "--tw-divide-opacity": 1 } as React.CSSProperties}>
{pairs.map(({ key, value }, i) => (
<div
key={key}
className="grid grid-cols-[9rem_1fr] gap-3 px-4 py-2.5 text-sm"
style={{ background: i % 2 === 0 ? "var(--surface)" : "var(--bg-3)" }}
>
<dt className="font-medium break-words" style={{ color: "var(--text-3)" }}>
{key}
</dt>
<dd className="min-w-0 break-words" style={{ color: "var(--text-1)" }}>
{value || <span style={{ color: "var(--text-4)" }}></span>}
</dd>
</div>
))}
</dl>
</div>
);
}
// ─── Collapsible section (ALL_CAPS: groups + Facts) ───────────────────────────
interface SectionStyle {
bg: string;
text: string;
border: string;
}
const FACTS_STYLE: SectionStyle = {
bg: COLOR.accentDim,
text: "#a5b4fc",
border: COLOR.accentBorder,
};
function CollapsibleSection({
label,
count,
style,
children,
}: {
label: string;
count: number;
style: SectionStyle;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-lg overflow-hidden" style={{ border: `1px solid ${style.border}` }}>
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"w-full flex items-center justify-between px-4 py-2.5 text-sm font-medium",
"transition-opacity hover:opacity-80",
)}
style={{ background: style.bg, color: style.text }}
>
<span className="flex items-center gap-2">
{label}
<span
className="text-xs font-mono px-1.5 py-0.5 rounded"
style={{
background: "rgba(0,0,0,0.2)",
color: style.text,
opacity: 0.75,
}}
>
{count}
</span>
</span>
<ChevronDown
className="w-4 h-4 transition-transform duration-200"
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
strokeWidth={2}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</div>
</Collapsible>
);
}
function ItemList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item) => (
<li
key={item}
className="px-4 py-2.5 text-sm leading-relaxed break-words"
style={{
color: "var(--text-2)",
borderTop: "1px solid var(--border)",
}}
>
{item}
</li>
))}
</ul>
);
}
// ─── Export ───────────────────────────────────────────────────────────────────
export function PeerCardViewer({ lines }: Props) {
if (!lines || lines.length === 0) {
return (
<p className="text-sm" style={{ color: "var(--text-4)" }}>
No card set.
</p>
);
}
const { titlePairs, facts, capsGroups } = parse(lines);
return (
<div className="space-y-2">
<MetadataCard pairs={titlePairs} />
{facts.length > 0 && (
<CollapsibleSection label="Facts" count={facts.length} style={FACTS_STYLE}>
<ItemList items={facts} />
</CollapsibleSection>
)}
{capsGroups.map((g) => {
const p = PALETTE[hashPalette(g.key)];
return (
<CollapsibleSection
key={g.key}
label={toLabel(g.key)}
count={g.items.length}
style={{ bg: p.bg, text: p.text, border: p.border }}
>
<ItemList items={g.items} />
</CollapsibleSection>
);
})}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { COLOR } from "@/lib/constants";
import { ArrowDown, ArrowUp } from "lucide-react";
export type SortDir = "asc" | "desc";
export interface SortOption {
value: string;
label: string;
}
interface SortControlProps {
options: SortOption[];
field: string;
dir: SortDir;
onChange: (field: string, dir: SortDir) => void;
}
export function SortControl({ options, field, dir, onChange }: SortControlProps) {
function handleClick(value: string) {
if (value === field) {
// Toggle direction on active option
onChange(value, dir === "desc" ? "asc" : "desc");
} else {
// New field always starts desc (most-recent-first convention)
onChange(value, "desc");
}
}
return (
<div className="flex items-center gap-1.5">
<span className="text-xs" style={{ color: "var(--text-4)" }}>
Sort:
</span>
<div className="flex items-center gap-0.5">
{options.map((opt) => {
const active = opt.value === field;
return (
<button
key={opt.value}
type="button"
onClick={() => handleClick(opt.value)}
className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium transition-all"
style={{
background: active ? COLOR.accentSubtle : "transparent",
color: active ? "var(--accent-text)" : "var(--text-3)",
border: `1px solid ${active ? COLOR.accentBorder : "transparent"}`,
}}
>
{opt.label}
{active &&
(dir === "desc" ? (
<ArrowDown className="w-2.5 h-2.5" strokeWidth={2.5} />
) : (
<ArrowUp className="w-2.5 h-2.5" strokeWidth={2.5} />
))}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { COLOR } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { DateTime } from "luxon";
interface Props {
/** ISO-like string: "2026-04-24 18:18:48" or any Luxon-parseable string */
value: string;
className?: string;
}
function parseTimestamp(value: string): DateTime {
// Honcho stores timestamps as UTC without timezone suffix — parse as UTC, display in local
const dt = DateTime.fromFormat(value, "yyyy-MM-dd HH:mm:ss", { zone: "utc" });
if (dt.isValid) return dt.toLocal();
// Fall back to ISO (may include timezone info)
return DateTime.fromISO(value);
}
function formatDisplay(dt: DateTime): { label: string; isRelative: boolean } {
const now = DateTime.now();
const diffMs = Math.abs(now.diff(dt, "milliseconds").milliseconds);
// Today → relative time ("2 hours ago", "just now")
if (dt.hasSame(now, "day")) return { label: dt.toRelative() ?? "just now", isRelative: true };
// Within the past year → month + day + time
if (diffMs < 365 * 24 * 3600 * 1000)
return { label: dt.toFormat("MMM d HH:mm"), isRelative: false };
// Older → full date
return { label: dt.toFormat("yyyy-MM-dd HH:mm"), isRelative: false };
}
export function TimestampChip({ value, className }: Props) {
const dt = parseTimestamp(value);
if (!dt.isValid) {
return (
<span className={cn("font-mono text-xs", className)} style={{ color: "var(--text-4)" }}>
{value}
</span>
);
}
const { label: display, isRelative } = formatDisplay(dt);
const full = dt.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ");
const relative = dt.toRelative() ?? "";
return (
<time
dateTime={dt.toISO() ?? value}
title={isRelative ? full : `${full} · ${relative}`}
className={cn(
"inline-flex items-center shrink-0 text-xs px-1.5 py-0.5 rounded",
"select-none cursor-default",
isRelative ? "font-sans" : "font-mono",
className,
)}
style={{
background: COLOR.accentSubtle,
color: "var(--accent-text)",
border: `1px solid ${COLOR.accentBorder}`,
}}
>
{display}
</time>
);
}

View File

@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
import { type VariantProps, cva } from "class-variance-authority";
import type { HTMLAttributes } from "react";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-secondary text-muted-foreground",
outline: "border-border text-muted-foreground",
destructive: "border-transparent bg-red-500/15 text-red-400",
success: "border-transparent bg-emerald-500/15 text-emerald-400",
warning: "border-transparent bg-amber-500/15 text-amber-400",
blue: "border-transparent bg-sky-500/15 text-sky-400",
},
},
defaultVariants: { variant: "default" },
},
);
interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { badgeVariants };

View File

@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import { forwardRef } from "react";
const buttonVariants = cva(
[
"inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-1)]",
"disabled:opacity-50 disabled:pointer-events-none",
],
{
variants: {
variant: {
primary: ["text-white", "[background:var(--accent)]", "focus-visible:ring-[var(--accent)]"],
accent: [
"[background:var(--accent-dim)] [color:var(--accent-text)]",
"[border:1px_solid_var(--accent-border)]",
"focus-visible:ring-[var(--accent)]",
],
surface: [
"[background:var(--surface)] [color:var(--text-2)]",
"[border:1px_solid_var(--border)]",
"focus-visible:ring-[var(--border)]",
],
ghost: [
"[color:var(--text-3)]",
"hover:[background:var(--surface)]",
"focus-visible:ring-[var(--border)]",
],
destructive: [
"bg-[rgba(239,68,68,0.08)] text-[#f87171]",
"border border-[rgba(239,68,68,0.2)]",
"focus-visible:ring-[#f87171]",
],
},
size: {
default: "px-4 py-2 text-sm",
sm: "px-3 py-1.5 text-xs",
icon: "p-1.5 text-sm",
},
},
defaultVariants: {
variant: "accent",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
);
},
);
Button.displayName = "Button";
export { buttonVariants };

View File

@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-xl border border-border bg-card text-card-foreground", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1 px-5 py-4", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h3 className={cn("text-sm font-semibold leading-none tracking-tight", className)} {...props} />
);
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-5 pb-5", className)} {...props} />;
}

View File

@@ -0,0 +1,9 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,105 @@
import { cn } from "@/lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { forwardRef } from "react";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 backdrop-blur-sm",
"bg-black/60",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
"rounded-2xl p-6 shadow-2xl",
"[background:var(--bg-2)] [border:1px_solid_var(--border-2)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 rounded-lg p-1 transition-colors",
"[color:var(--text-4)] hover:[color:var(--text-2)]",
"focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--bg-2)]",
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col gap-1.5 pb-4 mb-4 [border-bottom:1px_solid_var(--border)]",
className,
)}
{...props}
/>
);
}
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex justify-end gap-2 pt-4 mt-4", className)} {...props} />;
}
export const DialogTitle = forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
style={{ color: "var(--text-1)" }}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm", className)}
style={{ color: "var(--text-3)" }}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

View File

@@ -0,0 +1,42 @@
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"flex w-full rounded-lg px-3 py-2 text-sm transition-all outline-none",
"[background:var(--surface)] [color:var(--text-1)]",
"[border:1px_solid_var(--border-2)]",
"placeholder:[color:var(--text-4)]",
"focus:[border-color:var(--accent)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
{...props}
/>
));
Input.displayName = "Input";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex w-full rounded-lg px-3 py-2 text-sm transition-all outline-none resize-none",
"[background:var(--surface)] [color:var(--text-1)]",
"[border:1px_solid_var(--border-2)]",
"placeholder:[color:var(--text-4)]",
"focus:[border-color:var(--accent)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
{...props}
/>
),
);
Textarea.displayName = "Textarea";

View File

@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
import * as LabelPrimitive from "@radix-ui/react-label";
import { forwardRef } from "react";
export const Label = forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"block text-xs font-medium leading-none",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
style={{ color: "var(--text-2)" }}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { forwardRef } from "react";
export const Separator = forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 [background:var(--border)]",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;

Some files were not shown because too many files have changed in this diff Show More