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`
#
# 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

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.
#### Run without an open terminal
After `pnpm build`, install Workspace as a user-level launchd/systemd service:
```bash
chmod +x scripts/install-dashboard-service.sh
scripts/install-dashboard-service.sh
```
See [`docs/dashboard-service.md`](docs/dashboard-service.md) for macOS launchd, Linux systemd, logs, overrides, and uninstall steps.
#### Environment Variables
```env

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

View File

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

View File

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

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 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,

View File

@@ -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()

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([]),
)
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() })

View File

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

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_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 = {

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

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,
body {
@apply m-0 font-sans;
font-family: var(--interface-font-family);
font-size: calc(16px * var(--interface-density-scale));
letter-spacing: -0.15px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;