From 4f75b5835cc2f275e36d8adc28deb558844bceb5 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 19 May 2026 16:27:10 -0400 Subject: [PATCH] fix: batch remaining workspace bugfix slices (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: preserve pnpm approved builds * fix(router): support runtime basepath override for reverse-proxy hosting The TanStack router was created without a `basepath` option, so the same built bundle could not be hosted under a path prefix (e.g. behind a reverse proxy that mounts the app at `/workspaces//`). Hard refreshes appeared to work because SSR runs at the proxy-stripped path, but client-side navigation to dynamic routes such as `/chat/$sessionKey` silently fell through to the catch-all `/$` route — rendering the "404 — Not Found" page from inside the SPA. Read an optional `window.__HERMES_WORKSPACE_BASEPATH__` global and pass it through to `createRouter`. When unset, behavior is unchanged (`basepath: '/'`). The value is normalized so callers can pass either `/workspaces/abc`, `workspaces/abc`, or `/workspaces/abc/` without upsetting TanStack's pathname matching. This lets hosting layers inject a tiny inline script before the bundle loads to mount the app at any path, without rebuilding. * fix(chat): prevent stale thinking state after page refresh (closes #449) Root cause: sessionStorage 'waiting' flags persisted across page refreshes even for completed conversations. The Zustand store restored these stale entries on mount, and the active-run API check cleared them async — but there was a visible render window where the UI showed 'thinking'. Fix: 1. Added activeRunCheckDone state that gates the waitingForResponse memo. While the active-run API check is pending, stale restored state is not trusted — the thinking indicator stays hidden until verification. 2. Added onCheckComplete callback to useActiveRunCheck hook that fires after the API check finishes (success or error), unblocking the gate. 3. Added a useEffect that detects restored stale waiting state and sets pendingVerifySessionKeyRef so the gate only applies to the key that needs verification — not to genuine active streams. Test: e2e/chat-thinking-state.spec.ts injects a stale sessionStorage entry before page load, then verifies no thinking indicator appears and the stale entry is cleaned up by the API check. * fix(chat): eliminate duplicate messages flicker on stream completion (closes #441) Root cause: onDone handler used queryClient.invalidateQueries() which triggers an async refetch. During the refetch window, mergeHistoryMessages ran with stale cache data + realtime buffer, producing visible duplicates (extra user message + blank line) for 1-2 seconds until refetch completed. Fix: Directly merge realtime buffer into history cache via setQueryData(), then clear buffer synchronously. Background refetch runs after for consistency but doesn't block rendering. * fix: restore hermes-config and config-patch API routes The Aurora rename migration (efcb7d14) renamed hermes-config.ts to claude-config.ts, but the frontend and routeTree.gen.ts still reference the original paths. This caused all /api/hermes-config and /api/config-patch requests to fall through to the SPA HTML fallback, breaking config saves from the settings dialog and provider wizard with 'Failed to save' errors. Restored by creating thin route files that delegate to the existing handleHermesConfigGet/handleHermesConfigPatch handlers from src/server/hermes-config-route.ts. Fixes the settings dialog (hermes-config GET/PATCH) and provider wizard (config-patch POST) config save flows. * fix(server): add essential env vars to terminal session * fix(swarm): worker card shows stale state after task completes deriveWorkerState derived the badge from currentTask title substring matching and markCheckpointResult never cleared currentTask on terminal checkpoints, so a finished worker's card stayed permanently 'working'. - swarm-dispatch.ts: clear currentTask on terminal checkpoint (checkpointStatus !== 'in_progress'), matching conductor-stop's reset - operational-worker-card.tsx: deriveWorkerState reads authoritative checkpointStatus/state first, title heuristic only while in_progress - swarm2-screen.tsx: pass checkpointStatus/state into the card * fix(portable-history): replay authenticated portable chat history * fix(config): keep legacy claude-config shim on shared handlers * fix: harden splash hydration and docker uid mapping * fix: keep seen update notes dismissed * feat: consolidate workspace state under configurable state directory (closes #439) Adds HERMES_WORKSPACE_STATE_DIR env var support, consolidating 5 scattered state files under a single configurable directory. Changes: - New src/server/workspace-state-dir.ts with getStateDir() utility honoring HERMES_WORKSPACE_STATE_DIR → HERMES_HOME/workspace → CLAUDE_HOME/workspace → ~/.hermes/workspace (fallback chain) - Updated gateway-capabilities.ts (workspace-overrides.json) - Updated mcp-presets-store.ts (mcp-presets.json) - Updated mcp-hub-sources-store.ts (mcp-hub-sources.json) - Updated mcp-tools-cache.ts (cache/mcp-tools.json) - Updated knowledge-config.ts (knowledge-config.json) - Removed 5 duplicated hermesHome() functions, replaced with shared getStateDir() import Test: 6 vitest unit tests covering all env var priority combinations (cherry picked from commit d6bebe0614b0c7b9015bac5e35d315a8450ac146) * fix(conductor): surface native-swarm progress and harden worker startup * feat(chat): safely render HTML message markup * fix(chat): surface installed skills in slash autocomplete * fix: add swarm runtime reset endpoint * fix(conductor): mobile rendering — add overflow-y-auto, mobile bottom padding, OfficeView responsive height, tabbar fix * fix(send-stream): preserve runs on client disconnect * fix(profiles): skip profiles/default duplicate card * fix: accept HERMES_AGENT_PATH override * fix(profiles): allow disabling sticky active_profile writes * fix: preserve workspace chat session routing * fix(portable-history): skip replay when gateway session continuity is available --------- Co-authored-by: Hermes Agent Co-authored-by: jack Co-authored-by: Waylon Kenning Co-authored-by: Michael Rodriguez Co-authored-by: Vu Tran Co-authored-by: iltaek Co-authored-by: Aurora release bot Co-authored-by: jonathanmalkin Co-authored-by: KT-Hermes --- Dockerfile | 7 +- docker/entrypoint.sh | 48 +++ docs/workspace-chat-session-routing.md | 98 ++++++ e2e/chat-flicker-duplicate.spec.ts | 50 +++ e2e/chat-thinking-state.spec.ts | 53 +++ e2e/conductor-mobile-rendering.spec.ts | 103 ++++++ package.json | 2 + pnpm-lock.yaml | 24 ++ pnpm-workspace.yaml | 5 + src/components/prompt-kit/markdown.tsx | 107 ++++++ src/components/slash-command-menu.test.tsx | 30 +- src/components/slash-command-menu.tsx | 29 +- .../update-center-notifier.test.tsx | 63 ++++ src/components/update-center-notifier.tsx | 6 + .../usage-meter/context-alert-modal.tsx | 4 - src/components/usage-meter/usage-meter.tsx | 6 +- src/components/workspace-shell.tsx | 3 +- src/routeTree.gen.ts | 83 ++++- src/router.test.ts | 49 +++ src/router.tsx | 28 ++ src/routes/__root.tsx | 12 +- src/routes/api/-swarm-runtime-reset.test.ts | 142 ++++++++ src/routes/api/claude-config.ts | 312 +----------------- src/routes/api/conductor-spawn.ts | 51 ++- src/routes/api/conductor-stop.ts | 38 +-- src/routes/api/config-patch.ts | 16 + src/routes/api/hermes-config.ts | 19 ++ src/routes/api/send-stream.ts | 52 +-- src/routes/api/session-status.ts | 98 ++++-- src/routes/api/swarm-dispatch.ts | 14 +- src/routes/api/swarm-missions.ts | 43 +-- src/routes/api/swarm-runtime.reset.ts | 67 ++++ src/screens/chat/chat-screen.tsx | 39 ++- src/screens/chat/components/chat-composer.tsx | 59 +++- .../chat/hooks/use-active-run-check.ts | 8 +- .../chat/hooks/use-realtime-chat-history.ts | 96 ++++-- src/screens/gateway/conductor.tsx | 18 +- .../gateway/hooks/use-conductor-gateway.ts | 57 +++- .../swarm2/operational-worker-card.tsx | 30 +- src/screens/swarm2/swarm2-screen.tsx | 3 + src/server/__tests__/profiles-browser.test.ts | 65 ++++ src/server/chat-backends.ts | 2 + src/server/claude-agent.test.ts | 46 +++ src/server/claude-agent.ts | 5 +- src/server/gateway-capabilities.ts | 7 +- src/server/knowledge-config.ts | 5 +- src/server/mcp-hub-sources-store.ts | 8 +- src/server/mcp-presets-store.ts | 12 +- src/server/mcp-tools-cache.ts | 12 +- src/server/openai-compat-api.test.ts | 58 +++- src/server/openai-compat-api.ts | 11 +- src/server/portable-history.test.ts | 6 +- src/server/portable-history.ts | 13 +- src/server/profiles-browser.test.ts | 29 ++ src/server/profiles-browser.ts | 19 +- src/server/swarm-checkpoints.ts | 6 +- src/server/swarm-runtime-reset.ts | 103 ++++++ src/server/terminal-sessions.ts | 30 +- src/server/workspace-state-dir.test.ts | 56 ++++ src/server/workspace-state-dir.ts | 25 ++ src/styles.css | 2 +- vite.config.ts | 14 +- 62 files changed, 1937 insertions(+), 569 deletions(-) create mode 100755 docker/entrypoint.sh create mode 100644 docs/workspace-chat-session-routing.md create mode 100644 e2e/chat-flicker-duplicate.spec.ts create mode 100644 e2e/chat-thinking-state.spec.ts create mode 100644 e2e/conductor-mobile-rendering.spec.ts create mode 100644 pnpm-workspace.yaml create mode 100644 src/components/update-center-notifier.test.tsx create mode 100644 src/router.test.ts create mode 100644 src/routes/api/-swarm-runtime-reset.test.ts create mode 100644 src/routes/api/config-patch.ts create mode 100644 src/routes/api/hermes-config.ts create mode 100644 src/routes/api/swarm-runtime.reset.ts create mode 100644 src/server/claude-agent.test.ts create mode 100644 src/server/swarm-runtime-reset.ts create mode 100644 src/server/workspace-state-dir.test.ts create mode 100644 src/server/workspace-state-dir.ts diff --git a/Dockerfile b/Dockerfile index 4ba4c1de..07d6e31c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ # Or pull pre-built: # docker pull ghcr.io/outsourc-e/hermes-workspace:latest # +FROM tianon/gosu:1.19-bookworm AS gosu_source # ─── build stage ───────────────────────────────────────────────────────── FROM node:22-slim AS build RUN corepack enable && apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* @@ -32,6 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && groupadd -r workspace && useradd -r -g workspace -u 10010 -m workspace +COPY --from=gosu_source /gosu /usr/local/bin/gosu + WORKDIR /app # Copy build artefacts + runtime deps. @@ -44,8 +47,8 @@ COPY --from=build --chown=workspace:workspace /app/node_modules ./node_modules COPY --from=build --chown=workspace:workspace /app/package.json ./package.json COPY --from=build --chown=workspace:workspace /app/server-entry.js ./server-entry.js COPY --from=build --chown=workspace:workspace /app/skills ./skills +COPY --chown=workspace:workspace docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh -USER workspace ENV NODE_ENV=production \ PORT=3000 \ HOST=0.0.0.0 \ @@ -55,5 +58,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1 -ENTRYPOINT ["/usr/bin/tini", "--"] +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "--max-old-space-size=2048", "server-entry.js"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..70567e48 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +WORKSPACE_USER=workspace +WORKSPACE_GROUP=workspace +WORKSPACE_HOME="$(getent passwd "$WORKSPACE_USER" | cut -d: -f6)" +TARGET_UID="${HERMES_UID:-}" +TARGET_GID="${HERMES_GID:-}" + +fix_owner_if_needed() { + local path="$1" + if [ ! -e "$path" ]; then + return + fi + + local actual_uid + actual_uid=$(id -u "$WORKSPACE_USER") + local current_uid + current_uid=$(stat -c %u "$path" 2>/dev/null || true) + if [ -n "$current_uid" ] && [ "$current_uid" != "$actual_uid" ]; then + chown -R "$WORKSPACE_USER:$WORKSPACE_GROUP" "$path" 2>/dev/null || \ + echo "Warning: chown failed for $path (rootless container or restricted mount?) — continuing anyway" + fi +} + +if [ "$(id -u)" = "0" ]; then + current_uid=$(id -u "$WORKSPACE_USER") + current_gid=$(id -g "$WORKSPACE_USER") + + if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$current_gid" ]; then + echo "Changing workspace GID to $TARGET_GID" + groupmod -o -g "$TARGET_GID" "$WORKSPACE_GROUP" 2>/dev/null || true + fi + + if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$current_uid" ]; then + echo "Changing workspace UID to $TARGET_UID" + usermod -o -u "$TARGET_UID" "$WORKSPACE_USER" + fi + + mkdir -p "$WORKSPACE_HOME/.hermes" /workspace + fix_owner_if_needed "$WORKSPACE_HOME" + fix_owner_if_needed /workspace + + echo "Dropping root privileges" + exec gosu "$WORKSPACE_USER:$WORKSPACE_GROUP" "$0" "$@" +fi + +exec "$@" diff --git a/docs/workspace-chat-session-routing.md b/docs/workspace-chat-session-routing.md new file mode 100644 index 00000000..28977523 --- /dev/null +++ b/docs/workspace-chat-session-routing.md @@ -0,0 +1,98 @@ +# Workspace Chat Session Routing + +## Purpose + +Hermes Workspace supports a portable chat path through OpenAI-compatible `/v1/chat/completions`. In this mode, the browser route alone is not enough to preserve conversational context: Workspace must forward a stable server-side session identifier to the Hermes Agent gateway. + +This document records the routing contract and the failure mode that caused related turns and attachments to be stored as separate `api-*` sessions. + +## Routing Contract + +There are two distinct header layers: + +| Layer | Headers | Purpose | +| --- | --- | --- | +| Workspace UI route resolution | `X-Hermes-Session-Key`, `X-Hermes-Friendly-Id` | Tells the browser which Workspace chat route/friendly ID is resolved for the visible conversation. | +| Hermes Agent gateway continuation | `X-Hermes-Session-Id`, `X-Claude-Session-Id` | Tells the gateway which server-side Hermes session should receive the next chat completion request. | + +Do not conflate these. A response can correctly resolve a Workspace route while the next gateway request still loses server-side context if `X-Hermes-Session-Id` is missing. + +## Portable OpenAI-Compatible Flow + +1. `src/routes/api/send-stream.ts` receives `sessionKey`, `friendlyId`, `message`, `history`, and optional `attachments` from the UI. +2. It resolves a persistent Workspace `sessionKey`. +3. It builds OpenAI-compatible messages, including multimodal image parts when attachments are present. +4. It calls `openaiChat(..., { sessionId: portableSessionKey })`. +5. `src/server/openai-compat-api.ts` forwards that session ID to the gateway via: + - `X-Hermes-Session-Id` + - `X-Claude-Session-Id` as a legacy/back-compat alias. +6. Hermes Agent uses the provided session ID for continuity instead of deriving a fresh deterministic `api-*` session from the request payload. + +## Failure Mode + +The bug was coupling session-continuity headers to bearer-token presence: + +```ts +if (options.sessionId && bearer) { + headers['X-Hermes-Session-Id'] = options.sessionId + headers['X-Claude-Session-Id'] = options.sessionId +} +``` + +That made routing depend on auth configuration. If a bearer token was unavailable or not used, Workspace still had a local session key, but the gateway never received it. The gateway then derived sessions such as `api-*` from request content, which could split related turns and attachment-only/image requests across separate API sessions. + +## Correct Behavior + +Session routing is independent of whether a bearer token is configured. If the gateway requires auth, its auth check enforces the bearer token separately. + +```ts +const bearer = getBearerToken() +if (bearer) { + headers['Authorization'] = `Bearer ${bearer}` +} + +if (options.sessionId) { + headers['X-Hermes-Session-Id'] = options.sessionId + headers['X-Claude-Session-Id'] = options.sessionId +} +``` + +## Regression Coverage + +`src/server/openai-compat-api.test.ts` should cover both cases: + +- session headers are sent when a bearer token is present +- session headers are still sent when no bearer token is present + +`src/server/chat-backends.ts` should forward `options.sessionId` into `openaiChat(...)` for both streaming and non-streaming OpenAI-compatible calls. + +## Manual Verification Recipe + +1. Run the targeted test: + + ```bash + pnpm vitest run src/server/openai-compat-api.test.ts + ``` + +2. Build production assets: + + ```bash + pnpm build + ``` + +3. Restart Workspace where deployed: + + ```bash + systemctl --user restart hermes-workspace.service + systemctl --user is-active hermes-workspace.service + ``` + +4. Send two `/api/send-stream` turns with the same `sessionKey` and a unique token in the first prompt. +5. Search session history for that token. Both turns should appear under the same `session_id` equal to the supplied Workspace session key, not separate `api-*` sessions. +6. Send an image attachment with the same `sessionKey`; session history should show `[screenshot]` in that same session. + +## Operational Notes + +- Keep credentials redacted when inspecting `.env`, service files, or built bundles. +- In zero-fork deployments, Workspace commonly talks to Hermes Agent gateway on `127.0.0.1:8642` and Dashboard on `127.0.0.1:9119`. +- A successful `/health` probe means the gateway is reachable; it does not prove session continuity is wired correctly. Verify the actual chat path. diff --git a/e2e/chat-flicker-duplicate.spec.ts b/e2e/chat-flicker-duplicate.spec.ts new file mode 100644 index 00000000..56b8dee4 --- /dev/null +++ b/e2e/chat-flicker-duplicate.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test' + +test.describe('Chat UI flicker #441', () => { + test('chat messages should not contain duplicates after stream completion', async ({ page }) => { + // Navigate to the chat page + await page.goto('/chat') + await page.waitForLoadState('load') + + // Dismiss the "Hermes updated" modal if present + const continueBtn = page.getByRole('button', { name: 'Continue' }) + if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await continueBtn.click() + } + + // Wait for sessions to load in the sidebar + await page.waitForTimeout(3000) + + // Click on an existing session from the sidebar + const sessionLink = page.locator('a[href*="/chat/20"]').first() + if (await sessionLink.isVisible({ timeout: 10000 }).catch(() => false)) { + await sessionLink.click() + } + + // Wait for the session to load and messages to render + await page.waitForTimeout(5000) + + // Look for message-like elements. The chat uses data attributes + // Try a few approaches to find message bubbles + const messageElements = page.locator('.message, [role="listitem"], [data-message-id], [class*="message"]') + const msgCount = await messageElements.count() + + if (msgCount > 0) { + console.log(`Found ${msgCount} message elements`) + } + + // VERIFY: Page rendered without error — no error states visible + const errorState = page.getByRole('alert') + const hasError = await errorState.isVisible({ timeout: 1000 }).catch(() => false) + expect(hasError).toBe(false) + + // VERIFY: No "generating" or "thinking" state showing + const producingState = page.locator('text=/generating|waiting for response|Generating/i') + const producingCount = await producingState.count() + expect(producingCount).toBe(0) + + // VERIFY: The chat input is visible (page is functional) + const chatInput = page.locator('textarea, [contenteditable="true"]').first() + await expect(chatInput).toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/e2e/chat-thinking-state.spec.ts b/e2e/chat-thinking-state.spec.ts new file mode 100644 index 00000000..3b6a454a --- /dev/null +++ b/e2e/chat-thinking-state.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test' + +test.describe('Chat thinking state #449', () => { + test('should not show stale thinking state after page refresh for completed session', async ({ page }) => { + // This test simulates the exact bug scenario described in Issue #449: + // User had a conversation, the stream completed (clearing waiting state), + // page refreshes, and the assistant briefly shows "thinking" state. + + // Use an existing session that has completed messages + const SESSION_PATH = '/chat/20260515_150106_4be3a000' + + // Inject a stale waiting entry for THIS session before the page loads + await page.addInitScript((sessionKey) => { + window.sessionStorage.setItem( + `claude_waiting_${sessionKey}`, + JSON.stringify({ + since: Date.now() - 30000, // 30s ago — within the 120s TTL + runId: 'stale-run-id', + }), + ) + }, SESSION_PATH.replace('/chat/', '')) + + // Navigate directly to the session + await page.goto(SESSION_PATH) + await page.waitForLoadState('load') + + // Dismiss the "Hermes updated" modal if present + const continueBtn = page.getByRole('button', { name: 'Continue' }) + if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) { + await continueBtn.click() + } + + // Wait for app rehydration, Zustand store init, sessionStorage restore, + // and the active-run API check to complete + await page.waitForTimeout(5000) + + // VERIFY: No thinking indicator is visible after page refresh. + // The stale sessionStorage entry should have been cleared by the + // active-run API check, and the fix gates thinking on that check. + const thinkingIndicator = page.locator( + '[data-testid="thinking-indicator"], [aria-label="Assistant thinking"], .thinking-indicator, [data-thinking="true"]', + ) + const thinkingCount = await thinkingIndicator.count() + expect(thinkingCount).toBe(0) + + // VERIFY: The stale sessionStorage entry was cleaned up + const staleKey = SESSION_PATH.replace('/chat/', '') + const hasStaleEntry = await page.evaluate((key) => { + return window.sessionStorage.getItem(`claude_waiting_${key}`) !== null + }, staleKey) + expect(hasStaleEntry).toBe(false) + }) +}) diff --git a/e2e/conductor-mobile-rendering.spec.ts b/e2e/conductor-mobile-rendering.spec.ts new file mode 100644 index 00000000..e8915dac --- /dev/null +++ b/e2e/conductor-mobile-rendering.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' + +const BASE = process.env.HERMES_WORKSPACE_URL || 'http://localhost:3002' + +test.describe('Conductor mobile rendering', () => { + test.use({ + viewport: { width: 375, height: 667 }, // iPhone SE + }) + + test('conductor home page renders without clipping on mobile', async ({ page }) => { + await page.goto(`${BASE}/conductor`) + await page.waitForTimeout(2000) + + // Check that the main container is present + const main = page.locator('main') + await expect(main.first()).toBeVisible() + + // Verify the page is scrollable — bottom content should be reachable + const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight) + const clientHeight = await page.evaluate(() => document.documentElement.clientHeight) + expect(scrollHeight).toBeGreaterThanOrEqual(clientHeight) + + // Check that the Conductor badge or title is visible + const pageText = await page.locator('body').innerText() + expect(pageText).toContain('Conductor') + + // Scroll to the very bottom + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)) + await page.waitForTimeout(500) + + // Verify no content is cut off — the last visible element should not be flush + // with the bottom of the viewport + const bottomElement = await page.evaluate(() => { + const body = document.body + const bodyRect = body.getBoundingClientRect() + return bodyRect.bottom + }) + // body bottom should be within the document (not clipped off-screen) + expect(bottomElement).toBeGreaterThan(0) + }) + + test('conductor page has no horizontal overflow on mobile', async ({ page }) => { + await page.goto(`${BASE}/conductor`) + await page.waitForTimeout(2000) + + // Check for horizontal overflow + const hasHorizontalOverflow = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth + }) + expect(hasHorizontalOverflow).toBe(false) + }) + + test('conductor action buttons are present on mobile', async ({ page }) => { + await page.goto(`${BASE}/conductor`) + await page.waitForTimeout(2000) + + // Check for action buttons — they should be visible and clickable + const buttons = page.locator('button') + const buttonCount = await buttons.count() + expect(buttonCount).toBeGreaterThan(0) + }) + + test('conductor main container has proper bottom padding on mobile', async ({ page }) => { + await page.goto(`${BASE}/conductor`) + await page.waitForTimeout(2000) + + // Check the bottom padding of main elements + const bottomPadding = await page.evaluate(() => { + const mains = document.querySelectorAll('main') + if (mains.length === 0) return -1 + // Get computed padding-bottom from the last main (the conductor one) + const style = window.getComputedStyle(mains[mains.length - 1]) + return parseInt(style.paddingBottom, 10) || 0 + }) + // Bottom padding must exist (not 0) to prevent content from being flush with tab bar + expect(bottomPadding).toBeGreaterThanOrEqual(4) + }) + + test('conductor page body fills full viewport height without clipping at bottom', async ({ page }) => { + await page.goto(`${BASE}/conductor`) + await page.waitForTimeout(2000) + + // Verify body fills the viewport and can scroll + const bodyHeight = await page.evaluate(() => document.body.scrollHeight) + const vpHeight = await page.evaluate(() => window.innerHeight) + expect(bodyHeight).toBeGreaterThanOrEqual(vpHeight * 0.5) + + // Scroll to bottom — should not error + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(300) + + // The last visible element on the page should have bottom >= 0 + const lastElBottom = await page.evaluate(() => { + const all = document.querySelectorAll('main > div, main > section') + const last = all[all.length - 1] + if (!last) return -1 + const rect = last.getBoundingClientRect() + return rect.bottom + }) + // The last content element must be visible (not above the fold or clipped) + expect(lastElBottom).toBeGreaterThan(0) + }) +}) diff --git a/package.json b/package.json index bde03140..7b0f482f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,8 @@ "react-joyride": "^2.9.3", "react-markdown": "^10.1.0", "recharts": "^3.7.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^3.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 606fc9a1..790fd340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,12 @@ importers: recharts: specifier: ^3.7.0 version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -2408,6 +2414,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3846,6 +3853,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} @@ -5278,6 +5288,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-breaks@4.0.0: resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} @@ -10535,6 +10548,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 @@ -12345,6 +12364,11 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-breaks@4.0.0: dependencies: '@types/mdast': 4.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..bbc93b43 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + electron: true + electron-winstaller: true + esbuild: true + unrs-resolver: true diff --git a/src/components/prompt-kit/markdown.tsx b/src/components/prompt-kit/markdown.tsx index a60b8a0f..ba1d46cb 100644 --- a/src/components/prompt-kit/markdown.tsx +++ b/src/components/prompt-kit/markdown.tsx @@ -1,6 +1,8 @@ import { marked } from 'marked' import { createContext, memo, useContext, useId, useMemo, useRef } from 'react' import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' import remarkBreaks from 'remark-breaks' import remarkGfm from 'remark-gfm' import { CodeBlock } from './code-block' @@ -214,6 +216,9 @@ const INITIAL_COMPONENTS: Partial = { return
  • {children}
  • }, a: function AComponent({ children, href }) { + if (!href) { + return {children} + } return ( = { ) }, + img: function ImgComponent({ src, alt, ...props }) { + if (!src) { + return null + } + return {alt + }, blockquote: function BlockquoteComponent({ children }) { return (
    @@ -334,6 +345,101 @@ const INITIAL_COMPONENTS: Partial = { }, } +const HTML_SANITIZE_SCHEMA = { + tagNames: [ + 'a', + 'abbr', + 'article', + 'b', + 'bdi', + 'blockquote', + 'br', + 'caption', + 'center', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'dd', + 'del', + 'details', + 'dfn', + 'div', + 'dl', + 'dt', + 'em', + 'figcaption', + 'figure', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hgroup', + 'hr', + 'i', + 'img', + 'ins', + 'kbd', + 'li', + 'main', + 'mark', + 'nav', + 'ol', + 'p', + 'pre', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'section', + 'small', + 'span', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'time', + 'tr', + 'u', + 'ul', + 'var', + 'wbr', + ], + attributes: { + '*': ['className', 'class', 'title', 'lang', 'dir'], + a: ['href', 'target', 'rel', 'download'], + img: ['src', 'alt', 'width', 'height', 'loading'], + td: ['colspan', 'rowspan', 'headers'], + th: ['colspan', 'rowspan', 'headers', 'scope'], + col: ['span'], + colgroup: ['span'], + ol: ['start', 'type'], + li: ['value'], + details: ['open'], + time: ['datetime'], + data: ['value'], + del: ['datetime'], + ins: ['datetime'], + }, + protocols: { + a: { href: ['http', 'https', 'mailto', 'tel'] }, + img: { src: ['http', 'https', 'data'] }, + }, +} + const MemoizedMarkdownBlock = memo( function MarkdownBlock({ content, @@ -345,6 +451,7 @@ const MemoizedMarkdownBlock = memo( return ( {content} diff --git a/src/components/slash-command-menu.test.tsx b/src/components/slash-command-menu.test.tsx index 81649ead..4f1af7cc 100644 --- a/src/components/slash-command-menu.test.tsx +++ b/src/components/slash-command-menu.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_SLASH_COMMANDS } from './slash-command-menu' +import { DEFAULT_SLASH_COMMANDS, mergeSlashCommands } from './slash-command-menu' describe('DEFAULT_SLASH_COMMANDS', () => { it('includes /plugins in the slash autocomplete list', () => { @@ -43,3 +43,31 @@ describe('DEFAULT_SLASH_COMMANDS', () => { } }) }) + +describe('mergeSlashCommands', () => { + it('appends installed skills without replacing built-ins', () => { + const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [ + { + command: '/hermes-agent', + description: 'Complete guide to using and extending Hermes Agent', + }, + ]) + + expect(merged.map((entry) => entry.command)).toContain('/new') + expect(merged.map((entry) => entry.command)).toContain('/hermes-agent') + }) + + it('deduplicates by command label and keeps the first definition', () => { + const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [ + { + command: '/skills', + description: 'Conflicting duplicate that should be ignored', + }, + ]) + + expect(merged.filter((entry) => entry.command === '/skills')).toHaveLength(1) + expect( + merged.find((entry) => entry.command === '/skills')?.description, + ).toBe('Browse and manage skills') + }) +}) diff --git a/src/components/slash-command-menu.tsx b/src/components/slash-command-menu.tsx index 67db50cb..65284fff 100644 --- a/src/components/slash-command-menu.tsx +++ b/src/components/slash-command-menu.tsx @@ -22,6 +22,7 @@ export type SlashCommandMenuProps = { open: boolean query: string onSelect: (command: SlashCommandDefinition) => void + commands?: Array } export type SlashCommandMenuHandle = { @@ -41,8 +42,28 @@ export const DEFAULT_SLASH_COMMANDS: Array = [ { command: '/help', description: 'Show available commands' }, ] +export function mergeSlashCommands( + base: Array, + additions: Array, +): Array { + const merged: Array = [] + const seen = new Set() + + for (const entry of [...base, ...additions]) { + const command = entry.command.trim() + if (!command || seen.has(command)) continue + seen.add(command) + merged.push({ + command, + description: entry.description.trim() || 'Run command', + }) + } + + return merged +} + const SlashCommandMenu = forwardRef(function SlashCommandMenu( - { open, query, onSelect }: SlashCommandMenuProps, + { open, query, onSelect, commands = DEFAULT_SLASH_COMMANDS }: SlashCommandMenuProps, ref: Ref, ) { const [activeIndex, setActiveIndex] = useState(0) @@ -50,16 +71,16 @@ const SlashCommandMenu = forwardRef(function SlashCommandMenu( const filteredCommands = useMemo(() => { const normalizedQuery = query.trim() - if (!normalizedQuery) return DEFAULT_SLASH_COMMANDS + if (!normalizedQuery) return commands - return DEFAULT_SLASH_COMMANDS.filter((item) => + return commands.filter((item) => filter.contains( item, normalizedQuery, (target) => `${target.command} ${target.description}`, ), ) - }, [filter, query]) + }, [commands, filter, query]) useEffect(() => { setActiveIndex(0) diff --git a/src/components/update-center-notifier.test.tsx b/src/components/update-center-notifier.test.tsx new file mode 100644 index 00000000..bc166f38 --- /dev/null +++ b/src/components/update-center-notifier.test.tsx @@ -0,0 +1,63 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from 'vitest' + +import { __updateReleaseNotesStorageForTests } from './update-center-notifier' + +const { NOTES_SEEN_KEY, storeNotes } = __updateReleaseNotesStorageForTests + +const agentReleaseNotes = [ + { + product: 'agent' as const, + label: 'Hermes Agent', + from: 'c23a87bc163b188abc7e40fbdccf07a9739231c3', + to: '4fdfdf67499c33015ed56e6e5910d8bdc00aa901', + commits: ['Merge pull request #25045 (4fdfdf674)'], + }, +] + +function installLocalStorage() { + const store = new Map() + const storage = { + get length() { + return store.size + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null + }, + getItem(key: string) { + return store.get(key) ?? null + }, + setItem(key: string, value: string) { + store.set(key, value) + }, + removeItem(key: string) { + store.delete(key) + }, + clear() { + store.clear() + }, + } + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }) + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: storage, + }) +} + +beforeEach(() => { + installLocalStorage() +}) + +describe('update center release notes storage', () => { + it('does not reopen release notes already marked seen when status returns the same payload', () => { + const firstStored = storeNotes(agentReleaseNotes) + + expect(firstStored).not.toBeNull() + localStorage.setItem(NOTES_SEEN_KEY, firstStored!.id) + + expect(storeNotes(agentReleaseNotes)).toBeNull() + }) +}) diff --git a/src/components/update-center-notifier.tsx b/src/components/update-center-notifier.tsx index efc48f3b..8d2674e4 100644 --- a/src/components/update-center-notifier.tsx +++ b/src/components/update-center-notifier.tsx @@ -108,6 +108,7 @@ function storeNotes(sections: Array): Notes | null { localStorage.removeItem(NOTES_SEEN_KEY) } localStorage.setItem(NOTES_KEY, JSON.stringify(notes)) + if (localStorage.getItem(NOTES_SEEN_KEY) === id) return null return notes } @@ -520,3 +521,8 @@ function ReleaseNotes({ ) } + +export const __updateReleaseNotesStorageForTests = { + NOTES_SEEN_KEY, + storeNotes, +} diff --git a/src/components/usage-meter/context-alert-modal.tsx b/src/components/usage-meter/context-alert-modal.tsx index 40b55074..caad1598 100644 --- a/src/components/usage-meter/context-alert-modal.tsx +++ b/src/components/usage-meter/context-alert-modal.tsx @@ -119,10 +119,6 @@ function ContextAlertModalComponent({ emphasis /> )} - {!isChromeFreeSurface ? : null} + {!isChromeFreeSurface ? : null} {!isChromeFreeSurface && !isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? ( ) : null} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 37be432d..5caccaea 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -88,6 +88,7 @@ import { Route as ApiLocalProvidersRouteImport } from './routes/api/local-provid import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiHermesTasksRouteImport } from './routes/api/hermes-tasks' +import { Route as ApiHermesConfigRouteImport } from './routes/api/hermes-config' import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status' import { Route as ApiGatewayReprobeRouteImport } from './routes/api/gateway-reprobe' import { Route as ApiFilesRouteImport } from './routes/api/files' @@ -96,6 +97,7 @@ import { Route as ApiCrewStatusRouteImport } from './routes/api/crew-status' import { Route as ApiContextUsageRouteImport } from './routes/api/context-usage' import { Route as ApiConnectionStatusRouteImport } from './routes/api/connection-status' import { Route as ApiConnectionSettingsRouteImport } from './routes/api/connection-settings' +import { Route as ApiConfigPatchRouteImport } from './routes/api/config-patch' import { Route as ApiConductorStopRouteImport } from './routes/api/conductor-stop' import { Route as ApiConductorSpawnRouteImport } from './routes/api/conductor-spawn' import { Route as ApiClaudeUpdateRouteImport } from './routes/api/claude-update' @@ -110,6 +112,7 @@ import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts' import { Route as ApiUpdateWorkspaceRouteImport } from './routes/api/update/workspace' import { Route as ApiUpdateStatusRouteImport } from './routes/api/update/status' import { Route as ApiUpdateAgentRouteImport } from './routes/api/update/agent' +import { Route as ApiSwarmRuntimeResetRouteImport } from './routes/api/swarm-runtime.reset' import { Route as ApiSwarmMemorySearchRouteImport } from './routes/api/swarm-memory/search' import { Route as ApiSkillsUninstallRouteImport } from './routes/api/skills/uninstall' import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle' @@ -552,6 +555,11 @@ const ApiHermesTasksRoute = ApiHermesTasksRouteImport.update({ path: '/api/hermes-tasks', getParentRoute: () => rootRouteImport, } as any) +const ApiHermesConfigRoute = ApiHermesConfigRouteImport.update({ + id: '/api/hermes-config', + path: '/api/hermes-config', + getParentRoute: () => rootRouteImport, +} as any) const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({ id: '/api/gateway-status', path: '/api/gateway-status', @@ -592,6 +600,11 @@ const ApiConnectionSettingsRoute = ApiConnectionSettingsRouteImport.update({ path: '/api/connection-settings', getParentRoute: () => rootRouteImport, } as any) +const ApiConfigPatchRoute = ApiConfigPatchRouteImport.update({ + id: '/api/config-patch', + path: '/api/config-patch', + getParentRoute: () => rootRouteImport, +} as any) const ApiConductorStopRoute = ApiConductorStopRouteImport.update({ id: '/api/conductor-stop', path: '/api/conductor-stop', @@ -662,6 +675,11 @@ const ApiUpdateAgentRoute = ApiUpdateAgentRouteImport.update({ path: '/api/update/agent', getParentRoute: () => rootRouteImport, } as any) +const ApiSwarmRuntimeResetRoute = ApiSwarmRuntimeResetRouteImport.update({ + id: '/reset', + path: '/reset', + getParentRoute: () => ApiSwarmRuntimeRoute, +} as any) const ApiSwarmMemorySearchRoute = ApiSwarmMemorySearchRouteImport.update({ id: '/search', path: '/search', @@ -927,6 +945,7 @@ export interface FileRoutesByFullPath { '/api/claude-update': typeof ApiClaudeUpdateRoute '/api/conductor-spawn': typeof ApiConductorSpawnRoute '/api/conductor-stop': typeof ApiConductorStopRoute + '/api/config-patch': typeof ApiConfigPatchRoute '/api/connection-settings': typeof ApiConnectionSettingsRoute '/api/connection-status': typeof ApiConnectionStatusRoute '/api/context-usage': typeof ApiContextUsageRoute @@ -935,6 +954,7 @@ export interface FileRoutesByFullPath { '/api/files': typeof ApiFilesRoute '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute + '/api/hermes-config': typeof ApiHermesConfigRoute '/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -974,7 +994,7 @@ export interface FileRoutesByFullPath { '/api/swarm-project': typeof ApiSwarmProjectRoute '/api/swarm-reports': typeof ApiSwarmReportsRoute '/api/swarm-roster': typeof ApiSwarmRosterRoute - '/api/swarm-runtime': typeof ApiSwarmRuntimeRoute + '/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute @@ -1031,6 +1051,7 @@ export interface FileRoutesByFullPath { '/api/skills/toggle': typeof ApiSkillsToggleRoute '/api/skills/uninstall': typeof ApiSkillsUninstallRoute '/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute + '/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute '/api/update/agent': typeof ApiUpdateAgentRoute '/api/update/status': typeof ApiUpdateStatusRoute '/api/update/workspace': typeof ApiUpdateWorkspaceRoute @@ -1074,6 +1095,7 @@ export interface FileRoutesByTo { '/api/claude-update': typeof ApiClaudeUpdateRoute '/api/conductor-spawn': typeof ApiConductorSpawnRoute '/api/conductor-stop': typeof ApiConductorStopRoute + '/api/config-patch': typeof ApiConfigPatchRoute '/api/connection-settings': typeof ApiConnectionSettingsRoute '/api/connection-status': typeof ApiConnectionStatusRoute '/api/context-usage': typeof ApiContextUsageRoute @@ -1082,6 +1104,7 @@ export interface FileRoutesByTo { '/api/files': typeof ApiFilesRoute '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute + '/api/hermes-config': typeof ApiHermesConfigRoute '/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -1121,7 +1144,7 @@ export interface FileRoutesByTo { '/api/swarm-project': typeof ApiSwarmProjectRoute '/api/swarm-reports': typeof ApiSwarmReportsRoute '/api/swarm-roster': typeof ApiSwarmRosterRoute - '/api/swarm-runtime': typeof ApiSwarmRuntimeRoute + '/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute @@ -1178,6 +1201,7 @@ export interface FileRoutesByTo { '/api/skills/toggle': typeof ApiSkillsToggleRoute '/api/skills/uninstall': typeof ApiSkillsUninstallRoute '/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute + '/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute '/api/update/agent': typeof ApiUpdateAgentRoute '/api/update/status': typeof ApiUpdateStatusRoute '/api/update/workspace': typeof ApiUpdateWorkspaceRoute @@ -1223,6 +1247,7 @@ export interface FileRoutesById { '/api/claude-update': typeof ApiClaudeUpdateRoute '/api/conductor-spawn': typeof ApiConductorSpawnRoute '/api/conductor-stop': typeof ApiConductorStopRoute + '/api/config-patch': typeof ApiConfigPatchRoute '/api/connection-settings': typeof ApiConnectionSettingsRoute '/api/connection-status': typeof ApiConnectionStatusRoute '/api/context-usage': typeof ApiContextUsageRoute @@ -1231,6 +1256,7 @@ export interface FileRoutesById { '/api/files': typeof ApiFilesRoute '/api/gateway-reprobe': typeof ApiGatewayReprobeRoute '/api/gateway-status': typeof ApiGatewayStatusRoute + '/api/hermes-config': typeof ApiHermesConfigRoute '/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren '/api/history': typeof ApiHistoryRoute '/api/integrations': typeof ApiIntegrationsRoute @@ -1270,7 +1296,7 @@ export interface FileRoutesById { '/api/swarm-project': typeof ApiSwarmProjectRoute '/api/swarm-reports': typeof ApiSwarmReportsRoute '/api/swarm-roster': typeof ApiSwarmRosterRoute - '/api/swarm-runtime': typeof ApiSwarmRuntimeRoute + '/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren '/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute '/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute '/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute @@ -1327,6 +1353,7 @@ export interface FileRoutesById { '/api/skills/toggle': typeof ApiSkillsToggleRoute '/api/skills/uninstall': typeof ApiSkillsUninstallRoute '/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute + '/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute '/api/update/agent': typeof ApiUpdateAgentRoute '/api/update/status': typeof ApiUpdateStatusRoute '/api/update/workspace': typeof ApiUpdateWorkspaceRoute @@ -1373,6 +1400,7 @@ export interface FileRouteTypes { | '/api/claude-update' | '/api/conductor-spawn' | '/api/conductor-stop' + | '/api/config-patch' | '/api/connection-settings' | '/api/connection-status' | '/api/context-usage' @@ -1381,6 +1409,7 @@ export interface FileRouteTypes { | '/api/files' | '/api/gateway-reprobe' | '/api/gateway-status' + | '/api/hermes-config' | '/api/hermes-tasks' | '/api/history' | '/api/integrations' @@ -1477,6 +1506,7 @@ export interface FileRouteTypes { | '/api/skills/toggle' | '/api/skills/uninstall' | '/api/swarm-memory/search' + | '/api/swarm-runtime/reset' | '/api/update/agent' | '/api/update/status' | '/api/update/workspace' @@ -1520,6 +1550,7 @@ export interface FileRouteTypes { | '/api/claude-update' | '/api/conductor-spawn' | '/api/conductor-stop' + | '/api/config-patch' | '/api/connection-settings' | '/api/connection-status' | '/api/context-usage' @@ -1528,6 +1559,7 @@ export interface FileRouteTypes { | '/api/files' | '/api/gateway-reprobe' | '/api/gateway-status' + | '/api/hermes-config' | '/api/hermes-tasks' | '/api/history' | '/api/integrations' @@ -1624,6 +1656,7 @@ export interface FileRouteTypes { | '/api/skills/toggle' | '/api/skills/uninstall' | '/api/swarm-memory/search' + | '/api/swarm-runtime/reset' | '/api/update/agent' | '/api/update/status' | '/api/update/workspace' @@ -1668,6 +1701,7 @@ export interface FileRouteTypes { | '/api/claude-update' | '/api/conductor-spawn' | '/api/conductor-stop' + | '/api/config-patch' | '/api/connection-settings' | '/api/connection-status' | '/api/context-usage' @@ -1676,6 +1710,7 @@ export interface FileRouteTypes { | '/api/files' | '/api/gateway-reprobe' | '/api/gateway-status' + | '/api/hermes-config' | '/api/hermes-tasks' | '/api/history' | '/api/integrations' @@ -1772,6 +1807,7 @@ export interface FileRouteTypes { | '/api/skills/toggle' | '/api/skills/uninstall' | '/api/swarm-memory/search' + | '/api/swarm-runtime/reset' | '/api/update/agent' | '/api/update/status' | '/api/update/workspace' @@ -1817,6 +1853,7 @@ export interface RootRouteChildren { ApiClaudeUpdateRoute: typeof ApiClaudeUpdateRoute ApiConductorSpawnRoute: typeof ApiConductorSpawnRoute ApiConductorStopRoute: typeof ApiConductorStopRoute + ApiConfigPatchRoute: typeof ApiConfigPatchRoute ApiConnectionSettingsRoute: typeof ApiConnectionSettingsRoute ApiConnectionStatusRoute: typeof ApiConnectionStatusRoute ApiContextUsageRoute: typeof ApiContextUsageRoute @@ -1825,6 +1862,7 @@ export interface RootRouteChildren { ApiFilesRoute: typeof ApiFilesRoute ApiGatewayReprobeRoute: typeof ApiGatewayReprobeRoute ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute + ApiHermesConfigRoute: typeof ApiHermesConfigRoute ApiHermesTasksRoute: typeof ApiHermesTasksRouteWithChildren ApiHistoryRoute: typeof ApiHistoryRoute ApiIntegrationsRoute: typeof ApiIntegrationsRoute @@ -1864,7 +1902,7 @@ export interface RootRouteChildren { ApiSwarmProjectRoute: typeof ApiSwarmProjectRoute ApiSwarmReportsRoute: typeof ApiSwarmReportsRoute ApiSwarmRosterRoute: typeof ApiSwarmRosterRoute - ApiSwarmRuntimeRoute: typeof ApiSwarmRuntimeRoute + ApiSwarmRuntimeRoute: typeof ApiSwarmRuntimeRouteWithChildren ApiSwarmTmuxScrollRoute: typeof ApiSwarmTmuxScrollRoute ApiSwarmTmuxStartRoute: typeof ApiSwarmTmuxStartRoute ApiSwarmTmuxStopRoute: typeof ApiSwarmTmuxStopRoute @@ -2457,6 +2495,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHermesTasksRouteImport parentRoute: typeof rootRouteImport } + '/api/hermes-config': { + id: '/api/hermes-config' + path: '/api/hermes-config' + fullPath: '/api/hermes-config' + preLoaderRoute: typeof ApiHermesConfigRouteImport + parentRoute: typeof rootRouteImport + } '/api/gateway-status': { id: '/api/gateway-status' path: '/api/gateway-status' @@ -2513,6 +2558,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiConnectionSettingsRouteImport parentRoute: typeof rootRouteImport } + '/api/config-patch': { + id: '/api/config-patch' + path: '/api/config-patch' + fullPath: '/api/config-patch' + preLoaderRoute: typeof ApiConfigPatchRouteImport + parentRoute: typeof rootRouteImport + } '/api/conductor-stop': { id: '/api/conductor-stop' path: '/api/conductor-stop' @@ -2611,6 +2663,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiUpdateAgentRouteImport parentRoute: typeof rootRouteImport } + '/api/swarm-runtime/reset': { + id: '/api/swarm-runtime/reset' + path: '/reset' + fullPath: '/api/swarm-runtime/reset' + preLoaderRoute: typeof ApiSwarmRuntimeResetRouteImport + parentRoute: typeof ApiSwarmRuntimeRoute + } '/api/swarm-memory/search': { id: '/api/swarm-memory/search' path: '/search' @@ -3112,6 +3171,18 @@ const ApiSwarmMemoryRouteWithChildren = ApiSwarmMemoryRoute._addFileChildren( ApiSwarmMemoryRouteChildren, ) +interface ApiSwarmRuntimeRouteChildren { + ApiSwarmRuntimeResetRoute: typeof ApiSwarmRuntimeResetRoute +} + +const ApiSwarmRuntimeRouteChildren: ApiSwarmRuntimeRouteChildren = { + ApiSwarmRuntimeResetRoute: ApiSwarmRuntimeResetRoute, +} + +const ApiSwarmRuntimeRouteWithChildren = ApiSwarmRuntimeRoute._addFileChildren( + ApiSwarmRuntimeRouteChildren, +) + interface ApiHermesworldReservationsRouteChildren { ApiHermesworldReservationsConfirmRoute: typeof ApiHermesworldReservationsConfirmRoute } @@ -3162,6 +3233,7 @@ const rootRouteChildren: RootRouteChildren = { ApiClaudeUpdateRoute: ApiClaudeUpdateRoute, ApiConductorSpawnRoute: ApiConductorSpawnRoute, ApiConductorStopRoute: ApiConductorStopRoute, + ApiConfigPatchRoute: ApiConfigPatchRoute, ApiConnectionSettingsRoute: ApiConnectionSettingsRoute, ApiConnectionStatusRoute: ApiConnectionStatusRoute, ApiContextUsageRoute: ApiContextUsageRoute, @@ -3170,6 +3242,7 @@ const rootRouteChildren: RootRouteChildren = { ApiFilesRoute: ApiFilesRoute, ApiGatewayReprobeRoute: ApiGatewayReprobeRoute, ApiGatewayStatusRoute: ApiGatewayStatusRoute, + ApiHermesConfigRoute: ApiHermesConfigRoute, ApiHermesTasksRoute: ApiHermesTasksRouteWithChildren, ApiHistoryRoute: ApiHistoryRoute, ApiIntegrationsRoute: ApiIntegrationsRoute, @@ -3209,7 +3282,7 @@ const rootRouteChildren: RootRouteChildren = { ApiSwarmProjectRoute: ApiSwarmProjectRoute, ApiSwarmReportsRoute: ApiSwarmReportsRoute, ApiSwarmRosterRoute: ApiSwarmRosterRoute, - ApiSwarmRuntimeRoute: ApiSwarmRuntimeRoute, + ApiSwarmRuntimeRoute: ApiSwarmRuntimeRouteWithChildren, ApiSwarmTmuxScrollRoute: ApiSwarmTmuxScrollRoute, ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute, ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute, diff --git a/src/router.test.ts b/src/router.test.ts new file mode 100644 index 00000000..50162615 --- /dev/null +++ b/src/router.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { resolveRouterBasepath } from './router' + +function setBasepathGlobal(value: unknown) { + ;(window as unknown as Record).__HERMES_WORKSPACE_BASEPATH__ = + value +} + +function clearBasepathGlobal() { + delete (window as unknown as Record) + .__HERMES_WORKSPACE_BASEPATH__ +} + +afterEach(() => { + clearBasepathGlobal() +}) + +describe('resolveRouterBasepath', () => { + it('returns "/" when no global override is set', () => { + clearBasepathGlobal() + expect(resolveRouterBasepath()).toBe('/') + }) + + it('returns "/" when the global is not a string', () => { + setBasepathGlobal(42) + expect(resolveRouterBasepath()).toBe('/') + }) + + it('returns "/" when the global is an empty or whitespace string', () => { + setBasepathGlobal(' ') + expect(resolveRouterBasepath()).toBe('/') + }) + + it('normalizes a valid prefix with a leading slash and no trailing slash', () => { + setBasepathGlobal('/workspaces/abc/') + expect(resolveRouterBasepath()).toBe('/workspaces/abc') + }) + + it('adds a leading slash if one is missing', () => { + setBasepathGlobal('workspaces/abc') + expect(resolveRouterBasepath()).toBe('/workspaces/abc') + }) + + it('collapses multiple trailing slashes', () => { + setBasepathGlobal('/workspaces/abc////') + expect(resolveRouterBasepath()).toBe('/workspaces/abc') + }) +}) diff --git a/src/router.tsx b/src/router.tsx index 5c708369..f18ac9ed 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -3,11 +3,39 @@ import { createRouter } from '@tanstack/react-router' // Import the generated route tree import { routeTree } from './routeTree.gen' +declare global { + interface Window { + /** + * Optional runtime override for the TanStack router basepath. Allows the + * same built artifact to be hosted under a path prefix by reverse proxies + * or container orchestrators (e.g. mounted at `/workspaces//`) without + * a rebuild. Set this on `window` before the app bundle executes — for + * example via an inline `