fix(workspace): close round-two issue gaps
This commit is contained in:
@@ -17,10 +17,16 @@
|
|||||||
# Ollama / local: No key needed — just run `ollama serve`
|
# Ollama / local: No key needed — just run `ollama serve`
|
||||||
#
|
#
|
||||||
# Uncomment ONLY the key(s) for the providers you actually use.
|
# Uncomment ONLY the key(s) for the providers you actually use.
|
||||||
|
# See docs/api-key-registry.md for the broader SCOM key inventory and
|
||||||
|
# rotation checklist.
|
||||||
|
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# NOUS_API_KEY=...
|
||||||
# OPENAI_API_KEY=sk-...
|
# OPENAI_API_KEY=sk-...
|
||||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
# GOOGLE_API_KEY=AIza...
|
# GOOGLE_API_KEY=AIza...
|
||||||
|
# GOOGLE_AI_STUDIO_API_KEY=AIza...
|
||||||
|
# MINIMAX_API_KEY=...
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# Optional: Hermes Agent Connection
|
# Optional: Hermes Agent Connection
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -205,6 +205,17 @@ pnpm dev # Starts on http://localhost:3000
|
|||||||
|
|
||||||
> **Verify:** Open `http://localhost:3000` and complete the onboarding flow. First connect the backend, then verify chat works. If your gateway exposes Hermes Agent APIs, advanced features appear automatically.
|
> **Verify:** Open `http://localhost:3000` and complete the onboarding flow. First connect the backend, then verify chat works. If your gateway exposes Hermes Agent APIs, advanced features appear automatically.
|
||||||
|
|
||||||
|
#### Run without an open terminal
|
||||||
|
|
||||||
|
After `pnpm build`, install Workspace as a user-level launchd/systemd service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/install-dashboard-service.sh
|
||||||
|
scripts/install-dashboard-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`docs/dashboard-service.md`](docs/dashboard-service.md) for macOS launchd, Linux systemd, logs, overrides, and uninstall steps.
|
||||||
|
|
||||||
#### Environment Variables
|
#### Environment Variables
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
|||||||
86
docs/api-key-registry.md
Normal file
86
docs/api-key-registry.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# API key registry and rotation checklist
|
||||||
|
|
||||||
|
This registry groups supported environment keys so deployments can audit what is configured and rotate keys before a phase graduates.
|
||||||
|
|
||||||
|
## Rotation policy
|
||||||
|
|
||||||
|
- Treat all prototype keys as temporary.
|
||||||
|
- Rotate a group when a feature moves from prototype to production, when access is shared with a new operator, or after any suspected leak.
|
||||||
|
- Prefer provider dashboards or Infisical for storage. Do not commit real values to this repo.
|
||||||
|
- Keep `.env` values scoped to the minimum deployment that needs them.
|
||||||
|
|
||||||
|
## LLM inference
|
||||||
|
|
||||||
|
- `ANTHROPIC_API_KEY`
|
||||||
|
- `NOUS_API_KEY`
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `MINIMAX_API_KEY`
|
||||||
|
- `OPENROUTER_API_KEY`
|
||||||
|
|
||||||
|
## Image generation
|
||||||
|
|
||||||
|
- `LEONARDO_API_KEY`
|
||||||
|
- `LEONARDO_SEED_BLOG`
|
||||||
|
- `LEONARDO_SEED_EDUCATIONAL`
|
||||||
|
- `LEONARDO_SEED_POAP`
|
||||||
|
- `LEONARDO_SEED_PROTOCOL`
|
||||||
|
- `LEONARDO_SEED_SERIES`
|
||||||
|
- `KREA_API_TOKEN`
|
||||||
|
- `FAL_KEY`
|
||||||
|
|
||||||
|
## Web3 and on-chain
|
||||||
|
|
||||||
|
- `LENS_PRIVATE_KEY`
|
||||||
|
- `LENS_WALLET_ADDRESS`
|
||||||
|
- `LENS_PROFILE_ID`
|
||||||
|
- `LENS_SERVER_API_KEY`
|
||||||
|
- `GUILD_WALLET_PRIVATE_KEY`
|
||||||
|
- `GUILD_ID`
|
||||||
|
- `GUILD_PUBLISHER_ROLE_ID`
|
||||||
|
- `POAP_API_KEY`
|
||||||
|
- `POAP_AUTH_TOKEN`
|
||||||
|
- `POAP_EMAIL`
|
||||||
|
|
||||||
|
## Storage and infrastructure
|
||||||
|
|
||||||
|
- `R2_ACCESS_KEY_ID`
|
||||||
|
- `R2_SECRET_ACCESS_KEY`
|
||||||
|
- `R2_ENDPOINT`
|
||||||
|
- `R2_BACKUP_BUCKET`
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
- `SLACK_BOT_TOKEN`
|
||||||
|
- `SLACK_APP_TOKEN`
|
||||||
|
- `BLUEBUBBLES_PASSWORD`
|
||||||
|
- `EMAIL_PASSWORD`
|
||||||
|
- `HERMES_API_TOKEN`
|
||||||
|
|
||||||
|
## Integrations and tools
|
||||||
|
|
||||||
|
- `OPENCODE_ZEN_API_KEY`
|
||||||
|
- `SHOPIFY_ACCESS_TOKEN`
|
||||||
|
- `VAPI_PUBLIC_KEY`
|
||||||
|
- `VAPI_PRIVATE_KEY`
|
||||||
|
- `MCP_VAPI_API_KEY`
|
||||||
|
- `API_SERVER_KEY`
|
||||||
|
- `HERMES_PASSWORD`
|
||||||
|
|
||||||
|
## Platforms and auth
|
||||||
|
|
||||||
|
- `INFISICAL_CLIENT_ID`
|
||||||
|
- `INFISICAL_CLIENT_SECRET`
|
||||||
|
- `GOOGLE_API_KEY`
|
||||||
|
- `GOOGLE_AI_STUDIO_API_KEY`
|
||||||
|
|
||||||
|
## Operator handoff
|
||||||
|
|
||||||
|
When handing off a phase:
|
||||||
|
|
||||||
|
1. Export the active key list from the deployment secret store.
|
||||||
|
2. Compare it against this registry.
|
||||||
|
3. Rotate keys in the provider dashboard.
|
||||||
|
4. Update the deployment secret store.
|
||||||
|
5. Restart Hermes Agent / Workspace services.
|
||||||
|
6. Re-run provider/model checks in Workspace settings.
|
||||||
87
docs/dashboard-service.md
Normal file
87
docs/dashboard-service.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Run Hermes Workspace as a user service
|
||||||
|
|
||||||
|
Hermes Workspace can run without keeping a terminal open. The helper below installs a **user-level** service, not a system-wide root service.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
cp .env.example .env # if you have not configured it yet
|
||||||
|
```
|
||||||
|
|
||||||
|
Set at least the same environment you use for `pnpm start`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HERMES_API_URL=http://127.0.0.1:8642
|
||||||
|
export HERMES_DASHBOARD_URL=http://127.0.0.1:9119
|
||||||
|
export HERMES_API_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/install-dashboard-service.sh
|
||||||
|
scripts/install-dashboard-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
|
||||||
|
- `HOST=127.0.0.1`
|
||||||
|
- `PORT=3000`
|
||||||
|
- `NODE_ENV=production`
|
||||||
|
- command: `pnpm start`
|
||||||
|
|
||||||
|
Override them inline if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3123 HOST=127.0.0.1 scripts/install-dashboard-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## macOS launchd
|
||||||
|
|
||||||
|
The installer writes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/Library/LaunchAgents/com.hermes.workspace.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl print gui/$(id -u)/com.hermes.workspace
|
||||||
|
launchctl kickstart -k gui/$(id -u)/com.hermes.workspace
|
||||||
|
tail -f logs/hermes-workspace.out.log logs/hermes-workspace.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linux systemd user service
|
||||||
|
|
||||||
|
The installer writes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/systemd/user/hermes-workspace.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status hermes-workspace
|
||||||
|
journalctl --user -u hermes-workspace -f
|
||||||
|
systemctl --user restart hermes-workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need the service after logout on Linux, enable lingering once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
loginctl enable-linger "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/install-dashboard-service.sh uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security note
|
||||||
|
|
||||||
|
Do not bind to `0.0.0.0` unless `HERMES_PASSWORD` and your reverse-proxy/auth setup are configured. Workspace exposes files, terminals, and agent controls, so loopback is the safe default.
|
||||||
105
scripts/install-dashboard-service.sh
Executable file
105
scripts/install-dashboard-service.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Install Hermes Workspace as a user-level service.
|
||||||
|
# macOS: launchd user agent
|
||||||
|
# Linux: systemd --user unit
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SERVICE_NAME="hermes-workspace"
|
||||||
|
PORT="${PORT:-3000}"
|
||||||
|
HOST="${HOST:-127.0.0.1}"
|
||||||
|
NODE_ENV="${NODE_ENV:-production}"
|
||||||
|
PNPM_BIN="${PNPM_BIN:-$(command -v pnpm || true)}"
|
||||||
|
|
||||||
|
if [[ -z "$PNPM_BIN" ]]; then
|
||||||
|
echo "pnpm not found on PATH. Set PNPM_BIN=/path/to/pnpm and retry." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-install}" == "uninstall" ]]; then
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
plist="$HOME/Library/LaunchAgents/com.hermes.workspace.plist"
|
||||||
|
launchctl bootout "gui/$(id -u)" "$plist" 2>/dev/null || true
|
||||||
|
rm -f "$plist"
|
||||||
|
echo "Removed launchd user agent: $plist"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
systemctl --user disable --now "$SERVICE_NAME.service" 2>/dev/null || true
|
||||||
|
rm -f "$HOME/.config/systemd/user/$SERVICE_NAME.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
echo "Removed systemd user service: $SERVICE_NAME.service"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported OS: $(uname -s)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
mkdir -p "$HOME/Library/LaunchAgents" "$ROOT_DIR/logs"
|
||||||
|
plist="$HOME/Library/LaunchAgents/com.hermes.workspace.plist"
|
||||||
|
cat > "$plist" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key><string>com.hermes.workspace</string>
|
||||||
|
<key>WorkingDirectory</key><string>$ROOT_DIR</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>$PNPM_BIN</string>
|
||||||
|
<string>start</string>
|
||||||
|
</array>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>NODE_ENV</key><string>$NODE_ENV</string>
|
||||||
|
<key>HOST</key><string>$HOST</string>
|
||||||
|
<key>PORT</key><string>$PORT</string>
|
||||||
|
</dict>
|
||||||
|
<key>RunAtLoad</key><true/>
|
||||||
|
<key>KeepAlive</key><true/>
|
||||||
|
<key>StandardOutPath</key><string>$ROOT_DIR/logs/hermes-workspace.out.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>$ROOT_DIR/logs/hermes-workspace.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
launchctl bootout "gui/$(id -u)" "$plist" 2>/dev/null || true
|
||||||
|
launchctl bootstrap "gui/$(id -u)" "$plist"
|
||||||
|
launchctl kickstart -k "gui/$(id -u)/com.hermes.workspace"
|
||||||
|
echo "Installed launchd user agent: $plist"
|
||||||
|
;;
|
||||||
|
Linux)
|
||||||
|
mkdir -p "$HOME/.config/systemd/user" "$ROOT_DIR/logs"
|
||||||
|
unit="$HOME/.config/systemd/user/$SERVICE_NAME.service"
|
||||||
|
cat > "$unit" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Hermes Workspace dashboard
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=$ROOT_DIR
|
||||||
|
Environment=NODE_ENV=$NODE_ENV
|
||||||
|
Environment=HOST=$HOST
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
ExecStart=$PNPM_BIN start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now "$SERVICE_NAME.service"
|
||||||
|
echo "Installed systemd user service: $SERVICE_NAME.service"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported OS: $(uname -s)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -72,7 +72,10 @@ export function SearchModal() {
|
|||||||
const deferredQuery = useDeferredValue(debouncedQuery)
|
const deferredQuery = useDeferredValue(debouncedQuery)
|
||||||
|
|
||||||
// Real data (Phase 3.2)
|
// Real data (Phase 3.2)
|
||||||
const { sessions, files, skills } = useSearchData(scope)
|
const { sessions, sessionSearchResults, files, skills } = useSearchData(
|
||||||
|
scope,
|
||||||
|
deferredQuery,
|
||||||
|
)
|
||||||
const searchableFiles = useMemo(
|
const searchableFiles = useMemo(
|
||||||
() => files.filter((entry) => entry.type === 'file'),
|
() => files.filter((entry) => entry.type === 'file'),
|
||||||
[files],
|
[files],
|
||||||
@@ -182,12 +185,17 @@ export function SearchModal() {
|
|||||||
|
|
||||||
// Real sessions data — search across friendlyId, key, derived title,
|
// Real sessions data — search across friendlyId, key, derived title,
|
||||||
// and preview so user queries match chat content (#291).
|
// and preview so user queries match chat content (#291).
|
||||||
const chats = filterResults(
|
const chatCandidates =
|
||||||
|
sessionSearchResults.length > 0
|
||||||
|
? sessionSearchResults
|
||||||
|
: filterResults(
|
||||||
sessions,
|
sessions,
|
||||||
normalized,
|
normalized,
|
||||||
['friendlyId', 'key', 'title', 'preview'],
|
['friendlyId', 'key', 'title', 'preview'],
|
||||||
RESULT_LIMITS.chats,
|
RESULT_LIMITS.chats,
|
||||||
).map<SearchResultItemData>((entry) => ({
|
)
|
||||||
|
|
||||||
|
const chats = chatCandidates.slice(0, RESULT_LIMITS.chats).map<SearchResultItemData>((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
scope: 'chats',
|
scope: 'chats',
|
||||||
icon: <HugeiconsIcon icon={Chat01Icon} size={20} strokeWidth={1.5} />,
|
icon: <HugeiconsIcon icon={Chat01Icon} size={20} strokeWidth={1.5} />,
|
||||||
@@ -292,6 +300,7 @@ export function SearchModal() {
|
|||||||
quickActions,
|
quickActions,
|
||||||
scope,
|
scope,
|
||||||
searchableFiles,
|
searchableFiles,
|
||||||
|
sessionSearchResults,
|
||||||
sessions,
|
sessions,
|
||||||
skills,
|
skills,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const FILES_STALE_TIME_MS = 2 * 60_000
|
|||||||
const SKILLS_STALE_TIME_MS = 2 * 60_000
|
const SKILLS_STALE_TIME_MS = 2 * 60_000
|
||||||
const SEARCH_QUERY_GC_TIME_MS = 10 * 60_000
|
const SEARCH_QUERY_GC_TIME_MS = 10 * 60_000
|
||||||
const MAX_SEARCH_FILES = 2_500
|
const MAX_SEARCH_FILES = 2_500
|
||||||
|
const SESSION_FTS_STALE_TIME_MS = 15_000
|
||||||
|
|
||||||
export type SearchSession = {
|
export type SearchSession = {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,6 +23,7 @@ export type SearchSession = {
|
|||||||
title?: string
|
title?: string
|
||||||
preview?: string
|
preview?: string
|
||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
|
source?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchFile = {
|
export type SearchFile = {
|
||||||
@@ -60,6 +62,11 @@ type SkillsApiResponse = {
|
|||||||
skills?: Array<Record<string, unknown>>
|
skills?: Array<Record<string, unknown>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionSearchApiResponse = {
|
||||||
|
ok?: boolean
|
||||||
|
results?: Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
type SearchQueryScope =
|
type SearchQueryScope =
|
||||||
| 'all'
|
| 'all'
|
||||||
| 'chats'
|
| 'chats'
|
||||||
@@ -201,6 +208,38 @@ async function fetchFiles(
|
|||||||
return flattenFileTree(entries, MAX_SEARCH_FILES)
|
return flattenFileTree(entries, MAX_SEARCH_FILES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchSessionSearch(
|
||||||
|
query: string,
|
||||||
|
querySignal?: AbortSignal,
|
||||||
|
): Promise<Array<SearchSession>> {
|
||||||
|
const normalized = query.trim()
|
||||||
|
if (!normalized) return []
|
||||||
|
const data = await fetchJsonWithTimeout<SessionSearchApiResponse>(
|
||||||
|
`/api/sessions/search?q=${encodeURIComponent(normalized)}&limit=24`,
|
||||||
|
querySignal,
|
||||||
|
)
|
||||||
|
if (!data || data.ok === false) return []
|
||||||
|
const results = Array.isArray(data.results) ? data.results : []
|
||||||
|
return results.map((entry, index) => {
|
||||||
|
const key = String(entry.key || entry.session_id || entry.id || '')
|
||||||
|
const friendlyId = String(entry.friendlyId || key || 'unknown')
|
||||||
|
return {
|
||||||
|
id: String(entry.id || `${key}:${index}`),
|
||||||
|
key,
|
||||||
|
friendlyId,
|
||||||
|
title: String(entry.title || friendlyId || 'Untitled'),
|
||||||
|
preview: String(entry.snippet || entry.preview || ''),
|
||||||
|
updatedAt:
|
||||||
|
typeof entry.updatedAt === 'number'
|
||||||
|
? entry.updatedAt
|
||||||
|
: typeof entry.session_started === 'number'
|
||||||
|
? entry.session_started
|
||||||
|
: undefined,
|
||||||
|
source: typeof entry.source === 'string' ? entry.source : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSkills(
|
async function fetchSkills(
|
||||||
querySignal?: AbortSignal,
|
querySignal?: AbortSignal,
|
||||||
): Promise<Array<SearchSkill>> {
|
): Promise<Array<SearchSkill>> {
|
||||||
@@ -223,9 +262,10 @@ async function fetchSkills(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchData(scope: SearchQueryScope) {
|
export function useSearchData(scope: SearchQueryScope, query = '') {
|
||||||
const sessionsAvailable = useFeatureAvailable('sessions')
|
const sessionsAvailable = useFeatureAvailable('sessions')
|
||||||
const skillsAvailable = useFeatureAvailable('skills')
|
const skillsAvailable = useFeatureAvailable('skills')
|
||||||
|
const trimmedQuery = query.trim()
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
const sessionsQuery = useQuery({
|
const sessionsQuery = useQuery({
|
||||||
@@ -239,6 +279,20 @@ export function useSearchData(scope: SearchQueryScope) {
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sessionSearchQuery = useQuery({
|
||||||
|
queryKey: ['search', 'sessions-fts', trimmedQuery],
|
||||||
|
queryFn: ({ signal }) => fetchSessionSearch(trimmedQuery, signal),
|
||||||
|
enabled:
|
||||||
|
sessionsAvailable &&
|
||||||
|
trimmedQuery.length >= 2 &&
|
||||||
|
(scope === 'all' || scope === 'chats'),
|
||||||
|
staleTime: SESSION_FTS_STALE_TIME_MS,
|
||||||
|
gcTime: SEARCH_QUERY_GC_TIME_MS,
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
})
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ['search', 'files'],
|
queryKey: ['search', 'files'],
|
||||||
@@ -268,11 +322,15 @@ export function useSearchData(scope: SearchQueryScope) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sessions: sessionsQuery.data || [],
|
sessions: sessionsQuery.data || [],
|
||||||
|
sessionSearchResults: sessionSearchQuery.data || [],
|
||||||
files: filesQuery.data || [],
|
files: filesQuery.data || [],
|
||||||
skills: skillsQuery.data || [],
|
skills: skillsQuery.data || [],
|
||||||
activity: activityResults,
|
activity: activityResults,
|
||||||
isLoading:
|
isLoading:
|
||||||
sessionsQuery.isLoading || filesQuery.isLoading || skillsQuery.isLoading,
|
sessionsQuery.isLoading ||
|
||||||
|
sessionSearchQuery.isLoading ||
|
||||||
|
filesQuery.isLoading ||
|
||||||
|
skillsQuery.isLoading,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getTheme, setTheme } from '@/lib/theme'
|
|||||||
|
|
||||||
export type SettingsThemeMode = 'system' | 'light' | 'dark'
|
export type SettingsThemeMode = 'system' | 'light' | 'dark'
|
||||||
export type AccentColor = 'orange' | 'purple' | 'blue' | 'green'
|
export type AccentColor = 'orange' | 'purple' | 'blue' | 'green'
|
||||||
|
export type InterfaceFont = 'system' | 'inter' | 'serif' | 'mono'
|
||||||
|
export type InterfaceDensity = 'compact' | 'comfortable' | 'spacious'
|
||||||
|
|
||||||
export type StudioSettings = {
|
export type StudioSettings = {
|
||||||
claudeUrl: string
|
claudeUrl: string
|
||||||
@@ -22,6 +24,8 @@ export type StudioSettings = {
|
|||||||
preferredPremiumModel: string
|
preferredPremiumModel: string
|
||||||
onlySuggestCheaper: boolean
|
onlySuggestCheaper: boolean
|
||||||
showSystemMetricsFooter: boolean
|
showSystemMetricsFooter: boolean
|
||||||
|
interfaceFont: InterfaceFont
|
||||||
|
interfaceDensity: InterfaceDensity
|
||||||
/** Mobile chat nav mode: 'dock' = iMessage (no nav in chat), 'integrated' = chat input in nav pill, 'scroll-hide' = nav shows on scroll up */
|
/** Mobile chat nav mode: 'dock' = iMessage (no nav in chat), 'integrated' = chat input in nav pill, 'scroll-hide' = nav shows on scroll up */
|
||||||
mobileChatNavMode: 'dock' | 'integrated' | 'scroll-hide'
|
mobileChatNavMode: 'dock' | 'integrated' | 'scroll-hide'
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,8 @@ export const defaultStudioSettings: StudioSettings = {
|
|||||||
preferredPremiumModel: '',
|
preferredPremiumModel: '',
|
||||||
onlySuggestCheaper: false,
|
onlySuggestCheaper: false,
|
||||||
showSystemMetricsFooter: false,
|
showSystemMetricsFooter: false,
|
||||||
|
interfaceFont: 'system',
|
||||||
|
interfaceDensity: 'comfortable',
|
||||||
mobileChatNavMode: 'dock',
|
mobileChatNavMode: 'dock',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +108,20 @@ export function resolveTheme(theme: SettingsThemeMode): 'light' | 'dark' {
|
|||||||
: 'light'
|
: 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyInterfacePreferences(settings: Partial<StudioSettings>) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.dataset.interfaceFont = settings.interfaceFont ?? 'system'
|
||||||
|
document.documentElement.dataset.interfaceDensity = settings.interfaceDensity ?? 'comfortable'
|
||||||
|
}
|
||||||
|
|
||||||
export function applyTheme(_theme?: SettingsThemeMode) {
|
export function applyTheme(_theme?: SettingsThemeMode) {
|
||||||
setTheme(getTheme())
|
setTheme(getTheme())
|
||||||
document.documentElement.setAttribute('data-accent', 'orange')
|
document.documentElement.setAttribute('data-accent', 'orange')
|
||||||
|
applyInterfacePreferences(useSettingsStore.getState().settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeSettingsAppearance() {
|
export function initializeSettingsAppearance() {
|
||||||
setTheme(getTheme())
|
setTheme(getTheme())
|
||||||
document.documentElement.setAttribute('data-accent', 'orange')
|
document.documentElement.setAttribute('data-accent', 'orange')
|
||||||
|
applyInterfacePreferences(useSettingsStore.getState().settings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle'
|
|||||||
import { Route as ApiSkillsInstallRouteImport } from './routes/api/skills/install'
|
import { Route as ApiSkillsInstallRouteImport } from './routes/api/skills/install'
|
||||||
import { Route as ApiSkillsHubSearchRouteImport } from './routes/api/skills/hub-search'
|
import { Route as ApiSkillsHubSearchRouteImport } from './routes/api/skills/hub-search'
|
||||||
import { Route as ApiSessionsSendRouteImport } from './routes/api/sessions/send'
|
import { Route as ApiSessionsSendRouteImport } from './routes/api/sessions/send'
|
||||||
|
import { Route as ApiSessionsSearchRouteImport } from './routes/api/sessions/search'
|
||||||
import { Route as ApiRunsActiveRouteImport } from './routes/api/runs/active'
|
import { Route as ApiRunsActiveRouteImport } from './routes/api/runs/active'
|
||||||
import { Route as ApiProfilesUpdateRouteImport } from './routes/api/profiles/update'
|
import { Route as ApiProfilesUpdateRouteImport } from './routes/api/profiles/update'
|
||||||
import { Route as ApiProfilesToggleSkillRouteImport } from './routes/api/profiles/toggle-skill'
|
import { Route as ApiProfilesToggleSkillRouteImport } from './routes/api/profiles/toggle-skill'
|
||||||
@@ -735,6 +736,11 @@ const ApiSessionsSendRoute = ApiSessionsSendRouteImport.update({
|
|||||||
path: '/send',
|
path: '/send',
|
||||||
getParentRoute: () => ApiSessionsRoute,
|
getParentRoute: () => ApiSessionsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSessionsSearchRoute = ApiSessionsSearchRouteImport.update({
|
||||||
|
id: '/search',
|
||||||
|
path: '/search',
|
||||||
|
getParentRoute: () => ApiSessionsRoute,
|
||||||
|
} as any)
|
||||||
const ApiRunsActiveRoute = ApiRunsActiveRouteImport.update({
|
const ApiRunsActiveRoute = ApiRunsActiveRouteImport.update({
|
||||||
id: '/api/runs/active',
|
id: '/api/runs/active',
|
||||||
path: '/api/runs/active',
|
path: '/api/runs/active',
|
||||||
@@ -1117,6 +1123,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||||
|
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||||
@@ -1277,6 +1284,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||||
|
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||||
@@ -1439,6 +1447,7 @@ export interface FileRoutesById {
|
|||||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||||
|
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||||
@@ -1602,6 +1611,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profiles/toggle-skill'
|
| '/api/profiles/toggle-skill'
|
||||||
| '/api/profiles/update'
|
| '/api/profiles/update'
|
||||||
| '/api/runs/active'
|
| '/api/runs/active'
|
||||||
|
| '/api/sessions/search'
|
||||||
| '/api/sessions/send'
|
| '/api/sessions/send'
|
||||||
| '/api/skills/hub-search'
|
| '/api/skills/hub-search'
|
||||||
| '/api/skills/install'
|
| '/api/skills/install'
|
||||||
@@ -1762,6 +1772,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profiles/toggle-skill'
|
| '/api/profiles/toggle-skill'
|
||||||
| '/api/profiles/update'
|
| '/api/profiles/update'
|
||||||
| '/api/runs/active'
|
| '/api/runs/active'
|
||||||
|
| '/api/sessions/search'
|
||||||
| '/api/sessions/send'
|
| '/api/sessions/send'
|
||||||
| '/api/skills/hub-search'
|
| '/api/skills/hub-search'
|
||||||
| '/api/skills/install'
|
| '/api/skills/install'
|
||||||
@@ -1923,6 +1934,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/profiles/toggle-skill'
|
| '/api/profiles/toggle-skill'
|
||||||
| '/api/profiles/update'
|
| '/api/profiles/update'
|
||||||
| '/api/runs/active'
|
| '/api/runs/active'
|
||||||
|
| '/api/sessions/search'
|
||||||
| '/api/sessions/send'
|
| '/api/sessions/send'
|
||||||
| '/api/skills/hub-search'
|
| '/api/skills/hub-search'
|
||||||
| '/api/skills/install'
|
| '/api/skills/install'
|
||||||
@@ -2866,6 +2878,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiSessionsSendRouteImport
|
preLoaderRoute: typeof ApiSessionsSendRouteImport
|
||||||
parentRoute: typeof ApiSessionsRoute
|
parentRoute: typeof ApiSessionsRoute
|
||||||
}
|
}
|
||||||
|
'/api/sessions/search': {
|
||||||
|
id: '/api/sessions/search'
|
||||||
|
path: '/search'
|
||||||
|
fullPath: '/api/sessions/search'
|
||||||
|
preLoaderRoute: typeof ApiSessionsSearchRouteImport
|
||||||
|
parentRoute: typeof ApiSessionsRoute
|
||||||
|
}
|
||||||
'/api/runs/active': {
|
'/api/runs/active': {
|
||||||
id: '/api/runs/active'
|
id: '/api/runs/active'
|
||||||
path: '/api/runs/active'
|
path: '/api/runs/active'
|
||||||
@@ -3329,12 +3348,14 @@ const ApiMemoryRouteWithChildren = ApiMemoryRoute._addFileChildren(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface ApiSessionsRouteChildren {
|
interface ApiSessionsRouteChildren {
|
||||||
|
ApiSessionsSearchRoute: typeof ApiSessionsSearchRoute
|
||||||
ApiSessionsSendRoute: typeof ApiSessionsSendRoute
|
ApiSessionsSendRoute: typeof ApiSessionsSendRoute
|
||||||
ApiSessionsSessionKeyActiveRunRoute: typeof ApiSessionsSessionKeyActiveRunRoute
|
ApiSessionsSessionKeyActiveRunRoute: typeof ApiSessionsSessionKeyActiveRunRoute
|
||||||
ApiSessionsSessionKeyStatusRoute: typeof ApiSessionsSessionKeyStatusRoute
|
ApiSessionsSessionKeyStatusRoute: typeof ApiSessionsSessionKeyStatusRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const ApiSessionsRouteChildren: ApiSessionsRouteChildren = {
|
const ApiSessionsRouteChildren: ApiSessionsRouteChildren = {
|
||||||
|
ApiSessionsSearchRoute: ApiSessionsSearchRoute,
|
||||||
ApiSessionsSendRoute: ApiSessionsSendRoute,
|
ApiSessionsSendRoute: ApiSessionsSendRoute,
|
||||||
ApiSessionsSessionKeyActiveRunRoute: ApiSessionsSessionKeyActiveRunRoute,
|
ApiSessionsSessionKeyActiveRunRoute: ApiSessionsSessionKeyActiveRunRoute,
|
||||||
ApiSessionsSessionKeyStatusRoute: ApiSessionsSessionKeyStatusRoute,
|
ApiSessionsSessionKeyStatusRoute: ApiSessionsSessionKeyStatusRoute,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { Toaster } from '@/components/ui/toast'
|
|||||||
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
|
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
|
||||||
import { UpdateCenterNotifier } from '@/components/update-center-notifier'
|
import { UpdateCenterNotifier } from '@/components/update-center-notifier'
|
||||||
import { initializeSettingsAppearance, useSettings } from '@/hooks/use-settings'
|
import { applyInterfacePreferences, initializeSettingsAppearance, useSettings } from '@/hooks/use-settings'
|
||||||
import { useApplyChatWidth } from '@/hooks/use-chat-settings'
|
import { useApplyChatWidth } from '@/hooks/use-chat-settings'
|
||||||
import {
|
import {
|
||||||
ClaudeOnboarding,
|
ClaudeOnboarding,
|
||||||
@@ -274,6 +274,10 @@ function RootLayout() {
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useApplyChatWidth()
|
useApplyChatWidth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyInterfacePreferences(settings)
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
initializeSettingsAppearance()
|
initializeSettingsAppearance()
|
||||||
|
|||||||
116
src/routes/api/sessions/search.ts
Normal file
116
src/routes/api/sessions/search.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
import { isAuthenticated } from '../../../server/auth-middleware'
|
||||||
|
import {
|
||||||
|
ensureGatewayProbed,
|
||||||
|
searchSessions,
|
||||||
|
} from '../../../server/claude-api'
|
||||||
|
import { searchLocalSessions } from '../../../server/local-session-store'
|
||||||
|
|
||||||
|
type NormalizedSessionSearchResult = {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
friendlyId: string
|
||||||
|
title: string
|
||||||
|
snippet: string
|
||||||
|
role?: string | null
|
||||||
|
source?: string | null
|
||||||
|
model?: string | null
|
||||||
|
updatedAt?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(record: Record<string, unknown>, keys: Array<string>): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumber(record: Record<string, unknown>, keys: Array<string>): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(
|
||||||
|
value: unknown,
|
||||||
|
fallbackIndex: number,
|
||||||
|
): NormalizedSessionSearchResult | null {
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
const key = getString(record, ['session_id', 'sessionId', 'key', 'id'])
|
||||||
|
if (!key) return null
|
||||||
|
const title = getString(record, ['title', 'derivedTitle', 'label']) || key
|
||||||
|
const snippet = getString(record, ['snippet', 'preview', 'content', 'text'])
|
||||||
|
return {
|
||||||
|
id: `${key}:${fallbackIndex}`,
|
||||||
|
key,
|
||||||
|
friendlyId: key,
|
||||||
|
title,
|
||||||
|
snippet: snippet || `Session: ${key}`,
|
||||||
|
role: getString(record, ['role']) || null,
|
||||||
|
source: getString(record, ['source']) || null,
|
||||||
|
model: getString(record, ['model']) || null,
|
||||||
|
updatedAt: getNumber(record, ['updatedAt', 'last_active', 'session_started']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/sessions/search')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ request }) => {
|
||||||
|
if (!isAuthenticated(request)) {
|
||||||
|
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const query = (url.searchParams.get('q') || '').trim()
|
||||||
|
const rawLimit = Number(url.searchParams.get('limit') || '20')
|
||||||
|
const limit = Number.isFinite(rawLimit)
|
||||||
|
? Math.min(Math.max(Math.floor(rawLimit), 1), 50)
|
||||||
|
: 20
|
||||||
|
|
||||||
|
if (!query) return json({ ok: true, query, results: [] })
|
||||||
|
|
||||||
|
await ensureGatewayProbed()
|
||||||
|
|
||||||
|
const merged: Array<NormalizedSessionSearchResult> = []
|
||||||
|
try {
|
||||||
|
const remote = await searchSessions(query, limit)
|
||||||
|
const remoteResults = Array.isArray(remote.results)
|
||||||
|
? remote.results
|
||||||
|
: []
|
||||||
|
for (const [index, result] of remoteResults.entries()) {
|
||||||
|
const normalized = normalizeResult(result, index)
|
||||||
|
if (normalized) merged.push(normalized)
|
||||||
|
if (merged.length >= limit) break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some gateway-only deployments do not expose FTS search yet. Keep the
|
||||||
|
// endpoint useful by falling back to local portable sessions below.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.length < limit) {
|
||||||
|
const local = searchLocalSessions(query, limit - merged.length)
|
||||||
|
for (const [index, result] of local.entries()) {
|
||||||
|
merged.push({
|
||||||
|
id: `${result.id}:local:${index}`,
|
||||||
|
key: result.id,
|
||||||
|
friendlyId: result.id,
|
||||||
|
title: result.title || 'Local Chat',
|
||||||
|
snippet: result.snippet || `Local session: ${result.id}`,
|
||||||
|
source: 'local',
|
||||||
|
model: result.model,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true, query, count: merged.length, results: merged })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -17,6 +17,20 @@ const AcceptanceCriteriaSchema = z.preprocess(
|
|||||||
z.array(z.string().trim().min(1).max(5000)).default([]),
|
z.array(z.string().trim().min(1).max(5000)).default([]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const TagsSchema = z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
z.array(z.string().trim().min(1).max(120)).default([]),
|
||||||
|
)
|
||||||
|
|
||||||
const CreateCardSchema = z.object({
|
const CreateCardSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
spec: z.string().trim().max(5000).optional().default(''),
|
spec: z.string().trim().max(5000).optional().default(''),
|
||||||
@@ -28,6 +42,7 @@ const CreateCardSchema = z.object({
|
|||||||
reportPath: z.string().trim().max(500).optional().nullable(),
|
reportPath: z.string().trim().max(500).optional().nullable(),
|
||||||
createdBy: z.string().trim().max(120).optional().default('aurora'),
|
createdBy: z.string().trim().max(120).optional().default('aurora'),
|
||||||
parents: z.array(z.string().trim().min(1).max(200)).optional().default([]),
|
parents: z.array(z.string().trim().min(1).max(200)).optional().default([]),
|
||||||
|
tags: TagsSchema,
|
||||||
idempotencyKey: z.string().trim().max(500).optional().nullable(),
|
idempotencyKey: z.string().trim().max(500).optional().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,7 +73,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
|
|||||||
}
|
}
|
||||||
const data = parsed.data
|
const data = parsed.data
|
||||||
const card = await createKanbanCard({
|
const card = await createKanbanCard({
|
||||||
title: data.title ?? '',
|
title: data.title,
|
||||||
spec: data.spec,
|
spec: data.spec,
|
||||||
acceptanceCriteria: data.acceptanceCriteria,
|
acceptanceCriteria: data.acceptanceCriteria,
|
||||||
assignedWorker: data.assignedWorker,
|
assignedWorker: data.assignedWorker,
|
||||||
@@ -68,6 +83,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
|
|||||||
reportPath: data.reportPath,
|
reportPath: data.reportPath,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
parents: data.parents,
|
parents: data.parents,
|
||||||
|
tags: data.tags,
|
||||||
idempotencyKey: data.idempotencyKey,
|
idempotencyKey: data.idempotencyKey,
|
||||||
})
|
})
|
||||||
return json({ ok: true, card, backend: getKanbanBackendMeta() })
|
return json({ ok: true, card, backend: getKanbanBackendMeta() })
|
||||||
|
|||||||
@@ -386,6 +386,45 @@ function SettingsRoute() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WorkspaceThemePicker />
|
<WorkspaceThemePicker />
|
||||||
|
<div className="grid gap-3 pt-3 md:grid-cols-2">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-primary-900">
|
||||||
|
Interface font
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={settings.interfaceFont}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSettings({
|
||||||
|
interfaceFont: event.target.value as typeof settings.interfaceFont,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none"
|
||||||
|
>
|
||||||
|
<option value="system">System sans</option>
|
||||||
|
<option value="inter">Inter-style sans</option>
|
||||||
|
<option value="serif">Serif</option>
|
||||||
|
<option value="mono">Monospace</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-primary-900">
|
||||||
|
Spacing density
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={settings.interfaceDensity}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSettings({
|
||||||
|
interfaceDensity: event.target.value as typeof settings.interfaceDensity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none"
|
||||||
|
>
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
<option value="comfortable">Comfortable</option>
|
||||||
|
<option value="spacious">Spacious</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ export const CHAT_OPEN_MESSAGE_SEARCH_EVENT = 'claude:chat-open-message-search'
|
|||||||
|
|
||||||
export const CHAT_RUN_COMMAND_EVENT = 'claude:chat-run-command'
|
export const CHAT_RUN_COMMAND_EVENT = 'claude:chat-run-command'
|
||||||
|
|
||||||
|
export const CHAT_SUBMIT_SELECTION_EVENT = 'claude:chat-submit-selection'
|
||||||
|
|
||||||
export const CHAT_PENDING_COMMAND_STORAGE_KEY = 'claude.pending-chat-command'
|
export const CHAT_PENDING_COMMAND_STORAGE_KEY = 'claude.pending-chat-command'
|
||||||
|
|
||||||
export type ChatRunCommandDetail = {
|
export type ChatRunCommandDetail = {
|
||||||
command: string
|
command: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChatSubmitSelectionDetail = {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export const CHAT_OPEN_SETTINGS_EVENT = 'claude:chat-open-settings'
|
export const CHAT_OPEN_SETTINGS_EVENT = 'claude:chat-open-settings'
|
||||||
|
|
||||||
export type ChatOpenSettingsDetail = {
|
export type ChatOpenSettingsDetail = {
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ import {
|
|||||||
CHAT_OPEN_SETTINGS_EVENT,
|
CHAT_OPEN_SETTINGS_EVENT,
|
||||||
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
||||||
CHAT_RUN_COMMAND_EVENT,
|
CHAT_RUN_COMMAND_EVENT,
|
||||||
|
CHAT_SUBMIT_SELECTION_EVENT,
|
||||||
|
} from './chat-events'
|
||||||
|
import type {
|
||||||
|
ChatRunCommandDetail,
|
||||||
|
ChatSubmitSelectionDetail,
|
||||||
} from './chat-events'
|
} from './chat-events'
|
||||||
import type {ResponseWaitSnapshot} from './chat-screen-utils';
|
import type {ResponseWaitSnapshot} from './chat-screen-utils';
|
||||||
import type {
|
import type {
|
||||||
@@ -75,7 +80,6 @@ import type {
|
|||||||
} from './components/chat-composer'
|
} from './components/chat-composer'
|
||||||
import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store'
|
import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store'
|
||||||
import type { ChatAttachment, ChatMessage, SessionMeta } from './types'
|
import type { ChatAttachment, ChatMessage, SessionMeta } from './types'
|
||||||
import type { ChatRunCommandDetail } from './chat-events'
|
|
||||||
import type {AgentActivity} from '@/stores/chat-activity-store';
|
import type {AgentActivity} from '@/stores/chat-activity-store';
|
||||||
import { useChatSettingsStore } from '@/hooks/use-chat-settings'
|
import { useChatSettingsStore } from '@/hooks/use-chat-settings'
|
||||||
import { playChatComplete } from '@/lib/sounds'
|
import { playChatComplete } from '@/lib/sounds'
|
||||||
@@ -2567,6 +2571,23 @@ export function ChatScreen({
|
|||||||
}
|
}
|
||||||
}, [runPaletteSlashCommand])
|
}, [runPaletteSlashCommand])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleSubmitSelection(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<ChatSubmitSelectionDetail>).detail
|
||||||
|
const text = detail?.text?.trim()
|
||||||
|
if (!text) return
|
||||||
|
send(text, [], false, commandHelpers)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(CHAT_SUBMIT_SELECTION_EVENT, handleSubmitSelection)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
CHAT_SUBMIT_SELECTION_EVENT,
|
||||||
|
handleSubmitSelection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [commandHelpers, send])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pendingCommand = window.sessionStorage.getItem(
|
const pendingCommand = window.sessionStorage.getItem(
|
||||||
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
shouldAutoExpandHermesActivityCard,
|
shouldAutoExpandHermesActivityCard,
|
||||||
} from './streaming-activity-ui'
|
} from './streaming-activity-ui'
|
||||||
import { TuiActivityCard } from './tui-activity-card'
|
import { TuiActivityCard } from './tui-activity-card'
|
||||||
import type { ChatAttachment, ChatMessage, ToolCallContent } from '../types'
|
import type { ChatAttachment, ChatMessage, SelectionCardContent, ToolCallContent } from '../types'
|
||||||
import type { ToolPart } from '@/components/prompt-kit/tool'
|
import type { ToolPart } from '@/components/prompt-kit/tool'
|
||||||
import { AssistantAvatar, UserAvatar } from '@/components/avatars'
|
import { AssistantAvatar, UserAvatar } from '@/components/avatars'
|
||||||
import { CodeBlock } from '@/components/prompt-kit/code-block'
|
import { CodeBlock } from '@/components/prompt-kit/code-block'
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
useChatSettingsStore,
|
useChatSettingsStore,
|
||||||
} from '@/hooks/use-chat-settings'
|
} from '@/hooks/use-chat-settings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { CHAT_SUBMIT_SELECTION_EVENT } from '@/screens/chat/chat-events'
|
||||||
|
|
||||||
const WORDS_PER_TICK = 4
|
const WORDS_PER_TICK = 4
|
||||||
const TICK_INTERVAL_MS = 50
|
const TICK_INTERVAL_MS = 50
|
||||||
@@ -151,6 +152,93 @@ type MessageItemProps = {
|
|||||||
isLastAssistant?: boolean
|
isLastAssistant?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dispatchSelectionCardReply(text: string) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(CHAT_SUBMIT_SELECTION_EVENT, { detail: { text } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractiveSelectionCard({ card }: { card: SelectionCardContent }) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||||
|
const mode = card.mode ?? 'single'
|
||||||
|
const options = Array.isArray(card.options) ? card.options : []
|
||||||
|
const isMulti = mode === 'multi'
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(isMulti ? prev : [])
|
||||||
|
if (next.has(value)) next.delete(value)
|
||||||
|
else next.add(value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(value?: string) {
|
||||||
|
const values = value ? [value] : [...selected]
|
||||||
|
if (values.length === 0) return
|
||||||
|
dispatchSelectionCardReply(values.join(', '))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-2 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-sm">
|
||||||
|
<div className="border-b border-[var(--theme-border)] px-3 py-2">
|
||||||
|
<div className="text-sm font-semibold text-[var(--theme-text)]">
|
||||||
|
{card.title || 'Choose an option'}
|
||||||
|
</div>
|
||||||
|
{card.body ? (
|
||||||
|
<div className="mt-1 text-xs text-[var(--theme-muted)]">{card.body}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 p-2">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const value = option.value || option.label
|
||||||
|
const id = option.id || value || String(index)
|
||||||
|
const isSelected = selected.has(value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => (isMulti ? toggle(value) : submit(value))}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-start gap-2 rounded-xl border px-3 py-2 text-left text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)] text-[var(--theme-text)]'
|
||||||
|
: 'border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-current text-[10px]">
|
||||||
|
{isSelected ? '✓' : isMulti ? '' : index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium">{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="mt-0.5 block text-xs opacity-70">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isMulti || mode === 'confirm' ? (
|
||||||
|
<div className="flex items-center justify-between border-t border-[var(--theme-border)] px-3 py-2 text-xs text-[var(--theme-muted)]">
|
||||||
|
<span>{selected.size} selected</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
disabled={selected.size === 0}
|
||||||
|
className="rounded-full bg-[var(--theme-accent)] px-3 py-1.5 font-semibold text-primary-950 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{card.submitLabel || 'Send choice'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type InlineToolSection = {
|
type InlineToolSection = {
|
||||||
key: string
|
key: string
|
||||||
type: string
|
type: string
|
||||||
@@ -167,10 +255,12 @@ type InlineToolSection = {
|
|||||||
|
|
||||||
export type InlineRenderPlanItem =
|
export type InlineRenderPlanItem =
|
||||||
| { kind: 'text'; text: string }
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'selection-card'; card: SelectionCardContent }
|
||||||
| { kind: 'tool'; section: InlineToolSection }
|
| { kind: 'tool'; section: InlineToolSection }
|
||||||
|
|
||||||
export type CompactInlineRenderPlanItem =
|
export type CompactInlineRenderPlanItem =
|
||||||
| { kind: 'text'; text: string }
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'selection-card'; card: SelectionCardContent }
|
||||||
| { kind: 'tools'; sections: Array<InlineToolSection> }
|
| { kind: 'tools'; sections: Array<InlineToolSection> }
|
||||||
|
|
||||||
export function buildInlineToolRenderPlan(
|
export function buildInlineToolRenderPlan(
|
||||||
@@ -204,6 +294,11 @@ export function buildInlineToolRenderPlan(
|
|||||||
usedKeys.add(matchingSection.key)
|
usedKeys.add(matchingSection.key)
|
||||||
plan.push({ kind: 'tool', section: matchingSection })
|
plan.push({ kind: 'tool', section: matchingSection })
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'selectionCard') {
|
||||||
|
plan.push({ kind: 'selection-card', card: part })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2201,6 +2296,14 @@ function MessageItemComponent({
|
|||||||
.filter((img) => img.src.length > 0)
|
.filter((img) => img.src.length > 0)
|
||||||
}, [message.content])
|
}, [message.content])
|
||||||
const hasInlineImages = inlineImages.length > 0
|
const hasInlineImages = inlineImages.length > 0
|
||||||
|
const selectionCards = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(message.content) ? message.content : []).filter(
|
||||||
|
(part): part is SelectionCardContent => part.type === 'selectionCard',
|
||||||
|
),
|
||||||
|
[message.content],
|
||||||
|
)
|
||||||
|
const hasSelectionCards = selectionCards.length > 0
|
||||||
|
|
||||||
const hasText = displayText.length > 0
|
const hasText = displayText.length > 0
|
||||||
const hasRenderableAssistantText =
|
const hasRenderableAssistantText =
|
||||||
@@ -2393,6 +2496,7 @@ function MessageItemComponent({
|
|||||||
hasText ||
|
hasText ||
|
||||||
hasAttachments ||
|
hasAttachments ||
|
||||||
hasInlineImages ||
|
hasInlineImages ||
|
||||||
|
hasSelectionCards ||
|
||||||
(effectiveIsStreaming && hasRevealedText)
|
(effectiveIsStreaming && hasRevealedText)
|
||||||
|
|
||||||
// 'queued' = delivered to server, waiting for response (busy/backlogged)
|
// 'queued' = delivered to server, waiting for response (busy/backlogged)
|
||||||
@@ -2494,7 +2598,7 @@ function MessageItemComponent({
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex flex-col',
|
'group relative flex flex-col',
|
||||||
hasText || hasAttachments ? 'gap-0.5 md:gap-1' : 'gap-0',
|
hasText || hasAttachments || hasSelectionCards ? 'gap-0.5 md:gap-1' : 'gap-0',
|
||||||
wrapperClassName,
|
wrapperClassName,
|
||||||
isUser ? 'items-end' : 'items-start',
|
isUser ? 'items-end' : 'items-start',
|
||||||
!isUser && isNew && 'animate-[message-fade-in_0.4s_ease-out]',
|
!isUser && isNew && 'animate-[message-fade-in_0.4s_ease-out]',
|
||||||
@@ -2689,6 +2793,16 @@ function MessageItemComponent({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasSelectionCards ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{selectionCards.map((card, index) => (
|
||||||
|
<InteractiveSelectionCard
|
||||||
|
key={card.id || `${wrapperDataMessageId ?? 'selection'}-${index}`}
|
||||||
|
card={card}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{hasText &&
|
{hasText &&
|
||||||
(isUser ? (
|
(isUser ? (
|
||||||
<span className="text-pretty">{displayText}</span>
|
<span className="text-pretty">{displayText}</span>
|
||||||
|
|||||||
@@ -27,7 +27,26 @@ export type ThinkingContent = {
|
|||||||
thinkingSignature?: string
|
thinkingSignature?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageContent = TextContent | ToolCallContent | ThinkingContent
|
export type SelectionCardContent = {
|
||||||
|
type: 'selectionCard'
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
mode?: 'single' | 'multi' | 'confirm'
|
||||||
|
options?: Array<{
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
description?: string
|
||||||
|
}>
|
||||||
|
submitLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageContent =
|
||||||
|
| TextContent
|
||||||
|
| ToolCallContent
|
||||||
|
| ThinkingContent
|
||||||
|
| SelectionCardContent
|
||||||
|
|
||||||
export type ChatAttachment = {
|
export type ChatAttachment = {
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { WaveChatPanelsShowcase } from './components/wave-chat-panels-showcase'
|
import { WaveChatPanelsShowcase } from './components/wave-chat-panels-showcase'
|
||||||
|
|
||||||
const HERMES_WORLD_ORIGIN = 'https://hermes-world.ai'
|
const HERMES_WORLD_ORIGIN = 'https://hermes-world.ai'
|
||||||
|
|
||||||
export function HermesWorldEmbed() {
|
export function HermesWorldEmbed() {
|
||||||
const [loaded, setLoaded] = useState(false)
|
|
||||||
const showPanelShowcase = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('panels') === 'wave-chat'
|
const showPanelShowcase = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('panels') === 'wave-chat'
|
||||||
const src = useMemo(() => {
|
const playUrl = useMemo(() => {
|
||||||
const url = new URL('/play/', HERMES_WORLD_ORIGIN)
|
const url = new URL('/play/', HERMES_WORLD_ORIGIN)
|
||||||
url.searchParams.set('embed', 'workspace')
|
|
||||||
url.searchParams.set('source', 'hermes-workspace')
|
url.searchParams.set('source', 'hermes-workspace')
|
||||||
return url.toString()
|
return url.toString()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -18,32 +16,40 @@ export function HermesWorldEmbed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative h-full min-h-0 overflow-hidden bg-[#050015] text-white">
|
<main className="relative flex h-full min-h-0 items-center justify-center overflow-hidden bg-[#050015] px-4 text-white">
|
||||||
{!loaded && (
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,.24),transparent_48%),#050015]" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,.24),transparent_48%),#050015]">
|
<div className="relative max-w-xl rounded-3xl border border-white/12 bg-black/45 px-6 py-6 text-center shadow-2xl backdrop-blur-xl">
|
||||||
<div className="rounded-3xl border border-white/12 bg-black/35 px-6 py-5 text-center shadow-2xl backdrop-blur-xl">
|
<div className="text-xs font-bold uppercase tracking-[0.24em] text-cyan-200/70">
|
||||||
<div className="text-xs font-bold uppercase tracking-[0.24em] text-cyan-200/70">Hermes Workspace</div>
|
Hermes Workspace
|
||||||
<div className="mt-2 text-2xl font-black tracking-tight">Opening HermesWorld…</div>
|
|
||||||
<div className="mt-2 text-sm text-white/58">Runtime hosted by hermes-world.ai</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h1 className="mt-2 text-3xl font-black tracking-tight">
|
||||||
)}
|
Open HermesWorld in a full tab
|
||||||
<iframe
|
</h1>
|
||||||
title="HermesWorld"
|
<p className="mt-3 text-sm leading-relaxed text-white/65">
|
||||||
src={src}
|
HermesWorld currently refuses iframe embedding, so Workspace no longer
|
||||||
className="h-full w-full border-0 bg-[#050015]"
|
loads it in an iframe that fails with “refused to connect”. The hosted
|
||||||
allow="fullscreen; clipboard-read; clipboard-write; gamepad"
|
build should be opened directly while the game deployment fixes its
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
stale asset/MIME issue for <code className="rounded bg-white/10 px-1">/assets/styles-*.css</code>.
|
||||||
onLoad={() => setLoaded(true)}
|
</p>
|
||||||
/>
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href={`${HERMES_WORLD_ORIGIN}/play/`}
|
href={playUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="absolute right-3 top-3 z-10 rounded-full border border-white/15 bg-black/45 px-3 py-1.5 text-xs font-bold uppercase tracking-[0.16em] text-white/70 backdrop-blur transition hover:border-cyan-200/40 hover:text-white"
|
className="rounded-full bg-cyan-300 px-5 py-2 text-sm font-black uppercase tracking-[0.14em] text-slate-950 transition hover:bg-white"
|
||||||
>
|
>
|
||||||
Open full
|
Open full
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href={`${HERMES_WORLD_ORIGIN}/`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-full border border-white/15 bg-white/8 px-5 py-2 text-sm font-bold uppercase tracking-[0.14em] text-white/75 transition hover:border-cyan-200/40 hover:text-white"
|
||||||
|
>
|
||||||
|
Site root
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ type SwarmKanbanCard = {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
|
tags?: Array<string>
|
||||||
|
latestRun?: {
|
||||||
|
summary?: string | null
|
||||||
|
outcome?: string | null
|
||||||
|
status?: string | null
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanWorker = {
|
type KanbanWorker = {
|
||||||
@@ -158,6 +164,7 @@ async function createKanbanCard(input: {
|
|||||||
reviewer: string | null
|
reviewer: string | null
|
||||||
status: KanbanLane
|
status: KanbanLane
|
||||||
missionId: string | null
|
missionId: string | null
|
||||||
|
tags: Array<string>
|
||||||
}): Promise<SwarmKanbanCard> {
|
}): Promise<SwarmKanbanCard> {
|
||||||
const res = await fetch('/api/swarm-kanban', {
|
const res = await fetch('/api/swarm-kanban', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -187,6 +194,50 @@ function splitCriteria(value: string): Array<string> {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitTags(value: string): Array<string> {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedTaskLabel = { tier1: string; tier2?: string; color: string }
|
||||||
|
|
||||||
|
const LABEL_COLORS = [
|
||||||
|
'border-sky-400/50 bg-sky-500/10 text-sky-700',
|
||||||
|
'border-violet-400/50 bg-violet-500/10 text-violet-700',
|
||||||
|
'border-emerald-400/50 bg-emerald-500/10 text-emerald-700',
|
||||||
|
'border-amber-400/50 bg-amber-500/10 text-amber-700',
|
||||||
|
'border-rose-400/50 bg-rose-500/10 text-rose-700',
|
||||||
|
'border-cyan-400/50 bg-cyan-500/10 text-cyan-700',
|
||||||
|
]
|
||||||
|
|
||||||
|
function labelColor(tier1: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of tier1) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||||
|
return LABEL_COLORS[hash % LABEL_COLORS.length] ?? LABEL_COLORS[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTaskLabel(tag: string): ParsedTaskLabel | null {
|
||||||
|
const raw = tag.trim()
|
||||||
|
if (!raw.toLowerCase().startsWith('label:')) return null
|
||||||
|
const body = raw.slice('label:'.length).trim()
|
||||||
|
if (!body) return null
|
||||||
|
const [tier1, ...rest] = body.split('/').map((part) => part.trim()).filter(Boolean)
|
||||||
|
if (!tier1) return null
|
||||||
|
return { tier1, tier2: rest.join(' / ') || undefined, color: labelColor(tier1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsedSince(timestamp: number): string {
|
||||||
|
const ageMs = Math.max(0, Date.now() - timestamp)
|
||||||
|
const minutes = Math.floor(ageMs / 60_000)
|
||||||
|
if (minutes < 1) return '<1m'
|
||||||
|
if (minutes < 60) return `${minutes}m`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const remainingMinutes = minutes % 60
|
||||||
|
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`
|
||||||
|
}
|
||||||
|
|
||||||
function workerLabel(workers: Array<KanbanWorker>, workerId: string | null): string {
|
function workerLabel(workers: Array<KanbanWorker>, workerId: string | null): string {
|
||||||
if (!workerId) return 'Unassigned'
|
if (!workerId) return 'Unassigned'
|
||||||
const worker = workers.find((item) => item.id === workerId)
|
const worker = workers.find((item) => item.id === workerId)
|
||||||
@@ -209,6 +260,8 @@ export function Swarm2KanbanBoard({
|
|||||||
const [draftWorker, setDraftWorker] = useState(selectedWorkerId ?? '')
|
const [draftWorker, setDraftWorker] = useState(selectedWorkerId ?? '')
|
||||||
const [draftReviewer, setDraftReviewer] = useState('')
|
const [draftReviewer, setDraftReviewer] = useState('')
|
||||||
const [draftStatus, setDraftStatus] = useState<KanbanLane>('backlog')
|
const [draftStatus, setDraftStatus] = useState<KanbanLane>('backlog')
|
||||||
|
const [draftLabels, setDraftLabels] = useState('')
|
||||||
|
const [activeLabelFilter, setActiveLabelFilter] = useState<string | null>(null)
|
||||||
const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission))
|
const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission))
|
||||||
const [backendToast, setBackendToast] = useState<KanbanBackendPresentation | null>(null)
|
const [backendToast, setBackendToast] = useState<KanbanBackendPresentation | null>(null)
|
||||||
const lastToastedBackendKey = useRef<string | null>(null)
|
const lastToastedBackendKey = useRef<string | null>(null)
|
||||||
@@ -255,6 +308,7 @@ export function Swarm2KanbanBoard({
|
|||||||
reviewer: draftReviewer || null,
|
reviewer: draftReviewer || null,
|
||||||
status: draftStatus,
|
status: draftStatus,
|
||||||
missionId: linkLatestMission ? latestMission?.id ?? null : null,
|
missionId: linkLatestMission ? latestMission?.id ?? null : null,
|
||||||
|
tags: splitTags(draftLabels),
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setDraftTitle('')
|
setDraftTitle('')
|
||||||
@@ -263,6 +317,7 @@ export function Swarm2KanbanBoard({
|
|||||||
setDraftWorker(selectedWorkerId ?? '')
|
setDraftWorker(selectedWorkerId ?? '')
|
||||||
setDraftReviewer('')
|
setDraftReviewer('')
|
||||||
setDraftStatus('backlog')
|
setDraftStatus('backlog')
|
||||||
|
setDraftLabels('')
|
||||||
setComposerOpen(false)
|
setComposerOpen(false)
|
||||||
await queryClient.invalidateQueries({ queryKey: ['swarm2', 'kanban'] })
|
await queryClient.invalidateQueries({ queryKey: ['swarm2', 'kanban'] })
|
||||||
},
|
},
|
||||||
@@ -275,15 +330,38 @@ export function Swarm2KanbanBoard({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const labelOptions = useMemo(() => {
|
||||||
|
const labels = new Map<string, ParsedTaskLabel>()
|
||||||
|
for (const card of query.data?.cards ?? []) {
|
||||||
|
for (const tag of card.tags ?? []) {
|
||||||
|
const parsed = parseTaskLabel(tag)
|
||||||
|
if (parsed) labels.set(`${parsed.tier1}${parsed.tier2 ? `/${parsed.tier2}` : ''}`, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...labels.entries()].map(([key, label]) => ({ key, label }))
|
||||||
|
}, [query.data])
|
||||||
|
|
||||||
|
const visibleCards = useMemo(() => {
|
||||||
|
const cards = query.data?.cards ?? []
|
||||||
|
if (!activeLabelFilter) return cards
|
||||||
|
return cards.filter((card) =>
|
||||||
|
(card.tags ?? []).some((tag) => {
|
||||||
|
const parsed = parseTaskLabel(tag)
|
||||||
|
const key = parsed ? `${parsed.tier1}${parsed.tier2 ? `/${parsed.tier2}` : ''}` : ''
|
||||||
|
return key === activeLabelFilter || parsed?.tier1 === activeLabelFilter
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [activeLabelFilter, query.data])
|
||||||
|
|
||||||
const cardsByLane = useMemo(() => {
|
const cardsByLane = useMemo(() => {
|
||||||
const map = new Map<KanbanLane, Array<SwarmKanbanCard>>()
|
const map = new Map<KanbanLane, Array<SwarmKanbanCard>>()
|
||||||
for (const lane of LANES) map.set(lane.id, [])
|
for (const lane of LANES) map.set(lane.id, [])
|
||||||
for (const card of query.data?.cards ?? []) {
|
for (const card of visibleCards) {
|
||||||
const bucket = map.get(card.status) ?? map.get('backlog')!
|
const bucket = map.get(card.status) ?? map.get('backlog')!
|
||||||
bucket.push(card)
|
bucket.push(card)
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [query.data])
|
}, [visibleCards])
|
||||||
|
|
||||||
const total = query.data?.cards.length ?? 0
|
const total = query.data?.cards.length ?? 0
|
||||||
const reviewCount = cardsByLane.get('review')?.length ?? 0
|
const reviewCount = cardsByLane.get('review')?.length ?? 0
|
||||||
@@ -365,6 +443,39 @@ export function Swarm2KanbanBoard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{labelOptions.length > 0 ? (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveLabelFilter(null)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-2.5 py-1 font-semibold transition-colors',
|
||||||
|
!activeLabelFilter
|
||||||
|
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]'
|
||||||
|
: 'border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-muted)] hover:text-[var(--theme-text)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All labels
|
||||||
|
</button>
|
||||||
|
{labelOptions.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveLabelFilter(key)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-2.5 py-1 font-semibold transition-colors',
|
||||||
|
label.color,
|
||||||
|
activeLabelFilter === key ? 'ring-2 ring-[var(--theme-accent)]' : '',
|
||||||
|
)}
|
||||||
|
title={label.tier2 ? `${label.tier1} → ${label.tier2}` : label.tier1}
|
||||||
|
>
|
||||||
|
{label.tier1}
|
||||||
|
{label.tier2 ? <span className="ml-1 opacity-70">/{label.tier2}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{backendToast ? (
|
{backendToast ? (
|
||||||
<div className="fixed right-4 top-4 z-50 max-w-sm rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-3 text-sm text-[var(--theme-text)] shadow-[0_18px_60px_var(--theme-shadow)]" role="status" aria-live="polite">
|
<div className="fixed right-4 top-4 z-50 max-w-sm rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-3 text-sm text-[var(--theme-text)] shadow-[0_18px_60px_var(--theme-shadow)]" role="status" aria-live="polite">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -425,6 +536,11 @@ export function Swarm2KanbanBoard({
|
|||||||
{LANES.map((lane) => <option key={lane.id} value={lane.id}>{lane.label}</option>)}
|
{LANES.map((lane) => <option key={lane.id} value={lane.id}>{lane.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block text-xs md:col-span-2">
|
||||||
|
<span className="mb-1 block font-semibold text-[var(--theme-muted)]">Labels</span>
|
||||||
|
<input value={draftLabels} onChange={(event) => setDraftLabels(event.target.value)} placeholder="label:Hermes/Workspace, priority:high" className="w-full rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-sm text-[var(--theme-text)] outline-none" />
|
||||||
|
<span className="mt-1 block text-[10px] text-[var(--theme-muted)]">Use label:Business/Sub-scope for the two-tier board filter.</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 self-end rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-muted)]">
|
<label className="flex items-center gap-2 self-end rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-muted)]">
|
||||||
<input type="checkbox" checked={linkLatestMission} disabled={!latestMission} onChange={(event) => setLinkLatestMission(event.target.checked)} />
|
<input type="checkbox" checked={linkLatestMission} disabled={!latestMission} onChange={(event) => setLinkLatestMission(event.target.checked)} />
|
||||||
Link latest mission{latestMission ? `: ${latestMission.title}` : ''}
|
Link latest mission{latestMission ? `: ${latestMission.title}` : ''}
|
||||||
@@ -476,6 +592,27 @@ export function Swarm2KanbanBoard({
|
|||||||
{card.acceptanceCriteria.length > 3 ? <li>+{card.acceptanceCriteria.length - 3} more</li> : null}
|
{card.acceptanceCriteria.length > 3 ? <li>+{card.acceptanceCriteria.length - 3} more</li> : null}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
{card.tags?.length ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{card.tags.slice(0, 4).map((tag) => {
|
||||||
|
const parsed = parseTaskLabel(tag)
|
||||||
|
return parsed ? (
|
||||||
|
<span key={tag} className={cn('rounded-full border px-1.5 py-0.5 text-[9px] font-semibold', parsed.color)}>
|
||||||
|
{parsed.tier1}{parsed.tier2 ? <span className="opacity-70">/{parsed.tier2}</span> : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={tag} className="rounded-full border border-[var(--theme-border)] px-1.5 py-0.5 text-[9px] text-[var(--theme-muted)]">{tag}</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{card.status === 'running' || card.latestRun ? (
|
||||||
|
<div className="mt-2 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-2 py-1.5 text-[10px] text-emerald-700">
|
||||||
|
<div className="font-semibold">{card.status === 'running' ? `Running for ${formatElapsedSince(card.updatedAt)}` : 'Latest run'}</div>
|
||||||
|
{card.latestRun?.summary ? <div className="mt-0.5 line-clamp-2">{card.latestRun.summary}</div> : null}
|
||||||
|
{card.latestRun && (card.latestRun.status || card.latestRun.outcome) ? <div className="mt-0.5 opacity-75">{[card.latestRun.status, card.latestRun.outcome].filter(Boolean).join(' · ')}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-3 space-y-1 text-[10px] text-[var(--theme-muted)]">
|
<div className="mt-3 space-y-1 text-[10px] text-[var(--theme-muted)]">
|
||||||
<div>Owner: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.assignedWorker)}</span></div>
|
<div>Owner: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.assignedWorker)}</span></div>
|
||||||
<div>Reviewer: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.reviewer)}</span></div>
|
<div>Reviewer: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.reviewer)}</span></div>
|
||||||
|
|||||||
@@ -110,23 +110,53 @@ export function getLocalMessages(sessionId: string): Array<LocalMessage> {
|
|||||||
return store.messages[sessionId] ?? []
|
return store.messages[sessionId] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchLocalSessions(
|
||||||
|
query: string,
|
||||||
|
limit = 20,
|
||||||
|
): Array<LocalSession & { snippet: string }> {
|
||||||
|
const normalized = query.trim().toLowerCase()
|
||||||
|
if (!normalized) return []
|
||||||
|
|
||||||
|
const results: Array<LocalSession & { snippet: string }> = []
|
||||||
|
const sessions = listLocalSessions()
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const title = session.title || ''
|
||||||
|
const messages = store.messages[session.id] ?? []
|
||||||
|
const matchingMessage = messages.find((message) =>
|
||||||
|
message.content.toLowerCase().includes(normalized),
|
||||||
|
)
|
||||||
|
if (!title.toLowerCase().includes(normalized) && !matchingMessage) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = matchingMessage?.content || title || session.id
|
||||||
|
const lowerContent = content.toLowerCase()
|
||||||
|
const matchIndex = lowerContent.indexOf(normalized)
|
||||||
|
const start = matchIndex >= 0 ? Math.max(0, matchIndex - 80) : 0
|
||||||
|
const snippet = content.slice(start, start + 220).trim()
|
||||||
|
results.push({ ...session, snippet })
|
||||||
|
if (results.length >= limit) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
export function appendLocalMessage(
|
export function appendLocalMessage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
message: LocalMessage,
|
message: LocalMessage,
|
||||||
): void {
|
): void {
|
||||||
ensureLocalSession(sessionId)
|
const session = ensureLocalSession(sessionId)
|
||||||
if (!store.messages[sessionId]) store.messages[sessionId] = []
|
const messages = store.messages[sessionId] ?? []
|
||||||
store.messages[sessionId].push(message)
|
store.messages[sessionId] = messages
|
||||||
|
messages.push(message)
|
||||||
if (store.messages[sessionId].length > MAX_MESSAGES_PER_SESSION) {
|
if (store.messages[sessionId].length > MAX_MESSAGES_PER_SESSION) {
|
||||||
store.messages[sessionId] = store.messages[sessionId].slice(
|
store.messages[sessionId] = store.messages[sessionId].slice(
|
||||||
-MAX_MESSAGES_PER_SESSION,
|
-MAX_MESSAGES_PER_SESSION,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const session = store.sessions[sessionId]
|
|
||||||
if (session) {
|
|
||||||
session.messageCount = store.messages[sessionId].length
|
session.messageCount = store.messages[sessionId].length
|
||||||
session.updatedAt = Date.now()
|
session.updatedAt = Date.now()
|
||||||
}
|
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type SwarmKanbanCard = {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
spec: string
|
spec: string
|
||||||
acceptanceCriteria: string[]
|
acceptanceCriteria: Array<string>
|
||||||
assignedWorker: string | null
|
assignedWorker: string | null
|
||||||
reviewer: string | null
|
reviewer: string | null
|
||||||
status: SwarmKanbanLane | string
|
status: SwarmKanbanLane | string
|
||||||
@@ -19,13 +19,14 @@ export type SwarmKanbanCard = {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
parents?: string[]
|
parents?: Array<string>
|
||||||
children?: string[]
|
children?: Array<string>
|
||||||
latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null
|
latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null
|
||||||
|
tags?: Array<string>
|
||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SwarmKanbanFile = { cards: SwarmKanbanCard[] }
|
type SwarmKanbanFile = { cards: Array<SwarmKanbanCard> }
|
||||||
|
|
||||||
type ListFilters = {
|
type ListFilters = {
|
||||||
status?: string | null
|
status?: string | null
|
||||||
@@ -37,14 +38,15 @@ type ListFilters = {
|
|||||||
export type CreateSwarmKanbanCardInput = {
|
export type CreateSwarmKanbanCardInput = {
|
||||||
title: string
|
title: string
|
||||||
spec?: string
|
spec?: string
|
||||||
acceptanceCriteria?: string[]
|
acceptanceCriteria?: Array<string>
|
||||||
assignedWorker?: string | null
|
assignedWorker?: string | null
|
||||||
reviewer?: string | null
|
reviewer?: string | null
|
||||||
status?: SwarmKanbanLane | null
|
status?: SwarmKanbanLane | null
|
||||||
missionId?: string | null
|
missionId?: string | null
|
||||||
reportPath?: string | null
|
reportPath?: string | null
|
||||||
createdBy?: string | null
|
createdBy?: string | null
|
||||||
parents?: string[]
|
parents?: Array<string>
|
||||||
|
tags?: Array<string>
|
||||||
idempotencyKey?: string | null
|
idempotencyKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +84,18 @@ function normalizeStatus(value: unknown): SwarmKanbanLane {
|
|||||||
return SWARM_KANBAN_LANES.includes(value as SwarmKanbanLane) ? (value as SwarmKanbanLane) : 'backlog'
|
return SWARM_KANBAN_LANES.includes(value as SwarmKanbanLane) ? (value as SwarmKanbanLane) : 'backlog'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCriteria(value: unknown): string[] {
|
function normalizeCriteria(value: unknown): Array<string> {
|
||||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
|
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
|
||||||
if (typeof value === 'string') return value.split('\n').map((item) => item.trim()).filter(Boolean)
|
if (typeof value === 'string') return value.split('\n').map((item) => item.trim()).filter(Boolean)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTags(value: unknown): Array<string> {
|
||||||
|
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
|
||||||
|
if (typeof value === 'string') return value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
function optionalString(value: unknown): string | null {
|
function optionalString(value: unknown): string | null {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||||
}
|
}
|
||||||
@@ -107,10 +115,15 @@ function normalizeCard(card: (Partial<Omit<SwarmKanbanCard, 'status'>> & { id?:
|
|||||||
createdBy: typeof card.createdBy === 'string' && card.createdBy ? card.createdBy : 'swarm2-kanban',
|
createdBy: typeof card.createdBy === 'string' && card.createdBy ? card.createdBy : 'swarm2-kanban',
|
||||||
createdAt: typeof card.createdAt === 'number' ? card.createdAt : now,
|
createdAt: typeof card.createdAt === 'number' ? card.createdAt : now,
|
||||||
updatedAt: typeof card.updatedAt === 'number' ? card.updatedAt : now,
|
updatedAt: typeof card.updatedAt === 'number' ? card.updatedAt : now,
|
||||||
|
parents: normalizeTags(card.parents),
|
||||||
|
children: normalizeTags(card.children),
|
||||||
|
latestRun: card.latestRun ?? null,
|
||||||
|
tags: normalizeTags(card.tags),
|
||||||
|
source: typeof card.source === 'string' ? card.source : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSwarmKanbanCards(filters: ListFilters = {}): SwarmKanbanCard[] {
|
export function listSwarmKanbanCards(filters: ListFilters = {}): Array<SwarmKanbanCard> {
|
||||||
let cards = readKanbanFile().cards
|
let cards = readKanbanFile().cards
|
||||||
if (filters.status) cards = cards.filter((card) => card.status === normalizeStatus(filters.status))
|
if (filters.status) cards = cards.filter((card) => card.status === normalizeStatus(filters.status))
|
||||||
if (filters.assignedWorker) cards = cards.filter((card) => card.assignedWorker === filters.assignedWorker)
|
if (filters.assignedWorker) cards = cards.filter((card) => card.assignedWorker === filters.assignedWorker)
|
||||||
@@ -135,6 +148,8 @@ export function createSwarmKanbanCard(input: CreateSwarmKanbanCardInput): SwarmK
|
|||||||
createdBy: input.createdBy ?? 'swarm2-kanban',
|
createdBy: input.createdBy ?? 'swarm2-kanban',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
parents: input.parents,
|
||||||
|
tags: input.tags,
|
||||||
})
|
})
|
||||||
file.cards.push(card)
|
file.cards.push(card)
|
||||||
writeKanbanFile(file)
|
writeKanbanFile(file)
|
||||||
|
|||||||
@@ -190,9 +190,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--interface-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--interface-density-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-interface-font='inter'] {
|
||||||
|
--interface-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-interface-font='serif'] {
|
||||||
|
--interface-font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-interface-font='mono'] {
|
||||||
|
--interface-font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-interface-density='compact'] {
|
||||||
|
--interface-density-scale: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-interface-density='spacious'] {
|
||||||
|
--interface-density-scale: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply m-0 font-sans;
|
@apply m-0 font-sans;
|
||||||
|
font-family: var(--interface-font-family);
|
||||||
|
font-size: calc(16px * var(--interface-density-scale));
|
||||||
letter-spacing: -0.15px;
|
letter-spacing: -0.15px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|||||||
Reference in New Issue
Block a user