diff --git a/.env.example b/.env.example index 81b9c316..dc82f01f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d0fcf3ef..1d3b0cd7 100644 --- a/README.md +++ b/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 diff --git a/docs/api-key-registry.md b/docs/api-key-registry.md new file mode 100644 index 00000000..70f5ac0a --- /dev/null +++ b/docs/api-key-registry.md @@ -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. diff --git a/docs/dashboard-service.md b/docs/dashboard-service.md new file mode 100644 index 00000000..b1117695 --- /dev/null +++ b/docs/dashboard-service.md @@ -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. diff --git a/scripts/install-dashboard-service.sh b/scripts/install-dashboard-service.sh new file mode 100755 index 00000000..e8572b83 --- /dev/null +++ b/scripts/install-dashboard-service.sh @@ -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" < + + + + Labelcom.hermes.workspace + WorkingDirectory$ROOT_DIR + ProgramArguments + + $PNPM_BIN + start + + EnvironmentVariables + + NODE_ENV$NODE_ENV + HOST$HOST + PORT$PORT + + RunAtLoad + KeepAlive + StandardOutPath$ROOT_DIR/logs/hermes-workspace.out.log + StandardErrorPath$ROOT_DIR/logs/hermes-workspace.err.log + + +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" <&2 + exit 1 + ;; +esac diff --git a/src/components/search/search-modal.tsx b/src/components/search/search-modal.tsx index a9225e75..c34539b6 100644 --- a/src/components/search/search-modal.tsx +++ b/src/components/search/search-modal.tsx @@ -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((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((entry) => ({ id: entry.id, scope: 'chats', icon: , @@ -292,6 +300,7 @@ export function SearchModal() { quickActions, scope, searchableFiles, + sessionSearchResults, sessions, skills, ]) diff --git a/src/hooks/use-search-data.ts b/src/hooks/use-search-data.ts index 02cd72e0..171da05f 100644 --- a/src/hooks/use-search-data.ts +++ b/src/hooks/use-search-data.ts @@ -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> } +type SessionSearchApiResponse = { + ok?: boolean + results?: Array> +} + 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> { + const normalized = query.trim() + if (!normalized) return [] + const data = await fetchJsonWithTimeout( + `/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> { @@ -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, } } diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts index c010db38..bb5b1cc9 100644 --- a/src/hooks/use-settings.ts +++ b/src/hooks/use-settings.ts @@ -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) { + 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) } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5c5aa8d5..63135809 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e7d0ad09..8efdb1b6 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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() diff --git a/src/routes/api/sessions/search.ts b/src/routes/api/sessions/search.ts new file mode 100644 index 00000000..9712b001 --- /dev/null +++ b/src/routes/api/sessions/search.ts @@ -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, keys: Array): string { + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +function getNumber(record: Record, keys: Array): 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 + 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 = [] + 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 }) + }, + }, + }, +}) diff --git a/src/routes/api/swarm-kanban.ts b/src/routes/api/swarm-kanban.ts index 8d487eef..7b86266a 100644 --- a/src/routes/api/swarm-kanban.ts +++ b/src/routes/api/swarm-kanban.ts @@ -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() }) diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index d3bbd3b8..16b88a0d 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -386,6 +386,45 @@ function SettingsRoute() {

+
+ + +
diff --git a/src/screens/chat/chat-events.ts b/src/screens/chat/chat-events.ts index b426785d..846956be 100644 --- a/src/screens/chat/chat-events.ts +++ b/src/screens/chat/chat-events.ts @@ -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 = { diff --git a/src/screens/chat/chat-screen.tsx b/src/screens/chat/chat-screen.tsx index 38203298..9b81a52c 100644 --- a/src/screens/chat/chat-screen.tsx +++ b/src/screens/chat/chat-screen.tsx @@ -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).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, diff --git a/src/screens/chat/components/message-item.tsx b/src/screens/chat/components/message-item.tsx index e5d61502..5e16afea 100644 --- a/src/screens/chat/components/message-item.tsx +++ b/src/screens/chat/components/message-item.tsx @@ -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>(() => 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 ( +
+
+
+ {card.title || 'Choose an option'} +
+ {card.body ? ( +
{card.body}
+ ) : null} +
+
+ {options.map((option, index) => { + const value = option.value || option.label + const id = option.id || value || String(index) + const isSelected = selected.has(value) + return ( + + ) + })} +
+ {isMulti || mode === 'confirm' ? ( +
+ {selected.size} selected + +
+ ) : null} +
+ ) +} + 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 } 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({ ))} )} + {hasSelectionCards ? ( +
+ {selectionCards.map((card, index) => ( + + ))} +
+ ) : null} {hasText && (isUser ? ( {displayText} diff --git a/src/screens/chat/types.ts b/src/screens/chat/types.ts index e725ef0d..42e3b7b6 100644 --- a/src/screens/chat/types.ts +++ b/src/screens/chat/types.ts @@ -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 diff --git a/src/screens/playground/hermes-world-embed.tsx b/src/screens/playground/hermes-world-embed.tsx index 9c5bebf7..96cca469 100644 --- a/src/screens/playground/hermes-world-embed.tsx +++ b/src/screens/playground/hermes-world-embed.tsx @@ -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 ( -
- {!loaded && ( -
-
-
Hermes Workspace
-
Opening HermesWorld…
-
Runtime hosted by hermes-world.ai
-
+
+
+
+
+ Hermes Workspace
- )} -