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)
15
.github/workflows/ci.yml
vendored
@@ -8,9 +8,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Lint, type-check & test
|
name: Lint, type-check, test & build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -25,14 +24,4 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- run: pnpm lint
|
- run: pnpm turbo lint typecheck test build --filter=@openconcho/web
|
||||||
name: Biome lint
|
|
||||||
|
|
||||||
- run: pnpm exec tsc --noEmit -p tsconfig.app.json
|
|
||||||
name: Type check
|
|
||||||
|
|
||||||
- run: pnpm test
|
|
||||||
name: Tests
|
|
||||||
|
|
||||||
- run: pnpm build
|
|
||||||
name: Production build
|
|
||||||
|
|||||||
36
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: 'OpenConcho ${{ github.ref_name }}'
|
||||||
|
releaseBody: 'See assets below to download and install.'
|
||||||
|
releaseDraft: false
|
||||||
|
prerelease: false
|
||||||
38
.github/workflows/semantic-release.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Semantic Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
GIT_AUTHOR_NAME: github-actions[bot]
|
||||||
|
GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
|
||||||
|
GIT_COMMITTER_NAME: github-actions[bot]
|
||||||
|
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
|
||||||
4
.gitignore
vendored
@@ -36,3 +36,7 @@ dist-ssr
|
|||||||
# TypeScript build info
|
# TypeScript build info
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.tanstack/
|
.tanstack/
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
packages/desktop/src-tauri/target/
|
||||||
|
packages/desktop/src-tauri/gen/
|
||||||
|
|||||||
22
.releaserc.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"branches": ["main"],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
["@semantic-release/changelog", {
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}],
|
||||||
|
["@semantic-release/git", {
|
||||||
|
"assets": ["CHANGELOG.md", "package.json", "packages/*/package.json"],
|
||||||
|
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||||
|
}],
|
||||||
|
["@semantic-release/exec", {
|
||||||
|
"publishCmd": "echo new_release_published=true >> $GITHUB_OUTPUT && echo new_release_version=${nextRelease.version} >> $GITHUB_OUTPUT"
|
||||||
|
}],
|
||||||
|
["@semantic-release/github", {
|
||||||
|
"assets": [],
|
||||||
|
"successComment": false,
|
||||||
|
"failComment": false
|
||||||
|
}]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# honcho-ui
|
# openconcho
|
||||||
|
|
||||||
Frontend UI for self-hosted Honcho instances — browse memories, peers, sessions, conclusions, and chat with memory context.
|
Frontend UI for self-hosted Honcho instances — browse memories, peers, sessions, conclusions, and chat with memory context.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Read `docs/architecture.md` for component overview, data flow, and design decisi
|
|||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **No hardcoded URLs** — all connection config lives in `localStorage` under `honcho-ui:config`
|
- **No hardcoded URLs** — all connection config lives in `localStorage` under `openconcho:config`
|
||||||
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
|
- **TanStack Router flat-route params** — always cast `params` as `as never` at `navigate()` and `<Link>` callsites
|
||||||
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
|
- **`framer-motion` Variants typing** — import `type Variants` and annotate objects; never use `as const` on variant objects
|
||||||
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
|
- **Auth is optional** — token header only sent when non-empty; `checkConnection()` detects if auth is required
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ A clean, fast frontend for browsing and chatting with a self-hosted [Honcho](htt
|
|||||||
### Install & run
|
### Install & run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/offendingcommit/honcho-ui.git
|
git clone https://github.com/offendingcommit/openconcho.git
|
||||||
cd honcho-ui
|
cd openconcho
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
@@ -84,8 +84,8 @@ pnpm generate:api
|
|||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
- Base URL and token are stored in `localStorage` under `honcho-ui:config`
|
- Base URL and token are stored in `localStorage` under `openconcho:config`
|
||||||
- Theme preference is stored in `localStorage` under `honcho-ui:theme`
|
- Theme preference is stored in `localStorage` under `openconcho:theme`
|
||||||
- No telemetry, no analytics, no external requests beyond your configured Honcho instance
|
- No telemetry, no analytics, no external requests beyond your configured Honcho instance
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||||
|
"files": {
|
||||||
|
"ignore": ["src/routeTree.gen.ts", "src/api/schema.d.ts"]
|
||||||
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Architecture: honcho-ui
|
# Architecture: openconcho
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
66
package.json
@@ -1,60 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "honcho-ui",
|
"name": "openconcho",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm --filter @openconcho/desktop dev",
|
||||||
"build": "tsc -b && vite build",
|
"build": "turbo run build",
|
||||||
"preview": "vite preview",
|
"lint": "turbo run lint",
|
||||||
"lint": "biome check src/",
|
"test": "turbo run test",
|
||||||
"lint:fix": "biome check --write src/",
|
"typecheck": "turbo run typecheck"
|
||||||
"format": "biome format --write src/",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"generate:api": "openapi-typescript openapi.json -o src/api/schema.d.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fontsource/dm-mono": "^5.2.7",
|
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
|
||||||
"@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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@tanstack/router-plugin": "^1.120.3",
|
"@semantic-release/changelog": "^6.0.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@semantic-release/commit-analyzer": "^13.0.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@semantic-release/exec": "^7.1.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@semantic-release/git": "^10.0.0",
|
||||||
"@types/luxon": "^3.7.1",
|
"@semantic-release/github": "^10.0.0",
|
||||||
"@types/node": "^25.6.0",
|
"@semantic-release/release-notes-generator": "^14.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"semantic-release": "^24.0.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"turbo": "^2"
|
||||||
"@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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
packages/desktop/package.json
Normal 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
18
packages/desktop/src-tauri/Cargo.toml
Normal 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"
|
||||||
3
packages/desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
17
packages/desktop/src-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
packages/desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 300 B |
BIN
packages/desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
packages/desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
packages/desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 161 B |
BIN
packages/desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
packages/desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 329 B |
BIN
packages/desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 348 B |
BIN
packages/desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
packages/desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 104 B |
BIN
packages/desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 853 B |
BIN
packages/desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 122 B |
BIN
packages/desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 172 B |
BIN
packages/desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
packages/desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 131 B |
@@ -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>
|
||||||
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 375 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 277 B |
|
After Width: | Height: | Size: 284 B |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 428 B |
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 566 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
packages/desktop/src-tauri/icons/icon.icns
Normal file
BIN
packages/desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 91 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 147 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 194 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 186 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 186 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 264 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 264 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 420 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 353 B |
BIN
packages/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 389 B |
8
packages/desktop/src-tauri/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
6
packages/desktop/src-tauri/src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
35
packages/desktop/src-tauri/tauri.conf.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -1,6 +1,7 @@
|
|||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
import { httpFetch } from "@/lib/http";
|
||||||
import createClient from "openapi-fetch";
|
import createClient from "openapi-fetch";
|
||||||
import type { paths } from "./schema.d.ts";
|
import type { paths } from "./schema.d.ts";
|
||||||
import { loadConfig } from "@/lib/config";
|
|
||||||
|
|
||||||
export function createHonchoClient() {
|
export function createHonchoClient() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -11,10 +12,10 @@ export function createHonchoClient() {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
if (token) {
|
if (token) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createClient<paths>({ baseUrl, headers });
|
return createClient<paths>({ baseUrl, headers, fetch: httpFetch });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const client = {
|
export const client = {
|
||||||
@@ -6,7 +6,8 @@ export const QK = {
|
|||||||
|
|
||||||
peers: (wsId: string, page: number, size: number) => ["peers", wsId, page, size] 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,
|
peer: (wsId: string, pId: string) => ["peer", wsId, pId] as const,
|
||||||
peerRepresentation: (wsId: string, pId: string) => ["peer-representation", 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,
|
peerCard: (wsId: string, pId: string) => ["peer-card", wsId, pId] as const,
|
||||||
peerContext: (wsId: string, pId: string) => ["peer-context", wsId, pId] as const,
|
peerContext: (wsId: string, pId: string) => ["peer-context", wsId, pId] as const,
|
||||||
peerSessions: (wsId: string, pId: string, page: number, size: number) =>
|
peerSessions: (wsId: string, pId: string, page: number, size: number) =>
|
||||||
@@ -19,11 +20,15 @@ export const QK = {
|
|||||||
sessionSummaries: (wsId: string, sId: string) => ["session-summaries", wsId, sId] as const,
|
sessionSummaries: (wsId: string, sId: string) => ["session-summaries", wsId, sId] as const,
|
||||||
sessionContext: (wsId: string, sId: string) => ["session-context", 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,
|
sessionPeers: (wsId: string, sId: string) => ["session-peers", wsId, sId] as const,
|
||||||
peerConfig: (wsId: string, sId: string, pId: string) =>
|
peerConfig: (wsId: string, sId: string, pId: string) => ["peer-config", wsId, sId, pId] as const,
|
||||||
["peer-config", wsId, sId, pId] as const,
|
|
||||||
|
|
||||||
conclusions: (wsId: string, filters: Record<string, unknown>, page: number, size: number) =>
|
conclusions: (
|
||||||
["conclusions", wsId, filters, page, size] as const,
|
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>) =>
|
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
|
||||||
["conclusions-query", wsId, q, filters] as const,
|
["conclusions-query", wsId, q, filters] as const,
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { client } from "./client";
|
import { client } from "./client";
|
||||||
import { QK } from "./keys";
|
import { QK } from "./keys";
|
||||||
|
|
||||||
@@ -18,8 +18,7 @@ export function useWorkspaces(page = 1, pageSize = 20) {
|
|||||||
params: { query: { page, page_size: pageSize } },
|
params: { query: { page, page_size: pageSize } },
|
||||||
body: {},
|
body: {},
|
||||||
});
|
});
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -31,8 +30,7 @@ export function useWorkspace(workspaceId: string) {
|
|||||||
const { data, error } = await client.current.POST("/v3/workspaces", {
|
const { data, error } = await client.current.POST("/v3/workspaces", {
|
||||||
body: { id: workspaceId, metadata: {} },
|
body: { id: workspaceId, metadata: {} },
|
||||||
});
|
});
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -42,12 +40,11 @@ export function useUpdateWorkspace(workspaceId: string) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
|
mutationFn: async (body: { metadata?: Record<string, unknown> }) => {
|
||||||
const { data, error } = await client.current.PUT(
|
const { data, error } = await client.current.PUT("/v3/workspaces/{workspace_id}", {
|
||||||
"/v3/workspaces/{workspace_id}",
|
params: { path: { workspace_id: workspaceId } },
|
||||||
{ params: { path: { workspace_id: workspaceId } }, body },
|
body,
|
||||||
);
|
});
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["workspace", workspaceId] });
|
qc.invalidateQueries({ queryKey: ["workspace", workspaceId] });
|
||||||
@@ -79,10 +76,10 @@ export function useScheduleDream(workspaceId: string) {
|
|||||||
dream_type: "omni";
|
dream_type: "omni";
|
||||||
session_id?: string | null;
|
session_id?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const { error } = await client.current.POST(
|
const { error } = await client.current.POST("/v3/workspaces/{workspace_id}/schedule_dream", {
|
||||||
"/v3/workspaces/{workspace_id}/schedule_dream",
|
params: { path: { workspace_id: workspaceId } },
|
||||||
{ params: { path: { workspace_id: workspaceId } }, body },
|
body,
|
||||||
);
|
});
|
||||||
if (error) err(error);
|
if (error) err(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -96,8 +93,7 @@ export function useQueueStatus(workspaceId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/queue/status",
|
"/v3/workspaces/{workspace_id}/queue/status",
|
||||||
{ params: { path: { workspace_id: workspaceId } } },
|
{ params: { path: { workspace_id: workspaceId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
@@ -108,15 +104,11 @@ export function useSearchWorkspace(workspaceId: string, query: string, enabled =
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QK.workspaceSearch(workspaceId, query),
|
queryKey: QK.workspaceSearch(workspaceId, query),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/search", {
|
||||||
"/v3/workspaces/{workspace_id}/search",
|
|
||||||
{
|
|
||||||
params: { path: { workspace_id: workspaceId } },
|
params: { path: { workspace_id: workspaceId } },
|
||||||
body: { query, limit: 20 },
|
body: { query, limit: 20 },
|
||||||
},
|
});
|
||||||
);
|
return data ?? err(error);
|
||||||
if (error) err(error);
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||||
});
|
});
|
||||||
@@ -135,8 +127,7 @@ export function usePeers(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -146,15 +137,11 @@ export function usePeer(workspaceId: string, peerId: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QK.peer(workspaceId, peerId),
|
queryKey: QK.peer(workspaceId, peerId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/peers", {
|
||||||
"/v3/workspaces/{workspace_id}/peers",
|
|
||||||
{
|
|
||||||
params: { path: { workspace_id: workspaceId } },
|
params: { path: { workspace_id: workspaceId } },
|
||||||
body: { id: peerId, metadata: {} },
|
body: { id: peerId, metadata: {} },
|
||||||
},
|
});
|
||||||
);
|
return data ?? err(error);
|
||||||
if (error) err(error);
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -168,8 +155,7 @@ export function useUpdatePeer(workspaceId: string, peerId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}",
|
||||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } }, body },
|
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } }, body },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["peer", workspaceId, peerId] });
|
qc.invalidateQueries({ queryKey: ["peer", workspaceId, peerId] });
|
||||||
@@ -178,19 +164,18 @@ export function useUpdatePeer(workspaceId: string, peerId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePeerRepresentation(workspaceId: string, peerId: string) {
|
export function usePeerRepresentation(workspaceId: string, peerId: string, target?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QK.peerRepresentation(workspaceId, peerId),
|
queryKey: QK.peerRepresentation(workspaceId, peerId, target),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/representation",
|
||||||
{
|
{
|
||||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||||
body: { max_conclusions: 20 },
|
body: { max_conclusions: 20, ...(target ? { target } : {}) },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -204,8 +189,7 @@ export function usePeerCard(workspaceId: string, peerId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/card",
|
||||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -222,8 +206,7 @@ export function useSetPeerCard(workspaceId: string, peerId: string) {
|
|||||||
body: { peer_card: peerCard },
|
body: { peer_card: peerCard },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.peerCard(workspaceId, peerId) });
|
qc.invalidateQueries({ queryKey: QK.peerCard(workspaceId, peerId) });
|
||||||
@@ -239,8 +222,7 @@ export function usePeerContext(workspaceId: string, peerId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/peers/{peer_id}/context",
|
"/v3/workspaces/{workspace_id}/peers/{peer_id}/context",
|
||||||
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
{ params: { path: { workspace_id: workspaceId, peer_id: peerId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -260,8 +242,7 @@ export function usePeerSessions(workspaceId: string, peerId: string, page = 1, p
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -277,8 +258,7 @@ export function useSearchPeer(workspaceId: string, peerId: string) {
|
|||||||
body: { query, limit: 20 },
|
body: { query, limit: 20 },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,8 +274,7 @@ export function useChat(workspaceId: string, peerId: string) {
|
|||||||
body: { query: message, stream: false, reasoning_level: "low" },
|
body: { query: message, stream: false, reasoning_level: "low" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
|
qc.invalidateQueries({ queryKey: ["peer-context", workspaceId, peerId] });
|
||||||
@@ -319,8 +298,7 @@ export function useSessions(workspaceId: string, page = 1, pageSize = 20) {
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -334,8 +312,7 @@ export function useUpdateSession(workspaceId: string, sessionId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}",
|
||||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } }, body },
|
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } }, body },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
||||||
@@ -368,8 +345,7 @@ export function useCloneSession(workspaceId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/clone",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/clone",
|
||||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
qc.invalidateQueries({ queryKey: ["sessions", workspaceId] });
|
||||||
@@ -387,8 +363,7 @@ export function useSearchSession(workspaceId: string, sessionId: string) {
|
|||||||
body: { query, limit: 20 },
|
body: { query, limit: 20 },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -412,8 +387,7 @@ export function useSessionMessages(
|
|||||||
body: {},
|
body: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -432,8 +406,7 @@ export function useCreateMessages(workspaceId: string, sessionId: string) {
|
|||||||
body: { messages },
|
body: { messages },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
||||||
@@ -464,8 +437,7 @@ export function useUpdateMessage(workspaceId: string, sessionId: string) {
|
|||||||
body,
|
body,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
qc.invalidateQueries({ queryKey: ["session-messages", workspaceId, sessionId] });
|
||||||
@@ -483,8 +455,7 @@ export function useSessionPeers(workspaceId: string, sessionId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/peers",
|
||||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -506,8 +477,7 @@ export function useAddPeersToSession(workspaceId: string, sessionId: string) {
|
|||||||
body: peers,
|
body: peers,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
||||||
@@ -527,8 +497,7 @@ export function useSetSessionPeers(workspaceId: string, sessionId: string) {
|
|||||||
body: peers,
|
body: peers,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
qc.invalidateQueries({ queryKey: ["session-peers", workspaceId, sessionId] });
|
||||||
@@ -569,8 +538,7 @@ export function usePeerConfig(workspaceId: string, sessionId: string, peerId: st
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId) && Boolean(peerId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId) && Boolean(peerId),
|
||||||
});
|
});
|
||||||
@@ -589,8 +557,7 @@ export function useSetPeerConfig(workspaceId: string, sessionId: string, peerId:
|
|||||||
body: config,
|
body: config,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.peerConfig(workspaceId, sessionId, peerId) });
|
qc.invalidateQueries({ queryKey: QK.peerConfig(workspaceId, sessionId, peerId) });
|
||||||
@@ -608,8 +575,7 @@ export function useSessionSummaries(workspaceId: string, sessionId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/summaries",
|
||||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -623,8 +589,7 @@ export function useSessionContext(workspaceId: string, sessionId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/sessions/{session_id}/context",
|
"/v3/workspaces/{workspace_id}/sessions/{session_id}/context",
|
||||||
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
{ params: { path: { workspace_id: workspaceId, session_id: sessionId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
enabled: Boolean(workspaceId) && Boolean(sessionId),
|
||||||
});
|
});
|
||||||
@@ -637,22 +602,22 @@ export function useConclusions(
|
|||||||
filters: Record<string, unknown> = {},
|
filters: Record<string, unknown> = {},
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
|
reverse = false,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QK.conclusions(workspaceId, filters, page, pageSize),
|
queryKey: QK.conclusions(workspaceId, filters, page, pageSize, reverse),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST(
|
||||||
"/v3/workspaces/{workspace_id}/conclusions/list",
|
"/v3/workspaces/{workspace_id}/conclusions/list",
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
path: { workspace_id: workspaceId },
|
path: { workspace_id: workspaceId },
|
||||||
query: { page, page_size: pageSize },
|
query: { page, page_size: pageSize, reverse },
|
||||||
},
|
},
|
||||||
body: filters,
|
body: filters,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -674,8 +639,7 @@ export function useQueryConclusions(
|
|||||||
body: { query, top_k: 10, ...filters },
|
body: { query, top_k: 10, ...filters },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
enabled: enabled && Boolean(workspaceId) && Boolean(query),
|
||||||
});
|
});
|
||||||
@@ -697,8 +661,7 @@ export function useCreateConclusion(workspaceId: string) {
|
|||||||
body: { conclusions: [conclusion] },
|
body: { conclusions: [conclusion] },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
|
qc.invalidateQueries({ queryKey: ["conclusions", workspaceId] });
|
||||||
@@ -733,12 +696,10 @@ export function useWebhooks(workspaceId: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QK.webhooks(workspaceId),
|
queryKey: QK.webhooks(workspaceId),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await client.current.GET(
|
const { data, error } = await client.current.GET("/v3/workspaces/{workspace_id}/webhooks", {
|
||||||
"/v3/workspaces/{workspace_id}/webhooks",
|
params: { path: { workspace_id: workspaceId } },
|
||||||
{ params: { path: { workspace_id: workspaceId } } },
|
});
|
||||||
);
|
return data ?? err(error);
|
||||||
if (error) err(error);
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: Boolean(workspaceId),
|
enabled: Boolean(workspaceId),
|
||||||
});
|
});
|
||||||
@@ -748,15 +709,11 @@ export function useCreateWebhook(workspaceId: string) {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (url: string) => {
|
mutationFn: async (url: string) => {
|
||||||
const { data, error } = await client.current.POST(
|
const { data, error } = await client.current.POST("/v3/workspaces/{workspace_id}/webhooks", {
|
||||||
"/v3/workspaces/{workspace_id}/webhooks",
|
|
||||||
{
|
|
||||||
params: { path: { workspace_id: workspaceId } },
|
params: { path: { workspace_id: workspaceId } },
|
||||||
body: { url },
|
body: { url },
|
||||||
},
|
});
|
||||||
);
|
return data ?? err(error);
|
||||||
if (error) err(error);
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
|
qc.invalidateQueries({ queryKey: QK.webhooks(workspaceId) });
|
||||||
@@ -791,8 +748,7 @@ export function useTestWebhook(workspaceId: string) {
|
|||||||
"/v3/workspaces/{workspace_id}/webhooks/test",
|
"/v3/workspaces/{workspace_id}/webhooks/test",
|
||||||
{ params: { path: { workspace_id: workspaceId } } },
|
{ params: { path: { workspace_id: workspaceId } } },
|
||||||
);
|
);
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -803,8 +759,7 @@ export function useCreateKey() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { data, error } = await client.current.POST("/v3/keys", {});
|
const { data, error } = await client.current.POST("/v3/keys", {});
|
||||||
if (error) err(error);
|
return data ?? err(error);
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
3324
packages/web/src/api/schema.d.ts
vendored
Normal file
@@ -1,11 +1,15 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Send, Brain } from "lucide-react";
|
|
||||||
import { useChat } from "@/api/queries";
|
import { useChat } from "@/api/queries";
|
||||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
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 {
|
interface Message {
|
||||||
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
@@ -21,7 +25,9 @@ export function ChatPage() {
|
|||||||
const chatMutation = useChat(workspaceId, peerId);
|
const chatMutation = useChat(workspaceId, peerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
@@ -29,21 +35,22 @@ export function ChatPage() {
|
|||||||
if (!trimmed || chatMutation.isPending) return;
|
if (!trimmed || chatMutation.isPending) return;
|
||||||
|
|
||||||
setInput("");
|
setInput("");
|
||||||
setMessages((prev) => [...prev, { role: "user", content: trimmed }]);
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: "user", content: trimmed }]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatMutation.mutateAsync(trimmed);
|
const result = await chatMutation.mutateAsync(trimmed);
|
||||||
const responseText =
|
const responseText =
|
||||||
typeof result === "string"
|
(result as { content?: string | null }).content ??
|
||||||
? result
|
(typeof result === "string" ? result : JSON.stringify(result));
|
||||||
: typeof (result as { response?: unknown })?.response === "string"
|
setMessages((prev) => [
|
||||||
? (result as { response: string }).response
|
...prev,
|
||||||
: JSON.stringify(result);
|
{ id: crypto.randomUUID(), role: "assistant", content: responseText },
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: responseText }]);
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||||
},
|
},
|
||||||
@@ -78,13 +85,12 @@ export function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Brain className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Brain className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<h1 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
<SectionHeading as="h1" className="mb-0">
|
||||||
Memory-augmented chat
|
Memory-augmented chat
|
||||||
</h1>
|
</SectionHeading>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-3)" }}>
|
||||||
Honcho responds using accumulated context for{" "}
|
Honcho responds using accumulated context for <span className="font-mono">{peerId}</span>
|
||||||
<span className="font-mono">{peerId}</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +106,10 @@ export function ChatPage() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div
|
<div
|
||||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
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)" }}
|
style={{
|
||||||
|
background: "var(--accent-dim)",
|
||||||
|
border: "1px solid var(--accent-border)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Brain className="w-6 h-6" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Brain className="w-6 h-6" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
@@ -114,9 +123,9 @@ export function ChatPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
key={msg.id}
|
||||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
@@ -167,30 +176,23 @@ export function ChatPage() {
|
|||||||
style={{ borderTop: "1px solid var(--border)", background: "var(--bg-2)" }}
|
style={{ borderTop: "1px solid var(--border)", background: "var(--bg-2)" }}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3 max-w-3xl mx-auto">
|
<div className="flex gap-3 max-w-3xl mx-auto">
|
||||||
<textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Message this peer... (Enter to send, Shift+Enter for newline)"
|
placeholder="Message this peer... (Enter to send, Shift+Enter for newline)"
|
||||||
rows={2}
|
rows={2}
|
||||||
className="flex-1 px-4 py-3 text-sm rounded-xl resize-none outline-none transition-all"
|
className="flex-1 resize-none"
|
||||||
style={{
|
|
||||||
background: "var(--surface)",
|
|
||||||
border: "1px solid var(--border-2)",
|
|
||||||
color: "var(--text-1)",
|
|
||||||
}}
|
|
||||||
onFocus={(e) => { e.target.style.borderColor = "var(--accent)"; }}
|
|
||||||
onBlur={(e) => { e.target.style.borderColor = "var(--border-2)"; }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="primary"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() || chatMutation.isPending}
|
disabled={!input.trim() || chatMutation.isPending}
|
||||||
className="px-4 rounded-xl self-end mb-0.5 py-3 text-sm font-medium transition-all flex items-center gap-2 disabled:opacity-40"
|
className="self-end mb-0.5"
|
||||||
style={{ background: "var(--accent)", color: "#fff" }}
|
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" strokeWidth={1.5} />
|
<Send className="w-4 h-4" strokeWidth={1.5} />
|
||||||
<span className="hidden sm:block">Send</span>
|
<span className="hidden sm:block">Send</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -11,15 +11,17 @@ import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
|||||||
import { FormModal } from "@/components/shared/FormModal";
|
import { FormModal } from "@/components/shared/FormModal";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input, Textarea } from "@/components/ui/input";
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PageTitle, Body, Muted, Caption, MonoCaption } from "@/components/ui/typography";
|
import { Body, Caption, MonoCaption, Muted, PageTitle } from "@/components/ui/typography";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ArrowLeft, Clock, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
import { ArrowLeft, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
type Conclusion = components["schemas"]["Conclusion"];
|
type Conclusion = components["schemas"]["Conclusion"];
|
||||||
@@ -31,6 +33,12 @@ const createSchema = z.object({
|
|||||||
session_id: z.string().optional(),
|
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 = {
|
const itemVariants = {
|
||||||
hidden: { opacity: 0, y: 8 },
|
hidden: { opacity: 0, y: 8 },
|
||||||
show: (i: number) => ({
|
show: (i: number) => ({
|
||||||
@@ -43,12 +51,16 @@ const itemVariants = {
|
|||||||
export function ConclusionBrowser() {
|
export function ConclusionBrowser() {
|
||||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortField, setSortField] = useState("created_at");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeSearch, setActiveSearch] = useState("");
|
const [activeSearch, setActiveSearch] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error } = useConclusions(workspaceId, {}, page);
|
// 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(
|
const { data: searchResults, isLoading: searchLoading } = useQueryConclusions(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
activeSearch,
|
activeSearch,
|
||||||
@@ -62,11 +74,28 @@ export function ConclusionBrowser() {
|
|||||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
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
|
const displayedConclusions: Conclusion[] = activeSearch
|
||||||
? Array.isArray(searchResults)
|
? Array.isArray(searchResults)
|
||||||
? searchResults
|
? searchResults
|
||||||
: []
|
: []
|
||||||
: conclusions;
|
: sortedConclusions;
|
||||||
|
|
||||||
|
function handleSort(field: string, dir: SortDir) {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir(dir);
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
function handleSearch(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -75,7 +104,7 @@ export function ConclusionBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
@@ -91,26 +120,31 @@ export function ConclusionBrowser() {
|
|||||||
<PageTitle>Conclusions</PageTitle>
|
<PageTitle>Conclusions</PageTitle>
|
||||||
{total > 0 && !activeSearch && (
|
{total > 0 && !activeSearch && (
|
||||||
<span
|
<span
|
||||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--accent-dim)",
|
background: COLOR.accentSubtle,
|
||||||
color: "var(--accent-text)",
|
color: COLOR.accentText,
|
||||||
border: "1px solid var(--accent-border)",
|
border: `1px solid ${COLOR.accentBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{total}
|
{total}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button
|
<div className="ml-auto flex items-center gap-2">
|
||||||
variant="accent"
|
{!activeSearch && (
|
||||||
size="sm"
|
<SortControl
|
||||||
onClick={() => setCreateOpen(true)}
|
options={SORT_OPTIONS}
|
||||||
className="ml-auto"
|
field={sortField}
|
||||||
>
|
dir={sortDir}
|
||||||
|
onChange={handleSort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button variant="accent" size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
New
|
New
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Muted className="mt-0.5">Distilled memory observations about peers</Muted>
|
<Muted className="mt-0.5">Distilled memory observations about peers</Muted>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -228,7 +262,7 @@ export function ConclusionBrowser() {
|
|||||||
<Link
|
<Link
|
||||||
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
to={"/workspaces/$workspaceId/sessions/$sessionId" as never}
|
||||||
params={{ workspaceId, sessionId: c.session_id } as never}
|
params={{ workspaceId, sessionId: c.session_id } as never}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
className="flex items-center gap-1 text-xs font-mono hover:underline"
|
className="flex items-center gap-1 text-xs font-mono hover:underline"
|
||||||
style={{ color: "var(--accent-text)" }}
|
style={{ color: "var(--accent-text)" }}
|
||||||
>
|
>
|
||||||
@@ -236,13 +270,10 @@ export function ConclusionBrowser() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{c.created_at && (
|
{c.created_at && (
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<div className="ml-auto">
|
||||||
<Clock
|
<TimestampChip
|
||||||
className="w-3 h-3"
|
value={c.created_at.replace("T", " ").replace(/\.\d+Z?$/, "")}
|
||||||
style={{ color: "var(--text-4)" }}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
/>
|
||||||
<MonoCaption>{new Date(c.created_at).toLocaleString()}</MonoCaption>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +369,7 @@ function CreateConclusionModal({
|
|||||||
{field === "observer_id" ? "Observer peer ID" : "Observed peer ID"}{" "}
|
{field === "observer_id" ? "Observer peer ID" : "Observed peer ID"}{" "}
|
||||||
<span style={{ color: COLOR.destructive }}>*</span>
|
<span style={{ color: COLOR.destructive }}>*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input value={fields[field]} onChange={set(field)} placeholder="peer_id" />
|
||||||
value={fields[field]}
|
|
||||||
onChange={set(field)}
|
|
||||||
placeholder="peer_id"
|
|
||||||
/>
|
|
||||||
{validationErrors[field] && (
|
{validationErrors[field] && (
|
||||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||||
{validationErrors[field]}
|
{validationErrors[field]}
|
||||||
@@ -371,11 +398,7 @@ function CreateConclusionModal({
|
|||||||
<Label className="mb-1">
|
<Label className="mb-1">
|
||||||
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
|
Session ID <span style={{ color: "var(--text-4)" }}>(optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input value={fields.session_id} onChange={set("session_id")} placeholder="session_id" />
|
||||||
value={fields.session_id}
|
|
||||||
onChange={set("session_id")}
|
|
||||||
placeholder="session_id"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs" style={{ color: COLOR.destructive }}>
|
<p className="text-xs" style={{ color: COLOR.destructive }}>
|
||||||
@@ -383,20 +406,10 @@ function CreateConclusionModal({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button type="button" variant="surface" size="sm" onClick={onClose}>
|
||||||
type="button"
|
|
||||||
variant="surface"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" variant="accent" size="sm" disabled={loading}>
|
||||||
type="submit"
|
|
||||||
variant="accent"
|
|
||||||
size="sm"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Creating..." : "Create"}
|
{loading ? "Creating..." : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,6 +2,7 @@ import { useQueueStatus, useWorkspaces } from "@/api/queries";
|
|||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
|
import { Body, Muted, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
import { formatCount } from "@/lib/utils";
|
import { formatCount } from "@/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
@@ -103,7 +104,6 @@ function WorkspaceQueueRow({ workspaceId }: { workspaceId: string }) {
|
|||||||
|
|
||||||
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
function GlobalQueueBanner({ workspaces }: { workspaces: Array<{ id: string }> }) {
|
||||||
const statuses = workspaces.map((ws) => {
|
const statuses = workspaces.map((ws) => {
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: intentional map over stable list
|
|
||||||
const { data } = useQueueStatus(ws.id);
|
const { data } = useQueueStatus(ws.id);
|
||||||
return data as QueueStatus | undefined;
|
return data as QueueStatus | undefined;
|
||||||
});
|
});
|
||||||
@@ -150,30 +150,20 @@ export function Dashboard() {
|
|||||||
const [page] = useState(1);
|
const [page] = useState(1);
|
||||||
const { data, isLoading, error } = useWorkspaces(page, 50);
|
const { data, isLoading, error } = useWorkspaces(page, 50);
|
||||||
|
|
||||||
const workspaces = (
|
const workspaces =
|
||||||
data as { items?: Array<{ id: string; created_at?: string }> } | undefined
|
(data as { items?: Array<{ id: string; created_at?: string }> } | undefined)?.items ?? [];
|
||||||
)?.items ?? [];
|
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container page-container--xl">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
initial={{ opacity: 0, y: -8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<LayoutDashboard
|
<LayoutDashboard
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<h1
|
<PageTitle>Dashboard</PageTitle>
|
||||||
className="text-xl font-semibold tracking-tight"
|
|
||||||
style={{ color: "var(--text-1)" }}
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</h1>
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
@@ -187,9 +177,7 @@ export function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-2)" }}>
|
<Body className="leading-none">Overview of your Honcho instance</Body>
|
||||||
Overview of your Honcho instance
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
@@ -217,14 +205,8 @@ export function Dashboard() {
|
|||||||
className="flex items-center gap-2 px-4 py-3"
|
className="flex items-center gap-2 px-4 py-3"
|
||||||
style={{ borderBottom: "1px solid var(--border)" }}
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
<Activity
|
<Activity className="w-4 h-4" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
className="w-4 h-4"
|
<SectionHeading className="mb-0">Queue Status</SectionHeading>
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
<h2 className="text-sm font-medium" style={{ color: "var(--text-1)" }}>
|
|
||||||
Queue Status
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
<span className="text-xs ml-1" style={{ color: "var(--text-4)" }}>
|
||||||
all workspaces · updates every 10s
|
all workspaces · updates every 10s
|
||||||
</span>
|
</span>
|
||||||
@@ -276,9 +258,7 @@ export function Dashboard() {
|
|||||||
style={{ color: "var(--text-4)" }}
|
style={{ color: "var(--text-4)" }}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm" style={{ color: "var(--text-3)" }}>
|
<Muted>No workspaces found.</Muted>
|
||||||
No workspaces found.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
import { loadConfig } from "@/lib/config";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
import { Link, useMatchRoute } from "@tanstack/react-router";
|
import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { LayoutDashboard, Boxes, Settings, Brain, ChevronRight, Sun, Moon } from "lucide-react";
|
import { Boxes, Brain, ChevronRight, LayoutDashboard, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { loadConfig } from "@/lib/config";
|
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||||
@@ -35,13 +36,16 @@ export function Sidebar() {
|
|||||||
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
background: "linear-gradient(135deg, #4f46e5, #7c3aed)",
|
||||||
boxShadow: "0 0 16px rgba(99,102,241,0.4)",
|
boxShadow: `0 0 16px ${COLOR.accentGlow}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Brain className="w-4 h-4 text-white" strokeWidth={2} />
|
<Brain className="w-4 h-4 text-white" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<span className="font-semibold text-sm tracking-tight" style={{ color: "var(--text-1)" }}>
|
<span
|
||||||
|
className="font-semibold text-sm tracking-tight"
|
||||||
|
style={{ color: "var(--text-1)" }}
|
||||||
|
>
|
||||||
Honcho UI
|
Honcho UI
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,10 +89,7 @@ export function Sidebar() {
|
|||||||
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Icon
|
<Icon className="w-4 h-4 shrink-0 relative z-10" strokeWidth={isActive ? 2 : 1.5} />
|
||||||
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>
|
<span className="relative z-10 font-medium hidden sm:block">{item.label}</span>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
@@ -110,6 +111,7 @@ export function Sidebar() {
|
|||||||
API v3
|
API v3
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors mx-auto sm:mx-0"
|
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors mx-auto sm:mx-0"
|
||||||
style={{
|
style={{
|
||||||
423
packages/web/src/components/peers/PeerDetail.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
packages/web/src/components/peers/PeerList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,14 @@ import { PageLoader } from "@/components/shared/LoadingSpinner";
|
|||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { PageTitle, SectionHeading, Body, Muted, Caption, MonoCaption } from "@/components/ui/typography";
|
import {
|
||||||
|
Body,
|
||||||
|
Caption,
|
||||||
|
MonoCaption,
|
||||||
|
Muted,
|
||||||
|
PageTitle,
|
||||||
|
SectionHeading,
|
||||||
|
} from "@/components/ui/typography";
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
||||||
@@ -64,9 +71,8 @@ export function SessionDetail() {
|
|||||||
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
const messages: Message[] = (msgData as { items?: Message[] } | undefined)?.items ?? [];
|
||||||
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
const totalPages = (msgData as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
|
|
||||||
const sessionPeerItems = (
|
const sessionPeerItems =
|
||||||
sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined
|
(sessionPeers as { items?: Array<{ id?: string; peer_id?: string }> } | undefined)?.items ?? [];
|
||||||
)?.items ?? [];
|
|
||||||
|
|
||||||
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
|
const memberPeerIds = new Set(sessionPeerItems.map((p) => p.id ?? p.peer_id ?? ""));
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ export function SessionDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container page-container--wide">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<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)" }}>
|
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
||||||
<Link
|
<Link
|
||||||
@@ -127,9 +133,7 @@ export function SessionDetail() {
|
|||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<PageTitle className="font-mono break-all">
|
<PageTitle className="font-mono break-all">{sessionId}</PageTitle>
|
||||||
{sessionId}
|
|
||||||
</PageTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -185,11 +189,7 @@ export function SessionDetail() {
|
|||||||
placeholder="Search within this session…"
|
placeholder="Search within this session…"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button type="submit" variant="accent" disabled={searchSession.isPending}>
|
||||||
type="submit"
|
|
||||||
variant="accent"
|
|
||||||
disabled={searchSession.isPending}
|
|
||||||
>
|
|
||||||
{searchSession.isPending ? "…" : "Search"}
|
{searchSession.isPending ? "…" : "Search"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -227,6 +227,7 @@ export function SessionDetail() {
|
|||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
type="button"
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className="relative flex-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-all"
|
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)" }}
|
style={{ color: tab === t.id ? "var(--text-1)" : "var(--text-3)" }}
|
||||||
@@ -270,9 +271,7 @@ export function SessionDetail() {
|
|||||||
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
<Badge variant={msg.peer_id ? "blue" : "default"}>
|
||||||
{msg.peer_id ?? "system"}
|
{msg.peer_id ?? "system"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{msg.token_count != null && (
|
{msg.token_count != null && <Caption>{msg.token_count} tokens</Caption>}
|
||||||
<Caption>{msg.token_count} tokens</Caption>
|
|
||||||
)}
|
|
||||||
{msg.created_at && (
|
{msg.created_at && (
|
||||||
<Caption>{new Date(msg.created_at).toLocaleString()}</Caption>
|
<Caption>{new Date(msg.created_at).toLocaleString()}</Caption>
|
||||||
)}
|
)}
|
||||||
@@ -394,6 +393,7 @@ function SessionPeersTab({
|
|||||||
{available.map((p) => (
|
{available.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
type="button"
|
||||||
onClick={() => onAdd(p.id)}
|
onClick={() => onAdd(p.id)}
|
||||||
disabled={adding}
|
disabled={adding}
|
||||||
className="w-full text-left py-1.5 px-3 rounded-lg text-xs font-mono transition-all disabled:opacity-40"
|
className="w-full text-left py-1.5 px-3 rounded-lg text-xs font-mono transition-all disabled:opacity-40"
|
||||||
@@ -427,9 +427,7 @@ function SummaryCard({ label, summary }: { label: string; summary: Summary }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{summary.token_count != null && (
|
{summary.token_count != null && <MonoCaption>{summary.token_count} tok</MonoCaption>}
|
||||||
<MonoCaption>{summary.token_count} tok</MonoCaption>
|
|
||||||
)}
|
|
||||||
{summary.created_at && (
|
{summary.created_at && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
<Clock className="w-3 h-3" style={{ color: "var(--text-4)" }} strokeWidth={1.5} />
|
||||||
@@ -4,15 +4,22 @@ import { EmptyState } from "@/components/shared/EmptyState";
|
|||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
import { PageLoader } from "@/components/shared/LoadingSpinner";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
import { PageTitle, MonoCaption } from "@/components/ui/typography";
|
import { SortControl, type SortDir } from "@/components/shared/SortControl";
|
||||||
|
import { MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { type Variants, motion } from "framer-motion";
|
import { type Variants, motion } from "framer-motion";
|
||||||
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type Session = components["schemas"]["Session"];
|
type Session = components["schemas"]["Session"];
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: "created_at", label: "Newest" },
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "id", label: "ID" },
|
||||||
|
];
|
||||||
|
|
||||||
const container: Variants = {
|
const container: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||||
@@ -25,6 +32,8 @@ const item: Variants = {
|
|||||||
export function SessionList() {
|
export function SessionList() {
|
||||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortField, setSortField] = useState("created_at");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, error } = useSessions(workspaceId, page);
|
const { data, isLoading, error } = useSessions(workspaceId, page);
|
||||||
|
|
||||||
@@ -32,24 +41,44 @@ export function SessionList() {
|
|||||||
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
|
||||||
const total = (data as { total?: number } | undefined)?.total ?? 0;
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
to="/workspaces/$workspaceId"
|
to="/workspaces/$workspaceId"
|
||||||
params={{ workspaceId } as never}
|
params={{ workspaceId } as never}
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
||||||
style={{ color: "rgba(148,163,184,0.5)" }}
|
style={{ color: COLOR.dimText }}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
||||||
{workspaceId}
|
{workspaceId}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<MessageSquare className="w-5 h-5" style={{ color: "#6366f1" }} strokeWidth={1.5} />
|
<MessageSquare className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||||
<PageTitle>Sessions</PageTitle>
|
<PageTitle>Sessions</PageTitle>
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span
|
<span
|
||||||
className="ml-auto text-xs font-mono px-2 py-0.5 rounded-full"
|
className="ml-1 text-xs font-mono px-2 py-0.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: COLOR.accentSubtle,
|
background: COLOR.accentSubtle,
|
||||||
color: COLOR.accentText,
|
color: COLOR.accentText,
|
||||||
@@ -59,8 +88,18 @@ export function SessionList() {
|
|||||||
{total}
|
{total}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<MonoCaption className="mt-0.5" as="p">
|
||||||
|
{workspaceId}
|
||||||
|
</MonoCaption>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ErrorAlert error={error instanceof Error ? error : null} />
|
<ErrorAlert error={error instanceof Error ? error : null} />
|
||||||
@@ -74,10 +113,10 @@ export function SessionList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && sessions.length > 0 && (
|
{!isLoading && sorted.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
<motion.div variants={container} initial="hidden" animate="show" className="space-y-2">
|
||||||
{sessions.map((session) => (
|
{sorted.map((session) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
variants={item}
|
variants={item}
|
||||||
@@ -89,19 +128,19 @@ export function SessionList() {
|
|||||||
}
|
}
|
||||||
className="w-full text-left rounded-xl px-5 py-4 group"
|
className="w-full text-left rounded-xl px-5 py-4 group"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.02)",
|
background: COLOR.cardBaseBg,
|
||||||
border: "1px solid rgba(255,255,255,0.06)",
|
border: `1px solid ${COLOR.cardBaseBorder}`,
|
||||||
}}
|
}}
|
||||||
whileHover={{
|
whileHover={{
|
||||||
background: "rgba(99,102,241,0.06)",
|
background: COLOR.accentDimHover,
|
||||||
borderColor: "rgba(99,102,241,0.2)",
|
borderColor: COLOR.accentBorder,
|
||||||
x: 2,
|
x: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm font-medium truncate"
|
className="font-mono text-sm font-medium truncate"
|
||||||
style={{ color: "#c7d2fe" }}
|
style={{ color: COLOR.accentSoft }}
|
||||||
>
|
>
|
||||||
{session.id}
|
{session.id}
|
||||||
</span>
|
</span>
|
||||||
@@ -125,7 +164,7 @@ export function SessionList() {
|
|||||||
)}
|
)}
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
className="w-4 h-4 opacity-30 group-hover:opacity-70 transition-opacity"
|
||||||
style={{ color: "#6366f1" }}
|
style={{ color: COLOR.accent }}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +174,7 @@ export function SessionList() {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock
|
<Clock
|
||||||
className="w-3 h-3"
|
className="w-3 h-3"
|
||||||
style={{ color: "rgba(148,163,184,0.3)" }}
|
style={{ color: COLOR.dimIcon }}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<MonoCaption>{new Date(session.created_at).toLocaleString()}</MonoCaption>
|
<MonoCaption>{new Date(session.created_at).toLocaleString()}</MonoCaption>
|
||||||
@@ -147,7 +186,7 @@ export function SessionList() {
|
|||||||
style={{
|
style={{
|
||||||
background: COLOR.accentDim,
|
background: COLOR.accentDim,
|
||||||
border: `1px solid ${COLOR.accentBorderStrong}`,
|
border: `1px solid ${COLOR.accentBorderStrong}`,
|
||||||
color: "rgba(148,163,184,0.6)",
|
color: COLOR.dimText,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(session.metadata as Record<string, string>).source}
|
{(session.metadata as Record<string, string>).source}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Wifi, WifiOff, Loader, CheckCircle, AlertCircle, Lock, LockOpen } from "lucide-react";
|
|
||||||
import {
|
|
||||||
configSchema,
|
|
||||||
loadConfig,
|
|
||||||
saveConfig,
|
|
||||||
checkConnection,
|
|
||||||
type Config,
|
|
||||||
type HealthStatus,
|
|
||||||
} from "@/lib/config";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Textarea } from "@/components/ui/input";
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
|
import {
|
||||||
|
type Config,
|
||||||
|
type HealthStatus,
|
||||||
|
checkConnection,
|
||||||
|
configSchema,
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
} from "@/lib/config";
|
||||||
import { COLOR } from "@/lib/constants";
|
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 {
|
interface SettingsFormProps {
|
||||||
onSaved?: () => void;
|
onSaved?: () => void;
|
||||||
@@ -81,14 +81,15 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
>
|
>
|
||||||
{/* Base URL */}
|
{/* Base URL */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 text-sm">
|
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
||||||
Honcho Base URL
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => { setBaseUrl(e.target.value); setHealth(null); }}
|
onChange={(e) => {
|
||||||
|
setBaseUrl(e.target.value);
|
||||||
|
setHealth(null);
|
||||||
|
}}
|
||||||
placeholder="http://localhost:8000"
|
placeholder="http://localhost:8000"
|
||||||
className="flex-1 font-mono rounded-xl"
|
className="flex-1 font-mono rounded-xl"
|
||||||
/>
|
/>
|
||||||
@@ -100,7 +101,10 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
className="rounded-xl"
|
className="rounded-xl"
|
||||||
>
|
>
|
||||||
{checking ? (
|
{checking ? (
|
||||||
<motion.div animate={{ rotate: 360 }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}>
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||||
|
>
|
||||||
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
<Loader className="w-4 h-4" strokeWidth={1.5} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
@@ -110,11 +114,11 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{errors.baseUrl && (
|
{errors.baseUrl && (
|
||||||
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>{errors.baseUrl}</p>
|
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||||
|
{errors.baseUrl}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<Muted className="text-xs mt-1.5">
|
<Muted className="text-xs mt-1.5">URL of your self-hosted Honcho instance</Muted>
|
||||||
URL of your self-hosted Honcho instance
|
|
||||||
</Muted>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Health status */}
|
{/* Health status */}
|
||||||
@@ -141,12 +145,13 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium" style={{ color: statusConfig[health.status].color }}>
|
<p
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: statusConfig[health.status].color }}
|
||||||
|
>
|
||||||
{statusConfig[health.status].label}
|
{statusConfig[health.status].label}
|
||||||
</p>
|
</p>
|
||||||
<Muted className="text-xs mt-0.5">
|
<Muted className="text-xs mt-0.5">{health.message}</Muted>
|
||||||
{health.message}
|
|
||||||
</Muted>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -155,14 +160,15 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
|
|
||||||
{/* Token */}
|
{/* Token */}
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label htmlFor="honcho-token" className="flex items-center gap-1.5 mb-1.5 text-sm">
|
||||||
htmlFor="honcho-token"
|
|
||||||
className="flex items-center gap-1.5 mb-1.5 text-sm"
|
|
||||||
>
|
|
||||||
{token ? (
|
{token ? (
|
||||||
<Lock className="w-3.5 h-3.5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<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} />
|
<LockOpen
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
API Token
|
API Token
|
||||||
<span
|
<span
|
||||||
43
packages/web/src/components/shared/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import { Body, Caption } from "@/components/ui/typography";
|
import { Body, Caption } from "@/components/ui/typography";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
@@ -26,13 +26,11 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
|
|||||||
border: `1px solid ${COLOR.accentBorderStrong}`,
|
border: `1px solid ${COLOR.accentBorderStrong}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5" style={{ color: "rgba(99,102,241,0.6)" }} strokeWidth={1.5} />
|
<Icon className="w-5 h-5" style={{ color: COLOR.accentMuted }} strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Body className="font-medium">{title}</Body>
|
<Body className="font-medium">{title}</Body>
|
||||||
{description && (
|
{description && <Caption className="mt-1.5 max-w-xs leading-relaxed">{description}</Caption>}
|
||||||
<Caption className="mt-1.5 max-w-xs leading-relaxed">{description}</Caption>
|
|
||||||
)}
|
|
||||||
{action && <div className="mt-4">{action}</div>}
|
{action && <div className="mt-4">{action}</div>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
interface ErrorAlertProps {
|
interface ErrorAlertProps {
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -9,14 +11,14 @@ export function ErrorAlert({ error, message }: ErrorAlertProps) {
|
|||||||
<div
|
<div
|
||||||
className="rounded-xl p-4 mb-4"
|
className="rounded-xl p-4 mb-4"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(239, 68, 68, 0.08)",
|
background: COLOR.destructiveDim,
|
||||||
border: "1px solid rgba(239, 68, 68, 0.25)",
|
border: `1px solid ${COLOR.destructiveBorderStrong}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-sm font-medium" style={{ color: "#f87171" }}>
|
<p className="text-sm font-medium" style={{ color: COLOR.destructive }}>
|
||||||
{message ?? "An error occurred"}
|
{message ?? "An error occurred"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1 font-mono" style={{ color: "rgba(248, 113, 113, 0.6)" }}>
|
<p className="text-xs mt-1 font-mono" style={{ color: COLOR.destructiveMuted }}>
|
||||||
{error.message}
|
{error.message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FormModalProps {
|
interface FormModalProps {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { Check, Pencil, X } from "lucide-react";
|
||||||
import { Pencil, Check, X } from "lucide-react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
interface InlineEditorProps {
|
interface InlineEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -41,6 +41,7 @@ export function InlineEditor({
|
|||||||
if (!editing) {
|
if (!editing) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
className={`group flex items-center gap-1.5 text-left transition-colors ${className}`}
|
className={`group flex items-center gap-1.5 text-left transition-colors ${className}`}
|
||||||
style={{ color: "var(--text-1)" }}
|
style={{ color: "var(--text-1)" }}
|
||||||
@@ -76,14 +77,22 @@ export function InlineEditor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onMouseDown={(e) => { e.preventDefault(); commit(); }}
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
}}
|
||||||
className="p-1 rounded"
|
className="p-1 rounded"
|
||||||
style={{ color: "var(--accent-text)" }}
|
style={{ color: "var(--accent-text)" }}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" strokeWidth={2.5} />
|
<Check className="w-3.5 h-3.5" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onMouseDown={(e) => { e.preventDefault(); cancel(); }}
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}}
|
||||||
className="p-1 rounded"
|
className="p-1 rounded"
|
||||||
style={{ color: "var(--text-4)" }}
|
style={{ color: "var(--text-4)" }}
|
||||||
>
|
>
|
||||||
@@ -5,13 +5,21 @@ interface JsonViewerProps {
|
|||||||
|
|
||||||
export function JsonViewer({ data, maxHeight = "200px" }: JsonViewerProps) {
|
export function JsonViewer({ data, maxHeight = "200px" }: JsonViewerProps) {
|
||||||
if (data === null || data === undefined) {
|
if (data === null || data === undefined) {
|
||||||
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>empty</span>;
|
return (
|
||||||
|
<span className="text-xs italic" style={{ color: "var(--text-4)" }}>
|
||||||
|
empty
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty =
|
const isEmpty =
|
||||||
typeof data === "object" && data !== null && Object.keys(data as object).length === 0;
|
typeof data === "object" && data !== null && Object.keys(data as object).length === 0;
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
return <span className="text-xs italic" style={{ color: "var(--text-4)" }}>{}</span>;
|
return (
|
||||||
|
<span className="text-xs italic" style={{ color: "var(--text-4)" }}>
|
||||||
|
{}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
interface LoadingSpinnerProps {
|
||||||
@@ -13,13 +14,13 @@ export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerPr
|
|||||||
<motion.div
|
<motion.div
|
||||||
className={className}
|
className={className}
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||||
style={{
|
style={{
|
||||||
width: s,
|
width: s,
|
||||||
height: s,
|
height: s,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
border: `2px solid rgba(99,102,241,0.15)`,
|
border: `2px solid ${COLOR.accentSpinnerTrack}`,
|
||||||
borderTopColor: "#6366f1",
|
borderTopColor: COLOR.accent,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -31,9 +32,9 @@ export function PageLoader() {
|
|||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-px w-24"
|
className="h-px w-24"
|
||||||
style={{ background: "linear-gradient(90deg, transparent, #6366f1, transparent)" }}
|
style={{ background: `linear-gradient(90deg, transparent, ${COLOR.accent}, transparent)` }}
|
||||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
549
packages/web/src/components/shared/MarkdownRenderer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ export function Pagination({ page, totalPages, onPageChange }: PaginationProps)
|
|||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<MonoCaption className="px-2">{page} / {totalPages}</MonoCaption>
|
<MonoCaption className="px-2">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</MonoCaption>
|
||||||
<Button
|
<Button
|
||||||
variant="surface"
|
variant="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { COLOR } from "@/lib/constants";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -75,7 +76,7 @@ function parse(lines: string[]): Parsed {
|
|||||||
capsMap.set(p.key, []);
|
capsMap.set(p.key, []);
|
||||||
capsOrder.push(p.key);
|
capsOrder.push(p.key);
|
||||||
}
|
}
|
||||||
capsMap.get(p.key)!.push(p.value);
|
capsMap.get(p.key)?.push(p.value);
|
||||||
} else {
|
} else {
|
||||||
facts.push(p.text);
|
facts.push(p.text);
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,7 @@ function parse(lines: string[]): Parsed {
|
|||||||
return {
|
return {
|
||||||
titlePairs,
|
titlePairs,
|
||||||
facts,
|
facts,
|
||||||
capsGroups: capsOrder.map((k) => ({ key: k, items: capsMap.get(k)! })),
|
capsGroups: capsOrder.map((k) => ({ key: k, items: capsMap.get(k) ?? [] })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +98,11 @@ function MetadataCard({ pairs }: { pairs: Array<{ key: string; value: string }>
|
|||||||
<dl className="divide-y" style={{ "--tw-divide-opacity": 1 } as React.CSSProperties}>
|
<dl className="divide-y" style={{ "--tw-divide-opacity": 1 } as React.CSSProperties}>
|
||||||
{pairs.map(({ key, value }, i) => (
|
{pairs.map(({ key, value }, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${key}-${i}`}
|
key={key}
|
||||||
className="grid grid-cols-[9rem_1fr] gap-3 px-4 py-2.5 text-sm"
|
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)" }}
|
style={{ background: i % 2 === 0 ? "var(--surface)" : "var(--bg-3)" }}
|
||||||
>
|
>
|
||||||
<dt className="font-medium truncate" style={{ color: "var(--text-3)" }}>
|
<dt className="font-medium break-words" style={{ color: "var(--text-3)" }}>
|
||||||
{key}
|
{key}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="min-w-0 break-words" style={{ color: "var(--text-1)" }}>
|
<dd className="min-w-0 break-words" style={{ color: "var(--text-1)" }}>
|
||||||
@@ -123,9 +124,9 @@ interface SectionStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FACTS_STYLE: SectionStyle = {
|
const FACTS_STYLE: SectionStyle = {
|
||||||
bg: "rgba(99,102,241,0.08)",
|
bg: COLOR.accentDim,
|
||||||
text: "#a5b4fc",
|
text: "#a5b4fc",
|
||||||
border: "rgba(99,102,241,0.2)",
|
border: COLOR.accentBorder,
|
||||||
};
|
};
|
||||||
|
|
||||||
function CollapsibleSection({
|
function CollapsibleSection({
|
||||||
@@ -182,13 +183,13 @@ function CollapsibleSection({
|
|||||||
function ItemList({ items }: { items: string[] }) {
|
function ItemList({ items }: { items: string[] }) {
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
{items.map((item, i) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item}
|
||||||
className="px-4 py-2.5 text-sm leading-relaxed"
|
className="px-4 py-2.5 text-sm leading-relaxed break-words"
|
||||||
style={{
|
style={{
|
||||||
color: "var(--text-2)",
|
color: "var(--text-2)",
|
||||||
borderTop: i > 0 ? "1px solid var(--border)" : "1px solid var(--border)",
|
borderTop: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||