fix(workspace): close round-two issue gaps

This commit is contained in:
Aurora
2026-06-05 16:56:08 -04:00
parent 4b9c7bd9a4
commit cb054c59d0
22 changed files with 1009 additions and 62 deletions

View File

@@ -17,10 +17,16 @@
# Ollama / local: No key needed — just run `ollama serve` # Ollama / local: No key needed — just run `ollama serve`
# #
# Uncomment ONLY the key(s) for the providers you actually use. # Uncomment ONLY the key(s) for the providers you actually use.
# See docs/api-key-registry.md for the broader SCOM key inventory and
# rotation checklist.
# ANTHROPIC_API_KEY=sk-ant-...
# NOUS_API_KEY=...
# OPENAI_API_KEY=sk-... # OPENAI_API_KEY=sk-...
# OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_API_KEY=sk-or-v1-...
# GOOGLE_API_KEY=AIza... # GOOGLE_API_KEY=AIza...
# GOOGLE_AI_STUDIO_API_KEY=AIza...
# MINIMAX_API_KEY=...
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# Optional: Hermes Agent Connection # Optional: Hermes Agent Connection

View File

@@ -205,6 +205,17 @@ pnpm dev # Starts on http://localhost:3000
> **Verify:** Open `http://localhost:3000` and complete the onboarding flow. First connect the backend, then verify chat works. If your gateway exposes Hermes Agent APIs, advanced features appear automatically. > **Verify:** Open `http://localhost:3000` and complete the onboarding flow. First connect the backend, then verify chat works. If your gateway exposes Hermes Agent APIs, advanced features appear automatically.
#### Run without an open terminal
After `pnpm build`, install Workspace as a user-level launchd/systemd service:
```bash
chmod +x scripts/install-dashboard-service.sh
scripts/install-dashboard-service.sh
```
See [`docs/dashboard-service.md`](docs/dashboard-service.md) for macOS launchd, Linux systemd, logs, overrides, and uninstall steps.
#### Environment Variables #### Environment Variables
```env ```env

86
docs/api-key-registry.md Normal file
View 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
View 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.

View 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

View File

