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`
|
||||
#
|
||||
# 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-...
|
||||
# OPENROUTER_API_KEY=sk-or-v1-...
|
||||
# GOOGLE_API_KEY=AIza...
|
||||
# GOOGLE_AI_STUDIO_API_KEY=AIza...
|
||||
# MINIMAX_API_KEY=...
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 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.
|
||||
|
||||
#### 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
|
||||
|
||||
```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)
|
||||
|
||||
// Real data (Phase 3.2)
|
||||
const { sessions, files, skills } = useSearchData(scope)
|
||||
const { sessions, sessionSearchResults, files, skills } = useSearchData(
|
||||
scope,
|
||||
deferredQuery,
|
||||
)
|
||||
const searchableFiles = useMemo(
|
||||
() => files.filter((entry) => entry.type === 'file'),
|
||||
[files],
|
||||
@@ -182,12 +185,17 @@ export function SearchModal() {
|
||||
|
||||
// Real sessions data — search across friendlyId, key, derived title,
|
||||
// and preview so user queries match chat content (#291).
|
||||
const chats = filterResults(
|
||||
sessions,
|
||||
normalized,
|
||||
['friendlyId', 'key', 'title', 'preview'],
|
||||
RESULT_LIMITS.chats,
|
||||
).map<SearchResultItemData>((entry) => ({
|
||||
const chatCandidates =
|
||||
sessionSearchResults.length > 0
|
||||
? sessionSearchResults
|
||||
: filterResults(
|
||||
sessions,
|
||||
normalized,
|
||||
['friendlyId', 'key', 'title', 'preview'],
|
||||
RESULT_LIMITS.chats,
|
||||
)
|
||||
|
||||
const chats = chatCandidates.slice(0, RESULT_LIMITS.chats).map<SearchResultItemData>((entry) => ({
|
||||
id: entry.id,
|
||||
scope: 'chats',
|
||||
icon: <HugeiconsIcon icon={Chat01Icon} size={20} strokeWidth={1.5} />,
|
||||
@@ -292,6 +300,7 @@ export function SearchModal() {
|
||||
quickActions,
|
||||
scope,
|
||||
searchableFiles,
|
||||
sessionSearchResults,
|
||||
sessions,
|
||||
skills,
|
||||
])
|
||||
|
||||
@@ -14,6 +14,7 @@ const FILES_STALE_TIME_MS = 2 * 60_000
|
||||
const SKILLS_STALE_TIME_MS = 2 * 60_000
|
||||
const SEARCH_QUERY_GC_TIME_MS = 10 * 60_000
|
||||
const MAX_SEARCH_FILES = 2_500
|
||||
const SESSION_FTS_STALE_TIME_MS = 15_000
|
||||
|
||||
export type SearchSession = {
|
||||
id: string
|
||||
@@ -22,6 +23,7 @@ export type SearchSession = {
|
||||
title?: string
|
||||
preview?: string
|
||||
updatedAt?: number
|
||||
source?: string | null
|
||||
}
|
||||
|
||||
export type SearchFile = {
|
||||
@@ -60,6 +62,11 @@ type SkillsApiResponse = {
|
||||
skills?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
type SessionSearchApiResponse = {
|
||||
ok?: boolean
|
||||
results?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
type SearchQueryScope =
|
||||
| 'all'
|
||||
| 'chats'
|
||||
@@ -201,6 +208,38 @@ async function fetchFiles(
|
||||
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(
|
||||
querySignal?: AbortSignal,
|
||||
): 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 skillsAvailable = useFeatureAvailable('skills')
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
// Sessions
|
||||
const sessionsQuery = useQuery({
|
||||
@@ -239,6 +279,20 @@ export function useSearchData(scope: SearchQueryScope) {
|
||||
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
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ['search', 'files'],
|
||||
@@ -268,11 +322,15 @@ export function useSearchData(scope: SearchQueryScope) {
|
||||
|
||||
return {
|
||||
sessions: sessionsQuery.data || [],
|
||||
sessionSearchResults: sessionSearchQuery.data || [],
|
||||
files: filesQuery.data || [],
|
||||
skills: skillsQuery.data || [],
|
||||
activity: activityResults,
|
||||
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 AccentColor = 'orange' | 'purple' | 'blue' | 'green'
|
||||
export type InterfaceFont = 'system' | 'inter' | 'serif' | 'mono'
|
||||
export type InterfaceDensity = 'compact' | 'comfortable' | 'spacious'
|
||||
|
||||
export type StudioSettings = {
|
||||
claudeUrl: string
|
||||
@@ -22,6 +24,8 @@ export type StudioSettings = {
|
||||
preferredPremiumModel: string
|
||||
onlySuggestCheaper: 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 */
|
||||
mobileChatNavMode: 'dock' | 'integrated' | 'scroll-hide'
|
||||
}
|
||||
@@ -47,6 +51,8 @@ export const defaultStudioSettings: StudioSettings = {
|
||||
preferredPremiumModel: '',
|
||||
onlySuggestCheaper: false,
|
||||
showSystemMetricsFooter: false,
|
||||
interfaceFont: 'system',
|
||||
interfaceDensity: 'comfortable',
|
||||
mobileChatNavMode: 'dock',
|
||||
}
|
||||
|
||||
@@ -102,12 +108,20 @@ export function resolveTheme(theme: SettingsThemeMode): 'light' | 'dark' {
|
||||
: '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) {
|
||||
setTheme(getTheme())
|
||||
document.documentElement.setAttribute('data-accent', 'orange')
|
||||
applyInterfacePreferences(useSettingsStore.getState().settings)
|
||||
}
|
||||
|
||||
export function initializeSettingsAppearance() {
|
||||
setTheme(getTheme())
|
||||
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 ApiSkillsHubSearchRouteImport } from './routes/api/skills/hub-search'
|
||||
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 ApiProfilesUpdateRouteImport } from './routes/api/profiles/update'
|
||||
import { Route as ApiProfilesToggleSkillRouteImport } from './routes/api/profiles/toggle-skill'
|
||||
@@ -735,6 +736,11 @@ const ApiSessionsSendRoute = ApiSessionsSendRouteImport.update({
|
||||
path: '/send',
|
||||
getParentRoute: () => ApiSessionsRoute,
|
||||
} as any)
|
||||
const ApiSessionsSearchRoute = ApiSessionsSearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => ApiSessionsRoute,
|
||||
} as any)
|
||||
const ApiRunsActiveRoute = ApiRunsActiveRouteImport.update({
|
||||
id: '/api/runs/active',
|
||||
path: '/api/runs/active',
|
||||
@@ -1117,6 +1123,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||
@@ -1277,6 +1284,7 @@ export interface FileRoutesByTo {
|
||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||
@@ -1439,6 +1447,7 @@ export interface FileRoutesById {
|
||||
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
|
||||
'/api/profiles/update': typeof ApiProfilesUpdateRoute
|
||||
'/api/runs/active': typeof ApiRunsActiveRoute
|
||||
'/api/sessions/search': typeof ApiSessionsSearchRoute
|
||||
'/api/sessions/send': typeof ApiSessionsSendRoute
|
||||
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
|
||||
'/api/skills/install': typeof ApiSkillsInstallRoute
|
||||
@@ -1602,6 +1611,7 @@ export interface FileRouteTypes {
|
||||
| '/api/profiles/toggle-skill'
|
||||
| '/api/profiles/update'
|
||||
| '/api/runs/active'
|
||||
| '/api/sessions/search'
|
||||
| '/api/sessions/send'
|
||||
| '/api/skills/hub-search'
|
||||
| '/api/skills/install'
|
||||
@@ -1762,6 +1772,7 @@ export interface FileRouteTypes {
|
||||
| '/api/profiles/toggle-skill'
|
||||
| '/api/profiles/update'
|
||||
| '/api/runs/active'
|
||||
| '/api/sessions/search'
|
||||
| '/api/sessions/send'
|
||||
| '/api/skills/hub-search'
|
||||
| '/api/skills/install'
|
||||
@@ -1923,6 +1934,7 @@ export interface FileRouteTypes {
|
||||
| '/api/profiles/toggle-skill'
|
||||
| '/api/profiles/update'
|
||||
| '/api/runs/active'
|
||||
| '/api/sessions/search'
|
||||
| '/api/sessions/send'
|
||||
| '/api/skills/hub-search'
|
||||
| '/api/skills/install'
|
||||
@@ -2866,6 +2878,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiSessionsSendRouteImport
|
||||
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': {
|
||||
id: '/api/runs/active'
|
||||
path: '/api/runs/active'
|
||||
@@ -3329,12 +3348,14 @@ const ApiMemoryRouteWithChildren = ApiMemoryRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface ApiSessionsRouteChildren {
|
||||
ApiSessionsSearchRoute: typeof ApiSessionsSearchRoute
|
||||
ApiSessionsSendRoute: typeof ApiSessionsSendRoute
|
||||
ApiSessionsSessionKeyActiveRunRoute: typeof ApiSessionsSessionKeyActiveRunRoute
|
||||
ApiSessionsSessionKeyStatusRoute: typeof ApiSessionsSessionKeyStatusRoute
|
||||
}
|
||||
|
||||
const ApiSessionsRouteChildren: ApiSessionsRouteChildren = {
|
||||
ApiSessionsSearchRoute: ApiSessionsSearchRoute,
|
||||
ApiSessionsSendRoute: ApiSessionsSendRoute,
|
||||
ApiSessionsSessionKeyActiveRunRoute: ApiSessionsSessionKeyActiveRunRoute,
|
||||
ApiSessionsSessionKeyStatusRoute: ApiSessionsSessionKeyStatusRoute,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Toaster } from '@/components/ui/toast'
|
||||
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
|
||||
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 {
|
||||
ClaudeOnboarding,
|
||||
@@ -274,6 +274,10 @@ function RootLayout() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useApplyChatWidth()
|
||||
|
||||
useEffect(() => {
|
||||
applyInterfacePreferences(settings)
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
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([]),
|
||||
)
|
||||
|
||||
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({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
spec: z.string().trim().max(5000).optional().default(''),
|
||||
@@ -28,6 +42,7 @@ const CreateCardSchema = z.object({
|
||||
reportPath: z.string().trim().max(500).optional().nullable(),
|
||||
createdBy: z.string().trim().max(120).optional().default('aurora'),
|
||||
parents: z.array(z.string().trim().min(1).max(200)).optional().default([]),
|
||||
tags: TagsSchema,
|
||||
idempotencyKey: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
|
||||
@@ -58,7 +73,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
|
||||
}
|
||||
const data = parsed.data
|
||||
const card = await createKanbanCard({
|
||||
title: data.title ?? '',
|
||||
title: data.title,
|
||||
spec: data.spec,
|
||||
acceptanceCriteria: data.acceptanceCriteria,
|
||||
assignedWorker: data.assignedWorker,
|
||||
@@ -68,6 +83,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
|
||||
reportPath: data.reportPath,
|
||||
createdBy: data.createdBy,
|
||||
parents: data.parents,
|
||||
tags: data.tags,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
})
|
||||
return json({ ok: true, card, backend: getKanbanBackendMeta() })
|
||||
|
||||
@@ -386,6 +386,45 @@ function SettingsRoute() {
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</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_SUBMIT_SELECTION_EVENT = 'claude:chat-submit-selection'
|
||||
|
||||
export const CHAT_PENDING_COMMAND_STORAGE_KEY = 'claude.pending-chat-command'
|
||||
|
||||
export type ChatRunCommandDetail = {
|
||||
command: string
|
||||
}
|
||||
|
||||
export type ChatSubmitSelectionDetail = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export const CHAT_OPEN_SETTINGS_EVENT = 'claude:chat-open-settings'
|
||||
|
||||
export type ChatOpenSettingsDetail = {
|
||||
|
||||
@@ -65,6 +65,11 @@ import {
|
||||
CHAT_OPEN_SETTINGS_EVENT,
|
||||
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
||||
CHAT_RUN_COMMAND_EVENT,
|
||||
CHAT_SUBMIT_SELECTION_EVENT,
|
||||
} from './chat-events'
|
||||
import type {
|
||||
ChatRunCommandDetail,
|
||||
ChatSubmitSelectionDetail,
|
||||
} from './chat-events'
|
||||
import type {ResponseWaitSnapshot} from './chat-screen-utils';
|
||||
import type {
|
||||
@@ -75,7 +80,6 @@ import type {
|
||||
} from './components/chat-composer'
|
||||
import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store'
|
||||
import type { ChatAttachment, ChatMessage, SessionMeta } from './types'
|
||||
import type { ChatRunCommandDetail } from './chat-events'
|
||||
import type {AgentActivity} from '@/stores/chat-activity-store';
|
||||
import { useChatSettingsStore } from '@/hooks/use-chat-settings'
|
||||
import { playChatComplete } from '@/lib/sounds'
|
||||
@@ -2567,6 +2571,23 @@ export function ChatScreen({
|
||||
}
|
||||
}, [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(() => {
|
||||
const pendingCommand = window.sessionStorage.getItem(
|
||||
CHAT_PENDING_COMMAND_STORAGE_KEY,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
shouldAutoExpandHermesActivityCard,
|
||||
} from './streaming-activity-ui'
|
||||
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 { AssistantAvatar, UserAvatar } from '@/components/avatars'
|
||||
import { CodeBlock } from '@/components/prompt-kit/code-block'
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
useChatSettingsStore,
|
||||
} from '@/hooks/use-chat-settings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CHAT_SUBMIT_SELECTION_EVENT } from '@/screens/chat/chat-events'
|
||||
|
||||
const WORDS_PER_TICK = 4
|
||||
const TICK_INTERVAL_MS = 50
|
||||
@@ -151,6 +152,93 @@ type MessageItemProps = {
|
||||
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 = {
|
||||
key: string
|
||||
type: string
|
||||
@@ -167,10 +255,12 @@ type InlineToolSection = {
|
||||
|
||||
export type InlineRenderPlanItem =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'selection-card'; card: SelectionCardContent }
|
||||
| { kind: 'tool'; section: InlineToolSection }
|
||||
|
||||
export type CompactInlineRenderPlanItem =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'selection-card'; card: SelectionCardContent }
|
||||
| { kind: 'tools'; sections: Array<InlineToolSection> }
|
||||
|
||||
export function buildInlineToolRenderPlan(
|
||||
@@ -204,6 +294,11 @@ export function buildInlineToolRenderPlan(
|
||||
usedKeys.add(matchingSection.key)
|
||||
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)
|
||||
}, [message.content])
|
||||
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 hasRenderableAssistantText =
|
||||
@@ -2393,6 +2496,7 @@ function MessageItemComponent({
|
||||
hasText ||
|
||||
hasAttachments ||
|
||||
hasInlineImages ||
|
||||
hasSelectionCards ||
|
||||
(effectiveIsStreaming && hasRevealedText)
|
||||
|
||||
// 'queued' = delivered to server, waiting for response (busy/backlogged)
|
||||
@@ -2494,7 +2598,7 @@ function MessageItemComponent({
|
||||
}
|
||||
className={cn(
|
||||
'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,
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
!isUser && isNew && 'animate-[message-fade-in_0.4s_ease-out]',
|
||||
@@ -2689,6 +2793,16 @@ function MessageItemComponent({
|
||||
))}
|
||||
</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 &&
|
||||
(isUser ? (
|
||||
<span className="text-pretty">{displayText}</span>
|
||||
|
||||
@@ -27,7 +27,26 @@ export type ThinkingContent = {
|
||||
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 = {
|
||||
id?: string
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { WaveChatPanelsShowcase } from './components/wave-chat-panels-showcase'
|
||||
|
||||
const HERMES_WORLD_ORIGIN = 'https://hermes-world.ai'
|
||||
|
||||
export function HermesWorldEmbed() {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
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)
|
||||
url.searchParams.set('embed', 'workspace')
|
||||
url.searchParams.set('source', 'hermes-workspace')
|
||||
return url.toString()
|
||||
}, [])
|
||||
@@ -18,32 +16,40 @@ export function HermesWorldEmbed() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative h-full min-h-0 overflow-hidden bg-[#050015] text-white">
|
||||
{!loaded && (
|
||||
<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="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">Hermes Workspace</div>
|
||||
<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>
|
||||
<main className="relative flex h-full min-h-0 items-center justify-center overflow-hidden bg-[#050015] px-4 text-white">
|
||||
<div className="absolute inset-0 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="text-xs font-bold uppercase tracking-[0.24em] text-cyan-200/70">
|
||||
Hermes Workspace
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
title="HermesWorld"
|
||||
src={src}
|
||||
className="h-full w-full border-0 bg-[#050015]"
|
||||
allow="fullscreen; clipboard-read; clipboard-write; gamepad"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
<a
|
||||
href={`${HERMES_WORLD_ORIGIN}/play/`}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
Open full
|
||||
</a>
|
||||
<h1 className="mt-2 text-3xl font-black tracking-tight">
|
||||
Open HermesWorld in a full tab
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/65">
|
||||
HermesWorld currently refuses iframe embedding, so Workspace no longer
|
||||
loads it in an iframe that fails with “refused to connect”. The hosted
|
||||
build should be opened directly while the game deployment fixes its
|
||||
stale asset/MIME issue for <code className="rounded bg-white/10 px-1">/assets/styles-*.css</code>.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
<a
|
||||
href={playUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ type SwarmKanbanCard = {
|
||||
createdBy: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
tags?: Array<string>
|
||||
latestRun?: {
|
||||
summary?: string | null
|
||||
outcome?: string | null
|
||||
status?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type KanbanWorker = {
|
||||
@@ -158,6 +164,7 @@ async function createKanbanCard(input: {
|
||||
reviewer: string | null
|
||||
status: KanbanLane
|
||||
missionId: string | null
|
||||
tags: Array<string>
|
||||
}): Promise<SwarmKanbanCard> {
|
||||
const res = await fetch('/api/swarm-kanban', {
|
||||
method: 'POST',
|
||||
@@ -187,6 +194,50 @@ function splitCriteria(value: string): Array<string> {
|
||||
.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 {
|
||||
if (!workerId) return 'Unassigned'
|
||||
const worker = workers.find((item) => item.id === workerId)
|
||||
@@ -209,6 +260,8 @@ export function Swarm2KanbanBoard({
|
||||
const [draftWorker, setDraftWorker] = useState(selectedWorkerId ?? '')
|
||||
const [draftReviewer, setDraftReviewer] = useState('')
|
||||
const [draftStatus, setDraftStatus] = useState<KanbanLane>('backlog')
|
||||
const [draftLabels, setDraftLabels] = useState('')
|
||||
const [activeLabelFilter, setActiveLabelFilter] = useState<string | null>(null)
|
||||
const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission))
|
||||
const [backendToast, setBackendToast] = useState<KanbanBackendPresentation | null>(null)
|
||||
const lastToastedBackendKey = useRef<string | null>(null)
|
||||
@@ -255,6 +308,7 @@ export function Swarm2KanbanBoard({
|
||||
reviewer: draftReviewer || null,
|
||||
status: draftStatus,
|
||||
missionId: linkLatestMission ? latestMission?.id ?? null : null,
|
||||
tags: splitTags(draftLabels),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setDraftTitle('')
|
||||
@@ -263,6 +317,7 @@ export function Swarm2KanbanBoard({
|
||||
setDraftWorker(selectedWorkerId ?? '')
|
||||
setDraftReviewer('')
|
||||
setDraftStatus('backlog')
|
||||
setDraftLabels('')
|
||||
setComposerOpen(false)
|
||||
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 map = new Map<KanbanLane, Array<SwarmKanbanCard>>()
|
||||
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')!
|
||||
bucket.push(card)
|
||||
}
|
||||
return map
|
||||
}, [query.data])
|
||||
}, [visibleCards])
|
||||
|
||||
const total = query.data?.cards.length ?? 0
|
||||
const reviewCount = cardsByLane.get('review')?.length ?? 0
|
||||
@@ -365,6 +443,39 @@ export function Swarm2KanbanBoard({
|
||||
</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 ? (
|
||||
<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">
|
||||
@@ -425,6 +536,11 @@ export function Swarm2KanbanBoard({
|
||||
{LANES.map((lane) => <option key={lane.id} value={lane.id}>{lane.label}</option>)}
|
||||
</select>
|
||||
</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)]">
|
||||
<input type="checkbox" checked={linkLatestMission} disabled={!latestMission} onChange={(event) => setLinkLatestMission(event.target.checked)} />
|
||||
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}
|
||||
</ul>
|
||||
) : 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>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>
|
||||
|
||||
@@ -110,23 +110,53 @@ export function getLocalMessages(sessionId: string): Array<LocalMessage> {
|
||||
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(
|
||||
sessionId: string,
|
||||
message: LocalMessage,
|
||||
): void {
|
||||
ensureLocalSession(sessionId)
|
||||
if (!store.messages[sessionId]) store.messages[sessionId] = []
|
||||
store.messages[sessionId].push(message)
|
||||
const session = ensureLocalSession(sessionId)
|
||||
const messages = store.messages[sessionId] ?? []
|
||||
store.messages[sessionId] = messages
|
||||
messages.push(message)
|
||||
if (store.messages[sessionId].length > MAX_MESSAGES_PER_SESSION) {
|
||||
store.messages[sessionId] = store.messages[sessionId].slice(
|
||||
-MAX_MESSAGES_PER_SESSION,
|
||||
)
|
||||
}
|
||||
const session = store.sessions[sessionId]
|
||||
if (session) {
|
||||
session.messageCount = store.messages[sessionId].length
|
||||
session.updatedAt = Date.now()
|
||||
}
|
||||
session.messageCount = store.messages[sessionId].length
|
||||
session.updatedAt = Date.now()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export type SwarmKanbanCard = {
|
||||
id: string
|
||||
title: string
|
||||
spec: string
|
||||
acceptanceCriteria: string[]
|
||||
acceptanceCriteria: Array<string>
|
||||
assignedWorker: string | null
|
||||
reviewer: string | null
|
||||
status: SwarmKanbanLane | string
|
||||
@@ -19,13 +19,14 @@ export type SwarmKanbanCard = {
|
||||
createdBy: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
parents?: string[]
|
||||
children?: string[]
|
||||
parents?: Array<string>
|
||||
children?: Array<string>
|
||||
latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null
|
||||
tags?: Array<string>
|
||||
source?: string
|
||||
}
|
||||
|
||||
type SwarmKanbanFile = { cards: SwarmKanbanCard[] }
|
||||
type SwarmKanbanFile = { cards: Array<SwarmKanbanCard> }
|
||||
|
||||
type ListFilters = {
|
||||
status?: string | null
|
||||
@@ -37,14 +38,15 @@ type ListFilters = {
|
||||
export type CreateSwarmKanbanCardInput = {
|
||||
title: string
|
||||
spec?: string
|
||||
acceptanceCriteria?: string[]
|
||||
acceptanceCriteria?: Array<string>
|
||||
assignedWorker?: string | null
|
||||
reviewer?: string | null
|
||||
status?: SwarmKanbanLane | null
|
||||
missionId?: string | null
|
||||
reportPath?: string | null
|
||||
createdBy?: string | null
|
||||
parents?: string[]
|
||||
parents?: Array<string>
|
||||
tags?: Array<string>
|
||||
idempotencyKey?: string | null
|
||||
}
|
||||
|
||||
@@ -82,12 +84,18 @@ function normalizeStatus(value: unknown): SwarmKanbanLane {
|
||||
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 (typeof value === 'string') return value.split('\n').map((item) => item.trim()).filter(Boolean)
|
||||
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 {
|
||||
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',
|
||||
createdAt: typeof card.createdAt === 'number' ? card.createdAt : 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
|
||||
if (filters.status) cards = cards.filter((card) => card.status === normalizeStatus(filters.status))
|
||||
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',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
parents: input.parents,
|
||||
tags: input.tags,
|
||||
})
|
||||
file.cards.push(card)
|
||||
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,
|
||||
body {
|
||||
@apply m-0 font-sans;
|
||||
font-family: var(--interface-font-family);
|
||||
font-size: calc(16px * var(--interface-density-scale));
|
||||
letter-spacing: -0.15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
Reference in New Issue
Block a user