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
- )}
-
)
}
diff --git a/src/screens/swarm2/swarm2-kanban-board.tsx b/src/screens/swarm2/swarm2-kanban-board.tsx
index c93fe79a..80a3137c 100644
--- a/src/screens/swarm2/swarm2-kanban-board.tsx
+++ b/src/screens/swarm2/swarm2-kanban-board.tsx
@@ -19,6 +19,12 @@ type SwarmKanbanCard = {
createdBy: string
createdAt: number
updatedAt: number
+ tags?: Array
+ 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
}): Promise {
const res = await fetch('/api/swarm-kanban', {
method: 'POST',
@@ -187,6 +194,50 @@ function splitCriteria(value: string): Array {
.filter(Boolean)
}
+function splitTags(value: string): Array {
+ 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, 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('backlog')
+ const [draftLabels, setDraftLabels] = useState('')
+ const [activeLabelFilter, setActiveLabelFilter] = useState(null)
const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission))
const [backendToast, setBackendToast] = useState(null)
const lastToastedBackendKey = useRef(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()
+ 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>()
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({
+ {labelOptions.length > 0 ? (
+
+
+ {labelOptions.map(({ key, label }) => (
+
+ ))}
+
+ ) : null}
+
{backendToast ? (
@@ -425,6 +536,11 @@ export function Swarm2KanbanBoard({
{LANES.map((lane) =>
)}
+