@@ -72,7 +72,10 @@ export function SearchModal() {
const deferredQuery = useDeferredValue(debouncedQuery) const deferredQuery = useDeferredValue(debouncedQuery)
// Real data (Phase 3.2) // Real data (Phase 3.2)
const { sessions, files, skills } = useSearchData(scope) const { sessions, sessionSearchResults, files, skills } = useSearchData(
scope,
deferredQuery,
)
const searchableFiles = useMemo( const searchableFiles = useMemo(
() => files.filter((entry) => entry.type === 'file'), () => files.filter((entry) => entry.type === 'file'),
[files], [files],
@@ -182,12 +185,17 @@ export function SearchModal() {
// Real sessions data — search across friendlyId, key, derived title, // Real sessions data — search across friendlyId, key, derived title,
// and preview so user queries match chat content (#291). // and preview so user queries match chat content (#291).
const chats = filterResults( const chatCandidates =
sessions, sessionSearchResults.length > 0
normalized, ? sessionSearchResults
['friendlyId', 'key', 'title', 'preview'], : filterResults(
RESULT_LIMITS.chats, sessions,
).map<SearchResultItemData>((entry) => ({ normalized,
['friendlyId', 'key', 'title', 'preview'],
RESULT_LIMITS.chats,
)
const chats = chatCandidates.slice(0, RESULT_LIMITS.chats).map<SearchResultItemData>((entry) => ({
id: entry.id, id: entry.id,
scope: 'chats', scope: 'chats',
icon: <HugeiconsIcon icon={Chat01Icon} size={20} strokeWidth={1.5} />, icon: <HugeiconsIcon icon={Chat01Icon} size={20} strokeWidth={1.5} />,
@@ -292,6 +300,7 @@ export function SearchModal() {
quickActions, quickActions,
scope, scope,
searchableFiles, searchableFiles,
sessionSearchResults,
sessions, sessions,
skills, skills,
]) ])

View File

@@ -14,6 +14,7 @@ const FILES_STALE_TIME_MS = 2 * 60_000
const SKILLS_STALE_TIME_MS = 2 * 60_000 const SKILLS_STALE_TIME_MS = 2 * 60_000
const SEARCH_QUERY_GC_TIME_MS = 10 * 60_000 const SEARCH_QUERY_GC_TIME_MS = 10 * 60_000
const MAX_SEARCH_FILES = 2_500 const MAX_SEARCH_FILES = 2_500
const SESSION_FTS_STALE_TIME_MS = 15_000
export type SearchSession = { export type SearchSession = {
id: string id: string
@@ -22,6 +23,7 @@ export type SearchSession = {
title?: string title?: string
preview?: string preview?: string
updatedAt?: number updatedAt?: number
source?: string | null
} }
export type SearchFile = { export type SearchFile = {
@@ -60,6 +62,11 @@ type SkillsApiResponse = {
skills?: Array<Record<string, unknown>> skills?: Array<Record<string, unknown>>
} }
type SessionSearchApiResponse = {
ok?: boolean
results?: Array<Record<string, unknown>>
}
type SearchQueryScope = type SearchQueryScope =
| 'all' | 'all'
| 'chats' | 'chats'
@@ -201,6 +208,38 @@ async function fetchFiles(
return flattenFileTree(entries, MAX_SEARCH_FILES) return flattenFileTree(entries, MAX_SEARCH_FILES)
} }
async function fetchSessionSearch(
query: string,
querySignal?: AbortSignal,
): Promise<Array<SearchSession>> {
const normalized = query.trim()
if (!normalized) return []
const data = await fetchJsonWithTimeout<SessionSearchApiResponse>(
`/api/sessions/search?q=${encodeURIComponent(normalized)}&limit=24`,
querySignal,
)
if (!data || data.ok === false) return []
const results = Array.isArray(data.results) ? data.results : []
return results.map((entry, index) => {
const key = String(entry.key || entry.session_id || entry.id || '')
const friendlyId = String(entry.friendlyId || key || 'unknown')
return {
id: String(entry.id || `${key}:${index}`),
key,
friendlyId,
title: String(entry.title || friendlyId || 'Untitled'),
preview: String(entry.snippet || entry.preview || ''),
updatedAt:
typeof entry.updatedAt === 'number'
? entry.updatedAt
: typeof entry.session_started === 'number'
? entry.session_started
: undefined,
source: typeof entry.source === 'string' ? entry.source : null,
}
})
}
async function fetchSkills( async function fetchSkills(
querySignal?: AbortSignal, querySignal?: AbortSignal,
): Promise<Array<SearchSkill>> { ): Promise<Array<SearchSkill>> {
@@ -223,9 +262,10 @@ async function fetchSkills(
}) })
} }
export function useSearchData(scope: SearchQueryScope) { export function useSearchData(scope: SearchQueryScope, query = '') {
const sessionsAvailable = useFeatureAvailable('sessions') const sessionsAvailable = useFeatureAvailable('sessions')
const skillsAvailable = useFeatureAvailable('skills') const skillsAvailable = useFeatureAvailable('skills')
const trimmedQuery = query.trim()
// Sessions // Sessions
const sessionsQuery = useQuery({ const sessionsQuery = useQuery({
@@ -239,6 +279,20 @@ export function useSearchData(scope: SearchQueryScope) {
refetchOnReconnect: false, refetchOnReconnect: false,
}) })
const sessionSearchQuery = useQuery({
queryKey: ['search', 'sessions-fts', trimmedQuery],
queryFn: ({ signal }) => fetchSessionSearch(trimmedQuery, signal),
enabled:
sessionsAvailable &&
trimmedQuery.length >= 2 &&
(scope === 'all' || scope === 'chats'),
staleTime: SESSION_FTS_STALE_TIME_MS,
gcTime: SEARCH_QUERY_GC_TIME_MS,
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
// Files // Files
const filesQuery = useQuery({ const filesQuery = useQuery({
queryKey: ['search', 'files'], queryKey: ['search', 'files'],
@@ -268,11 +322,15 @@ export function useSearchData(scope: SearchQueryScope) {
return { return {
sessions: sessionsQuery.data || [], sessions: sessionsQuery.data || [],
sessionSearchResults: sessionSearchQuery.data || [],
files: filesQuery.data || [], files: filesQuery.data || [],
skills: skillsQuery.data || [], skills: skillsQuery.data || [],
activity: activityResults, activity: activityResults,
isLoading: isLoading:
sessionsQuery.isLoading || filesQuery.isLoading || skillsQuery.isLoading, sessionsQuery.isLoading ||
sessionSearchQuery.isLoading ||
filesQuery.isLoading ||
skillsQuery.isLoading,
} }
} }

View File

@@ -5,6 +5,8 @@ import { getTheme, setTheme } from '@/lib/theme'
export type SettingsThemeMode = 'system' | 'light' | 'dark' export type SettingsThemeMode = 'system' | 'light' | 'dark'
export type AccentColor = 'orange' | 'purple' | 'blue' | 'green' export type AccentColor = 'orange' | 'purple' | 'blue' | 'green'
export type InterfaceFont = 'system' | 'inter' | 'serif' | 'mono'
export type InterfaceDensity = 'compact' | 'comfortable' | 'spacious'
export type StudioSettings = { export type StudioSettings = {
claudeUrl: string claudeUrl: string
@@ -22,6 +24,8 @@ export type StudioSettings = {
preferredPremiumModel: string preferredPremiumModel: string
onlySuggestCheaper: boolean onlySuggestCheaper: boolean
showSystemMetricsFooter: boolean showSystemMetricsFooter: boolean
interfaceFont: InterfaceFont
interfaceDensity: InterfaceDensity
/** Mobile chat nav mode: 'dock' = iMessage (no nav in chat), 'integrated' = chat input in nav pill, 'scroll-hide' = nav shows on scroll up */ /** Mobile chat nav mode: 'dock' = iMessage (no nav in chat), 'integrated' = chat input in nav pill, 'scroll-hide' = nav shows on scroll up */
mobileChatNavMode: 'dock' | 'integrated' | 'scroll-hide' mobileChatNavMode: 'dock' | 'integrated' | 'scroll-hide'
} }
@@ -47,6 +51,8 @@ export const defaultStudioSettings: StudioSettings = {
preferredPremiumModel: '', preferredPremiumModel: '',
onlySuggestCheaper: false, onlySuggestCheaper: false,
showSystemMetricsFooter: false, showSystemMetricsFooter: false,
interfaceFont: 'system',
interfaceDensity: 'comfortable',
mobileChatNavMode: 'dock', mobileChatNavMode: 'dock',
} }
@@ -102,12 +108,20 @@ export function resolveTheme(theme: SettingsThemeMode): 'light' | 'dark' {
: 'light' : 'light'
} }
export function applyInterfacePreferences(settings: Partial<StudioSettings>) {
if (typeof document === 'undefined') return
document.documentElement.dataset.interfaceFont = settings.interfaceFont ?? 'system'
document.documentElement.dataset.interfaceDensity = settings.interfaceDensity ?? 'comfortable'
}
export function applyTheme(_theme?: SettingsThemeMode) { export function applyTheme(_theme?: SettingsThemeMode) {
setTheme(getTheme()) setTheme(getTheme())
document.documentElement.setAttribute('data-accent', 'orange') document.documentElement.setAttribute('data-accent', 'orange')
applyInterfacePreferences(useSettingsStore.getState().settings)
} }
export function initializeSettingsAppearance() { export function initializeSettingsAppearance() {
setTheme(getTheme()) setTheme(getTheme())
document.documentElement.setAttribute('data-accent', 'orange') document.documentElement.setAttribute('data-accent', 'orange')
applyInterfacePreferences(useSettingsStore.getState().settings)
} }

View File

@@ -122,6 +122,7 @@ import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle'
import { Route as ApiSkillsInstallRouteImport } from './routes/api/skills/install' import { Route as ApiSkillsInstallRouteImport } from './routes/api/skills/install'
import { Route as ApiSkillsHubSearchRouteImport } from './routes/api/skills/hub-search' import { Route as ApiSkillsHubSearchRouteImport } from './routes/api/skills/hub-search'
import { Route as ApiSessionsSendRouteImport } from './routes/api/sessions/send' import { Route as ApiSessionsSendRouteImport } from './routes/api/sessions/send'
import { Route as ApiSessionsSearchRouteImport } from './routes/api/sessions/search'
import { Route as ApiRunsActiveRouteImport } from './routes/api/runs/active' import { Route as ApiRunsActiveRouteImport } from './routes/api/runs/active'
import { Route as ApiProfilesUpdateRouteImport } from './routes/api/profiles/update' import { Route as ApiProfilesUpdateRouteImport } from './routes/api/profiles/update'
import { Route as ApiProfilesToggleSkillRouteImport } from './routes/api/profiles/toggle-skill' import { Route as ApiProfilesToggleSkillRouteImport } from './routes/api/profiles/toggle-skill'
@@ -735,6 +736,11 @@ const ApiSessionsSendRoute = ApiSessionsSendRouteImport.update({
path: '/send', path: '/send',
getParentRoute: () => ApiSessionsRoute, getParentRoute: () => ApiSessionsRoute,
} as any) } as any)
const ApiSessionsSearchRoute = ApiSessionsSearchRouteImport.update({
id: '/search',
path: '/search',
getParentRoute: () => ApiSessionsRoute,
} as any)
const ApiRunsActiveRoute = ApiRunsActiveRouteImport.update({ const ApiRunsActiveRoute = ApiRunsActiveRouteImport.update({
id: '/api/runs/active', id: '/api/runs/active',
path: '/api/runs/active', path: '/api/runs/active',
@@ -1117,6 +1123,7 @@ export interface FileRoutesByFullPath {
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute '/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
'/api/profiles/update': typeof ApiProfilesUpdateRoute '/api/profiles/update': typeof ApiProfilesUpdateRoute
'/api/runs/active': typeof ApiRunsActiveRoute '/api/runs/active': typeof ApiRunsActiveRoute
'/api/sessions/search': typeof ApiSessionsSearchRoute
'/api/sessions/send': typeof ApiSessionsSendRoute '/api/sessions/send': typeof ApiSessionsSendRoute
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute '/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
'/api/skills/install': typeof ApiSkillsInstallRoute '/api/skills/install': typeof ApiSkillsInstallRoute
@@ -1277,6 +1284,7 @@ export interface FileRoutesByTo {
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute '/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
'/api/profiles/update': typeof ApiProfilesUpdateRoute '/api/profiles/update': typeof ApiProfilesUpdateRoute
'/api/runs/active': typeof ApiRunsActiveRoute '/api/runs/active': typeof ApiRunsActiveRoute
'/api/sessions/search': typeof ApiSessionsSearchRoute
'/api/sessions/send': typeof ApiSessionsSendRoute '/api/sessions/send': typeof ApiSessionsSendRoute
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute '/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
'/api/skills/install': typeof ApiSkillsInstallRoute '/api/skills/install': typeof ApiSkillsInstallRoute
@@ -1439,6 +1447,7 @@ export interface FileRoutesById {
'/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute '/api/profiles/toggle-skill': typeof ApiProfilesToggleSkillRoute
'/api/profiles/update': typeof ApiProfilesUpdateRoute '/api/profiles/update': typeof ApiProfilesUpdateRoute
'/api/runs/active': typeof ApiRunsActiveRoute '/api/runs/active': typeof ApiRunsActiveRoute
'/api/sessions/search': typeof ApiSessionsSearchRoute
'/api/sessions/send': typeof ApiSessionsSendRoute '/api/sessions/send': typeof ApiSessionsSendRoute
'/api/skills/hub-search': typeof ApiSkillsHubSearchRoute '/api/skills/hub-search': typeof ApiSkillsHubSearchRoute
'/api/skills/install': typeof ApiSkillsInstallRoute '/api/skills/install': typeof ApiSkillsInstallRoute
@@ -1602,6 +1611,7 @@ export interface FileRouteTypes {
| '/api/profiles/toggle-skill' | '/api/profiles/toggle-skill'
| '/api/profiles/update' | '/api/profiles/update'
| '/api/runs/active' | '/api/runs/active'
| '/api/sessions/search'
| '/api/sessions/send' | '/api/sessions/send'
| '/api/skills/hub-search' | '/api/skills/hub-search'
| '/api/skills/install' | '/api/skills/install'
@@ -1762,6 +1772,7 @@ export interface FileRouteTypes {
| '/api/profiles/toggle-skill' | '/api/profiles/toggle-skill'
| '/api/profiles/update' | '/api/profiles/update'
| '/api/runs/active' | '/api/runs/active'
| '/api/sessions/search'
| '/api/sessions/send' | '/api/sessions/send'
| '/api/skills/hub-search' | '/api/skills/hub-search'
| '/api/skills/install' | '/api/skills/install'
@@ -1923,6 +1934,7 @@ export interface FileRouteTypes {
| '/api/profiles/toggle-skill' | '/api/profiles/toggle-skill'
| '/api/profiles/update' | '/api/profiles/update'
| '/api/runs/active' | '/api/runs/active'
| '/api/sessions/search'
| '/api/sessions/send' | '/api/sessions/send'
| '/api/skills/hub-search' | '/api/skills/hub-search'
| '/api/skills/install' | '/api/skills/install'
@@ -2866,6 +2878,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSessionsSendRouteImport preLoaderRoute: typeof ApiSessionsSendRouteImport
parentRoute: typeof ApiSessionsRoute parentRoute: typeof ApiSessionsRoute
} }
'/api/sessions/search': {
id: '/api/sessions/search'
path: '/search'
fullPath: '/api/sessions/search'
preLoaderRoute: typeof ApiSessionsSearchRouteImport
parentRoute: typeof ApiSessionsRoute
}
'/api/runs/active': { '/api/runs/active': {
id: '/api/runs/active' id: '/api/runs/active'
path: '/api/runs/active' path: '/api/runs/active'
@@ -3329,12 +3348,14 @@ const ApiMemoryRouteWithChildren = ApiMemoryRoute._addFileChildren(
) )
interface ApiSessionsRouteChildren { interface ApiSessionsRouteChildren {
ApiSessionsSearchRoute: typeof ApiSessionsSearchRoute
ApiSessionsSendRoute: typeof ApiSessionsSendRoute ApiSessionsSendRoute: typeof ApiSessionsSendRoute
ApiSessionsSessionKeyActiveRunRoute: typeof ApiSessionsSessionKeyActiveRunRoute ApiSessionsSessionKeyActiveRunRoute: typeof ApiSessionsSessionKeyActiveRunRoute
ApiSessionsSessionKeyStatusRoute: typeof ApiSessionsSessionKeyStatusRoute ApiSessionsSessionKeyStatusRoute: typeof ApiSessionsSessionKeyStatusRoute
} }
const ApiSessionsRouteChildren: ApiSessionsRouteChildren = { const ApiSessionsRouteChildren: ApiSessionsRouteChildren = {
ApiSessionsSearchRoute: ApiSessionsSearchRoute,
ApiSessionsSendRoute: ApiSessionsSendRoute, ApiSessionsSendRoute: ApiSessionsSendRoute,
ApiSessionsSessionKeyActiveRunRoute: ApiSessionsSessionKeyActiveRunRoute, ApiSessionsSessionKeyActiveRunRoute: ApiSessionsSessionKeyActiveRunRoute,
ApiSessionsSessionKeyStatusRoute: ApiSessionsSessionKeyStatusRoute, ApiSessionsSessionKeyStatusRoute: ApiSessionsSessionKeyStatusRoute,

View File

@@ -20,7 +20,7 @@ import { Toaster } from '@/components/ui/toast'
import { OnboardingTour } from '@/components/onboarding/onboarding-tour' import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal' import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
import { UpdateCenterNotifier } from '@/components/update-center-notifier' import { UpdateCenterNotifier } from '@/components/update-center-notifier'
import { initializeSettingsAppearance, useSettings } from '@/hooks/use-settings' import { applyInterfacePreferences, initializeSettingsAppearance, useSettings } from '@/hooks/use-settings'
import { useApplyChatWidth } from '@/hooks/use-chat-settings' import { useApplyChatWidth } from '@/hooks/use-chat-settings'
import { import {
ClaudeOnboarding, ClaudeOnboarding,
@@ -274,6 +274,10 @@ function RootLayout() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useApplyChatWidth() useApplyChatWidth()
useEffect(() => {
applyInterfacePreferences(settings)
}, [settings])
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
initializeSettingsAppearance() initializeSettingsAppearance()

View 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 })
},
},
},
})

View File

@@ -17,6 +17,20 @@ const AcceptanceCriteriaSchema = z.preprocess(
z.array(z.string().trim().min(1).max(5000)).default([]), z.array(z.string().trim().min(1).max(5000)).default([]),
) )
const TagsSchema = z.preprocess(
(value) => {
if (Array.isArray(value)) return value
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
return []
},
z.array(z.string().trim().min(1).max(120)).default([]),
)
const CreateCardSchema = z.object({ const CreateCardSchema = z.object({
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
spec: z.string().trim().max(5000).optional().default(''), spec: z.string().trim().max(5000).optional().default(''),
@@ -28,6 +42,7 @@ const CreateCardSchema = z.object({
reportPath: z.string().trim().max(500).optional().nullable(), reportPath: z.string().trim().max(500).optional().nullable(),
createdBy: z.string().trim().max(120).optional().default('aurora'), createdBy: z.string().trim().max(120).optional().default('aurora'),
parents: z.array(z.string().trim().min(1).max(200)).optional().default([]), parents: z.array(z.string().trim().min(1).max(200)).optional().default([]),
tags: TagsSchema,
idempotencyKey: z.string().trim().max(500).optional().nullable(), idempotencyKey: z.string().trim().max(500).optional().nullable(),
}) })
@@ -58,7 +73,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
} }
const data = parsed.data const data = parsed.data
const card = await createKanbanCard({ const card = await createKanbanCard({
title: data.title ?? '', title: data.title,
spec: data.spec, spec: data.spec,
acceptanceCriteria: data.acceptanceCriteria, acceptanceCriteria: data.acceptanceCriteria,
assignedWorker: data.assignedWorker, assignedWorker: data.assignedWorker,
@@ -68,6 +83,7 @@ export const Route = createFileRoute('/api/swarm-kanban')({
reportPath: data.reportPath, reportPath: data.reportPath,
createdBy: data.createdBy, createdBy: data.createdBy,
parents: data.parents, parents: data.parents,
tags: data.tags,
idempotencyKey: data.idempotencyKey, idempotencyKey: data.idempotencyKey,
}) })
return json({ ok: true, card, backend: getKanbanBackendMeta() }) return json({ ok: true, card, backend: getKanbanBackendMeta() })

View File

@@ -386,6 +386,45 @@ function SettingsRoute() {
</p> </p>
</div> </div>
<WorkspaceThemePicker /> <WorkspaceThemePicker />
<div className="grid gap-3 pt-3 md:grid-cols-2">
<label className="block text-sm">
<span className="mb-1 block font-medium text-primary-900">
Interface font
</span>
<select
value={settings.interfaceFont}
onChange={(event) =>
updateSettings({
interfaceFont: event.target.value as typeof settings.interfaceFont,
})
}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none"
>
<option value="system">System sans</option>
<option value="inter">Inter-style sans</option>
<option value="serif">Serif</option>
<option value="mono">Monospace</option>
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block font-medium text-primary-900">
Spacing density
</span>
<select
value={settings.interfaceDensity}
onChange={(event) =>
updateSettings({
interfaceDensity: event.target.value as typeof settings.interfaceDensity,
})
}
className="w-full rounded-xl border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-900 outline-none"
>
<option value="compact">Compact</option>
<option value="comfortable">Comfortable</option>
<option value="spacious">Spacious</option>
</select>
</label>
</div>
</div> </div>
</SettingsSection> </SettingsSection>
</> </>

View File

@@ -2,12 +2,18 @@ export const CHAT_OPEN_MESSAGE_SEARCH_EVENT = 'claude:chat-open-message-search'
export const CHAT_RUN_COMMAND_EVENT = 'claude:chat-run-command' export const CHAT_RUN_COMMAND_EVENT = 'claude:chat-run-command'
export const CHAT_SUBMIT_SELECTION_EVENT = 'claude:chat-submit-selection'
export const CHAT_PENDING_COMMAND_STORAGE_KEY = 'claude.pending-chat-command' export const CHAT_PENDING_COMMAND_STORAGE_KEY = 'claude.pending-chat-command'
export type ChatRunCommandDetail = { export type ChatRunCommandDetail = {
command: string command: string
} }
export type ChatSubmitSelectionDetail = {
text: string
}
export const CHAT_OPEN_SETTINGS_EVENT = 'claude:chat-open-settings' export const CHAT_OPEN_SETTINGS_EVENT = 'claude:chat-open-settings'
export type ChatOpenSettingsDetail = { export type ChatOpenSettingsDetail = {

View File

@@ -65,6 +65,11 @@ import {
CHAT_OPEN_SETTINGS_EVENT, CHAT_OPEN_SETTINGS_EVENT,
CHAT_PENDING_COMMAND_STORAGE_KEY, CHAT_PENDING_COMMAND_STORAGE_KEY,
CHAT_RUN_COMMAND_EVENT, CHAT_RUN_COMMAND_EVENT,
CHAT_SUBMIT_SELECTION_EVENT,
} from './chat-events'
import type {
ChatRunCommandDetail,
ChatSubmitSelectionDetail,
} from './chat-events' } from './chat-events'
import type {ResponseWaitSnapshot} from './chat-screen-utils'; import type {ResponseWaitSnapshot} from './chat-screen-utils';
import type { import type {
@@ -75,7 +80,6 @@ import type {
} from './components/chat-composer' } from './components/chat-composer'
import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store' import type { ApprovalRequest } from '@/screens/gateway/lib/approvals-store'
import type { ChatAttachment, ChatMessage, SessionMeta } from './types' import type { ChatAttachment, ChatMessage, SessionMeta } from './types'
import type { ChatRunCommandDetail } from './chat-events'
import type {AgentActivity} from '@/stores/chat-activity-store'; import type {AgentActivity} from '@/stores/chat-activity-store';
import { useChatSettingsStore } from '@/hooks/use-chat-settings' import { useChatSettingsStore } from '@/hooks/use-chat-settings'
import { playChatComplete } from '@/lib/sounds' import { playChatComplete } from '@/lib/sounds'
@@ -2567,6 +2571,23 @@ export function ChatScreen({
} }
}, [runPaletteSlashCommand]) }, [runPaletteSlashCommand])
useEffect(() => {
function handleSubmitSelection(event: Event) {
const detail = (event as CustomEvent<ChatSubmitSelectionDetail>).detail
const text = detail?.text?.trim()
if (!text) return
send(text, [], false, commandHelpers)
}
window.addEventListener(CHAT_SUBMIT_SELECTION_EVENT, handleSubmitSelection)
return () => {
window.removeEventListener(
CHAT_SUBMIT_SELECTION_EVENT,
handleSubmitSelection,
)
}
}, [commandHelpers, send])
useEffect(() => { useEffect(() => {
const pendingCommand = window.sessionStorage.getItem( const pendingCommand = window.sessionStorage.getItem(
CHAT_PENDING_COMMAND_STORAGE_KEY, CHAT_PENDING_COMMAND_STORAGE_KEY,

View File

@@ -12,7 +12,7 @@ import {
shouldAutoExpandHermesActivityCard, shouldAutoExpandHermesActivityCard,
} from './streaming-activity-ui' } from './streaming-activity-ui'
import { TuiActivityCard } from './tui-activity-card' import { TuiActivityCard } from './tui-activity-card'
import type { ChatAttachment, ChatMessage, ToolCallContent } from '../types' import type { ChatAttachment, ChatMessage, SelectionCardContent, ToolCallContent } from '../types'
import type { ToolPart } from '@/components/prompt-kit/tool' import type { ToolPart } from '@/components/prompt-kit/tool'
import { AssistantAvatar, UserAvatar } from '@/components/avatars' import { AssistantAvatar, UserAvatar } from '@/components/avatars'
import { CodeBlock } from '@/components/prompt-kit/code-block' import { CodeBlock } from '@/components/prompt-kit/code-block'
@@ -36,6 +36,7 @@ import {
useChatSettingsStore, useChatSettingsStore,
} from '@/hooks/use-chat-settings' } from '@/hooks/use-chat-settings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CHAT_SUBMIT_SELECTION_EVENT } from '@/screens/chat/chat-events'
const WORDS_PER_TICK = 4 const WORDS_PER_TICK = 4
const TICK_INTERVAL_MS = 50 const TICK_INTERVAL_MS = 50
@@ -151,6 +152,93 @@ type MessageItemProps = {
isLastAssistant?: boolean isLastAssistant?: boolean
} }
function dispatchSelectionCardReply(text: string) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent(CHAT_SUBMIT_SELECTION_EVENT, { detail: { text } }),
)
}
function InteractiveSelectionCard({ card }: { card: SelectionCardContent }) {
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const mode = card.mode ?? 'single'
const options = Array.isArray(card.options) ? card.options : []
const isMulti = mode === 'multi'
function toggle(value: string) {
setSelected((prev) => {
const next = new Set(isMulti ? prev : [])
if (next.has(value)) next.delete(value)
else next.add(value)
return next
})
}
function submit(value?: string) {
const values = value ? [value] : [...selected]
if (values.length === 0) return
dispatchSelectionCardReply(values.join(', '))
}
return (
<div className="my-2 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-sm">
<div className="border-b border-[var(--theme-border)] px-3 py-2">
<div className="text-sm font-semibold text-[var(--theme-text)]">
{card.title || 'Choose an option'}
</div>
{card.body ? (
<div className="mt-1 text-xs text-[var(--theme-muted)]">{card.body}</div>
) : null}
</div>
<div className="space-y-1.5 p-2">
{options.map((option, index) => {
const value = option.value || option.label
const id = option.id || value || String(index)
const isSelected = selected.has(value)
return (
<button
key={id}
type="button"
onClick={() => (isMulti ? toggle(value) : submit(value))}
className={cn(
'flex w-full items-start gap-2 rounded-xl border px-3 py-2 text-left text-sm transition-colors',
isSelected
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)] text-[var(--theme-text)]'
: 'border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
)}
>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-current text-[10px]">
{isSelected ? '✓' : isMulti ? '' : index + 1}
</span>
<span className="min-w-0">
<span className="block font-medium">{option.label}</span>
{option.description ? (
<span className="mt-0.5 block text-xs opacity-70">
{option.description}
</span>
) : null}
</span>
</button>
)
})}
</div>
{isMulti || mode === 'confirm' ? (
<div className="flex items-center justify-between border-t border-[var(--theme-border)] px-3 py-2 text-xs text-[var(--theme-muted)]">
<span>{selected.size} selected</span>
<button
type="button"
onClick={() => submit()}
disabled={selected.size === 0}
className="rounded-full bg-[var(--theme-accent)] px-3 py-1.5 font-semibold text-primary-950 disabled:opacity-50"
>
{card.submitLabel || 'Send choice'}
</button>
</div>
) : null}
</div>
)
}
type InlineToolSection = { type InlineToolSection = {
key: string key: string
type: string type: string
@@ -167,10 +255,12 @@ type InlineToolSection = {
export type InlineRenderPlanItem = export type InlineRenderPlanItem =
| { kind: 'text'; text: string } | { kind: 'text'; text: string }
| { kind: 'selection-card'; card: SelectionCardContent }
| { kind: 'tool'; section: InlineToolSection } | { kind: 'tool'; section: InlineToolSection }
export type CompactInlineRenderPlanItem = export type CompactInlineRenderPlanItem =
| { kind: 'text'; text: string } | { kind: 'text'; text: string }
| { kind: 'selection-card'; card: SelectionCardContent }
| { kind: 'tools'; sections: Array<InlineToolSection> } | { kind: 'tools'; sections: Array<InlineToolSection> }
export function buildInlineToolRenderPlan( export function buildInlineToolRenderPlan(
@@ -204,6 +294,11 @@ export function buildInlineToolRenderPlan(
usedKeys.add(matchingSection.key) usedKeys.add(matchingSection.key)
plan.push({ kind: 'tool', section: matchingSection }) plan.push({ kind: 'tool', section: matchingSection })
} }
continue
}
if (part.type === 'selectionCard') {
plan.push({ kind: 'selection-card', card: part })
} }
} }
@@ -2201,6 +2296,14 @@ function MessageItemComponent({
.filter((img) => img.src.length > 0) .filter((img) => img.src.length > 0)
}, [message.content]) }, [message.content])
const hasInlineImages = inlineImages.length > 0 const hasInlineImages = inlineImages.length > 0
const selectionCards = useMemo(
() =>
(Array.isArray(message.content) ? message.content : []).filter(
(part): part is SelectionCardContent => part.type === 'selectionCard',
),
[message.content],
)
const hasSelectionCards = selectionCards.length > 0
const hasText = displayText.length > 0 const hasText = displayText.length > 0
const hasRenderableAssistantText = const hasRenderableAssistantText =
@@ -2393,6 +2496,7 @@ function MessageItemComponent({
hasText || hasText ||
hasAttachments || hasAttachments ||
hasInlineImages || hasInlineImages ||
hasSelectionCards ||
(effectiveIsStreaming && hasRevealedText) (effectiveIsStreaming && hasRevealedText)
// 'queued' = delivered to server, waiting for response (busy/backlogged) // 'queued' = delivered to server, waiting for response (busy/backlogged)
@@ -2494,7 +2598,7 @@ function MessageItemComponent({
} }
className={cn( className={cn(
'group relative flex flex-col', 'group relative flex flex-col',
hasText || hasAttachments ? 'gap-0.5 md:gap-1' : 'gap-0', hasText || hasAttachments || hasSelectionCards ? 'gap-0.5 md:gap-1' : 'gap-0',
wrapperClassName, wrapperClassName,
isUser ? 'items-end' : 'items-start', isUser ? 'items-end' : 'items-start',
!isUser && isNew && 'animate-[message-fade-in_0.4s_ease-out]', !isUser && isNew && 'animate-[message-fade-in_0.4s_ease-out]',
@@ -2689,6 +2793,16 @@ function MessageItemComponent({
))} ))}
</div> </div>
)} )}
{hasSelectionCards ? (
<div className="flex flex-col gap-2">
{selectionCards.map((card, index) => (
<InteractiveSelectionCard
key={card.id || `${wrapperDataMessageId ?? 'selection'}-${index}`}
card={card}
/>
))}
</div>
) : null}
{hasText && {hasText &&
(isUser ? ( (isUser ? (
<span className="text-pretty">{displayText}</span> <span className="text-pretty">{displayText}</span>

View File

@@ -27,7 +27,26 @@ export type ThinkingContent = {
thinkingSignature?: string thinkingSignature?: string
} }
export type MessageContent = TextContent | ToolCallContent | ThinkingContent export type SelectionCardContent = {
type: 'selectionCard'
id?: string
title?: string
body?: string
mode?: 'single' | 'multi' | 'confirm'
options?: Array<{
id?: string
label: string
value?: string
description?: string
}>
submitLabel?: string
}
export type MessageContent =
| TextContent
| ToolCallContent
| ThinkingContent
| SelectionCardContent
export type ChatAttachment = { export type ChatAttachment = {
id?: string id?: string

View File

@@ -1,14 +1,12 @@
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import { WaveChatPanelsShowcase } from './components/wave-chat-panels-showcase' import { WaveChatPanelsShowcase } from './components/wave-chat-panels-showcase'
const HERMES_WORLD_ORIGIN = 'https://hermes-world.ai' const HERMES_WORLD_ORIGIN = 'https://hermes-world.ai'
export function HermesWorldEmbed() { export function HermesWorldEmbed() {
const [loaded, setLoaded] = useState(false)
const showPanelShowcase = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('panels') === 'wave-chat' const showPanelShowcase = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('panels') === 'wave-chat'
const src = useMemo(() => { const playUrl = useMemo(() => {
const url = new URL('/play/', HERMES_WORLD_ORIGIN) const url = new URL('/play/', HERMES_WORLD_ORIGIN)
url.searchParams.set('embed', 'workspace')
url.searchParams.set('source', 'hermes-workspace') url.searchParams.set('source', 'hermes-workspace')
return url.toString() return url.toString()
}, []) }, [])
@@ -18,32 +16,40 @@ export function HermesWorldEmbed() {
} }
return ( return (
<main className="relative h-full min-h-0 overflow-hidden bg-[#050015] text-white"> <main className="relative flex h-full min-h-0 items-center justify-center overflow-hidden bg-[#050015] px-4 text-white">
{!loaded && ( <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,.24),transparent_48%),#050015]" />
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,.24),transparent_48%),#050015]"> <div className="relative max-w-xl rounded-3xl border border-white/12 bg-black/45 px-6 py-6 text-center shadow-2xl backdrop-blur-xl">
<div className="rounded-3xl border border-white/12 bg-black/35 px-6 py-5 text-center shadow-2xl backdrop-blur-xl"> <div className="text-xs font-bold uppercase tracking-[0.24em] text-cyan-200/70">
<div className="text-xs font-bold uppercase tracking-[0.24em] text-cyan-200/70">Hermes Workspace</div> Hermes Workspace
<div className="mt-2 text-2xl font-black tracking-tight">Opening HermesWorld</div>
<div className="mt-2 text-sm text-white/58">Runtime hosted by hermes-world.ai</div>
</div>
</div> </div>
)} <h1 className="mt-2 text-3xl font-black tracking-tight">
<iframe Open HermesWorld in a full tab
title="HermesWorld" </h1>
src={src} <p className="mt-3 text-sm leading-relaxed text-white/65">
className="h-full w-full border-0 bg-[#050015]" HermesWorld currently refuses iframe embedding, so Workspace no longer
allow="fullscreen; clipboard-read; clipboard-write; gamepad" loads it in an iframe that fails with refused to connect. The hosted
referrerPolicy="strict-origin-when-cross-origin" build should be opened directly while the game deployment fixes its
onLoad={() => setLoaded(true)} stale asset/MIME issue for <code className="rounded bg-white/10 px-1">/assets/styles-*.css</code>.
/> </p>
<a <div className="mt-5 flex flex-wrap justify-center gap-2">
href={`${HERMES_WORLD_ORIGIN}/play/`} <a
target="_blank" href={playUrl}
rel="noopener noreferrer" target="_blank"
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" 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> 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> </main>
) )
} }

View File

@@ -19,6 +19,12 @@ type SwarmKanbanCard = {
createdBy: string createdBy: string
createdAt: number createdAt: number
updatedAt: number updatedAt: number
tags?: Array<string>
latestRun?: {
summary?: string | null
outcome?: string | null
status?: string | null
} | null
} }
type KanbanWorker = { type KanbanWorker = {
@@ -158,6 +164,7 @@ async function createKanbanCard(input: {
reviewer: string | null reviewer: string | null
status: KanbanLane status: KanbanLane
missionId: string | null missionId: string | null
tags: Array<string>
}): Promise<SwarmKanbanCard> { }): Promise<SwarmKanbanCard> {
const res = await fetch('/api/swarm-kanban', { const res = await fetch('/api/swarm-kanban', {
method: 'POST', method: 'POST',
@@ -187,6 +194,50 @@ function splitCriteria(value: string): Array<string> {
.filter(Boolean) .filter(Boolean)
} }
function splitTags(value: string): Array<string> {
return value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
}
type ParsedTaskLabel = { tier1: string; tier2?: string; color: string }
const LABEL_COLORS = [
'border-sky-400/50 bg-sky-500/10 text-sky-700',
'border-violet-400/50 bg-violet-500/10 text-violet-700',
'border-emerald-400/50 bg-emerald-500/10 text-emerald-700',
'border-amber-400/50 bg-amber-500/10 text-amber-700',
'border-rose-400/50 bg-rose-500/10 text-rose-700',
'border-cyan-400/50 bg-cyan-500/10 text-cyan-700',
]
function labelColor(tier1: string): string {
let hash = 0
for (const char of tier1) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
return LABEL_COLORS[hash % LABEL_COLORS.length] ?? LABEL_COLORS[0]
}
function parseTaskLabel(tag: string): ParsedTaskLabel | null {
const raw = tag.trim()
if (!raw.toLowerCase().startsWith('label:')) return null
const body = raw.slice('label:'.length).trim()
if (!body) return null
const [tier1, ...rest] = body.split('/').map((part) => part.trim()).filter(Boolean)
if (!tier1) return null
return { tier1, tier2: rest.join(' / ') || undefined, color: labelColor(tier1) }
}
function formatElapsedSince(timestamp: number): string {
const ageMs = Math.max(0, Date.now() - timestamp)
const minutes = Math.floor(ageMs / 60_000)
if (minutes < 1) return '<1m'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`
}
function workerLabel(workers: Array<KanbanWorker>, workerId: string | null): string { function workerLabel(workers: Array<KanbanWorker>, workerId: string | null): string {
if (!workerId) return 'Unassigned' if (!workerId) return 'Unassigned'
const worker = workers.find((item) => item.id === workerId) const worker = workers.find((item) => item.id === workerId)
@@ -209,6 +260,8 @@ export function Swarm2KanbanBoard({
const [draftWorker, setDraftWorker] = useState(selectedWorkerId ?? '') const [draftWorker, setDraftWorker] = useState(selectedWorkerId ?? '')
const [draftReviewer, setDraftReviewer] = useState('') const [draftReviewer, setDraftReviewer] = useState('')
const [draftStatus, setDraftStatus] = useState<KanbanLane>('backlog') const [draftStatus, setDraftStatus] = useState<KanbanLane>('backlog')
const [draftLabels, setDraftLabels] = useState('')
const [activeLabelFilter, setActiveLabelFilter] = useState<string | null>(null)
const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission)) const [linkLatestMission, setLinkLatestMission] = useState(Boolean(latestMission))
const [backendToast, setBackendToast] = useState<KanbanBackendPresentation | null>(null) const [backendToast, setBackendToast] = useState<KanbanBackendPresentation | null>(null)
const lastToastedBackendKey = useRef<string | null>(null) const lastToastedBackendKey = useRef<string | null>(null)
@@ -255,6 +308,7 @@ export function Swarm2KanbanBoard({
reviewer: draftReviewer || null, reviewer: draftReviewer || null,
status: draftStatus, status: draftStatus,
missionId: linkLatestMission ? latestMission?.id ?? null : null, missionId: linkLatestMission ? latestMission?.id ?? null : null,
tags: splitTags(draftLabels),
}), }),
onSuccess: async () => { onSuccess: async () => {
setDraftTitle('') setDraftTitle('')
@@ -263,6 +317,7 @@ export function Swarm2KanbanBoard({
setDraftWorker(selectedWorkerId ?? '') setDraftWorker(selectedWorkerId ?? '')
setDraftReviewer('') setDraftReviewer('')
setDraftStatus('backlog') setDraftStatus('backlog')
setDraftLabels('')
setComposerOpen(false) setComposerOpen(false)
await queryClient.invalidateQueries({ queryKey: ['swarm2', 'kanban'] }) await queryClient.invalidateQueries({ queryKey: ['swarm2', 'kanban'] })
}, },
@@ -275,15 +330,38 @@ export function Swarm2KanbanBoard({
}, },
}) })
const labelOptions = useMemo(() => {
const labels = new Map<string, ParsedTaskLabel>()
for (const card of query.data?.cards ?? []) {
for (const tag of card.tags ?? []) {
const parsed = parseTaskLabel(tag)
if (parsed) labels.set(`${parsed.tier1}${parsed.tier2 ? `/${parsed.tier2}` : ''}`, parsed)
}
}
return [...labels.entries()].map(([key, label]) => ({ key, label }))
}, [query.data])
const visibleCards = useMemo(() => {
const cards = query.data?.cards ?? []
if (!activeLabelFilter) return cards
return cards.filter((card) =>
(card.tags ?? []).some((tag) => {
const parsed = parseTaskLabel(tag)
const key = parsed ? `${parsed.tier1}${parsed.tier2 ? `/${parsed.tier2}` : ''}` : ''
return key === activeLabelFilter || parsed?.tier1 === activeLabelFilter
}),
)
}, [activeLabelFilter, query.data])
const cardsByLane = useMemo(() => { const cardsByLane = useMemo(() => {
const map = new Map<KanbanLane, Array<SwarmKanbanCard>>() const map = new Map<KanbanLane, Array<SwarmKanbanCard>>()
for (const lane of LANES) map.set(lane.id, []) for (const lane of LANES) map.set(lane.id, [])
for (const card of query.data?.cards ?? []) { for (const card of visibleCards) {
const bucket = map.get(card.status) ?? map.get('backlog')! const bucket = map.get(card.status) ?? map.get('backlog')!
bucket.push(card) bucket.push(card)
} }
return map return map
}, [query.data]) }, [visibleCards])
const total = query.data?.cards.length ?? 0 const total = query.data?.cards.length ?? 0
const reviewCount = cardsByLane.get('review')?.length ?? 0 const reviewCount = cardsByLane.get('review')?.length ?? 0
@@ -365,6 +443,39 @@ export function Swarm2KanbanBoard({
</div> </div>
</div> </div>
{labelOptions.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
<button
type="button"
onClick={() => setActiveLabelFilter(null)}
className={cn(
'rounded-full border px-2.5 py-1 font-semibold transition-colors',
!activeLabelFilter
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]'
: 'border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-muted)] hover:text-[var(--theme-text)]',
)}
>
All labels
</button>
{labelOptions.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setActiveLabelFilter(key)}
className={cn(
'rounded-full border px-2.5 py-1 font-semibold transition-colors',
label.color,
activeLabelFilter === key ? 'ring-2 ring-[var(--theme-accent)]' : '',
)}
title={label.tier2 ? `${label.tier1}${label.tier2}` : label.tier1}
>
{label.tier1}
{label.tier2 ? <span className="ml-1 opacity-70">/{label.tier2}</span> : null}
</button>
))}
</div>
) : null}
{backendToast ? ( {backendToast ? (
<div className="fixed right-4 top-4 z-50 max-w-sm rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-3 text-sm text-[var(--theme-text)] shadow-[0_18px_60px_var(--theme-shadow)]" role="status" aria-live="polite"> <div className="fixed right-4 top-4 z-50 max-w-sm rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-3 text-sm text-[var(--theme-text)] shadow-[0_18px_60px_var(--theme-shadow)]" role="status" aria-live="polite">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -425,6 +536,11 @@ export function Swarm2KanbanBoard({
{LANES.map((lane) => <option key={lane.id} value={lane.id}>{lane.label}</option>)} {LANES.map((lane) => <option key={lane.id} value={lane.id}>{lane.label}</option>)}
</select> </select>
</label> </label>
<label className="block text-xs md:col-span-2">
<span className="mb-1 block font-semibold text-[var(--theme-muted)]">Labels</span>
<input value={draftLabels} onChange={(event) => setDraftLabels(event.target.value)} placeholder="label:Hermes/Workspace, priority:high" className="w-full rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-sm text-[var(--theme-text)] outline-none" />
<span className="mt-1 block text-[10px] text-[var(--theme-muted)]">Use label:Business/Sub-scope for the two-tier board filter.</span>
</label>
<label className="flex items-center gap-2 self-end rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-muted)]"> <label className="flex items-center gap-2 self-end rounded-xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-muted)]">
<input type="checkbox" checked={linkLatestMission} disabled={!latestMission} onChange={(event) => setLinkLatestMission(event.target.checked)} /> <input type="checkbox" checked={linkLatestMission} disabled={!latestMission} onChange={(event) => setLinkLatestMission(event.target.checked)} />
Link latest mission{latestMission ? `: ${latestMission.title}` : ''} Link latest mission{latestMission ? `: ${latestMission.title}` : ''}
@@ -476,6 +592,27 @@ export function Swarm2KanbanBoard({
{card.acceptanceCriteria.length > 3 ? <li>+{card.acceptanceCriteria.length - 3} more</li> : null} {card.acceptanceCriteria.length > 3 ? <li>+{card.acceptanceCriteria.length - 3} more</li> : null}
</ul> </ul>
) : null} ) : null}
{card.tags?.length ? (
<div className="mt-2 flex flex-wrap gap-1">
{card.tags.slice(0, 4).map((tag) => {
const parsed = parseTaskLabel(tag)
return parsed ? (
<span key={tag} className={cn('rounded-full border px-1.5 py-0.5 text-[9px] font-semibold', parsed.color)}>
{parsed.tier1}{parsed.tier2 ? <span className="opacity-70">/{parsed.tier2}</span> : null}
</span>
) : (
<span key={tag} className="rounded-full border border-[var(--theme-border)] px-1.5 py-0.5 text-[9px] text-[var(--theme-muted)]">{tag}</span>
)
})}
</div>
) : null}
{card.status === 'running' || card.latestRun ? (
<div className="mt-2 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-2 py-1.5 text-[10px] text-emerald-700">
<div className="font-semibold">{card.status === 'running' ? `Running for ${formatElapsedSince(card.updatedAt)}` : 'Latest run'}</div>
{card.latestRun?.summary ? <div className="mt-0.5 line-clamp-2">{card.latestRun.summary}</div> : null}
{card.latestRun && (card.latestRun.status || card.latestRun.outcome) ? <div className="mt-0.5 opacity-75">{[card.latestRun.status, card.latestRun.outcome].filter(Boolean).join(' · ')}</div> : null}
</div>
) : null}
<div className="mt-3 space-y-1 text-[10px] text-[var(--theme-muted)]"> <div className="mt-3 space-y-1 text-[10px] text-[var(--theme-muted)]">
<div>Owner: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.assignedWorker)}</span></div> <div>Owner: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.assignedWorker)}</span></div>
<div>Reviewer: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.reviewer)}</span></div> <div>Reviewer: <span className="font-semibold text-[var(--theme-text)]">{workerLabel(workers, card.reviewer)}</span></div>

View File

@@ -110,23 +110,53 @@ export function getLocalMessages(sessionId: string): Array<LocalMessage> {
return store.messages[sessionId] ?? [] return store.messages[sessionId] ?? []
} }
export function searchLocalSessions(
query: string,
limit = 20,
): Array<LocalSession & { snippet: string }> {
const normalized = query.trim().toLowerCase()
if (!normalized) return []
const results: Array<LocalSession & { snippet: string }> = []
const sessions = listLocalSessions()
for (const session of sessions) {
const title = session.title || ''
const messages = store.messages[session.id] ?? []
const matchingMessage = messages.find((message) =>
message.content.toLowerCase().includes(normalized),
)
if (!title.toLowerCase().includes(normalized) && !matchingMessage) {
continue
}
const content = matchingMessage?.content || title || session.id
const lowerContent = content.toLowerCase()
const matchIndex = lowerContent.indexOf(normalized)
const start = matchIndex >= 0 ? Math.max(0, matchIndex - 80) : 0
const snippet = content.slice(start, start + 220).trim()
results.push({ ...session, snippet })
if (results.length >= limit) break
}
return results
}
export function appendLocalMessage( export function appendLocalMessage(
sessionId: string, sessionId: string,
message: LocalMessage, message: LocalMessage,
): void { ): void {
ensureLocalSession(sessionId) const session = ensureLocalSession(sessionId)
if (!store.messages[sessionId]) store.messages[sessionId] = [] const messages = store.messages[sessionId] ?? []
store.messages[sessionId].push(message) store.messages[sessionId] = messages
messages.push(message)
if (store.messages[sessionId].length > MAX_MESSAGES_PER_SESSION) { if (store.messages[sessionId].length > MAX_MESSAGES_PER_SESSION) {
store.messages[sessionId] = store.messages[sessionId].slice( store.messages[sessionId] = store.messages[sessionId].slice(
-MAX_MESSAGES_PER_SESSION, -MAX_MESSAGES_PER_SESSION,
) )
} }
const session = store.sessions[sessionId] session.messageCount = store.messages[sessionId].length
if (session) { session.updatedAt = Date.now()
session.messageCount = store.messages[sessionId].length
session.updatedAt = Date.now()
}
scheduleSave() scheduleSave()
} }

View File

@@ -10,7 +10,7 @@ export type SwarmKanbanCard = {
id: string id: string
title: string title: string
spec: string spec: string
acceptanceCriteria: string[] acceptanceCriteria: Array<string>
assignedWorker: string | null assignedWorker: string | null
reviewer: string | null reviewer: string | null
status: SwarmKanbanLane | string status: SwarmKanbanLane | string
@@ -19,13 +19,14 @@ export type SwarmKanbanCard = {
createdBy: string createdBy: string
createdAt: number createdAt: number
updatedAt: number updatedAt: number
parents?: string[] parents?: Array<string>
children?: string[] children?: Array<string>
latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null
tags?: Array<string>
source?: string source?: string
} }
type SwarmKanbanFile = { cards: SwarmKanbanCard[] } type SwarmKanbanFile = { cards: Array<SwarmKanbanCard> }
type ListFilters = { type ListFilters = {
status?: string | null status?: string | null
@@ -37,14 +38,15 @@ type ListFilters = {
export type CreateSwarmKanbanCardInput = { export type CreateSwarmKanbanCardInput = {
title: string title: string
spec?: string spec?: string
acceptanceCriteria?: string[] acceptanceCriteria?: Array<string>
assignedWorker?: string | null assignedWorker?: string | null
reviewer?: string | null reviewer?: string | null
status?: SwarmKanbanLane | null status?: SwarmKanbanLane | null
missionId?: string | null missionId?: string | null
reportPath?: string | null reportPath?: string | null
createdBy?: string | null createdBy?: string | null
parents?: string[] parents?: Array<string>
tags?: Array<string>
idempotencyKey?: string | null idempotencyKey?: string | null
} }
@@ -82,12 +84,18 @@ function normalizeStatus(value: unknown): SwarmKanbanLane {
return SWARM_KANBAN_LANES.includes(value as SwarmKanbanLane) ? (value as SwarmKanbanLane) : 'backlog' return SWARM_KANBAN_LANES.includes(value as SwarmKanbanLane) ? (value as SwarmKanbanLane) : 'backlog'
} }
function normalizeCriteria(value: unknown): string[] { function normalizeCriteria(value: unknown): Array<string> {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean) if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
if (typeof value === 'string') return value.split('\n').map((item) => item.trim()).filter(Boolean) if (typeof value === 'string') return value.split('\n').map((item) => item.trim()).filter(Boolean)
return [] return []
} }
function normalizeTags(value: unknown): Array<string> {
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean)
if (typeof value === 'string') return value.split(',').map((item) => item.trim()).filter(Boolean)
return []
}
function optionalString(value: unknown): string | null { function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null return typeof value === 'string' && value.trim() ? value.trim() : null
} }
@@ -107,10 +115,15 @@ function normalizeCard(card: (Partial<Omit<SwarmKanbanCard, 'status'>> & { id?:
createdBy: typeof card.createdBy === 'string' && card.createdBy ? card.createdBy : 'swarm2-kanban', createdBy: typeof card.createdBy === 'string' && card.createdBy ? card.createdBy : 'swarm2-kanban',
createdAt: typeof card.createdAt === 'number' ? card.createdAt : now, createdAt: typeof card.createdAt === 'number' ? card.createdAt : now,
updatedAt: typeof card.updatedAt === 'number' ? card.updatedAt : now, updatedAt: typeof card.updatedAt === 'number' ? card.updatedAt : now,
parents: normalizeTags(card.parents),
children: normalizeTags(card.children),
latestRun: card.latestRun ?? null,
tags: normalizeTags(card.tags),
source: typeof card.source === 'string' ? card.source : undefined,
} }
} }
export function listSwarmKanbanCards(filters: ListFilters = {}): SwarmKanbanCard[] { export function listSwarmKanbanCards(filters: ListFilters = {}): Array<SwarmKanbanCard> {
let cards = readKanbanFile().cards let cards = readKanbanFile().cards
if (filters.status) cards = cards.filter((card) => card.status === normalizeStatus(filters.status)) if (filters.status) cards = cards.filter((card) => card.status === normalizeStatus(filters.status))
if (filters.assignedWorker) cards = cards.filter((card) => card.assignedWorker === filters.assignedWorker) if (filters.assignedWorker) cards = cards.filter((card) => card.assignedWorker === filters.assignedWorker)
@@ -135,6 +148,8 @@ export function createSwarmKanbanCard(input: CreateSwarmKanbanCardInput): SwarmK
createdBy: input.createdBy ?? 'swarm2-kanban', createdBy: input.createdBy ?? 'swarm2-kanban',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
parents: input.parents,
tags: input.tags,
}) })
file.cards.push(card) file.cards.push(card)
writeKanbanFile(file) writeKanbanFile(file)

View File

@@ -190,9 +190,36 @@
} }
} }
:root {
--interface-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--interface-density-scale: 1;
}
[data-interface-font='inter'] {
--interface-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
[data-interface-font='serif'] {
--interface-font-family: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
}
[data-interface-font='mono'] {
--interface-font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
}
[data-interface-density='compact'] {
--interface-density-scale: 0.92;
}
[data-interface-density='spacious'] {
--interface-density-scale: 1.08;
}
html, html,
body { body {
@apply m-0 font-sans; @apply m-0 font-sans;
font-family: var(--interface-font-family);
font-size: calc(16px * var(--interface-density-scale));
letter-spacing: -0.15px; letter-spacing: -0.15px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;