Merge remote-tracking branch 'origin/main' into ethie/pm-clean

# Conflicts:
#	.gitignore
#	Dockerfile
#	agent/onboarding.py
#	apps/desktop/electron/main.ts
#	apps/desktop/electron/pool-stop.ts
#	apps/desktop/src/components/model-picker.test.tsx
#	apps/desktop/src/store/updates.ts
#	apps/desktop/vite.config.ts
#	datagen-config-examples/run_browser_tasks.sh
#	docs/rca-ssl-cacert-post-git-pull.md
#	gateway/run.py
#	hermes_cli/backup.py
#	hermes_cli/credential_lifecycle.py
#	hermes_cli/dashboard_procs.py
#	hermes_cli/doctor_state.py
#	hermes_cli/env_loader.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/psutil_android.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_cmd_windows.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_server_config.py
#	hermes_cli/web_server_cron.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/holographic/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/mem0/__init__.py
#	plugins/platforms/google_chat/oauth.py
#	plugins/platforms/photon/adapter.py
#	scripts/ci/list_os_marked_tests.py
#	scripts/run_tests.sh
#	tests/agent/test_compression_stall_fallback.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/gateway/test_google_chat_oauth_dependencies.py
#	tests/hermes_cli/conftest.py
#	tests/hermes_cli/test_cli_init.py
#	tests/hermes_cli/test_gateway_migrate_multiplex.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_relaunch.py
#	tests/hermes_cli/test_update_check.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_worktree_gc.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_autostash_conflict_recovery.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_commit_pin_rollback.py
#	tests/scripts/install/test_install_diverged_update.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_macos_launcher.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_ps1_ascii_only.py
#	tests/scripts/install/test_install_ps1_browser_install.py
#	tests/scripts/install/test_install_ps1_managed_node_swap.py
#	tests/scripts/install/test_install_ps1_native_stderr_eap.py
#	tests/scripts/install/test_install_ps1_node_path_for_npm.py
#	tests/scripts/install/test_install_ps1_python_fallback_venv.py
#	tests/scripts/install/test_install_ps1_resolver_strictmode.py
#	tests/scripts/install/test_install_ps1_uv_install_fallback.py
#	tests/scripts/install/test_install_ps1_uv_powershell_host.py
#	tests/scripts/install/test_install_ps1_venv_process_tree.py
#	tests/scripts/install/test_install_ps1_venv_recreate_safety.py
#	tests/scripts/install/test_install_ps1_venv_rename_abort.py
#	tests/scripts/install/test_install_ps1_venv_transaction_boundary.py
#	tests/scripts/install/test_install_ps1_web_server_syntax_probe.py
#	tests/scripts/install/test_install_scripts_computer_use.py
#	tests/scripts/install/test_install_sh_acp_launcher.py
#	tests/scripts/install/test_install_sh_bootstrap_marker.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_install_method_stamp.py
#	tests/scripts/install/test_install_sh_node_deps_failure.py
#	tests/scripts/install/test_install_sh_node_deps_workspaces.py
#	tests/scripts/install/test_install_sh_node_global_prefix.py
#	tests/scripts/install/test_install_sh_node_npm_check.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_node_probe.py
#	tests/scripts/install/test_install_sh_node_tarball_without_xz.py
#	tests/scripts/install/test_install_sh_pythonpath_sanitization.py
#	tests/scripts/install/test_install_sh_reuse_supported_python.py
#	tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py
#	tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_termux_network_prereqs.py
#	tests/scripts/install/test_install_sh_termux_python_bounds.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_project_metadata.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_tts_pythonpath_fallback.py
#	tests/tui_gateway/test_hosted_room_driver_runtime.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	tools/lazy_deps.py
#	tools/voice_mode.py
#	uv.lock
#	website/docs/developer-guide/macos-bundle-updates.md
#	website/docs/developer-guide/pm-audit-status.md
#	website/docs/developer-guide/shared-bundle-builds.md
#	website/docs/developer-guide/source-update-completion.md
#	website/docs/developer-guide/stable-releases.md
This commit is contained in:
ethernet
2026-09-14 15:38:34 -04:00
2096 changed files with 115965 additions and 30004 deletions

View File

@@ -1,455 +1,519 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EVENTS_CONNECT_TIMEOUT_MS } from "@/lib/events-reconnect";
import { EVENTS_CONNECT_TIMEOUT_MS } from '@/lib/events-reconnect'
const apiMocks = vi.hoisted(() => ({
buildWsUrl: vi.fn(async () => "ws://localhost/api/events?channel=chat-1"),
buildWsUrl: vi.fn(async () => 'ws://localhost/api/events?channel=chat-1'),
getModelInfo: vi.fn(async () => ({
capabilities: { supports_reasoning: false },
model: "test/model",
})),
}));
model: 'test/model'
}))
}))
const gatewayMocks = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown) => void>();
const handlers = new Map<string, (event: unknown) => void>()
return {
constructed: 0,
close: vi.fn(),
connect: vi.fn(async () => undefined),
handlers,
on: vi.fn((event: string, handler: (event: unknown) => void) => {
handlers.set(event, handler);
return () => handlers.delete(event);
handlers.set(event, handler)
return () => handlers.delete(event)
}),
onState: vi.fn((handler: (state: string) => void) => {
handler("open");
return () => undefined;
handler('open')
return () => undefined
}),
request: vi.fn(async () => ({ session_id: "sidecar-1" })),
};
});
request: vi.fn(async () => ({ session_id: 'sidecar-1' }))
}
})
const reloadMocks = vi.hoisted(() => ({
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true),
}));
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true)
}))
vi.mock("@/lib/api", () => ({
vi.mock('@/lib/api', () => ({
api: { getModelInfo: apiMocks.getModelInfo },
buildWsUrl: apiMocks.buildWsUrl,
}));
vi.mock("@/lib/dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure:
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
}));
vi.mock("@/lib/gatewayClient", () => ({
buildWsUrl: apiMocks.buildWsUrl
}))
vi.mock('@/lib/dashboard-auth-reload', () => ({
maybeReloadForLoopbackWsAuthFailure: reloadMocks.maybeReloadForLoopbackWsAuthFailure
}))
vi.mock('@/lib/gatewayClient', () => ({
GatewayClient: class {
close = gatewayMocks.close;
connect = gatewayMocks.connect;
on = gatewayMocks.on;
onState = gatewayMocks.onState;
request = gatewayMocks.request;
},
}));
vi.mock("@/components/ModelPickerDialog", () => ({
ModelPickerDialog: () => null,
}));
vi.mock("@/components/ModelReloadConfirm", () => ({
ModelReloadConfirm: () => null,
}));
vi.mock("@/components/ReasoningPicker", () => ({
ReasoningPicker: () => null,
}));
vi.mock("@nous-research/ui/ui/components/button", () => ({
Button: ({ children }: { children?: ReactNode }) => <button>{children}</button>,
}));
vi.mock("@nous-research/ui/ui/components/badge", () => ({
Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));
vi.mock("@nous-research/ui/ui/components/card", () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
constructor() {
gatewayMocks.constructed += 1
}
close = gatewayMocks.close
connect = gatewayMocks.connect
on = gatewayMocks.on
onState = gatewayMocks.onState
request = gatewayMocks.request
}
}))
vi.mock('@/components/ModelPickerDialog', () => ({
ModelPickerDialog: () => null
}))
vi.mock('@/components/ModelReloadConfirm', () => ({
ModelReloadConfirm: () => null
}))
vi.mock('@/components/ReasoningPicker', () => ({
ReasoningPicker: () => null
}))
vi.mock('@nous-research/ui/ui/components/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
)
}))
vi.mock('@nous-research/ui/ui/components/badge', () => ({
Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>
}))
vi.mock('@nous-research/ui/ui/components/card', () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>
}))
type EventLike = { code?: number; data?: string };
type EventLike = { code?: number; data?: string }
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
static instances: FakeWebSocket[] = []
private listeners = new Map<string, Array<(event: EventLike) => void>>();
readonly url: string;
closed = false;
private listeners = new Map<string, Array<(event: EventLike) => void>>()
readonly url: string
readyState = FakeWebSocket.CONNECTING
closed = false
sent: string[] = []
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
this.url = url
FakeWebSocket.instances.push(this)
}
addEventListener(type: string, listener: (event: EventLike) => void) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
const listeners = this.listeners.get(type) ?? []
listeners.push(listener)
this.listeners.set(type, listeners)
}
removeEventListener(type: string, listener: (event: EventLike) => void) {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(l => l !== listener)
)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.closed = true;
if (this.closed) {
return
}
this.closed = true
// Real sockets deliver a close event for a client-initiated close too;
// the shared client relies on it to settle a pending handshake.
this.emit('close', { code: 1005 })
}
emit(type: string, event: EventLike) {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
if (type === 'open') {
this.readyState = FakeWebSocket.OPEN
} else if (type === 'close') {
this.readyState = FakeWebSocket.CLOSED
}
// Snapshot: `once` listeners remove themselves while we iterate.
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event)
}
}
}
let container: HTMLDivElement;
let root: Root;
let container: HTMLDivElement
let root: Root
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(ui));
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
await act(async () => root.render(ui))
}
beforeEach(() => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
apiMocks.buildWsUrl.mockReset();
apiMocks.buildWsUrl.mockResolvedValue(
"ws://localhost/api/events?channel=chat-1",
);
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
vi.stubGlobal("WebSocket", FakeWebSocket);
});
FakeWebSocket.instances = []
vi.clearAllMocks()
apiMocks.buildWsUrl.mockReset()
apiMocks.buildWsUrl.mockResolvedValue('ws://localhost/api/events?channel=chat-1')
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true)
vi.stubGlobal('WebSocket', FakeWebSocket)
})
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});
await act(async () => root?.unmount())
container?.remove()
vi.unstubAllGlobals()
})
describe("ChatSidebar event socket", () => {
it("routes loopback 4401 closes through stale-token recovery", async () => {
const { ChatSidebar } = await import("./ChatSidebar");
describe('ChatSidebar event socket', () => {
it('routes loopback 4401 closes through stale-token recovery', async () => {
const { ChatSidebar } = await import('./ChatSidebar')
await render(<ChatSidebar channel="chat-1" />);
await render(<ChatSidebar channel="chat-1" />)
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
expect(apiMocks.buildWsUrl).toHaveBeenCalledWith("/api/events", {
channel: "chat-1",
});
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1))
expect(apiMocks.buildWsUrl).toHaveBeenCalledWith('/api/events', {
channel: 'chat-1'
})
FakeWebSocket.instances[0].emit("close", { code: 4401 });
FakeWebSocket.instances[0].emit('close', { code: 4401 })
expect(
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
).toHaveBeenCalledWith(4401);
});
});
expect(reloadMocks.maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401)
})
})
describe("ChatSidebar event socket reconnect", () => {
describe('ChatSidebar event socket reconnect', () => {
beforeEach(() => {
// Not loopback: exercise the gated-mode path so closes fall through to
// the reconnect logic instead of triggering a page reload.
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(false);
vi.useFakeTimers();
});
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(false)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers();
});
vi.useRealTimers()
})
async function renderSidebar() {
const { ChatSidebar } = await import("./ChatSidebar");
await render(<ChatSidebar channel="chat-1" />);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
const { ChatSidebar } = await import('./ChatSidebar')
await render(<ChatSidebar channel="chat-1" />)
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1))
}
/** Advance timers and flush the async `connect()` that fires on the tick. */
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await vi.advanceTimersByTimeAsync(ms)
})
}
it("reconnects after a transient close", async () => {
await renderSidebar();
it('reconnects after a transient close', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(FakeWebSocket.instances).toHaveLength(1);
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
expect(FakeWebSocket.instances).toHaveLength(1)
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
});
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(2)
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2)
})
it("keeps retrying when reconnect URL construction fails", async () => {
await renderSidebar();
it('keeps retrying when reconnect URL construction fails', async () => {
await renderSidebar()
apiMocks.buildWsUrl
.mockRejectedValueOnce(new Error("ticket endpoint unavailable"))
.mockResolvedValue("ws://localhost/api/events?channel=chat-1");
.mockRejectedValueOnce(new Error('ticket endpoint unavailable'))
.mockResolvedValue('ws://localhost/api/events?channel=chat-1')
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(container.textContent).toContain("reconnecting in 2s");
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(1)
expect(container.textContent).toContain('reconnecting in 2s')
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3);
});
await advance(2_000)
expect(FakeWebSocket.instances).toHaveLength(2)
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3)
})
it("times out a stalled URL request and retries", async () => {
let resolveStalledRequest!: (url: string) => void;
await renderSidebar();
it('times out a stalled URL request and retries', async () => {
let resolveStalledRequest!: (url: string) => void
await renderSidebar()
apiMocks.buildWsUrl
.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveStalledRequest = resolve;
}),
new Promise<string>(resolve => {
resolveStalledRequest = resolve
})
)
.mockResolvedValue("ws://localhost/api/events?channel=chat-1");
.mockResolvedValue('ws://localhost/api/events?channel=chat-1')
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000 + EVENTS_CONNECT_TIMEOUT_MS);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(container.textContent).toContain("reconnecting in 2s");
await advance(1_000 + EVENTS_CONNECT_TIMEOUT_MS)
expect(FakeWebSocket.instances).toHaveLength(1)
expect(container.textContent).toContain('reconnecting in 2s')
// A late ticket response from the timed-out attempt must not create a
// superseded socket alongside the scheduled replacement.
await act(async () => {
resolveStalledRequest("ws://localhost/api/events?channel=stale");
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(1);
resolveStalledRequest('ws://localhost/api/events?channel=stale')
await Promise.resolve()
})
expect(FakeWebSocket.instances).toHaveLength(1)
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3);
});
await advance(2_000)
expect(FakeWebSocket.instances).toHaveLength(2)
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(3)
})
it("times out a stalled WebSocket handshake and retries", async () => {
await renderSidebar();
it('times out a stalled WebSocket handshake and retries', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(2)
await advance(EVENTS_CONNECT_TIMEOUT_MS);
expect(FakeWebSocket.instances[1].closed).toBe(true);
expect(container.textContent).toContain("reconnecting in 2s");
await advance(EVENTS_CONNECT_TIMEOUT_MS)
expect(FakeWebSocket.instances[1].closed).toBe(true)
expect(container.textContent).toContain('reconnecting in 2s')
await advance(2_000);
expect(FakeWebSocket.instances).toHaveLength(3);
});
await advance(2_000)
expect(FakeWebSocket.instances).toHaveLength(3)
})
it("backs off exponentially across repeated failures", async () => {
await renderSidebar();
it('backs off exponentially across repeated failures', async () => {
await renderSidebar()
// 1s, then 2s, then 4s — a socket that never opens keeps backing off.
for (const [index, delay] of [1_000, 2_000, 4_000].entries()) {
await act(async () => {
FakeWebSocket.instances[index].emit("close", { code: 1006 });
});
FakeWebSocket.instances[index].emit('close', { code: 1006 })
})
// The previous (shorter) delay must not be enough to fire this one.
if (index > 0) {
await advance(delay - 1);
expect(FakeWebSocket.instances).toHaveLength(index + 1);
await advance(delay - 1)
expect(FakeWebSocket.instances).toHaveLength(index + 1)
}
await advance(delay);
expect(FakeWebSocket.instances).toHaveLength(index + 2);
await advance(delay)
expect(FakeWebSocket.instances).toHaveLength(index + 2)
}
});
})
it("schedules only one retry when error and close both fire", async () => {
await renderSidebar();
it('schedules only one retry when error and close both fire', async () => {
await renderSidebar()
// A failed socket emits `error` then `close`. Scheduling from both
// paths would queue two timers and leak the untracked one.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
FakeWebSocket.instances[0].emit('error', {})
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000);
await advance(1_000)
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
await advance(29_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});
FakeWebSocket.instances[1].emit('open', {})
})
await advance(29_000)
expect(FakeWebSocket.instances).toHaveLength(2)
})
it("resets the backoff after a successful reconnect", async () => {
await renderSidebar();
it('resets the backoff after a successful reconnect', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(2)
// Reconnected — the next drop should start from 1s again, not 2s.
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
FakeWebSocket.instances[1].emit("close", { code: 1006 });
});
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(3);
});
FakeWebSocket.instances[1].emit('open', {})
FakeWebSocket.instances[1].emit('close', { code: 1006 })
})
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(3)
})
it("does not retry auth rejections", async () => {
await renderSidebar();
it('does not retry auth rejections', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 4403 });
});
FakeWebSocket.instances[0].emit('close', { code: 4403 })
})
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
await advance(60_000)
expect(FakeWebSocket.instances).toHaveLength(1)
})
it("does not retry a normal closure", async () => {
await renderSidebar();
it('does not retry a normal closure', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1000 });
});
FakeWebSocket.instances[0].emit('close', { code: 1000 })
})
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
await advance(60_000)
expect(FakeWebSocket.instances).toHaveLength(1)
})
it("gives up after the attempt cap instead of retrying forever", async () => {
await renderSidebar();
it('gives up after the attempt cap instead of retrying forever', async () => {
await renderSidebar()
for (let i = 0; i < 40; i++) {
const socket =
FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
await act(async () => {
socket.emit("close", { code: 1006 });
});
await advance(30_000);
socket.emit('close', { code: 1006 })
})
await advance(30_000)
}
// 15 retries + the initial connection.
expect(FakeWebSocket.instances.length).toBeLessThanOrEqual(16);
});
expect(FakeWebSocket.instances.length).toBeLessThanOrEqual(16)
})
it("clears its own banner on a successful reconnect", async () => {
await renderSidebar();
it('clears its own banner on a successful reconnect', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(container.textContent).toContain("events feed disconnected");
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
expect(container.textContent).toContain('events feed disconnected')
await advance(1_000);
await advance(1_000)
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
FakeWebSocket.instances[1].emit('open', {})
})
// Banner gone entirely — including the "reconnect events feed" button,
// which only renders while `error` is set.
expect(container.textContent).not.toContain("events feed");
});
expect(container.textContent).not.toContain('events feed')
})
it("does not clear a credential warning when the feed recovers", async () => {
await renderSidebar();
it('does not clear a credential warning when the feed recovers', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
await advance(1_000);
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
await advance(1_000)
// A sidecar error lands while the events socket is still reconnecting.
// The banner is shared, so a blind `setError(null)` on reconnect would
// hide a real problem the user needs to see.
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
gatewayMocks.handlers.get('error')?.({
payload: { message: 'ANTHROPIC_API_KEY is not set' }
})
})
await act(async () => {
FakeWebSocket.instances[1].emit("open", {});
});
FakeWebSocket.instances[1].emit('open', {})
})
expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
});
expect(container.textContent).toContain('ANTHROPIC_API_KEY is not set')
})
it("does not overwrite a sidecar error when the feed drops", async () => {
await renderSidebar();
it('does not overwrite a sidecar error when the feed drops', async () => {
await renderSidebar()
// A sidecar error is already on the banner...
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
gatewayMocks.handlers.get('error')?.({
payload: { message: 'ANTHROPIC_API_KEY is not set' }
})
})
// ...when the events feed drops. `error` is that message's only home,
// so overwriting it loses the warning permanently — the feed's own
// banner would later clear itself to null and the sidecar never
// re-emits.
await act(async () => {
FakeWebSocket.instances[0].emit("error", {});
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
FakeWebSocket.instances[0].emit('error', {})
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
expect(container.textContent).toContain("ANTHROPIC_API_KEY is not set");
expect(container.textContent).toContain('ANTHROPIC_API_KEY is not set')
// The disconnect message must not have replaced it. (Matching the
// banner text specifically — "reconnect events feed" is the button
// label, which is expected to be present whenever a banner shows.)
expect(container.textContent).not.toContain("events feed disconnected");
});
expect(container.textContent).not.toContain('events feed disconnected')
})
it("still reconnects while a foreign banner suppresses its message", async () => {
await renderSidebar();
it('still reconnects while a foreign banner suppresses its message', async () => {
await renderSidebar()
await act(async () => {
gatewayMocks.handlers.get("error")?.({
payload: { message: "ANTHROPIC_API_KEY is not set" },
});
});
gatewayMocks.handlers.get('error')?.({
payload: { message: 'ANTHROPIC_API_KEY is not set' }
})
})
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
// Declining to write the banner must not disable the retry itself.
await advance(1_000);
expect(FakeWebSocket.instances).toHaveLength(2);
});
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(2)
})
it("clears the reconnect timer and closes the socket on unmount", async () => {
await renderSidebar();
it('reuses one JSON-RPC client across reconnects so seq replay can fire', async () => {
// The shared client only asks `session.events.since` for the gap when the
// instance that recorded the watermarks is the one that redials.
gatewayMocks.constructed = 0
await renderSidebar()
expect(gatewayMocks.constructed).toBe(1)
expect(gatewayMocks.connect).toHaveBeenCalledTimes(1)
// Drop the feed so the banner (and its reconnect button) renders.
await act(async () => {
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
const reconnectButton = Array.from(container.querySelectorAll('button')).find(b =>
/reconnect events feed/i.test(b.textContent ?? '')
)
expect(reconnectButton).toBeDefined()
await act(async () => {
reconnectButton!.click()
})
expect(gatewayMocks.close).toHaveBeenCalled()
expect(gatewayMocks.connect).toHaveBeenCalledTimes(2)
expect(gatewayMocks.constructed).toBe(1)
})
it('clears the reconnect timer and closes the socket on unmount', async () => {
await renderSidebar()
await act(async () => {
FakeWebSocket.instances[0].emit("close", { code: 1006 });
});
expect(vi.getTimerCount()).toBeGreaterThan(0);
FakeWebSocket.instances[0].emit('close', { code: 1006 })
})
expect(vi.getTimerCount()).toBeGreaterThan(0)
// Let the retry dial a second socket so unmount has a live one to close.
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(2)
await act(async () => {
FakeWebSocket.instances[1].emit('open', {})
FakeWebSocket.instances[1].emit('close', { code: 1006 })
})
expect(vi.getTimerCount()).toBeGreaterThan(0)
await advance(1_000)
expect(FakeWebSocket.instances).toHaveLength(3)
await act(async () => root.unmount());
await act(async () => root.unmount())
expect(FakeWebSocket.instances[0].closed).toBe(true);
expect(FakeWebSocket.instances[2].closed).toBe(true)
// The pending retry timer must be cleared, not merely neutered by the
// `unmounting` flag — a live timer keeps the effect closure alive.
expect(vi.getTimerCount()).toBe(0);
expect(vi.getTimerCount()).toBe(0)
await advance(60_000);
expect(FakeWebSocket.instances).toHaveLength(1);
});
});
await advance(60_000)
expect(FakeWebSocket.instances).toHaveLength(3)
})
})

View File

@@ -25,19 +25,17 @@
* terminal pane keeps working unimpaired.
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card } from "@nous-research/ui/ui/components/card";
import { Button } from '@nous-research/ui/ui/components/button'
import { Badge } from '@nous-research/ui/ui/components/badge'
import { Card } from '@nous-research/ui/ui/components/card'
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
import { ReasoningPicker } from "@/components/ReasoningPicker";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { api, buildWsUrl } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import { ModelPickerDialog } from '@/components/ModelPickerDialog'
import { ModelReloadConfirm } from '@/components/ModelReloadConfirm'
import { ReasoningPicker } from '@/components/ReasoningPicker'
import { GatewayClient, type ConnectionState } from '@/lib/gatewayClient'
import { EventsFeedClient } from '@/lib/eventsFeedClient'
import { api } from '@/lib/api'
import {
EVENTS_CONNECT_TIMEOUT_MS,
EVENTS_DISCONNECTED_MESSAGE,
EVENTS_MAX_RECONNECT_ATTEMPTS,
eventsGaveUpMessage,
eventsReconnectDelayMs,
@@ -45,53 +43,45 @@ import {
eventsRejectedMessage,
isEventsAuthRejection,
isEventsFeedMessage,
shouldRetryEventsClose,
} from "@/lib/events-reconnect";
import { titleFromSessionInfoPayload } from "@/lib/chat-title";
shouldRetryEventsClose
} from '@/lib/events-reconnect'
import { titleFromSessionInfoPayload } from '@/lib/chat-title'
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from '@/lib/utils'
import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
title?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
cwd?: string
model?: string
provider?: string
credential_warning?: string
title?: string
}
const STATE_LABEL: Record<ConnectionState, string> = {
idle: "idle",
connecting: "connecting",
open: "live",
closed: "closed",
error: "error",
};
idle: 'idle',
connecting: 'connecting',
open: 'live',
closed: 'closed',
error: 'error'
}
const STATE_TONE: Record<
ConnectionState,
"secondary" | "warning" | "success" | "destructive"
> = {
idle: "secondary",
connecting: "warning",
open: "success",
closed: "secondary",
error: "destructive",
};
const STATE_TONE: Record<ConnectionState, 'secondary' | 'warning' | 'success' | 'destructive'> = {
idle: 'secondary',
connecting: 'warning',
open: 'success',
closed: 'secondary',
error: 'destructive'
}
interface ChatSidebarProps {
channel: string;
channel: string
/** Chat profile from the dashboard switcher / URL scope. */
profile?: string;
className?: string;
onDashboardNewSessionRequest?: () => void;
onSessionTitleChange?: (title: string | null) => void;
profile?: string
className?: string
onDashboardNewSessionRequest?: () => void
onSessionTitleChange?: (title: string | null) => void
}
/** Build the ``session.create`` params for the sidecar session.
@@ -104,9 +94,9 @@ interface ChatSidebarProps {
export function sidecarSessionCreateParams(profile?: string): Record<string, unknown> {
return {
close_on_disconnect: true,
source: "tool",
...(profile ? { profile } : {}),
};
source: 'tool',
...(profile ? { profile } : {})
}
}
export function ChatSidebar({
@@ -114,20 +104,21 @@ export function ChatSidebar({
profile,
className,
onDashboardNewSessionRequest,
onSessionTitleChange,
onSessionTitleChange
}: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
// it's the signal that says "rebuild the client".
const [version, setVersion] = useState(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const gw = useMemo(() => new GatewayClient(), [version]);
// `version` bumps on reconnect (manual button, profile/channel switch) and
// re-runs the socket effects. The clients themselves live for the whole
// component: the shared client keeps per-session seq watermarks and asks
// the gateway to replay the gap on the next `connect()`, which only works
// when the SAME instance survives the drop.
const [version, setVersion] = useState(0)
const gw = useMemo(() => new GatewayClient(), [])
const feed = useMemo(() => new EventsFeedClient(), [])
const [state, setState] = useState<ConnectionState>("idle");
const [info, setInfo] = useState<SessionInfo>({});
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<ConnectionState>('idle')
const [info, setInfo] = useState<SessionInfo>({})
const [modelOpen, setModelOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
// The badge shows config.yaml's main model (`model.default`) via
// `/api/model/info` — the same value the Models page writes and a new chat
// session boots from. We deliberately don't use the sidecar's `session.info`
@@ -136,77 +127,78 @@ export function ChatSidebar({
// elsewhere, so the badge would go stale. Pass the chat profile explicitly so
// this card stays scoped to the PTY even if the global dashboard switcher
// changes while the chat is open.
const [effectiveModel, setEffectiveModel] = useState("");
const [effectiveModel, setEffectiveModel] = useState('')
// Whether the effective model supports reasoning effort — gates the
// ReasoningPicker. Read from the same `/api/model/info` capabilities the
// (currently unused) ModelInfoCard surfaces, so the dashboard exposes a
// control to *set* the level, not just a read-only "Reasoning" badge.
const [supportsReasoning, setSupportsReasoning] = useState(false);
const [supportsReasoning, setSupportsReasoning] = useState(false)
// Bumped on model change/save so ReasoningPicker re-reads the saved effort
// (config is profile-scoped the same way the model badge is).
const [modelRefreshKey, setModelRefreshKey] = useState(0);
const [modelRefreshKey, setModelRefreshKey] = useState(0)
// Set after the picker saves a model and the user declines the reload: config
// is updated but the running session keeps its model until rebuilt.
const [modelNotice, setModelNotice] = useState<string | null>(null);
const [modelNotice, setModelNotice] = useState<string | null>(null)
// Short name of a just-saved model awaiting confirm to reload (a fresh chat
// session is how the running chat adopts it; we confirm before discarding it).
const [pendingReloadModel, setPendingReloadModel] = useState<string | null>(
null,
);
const [pendingReloadModel, setPendingReloadModel] = useState<string | null>(null)
const refreshEffectiveModel = useCallback(() => {
void api
.getModelInfo(profile)
.then((r) => {
if (r?.model) setEffectiveModel(String(r.model));
setSupportsReasoning(!!r?.capabilities?.supports_reasoning);
.then(r => {
if (r?.model) setEffectiveModel(String(r.model))
setSupportsReasoning(!!r?.capabilities?.supports_reasoning)
// Bump so ReasoningPicker re-reads the saved effort for the new model.
setModelRefreshKey((k) => k + 1);
setModelRefreshKey(k => k + 1)
})
.catch(() => {
// Best-effort: keep the last known label rather than blanking it.
});
}, [profile]);
})
}, [profile])
// Profile or PTY channel change tears down both WebSockets. Bump `version`
// (same path as the manual Reconnect button) so the gateway client is
// recreated and the events feed resubscribes — otherwise the old events
// socket's close handler can leave a stale error banner after a switch.
const scopeKey = `${channel}\0${profile ?? ""}`;
const prevScopeKey = useRef<string | null>(null);
const scopeKey = `${channel}\0${profile ?? ''}`
const prevScopeKey = useRef<string | null>(null)
useEffect(() => {
if (prevScopeKey.current === null) {
prevScopeKey.current = scopeKey;
return;
prevScopeKey.current = scopeKey
return
}
if (prevScopeKey.current === scopeKey) return;
prevScopeKey.current = scopeKey;
setError(null);
setVersion((v) => v + 1);
}, [scopeKey]);
if (prevScopeKey.current === scopeKey) return
prevScopeKey.current = scopeKey
setError(null)
setVersion(v => v + 1)
}, [scopeKey])
useEffect(() => {
let cancelled = false;
let cancelled = false
queueMicrotask(() => {
if (cancelled) return;
setInfo({});
setError(null);
});
const offState = gw.onState(setState);
if (cancelled) return
setInfo({})
setError(null)
})
const offState = gw.onState(setState)
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
if (ev.payload) {
setInfo((prev) => ({ ...prev, ...ev.payload }));
const offSessionInfo = gw.on('session.info', ev => {
// session.info is surface-specific on the wire; narrow to the fields this sidebar reads.
const payload = ev.payload as SessionInfo | undefined
if (payload) {
setInfo(prev => ({ ...prev, ...payload }))
}
});
})
const offError = gw.on<{ message?: string }>("error", (ev) => {
const message = ev.payload?.message;
const offError = gw.on('error', ev => {
const message = ev.payload?.message
if (message) {
setError(message);
setError(message)
}
});
})
// Create the sidecar session so the gateway surfaces session-scoped
// signals (connection state, credential warnings). It's independent of the
@@ -215,264 +207,184 @@ export function ChatSidebar({
gw.connect()
.then(() => {
if (cancelled) {
return;
return
}
// close_on_disconnect: the gateway reaps this sidecar session (and its
// slash_worker subprocess) when the WS drops, instead of leaking it.
return gw.request<{ session_id: string }>("session.create", sidecarSessionCreateParams(profile));
return gw.request<{ session_id: string }>('session.create', sidecarSessionCreateParams(profile))
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
setError(e.message)
}
});
})
return () => {
cancelled = true;
offState();
offSessionInfo();
offError();
gw.close();
};
// `profile` is read from render; scope changes bump `version` → new `gw`.
cancelled = true
offState()
offSessionInfo()
offError()
gw.close()
}
// `profile` is read from render; scope changes bump `version` → redial.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gw]);
}, [gw, version])
// Event subscriber WebSocket — receives the rebroadcast of every
// dispatcher emit from the PTY child's gateway. See /api/pub +
// /api/events in hermes_cli/web_server.py for the broadcast hop.
//
// Failures (auth/loopback rejection, server too old to expose the
// endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
// Framing, dispatch and connect timeout come from the shared JSON-RPC
// client (`EventsFeedClient`); this effect owns only the retry policy and
// the banner. Failures (auth/loopback rejection, server too old to expose
// the endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort UX
// and the user always has a reconnect affordance.
useEffect(() => {
if (!channel) {
return;
return
}
// In loopback mode the legacy ?token=<session> path is fine; in gated
// mode we have to mint a single-use ticket from the cookie. `connect`
// keeps the outer effect synchronous so its ``return cleanup`` stays at
// the top level; `ws` is a closed-over binding the cleanup reads.
let unmounting = false;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let connectTimer: ReturnType<typeof setTimeout> | null = null;
let connectGeneration = 0;
let attempt = 0;
const clearConnectTimer = () => {
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
};
let unmounting = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let attempt = 0
// The banner is shared with `info.credential_warning` and the JSON-RPC
// sidecar, and `error` is those messages' only home — the sidecar does
// not re-emit. So the events feed may only write over an empty banner
// or one of its own messages, and may only clear its own.
const surface = (msg: string) =>
!unmounting &&
setError((current) =>
isEventsFeedMessage(current) ? msg : (current ?? msg),
);
!unmounting && setError(current => (isEventsFeedMessage(current) ? msg : (current ?? msg)))
const clearEventsBanner = () =>
!unmounting &&
setError((current) => (isEventsFeedMessage(current) ? null : current));
const clearEventsBanner = () => !unmounting && setError(current => (isEventsFeedMessage(current) ? null : current))
// Single scheduling path. `close` always follows `error` for a failed
// socket, so scheduling from `error` too would queue two timers and
// leak the first — only the latest is tracked for cleanup.
// Single scheduling path: the client collapses `error` + `close` of one
// socket generation into one `closed` transition, so only one timer is
// ever queued per drop.
const scheduleReconnect = () => {
if (unmounting || reconnectTimer) {
return;
return
}
if (attempt >= EVENTS_MAX_RECONNECT_ATTEMPTS) {
surface(eventsGaveUpMessage());
return;
surface(eventsGaveUpMessage())
return
}
const delay = eventsReconnectDelayMs(attempt);
attempt += 1;
surface(eventsReconnectingMessage(delay));
const delay = eventsReconnectDelayMs(attempt)
attempt += 1
surface(eventsReconnectingMessage(delay))
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, delay);
};
reconnectTimer = null
void connect()
}, delay)
}
const connect = async () => {
if (unmounting) {
return;
return
}
const generation = ++connectGeneration;
let socket: WebSocket | null = null;
// Cover the whole connection attempt, including gated-mode ticket
// minting. A failed or hanging pre-socket request otherwise emits no
// WebSocket close event and permanently strands the retry loop at its
// last "reconnecting in ..." banner.
clearConnectTimer();
connectTimer = setTimeout(() => {
connectTimer = null;
if (unmounting || generation !== connectGeneration) {
return;
}
// Invalidate any late ticket result or socket event from this attempt
// before scheduling its replacement.
connectGeneration += 1;
if (socket && ws === socket) {
ws = null;
socket.close();
}
scheduleReconnect();
}, EVENTS_CONNECT_TIMEOUT_MS);
try {
// Re-minted every attempt: tickets are single-use with a short TTL,
// so a reconnect cannot replay the URL from the first connection.
const url = await buildWsUrl("/api/events", { channel });
if (unmounting || generation !== connectGeneration) {
return;
}
socket = new WebSocket(url);
ws = socket;
await feed.connect(channel)
} catch {
if (unmounting || generation !== connectGeneration) {
return;
// Connect-phase failures (ticket mint, handshake timeout, close
// during handshake) reach the reconnect ladder through the close
// handler when a socket existed; a pre-socket failure has no close
// event, so schedule here. `onClose` de-dupes via `reconnectTimer`.
if (!unmounting && feed.lastCloseCode === null) {
scheduleReconnect()
}
clearConnectTimer();
connectGeneration += 1;
scheduleReconnect();
return;
}
}
// A superseded socket's late close must not schedule a retry on top
// of the one that replaced it.
const isCurrent = () => ws === socket;
const offClose = feed.onClose(code => {
if (unmounting) {
return
}
if (code !== undefined && isEventsAuthRejection(code)) {
surface(eventsRejectedMessage(code))
return
}
// `undefined` = handshake timeout / error without a close frame.
if (shouldRetryEventsClose(code)) {
scheduleReconnect()
}
})
socket.addEventListener("open", () => {
if (!isCurrent()) {
return;
}
clearConnectTimer();
attempt = 0;
clearEventsBanner();
});
const offState = feed.onState(state => {
if (state === 'open') {
attempt = 0
clearEventsBanner()
}
})
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
socket.addEventListener("error", () => {
if (isCurrent()) {
surface(EVENTS_DISCONNECTED_MESSAGE);
}
});
const offSessionInfo = feed.on('session.info', ev => {
const title = titleFromSessionInfoPayload(ev.payload)
if (title !== undefined) {
onSessionTitleChange?.(title)
}
})
const offNewSession = feed.on('dashboard.new_session_requested', () => {
onDashboardNewSessionRequest?.()
})
socket.addEventListener("close", (ev) => {
if (!isCurrent()) {
return;
}
clearConnectTimer();
if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
return;
}
if (isEventsAuthRejection(ev.code)) {
surface(eventsRejectedMessage(ev.code));
return;
}
if (shouldRetryEventsClose(ev.code)) {
scheduleReconnect();
}
});
socket.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "session.info") {
const title = titleFromSessionInfoPayload(payload);
if (title !== undefined) {
onSessionTitleChange?.(title);
}
} else if (type === "dashboard.new_session_requested") {
onDashboardNewSessionRequest?.();
}
});
};
void connect();
void connect()
return () => {
unmounting = true;
connectGeneration += 1;
clearConnectTimer();
unmounting = true
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
clearTimeout(reconnectTimer)
reconnectTimer = null
}
ws?.close();
};
}, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]);
offClose()
offState()
offSessionInfo()
offNewSession()
feed.close()
}
}, [channel, feed, onDashboardNewSessionRequest, onSessionTitleChange, version])
// Seed the badge on mount and re-read it whenever the sockets are rebuilt
// (a profile/channel switch bumps `version`).
useEffect(() => {
refreshEffectiveModel();
}, [refreshEffectiveModel, version]);
refreshEffectiveModel()
}, [refreshEffectiveModel, version])
const reconnect = useCallback(() => {
setError(null);
setModelNotice(null);
setPendingReloadModel(null);
setVersion((v) => v + 1);
}, []);
setError(null)
setModelNotice(null)
setPendingReloadModel(null)
setVersion(v => v + 1)
}, [])
// The picker writes config.yaml over REST and reloads — it doesn't ride the
// sidecar gateway session, so it's available whenever the sidebar is mounted.
const modelName = effectiveModel || info.model || "—";
const modelLabel = modelName.split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
const modelName = effectiveModel || info.model || '—'
const modelLabel = modelName.split('/').slice(-1)[0] ?? '—'
const banner = error ?? info.credential_warning ?? null
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1",
className,
'flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1',
className
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="text-display text-xs tracking-wider text-text-tertiary">
model
</div>
<div className="text-display text-xs tracking-wider text-text-tertiary">model</div>
<Button
ghost
size="sm"
onClick={() => setModelOpen(true)}
className={cn(
"max-w-full min-w-0 px-0 py-0",
"self-start normal-case tracking-normal text-sm font-medium",
"hover:underline disabled:no-underline",
'max-w-full min-w-0 px-0 py-0',
'self-start normal-case tracking-normal text-sm font-medium',
'hover:underline disabled:no-underline'
)}
title={modelName === "—" ? "switch model" : modelName}
title={modelName === '—' ? 'switch model' : modelName}
>
<span className="flex min-w-0 max-w-full items-center gap-1">
<span className="truncate">{modelLabel}</span>
@@ -493,9 +405,9 @@ export function ChatSidebar({
currentModel={modelName}
profile={profile}
refreshKey={modelRefreshKey}
onChanged={(effort) =>
onChanged={effort =>
setModelNotice(
`Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`,
`Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`
)
}
/>
@@ -506,9 +418,7 @@ export function ChatSidebar({
<Card className="flex items-start gap-2 border-warning/40 bg-warning/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div className="wrap-break-word min-w-0 flex-1 text-text-secondary">
{modelNotice}
</div>
<div className="wrap-break-word min-w-0 flex-1 text-text-secondary">{modelNotice}</div>
</Card>
)}
@@ -520,13 +430,7 @@ export function ChatSidebar({
<div className="wrap-break-word text-destructive">{banner}</div>
{error && (
<Button
size="sm"
outlined
className="mt-1"
onClick={reconnect}
prefix={<RefreshCw />}
>
<Button size="sm" outlined className="mt-1" onClick={reconnect} prefix={<RefreshCw />}>
reconnect events feed
</Button>
)}
@@ -542,29 +446,29 @@ export function ChatSidebar({
loader={() => api.getModelOptions(profile)}
alwaysGlobal
onApply={async ({ provider, model, confirmExpensiveModel }) => {
setModelNotice(null);
setPendingReloadModel(null);
setModelNotice(null)
setPendingReloadModel(null)
const result = await api.setModelAssignment(
{
confirm_expensive_model: confirmExpensiveModel,
scope: "main",
scope: 'main',
provider,
model,
model
},
profile,
);
profile
)
// confirm_required => the dialog shows the expensive-model prompt
// and calls back; don't announce until the user confirms.
if (!result.confirm_required) {
refreshEffectiveModel();
refreshEffectiveModel()
// Ask before reloading: applying the model starts a fresh chat.
setPendingReloadModel(model.split("/").slice(-1)[0]);
setPendingReloadModel(model.split('/').slice(-1)[0])
}
return result;
return result
}}
onClose={() => {
setModelOpen(false);
refreshEffectiveModel();
setModelOpen(false)
refreshEffectiveModel()
}}
/>
)}
@@ -572,13 +476,11 @@ export function ChatSidebar({
<ModelReloadConfirm
model={pendingReloadModel}
onCancel={() => {
const m = pendingReloadModel;
setPendingReloadModel(null);
setModelNotice(
`Model set to ${m}. Run /new or refresh the page to apply it to this chat.`,
);
const m = pendingReloadModel
setPendingReloadModel(null)
setModelNotice(`Model set to ${m}. Run /new or refresh the page to apply it to this chat.`)
}}
/>
</aside>
);
)
}

View File

@@ -3,7 +3,7 @@ import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
import { compactNumber } from "@hermes/shared";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
@@ -57,11 +57,11 @@ export function ModelInfoCard({
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
{compactNumber(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500 text-xs">
(override — auto: {formatTokenCount(info.auto_context_length)})
(override — auto: {compactNumber(info.auto_context_length)})
</span>
) : (
<span className="text-text-tertiary text-xs">
@@ -78,7 +78,7 @@ export function ModelInfoCard({
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
{compactNumber(caps.max_output_tokens)}
</span>
</div>
)}

View File

@@ -6,13 +6,13 @@ import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import type { GatewayClient } from "@/lib/gatewayClient";
import type { ModelOptionProvider, ModelOptionsResult } from "@hermes/shared";
import { Check, RefreshCw, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { fuzzyRank } from "@/lib/fuzzy";
import { queryMatchesProviderOnly } from "@/lib/model-picker-filter";
import { modelSearchText } from "@/lib/model-search-text";
import { fuzzyRank, modelSearchText } from "@hermes/shared";
/**
* Two-stage model picker modal.
@@ -34,21 +34,6 @@ import { modelSearchText } from "@/lib/model-search-text";
* requiring an open chat PTY.
*/
interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
warning?: string;
}
interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
interface ExpensiveModelConfirmResponse {
confirm_message?: string;
confirm_required?: boolean;
@@ -73,7 +58,7 @@ interface Props {
onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(options?: { refresh?: boolean }): Promise<ModelOptionsResponse>;
loader?(options?: { refresh?: boolean }): Promise<ModelOptionsResult>;
onApply?(args: {
confirmExpensiveModel?: boolean;
provider: string;
@@ -118,7 +103,7 @@ export function ModelPickerDialog(props: Props) {
useState<PendingExpensiveConfirm | null>(null);
const closedRef = useRef(false);
const applyOptions = (r: ModelOptionsResponse) => {
const applyOptions = (r: ModelOptionsResult) => {
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
@@ -132,10 +117,10 @@ export function ModelPickerDialog(props: Props) {
const requestOptions = (refresh = false) =>
standalone
? (loader as (options?: { refresh?: boolean }) => Promise<ModelOptionsResponse>)({
? (loader as (options?: { refresh?: boolean }) => Promise<ModelOptionsResult>)({
refresh,
})
: (gw as GatewayClient).request<ModelOptionsResponse>(
: (gw as GatewayClient).request<ModelOptionsResult>(
"model.options",
{
...(sessionId ? { session_id: sessionId } : {}),

View File

@@ -77,20 +77,12 @@ export function ReasoningPicker({
const prev = effort;
setEffort(next); // optimistic
setSaving(true);
// Read-modify-write the whole config — the dashboard's single-key save
// pattern — so we never clobber sibling keys. `saveConfig` PUTs the full
// object the agent boots from.
// Sparse patch: PUT /api/config deep-merges onto disk, so sending only
// the edited key never clobbers sibling keys — and never echoes a
// default-expanded snapshot back over values another surface changed
// meanwhile (a CLI-pinned auxiliary slot would come back as "auto").
void api
.getConfig(profile)
.then((cfg) => {
const base = (cfg ?? {}) as Record<string, unknown>;
const agent =
base.agent && typeof base.agent === "object"
? { ...(base.agent as Record<string, unknown>) }
: {};
agent.reasoning_effort = next;
return api.saveConfig({ ...base, agent }, profile);
})
.saveConfig({ agent: { reasoning_effort: next } }, profile)
.then(() => {
onChanged?.(next);
})

View File

@@ -1,171 +0,0 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<ListItem
key={`${it.text}-${i}`}
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-xs text-text-tertiary truncate ml-auto">
{it.meta}
</span>
)}
</ListItem>
);
})}
</div>
);
},
);

View File

@@ -1,3 +1,4 @@
import { applyDocumentLocale, LOCALE_ENDONYMS } from "@hermes/shared/i18n";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import type { Locale, Translations } from "./types";
import { en } from "./en";
@@ -38,40 +39,14 @@ const TRANSLATIONS: Record<Locale, Translations> = {
ar,
};
// Locales whose script flows right-to-left. Consumed by the provider to set the
// document direction so Tailwind's logical utilities (ms-/me-, ps-/pe-) flip.
const RTL_LOCALES = new Set<Locale>(["ar"]);
// Display metadata for the language picker — endonym (native name) so users
// recognize their language even if they don't speak the current UI language.
// Exposed as a constant so the LanguageSwitcher and any future settings page
// can share the same list.
//
// We intentionally do NOT pair locales with country flags. Languages are not
// countries (English ≠ GB, Portuguese ≠ PT, Spanish ≠ ES, Chinese variants ≠
// any single jurisdiction). Endonyms are unambiguous and avoid the political
// mismapping that flag pairings inevitably create.
export const LOCALE_META: Record<Locale, { name: string }> = {
en: { name: "English" },
zh: { name: "简体中文" },
"zh-hant": { name: "繁體中文" },
ja: { name: "日本語" },
de: { name: "Deutsch" },
es: { name: "Español" },
fr: { name: "Français" },
tr: { name: "Türkçe" },
uk: { name: "Українська" },
af: { name: "Afrikaans" },
ko: { name: "한국어" },
it: { name: "Italiano" },
ga: { name: "Gaeilge" },
pt: { name: "Português" },
ru: { name: "Русский" },
hu: { name: "Magyar" },
ar: { name: "العربية" },
};
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
// Display metadata for the language picker — endonyms from @hermes/shared so the
// desktop and web pickers can never disagree on a language's native name.
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
SUPPORTED_LOCALES.map((id) => [id, { name: LOCALE_ENDONYMS[id] }]),
) as Record<Locale, { name: string }>;
const STORAGE_KEY = "hermes-locale";
function isLocale(value: string): value is Locale {
@@ -113,9 +88,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = locale;
document.documentElement.dir = RTL_LOCALES.has(locale) ? "rtl" : "ltr";
applyDocumentLocale(locale);
}, [locale]);
const value: I18nContextValue = {

View File

@@ -1,46 +1,12 @@
import { en } from "./en";
import type { Translations } from "./types";
import { mergeTranslations, type TranslationOverride } from '@hermes/shared/i18n'
import { en } from './en'
import type { Translations } from './types'
// Partial-locale helper: a translation file supplies only the strings it has
// translated and every missing key falls back to English, while unknown keys
// still fail the type-check. Mirrors the desktop app's `defineLocale` so a new
// locale (e.g. Arabic) can land without hand-porting every future English key.
// still fail the type-check.
export type TranslationOverrides = TranslationOverride<Translations>
type TranslationOverride<T> = T extends (...args: never[]) => string
? T
: T extends readonly unknown[]
? T
: T extends string
? string
: T extends object
? { [K in keyof T]?: TranslationOverride<T[K]> }
: T;
export type TranslationOverrides = TranslationOverride<Translations>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeTranslations<T>(base: T, overrides: TranslationOverride<T> | undefined): T {
if (!isRecord(base) || !isRecord(overrides)) {
return (overrides ?? base) as T;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
continue;
}
const baseValue = result[key];
result[key] = isRecord(baseValue) && isRecord(value) ? mergeTranslations(baseValue, value) : value;
}
return result as T;
}
export function defineLocale(overrides: TranslationOverrides): Translations {
return mergeTranslations<Translations>(en, overrides);
}
export const defineLocale = (overrides: TranslationOverrides): Translations =>
mergeTranslations<Translations>(en, overrides)

View File

@@ -1,4 +1,8 @@
import { buildHermesWebSocketUrl } from "@hermes/shared";
import {
buildHermesWebSocketUrl,
type ModelOptionProvider,
type ModelOptionsResult,
} from "@hermes/shared";
// The dashboard can be served either at the root of its host (e.g.
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
@@ -543,7 +547,7 @@ export const api = {
// desktop chat pickers (#56974), so opt in explicitly here.
qs.set("include_unconfigured", "1");
const suffix = qs.toString() ? `?${qs.toString()}` : "";
return fetchJSON<ModelOptionsResponse>(`/api/model/options${suffix}`);
return fetchJSON<ModelOptionsResult>(`/api/model/options${suffix}`);
},
getAuxiliaryModels: (profile = getManagementProfile()) =>
fetchJSON<AuxiliaryModelsResponse>(
@@ -2438,23 +2442,7 @@ export interface ModelInfoResponse {
// ── Model options / assignment types ──────────────────────────────────
export interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
is_user_defined?: boolean;
source?: string;
warning?: string;
authenticated?: boolean;
}
export interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
export type { ModelOptionProvider, ModelOptionsResult };
export interface AuxiliaryTaskAssignment {
task: string;

View File

@@ -6,31 +6,32 @@
* decision so both can be unit-tested without a fake WebSocket.
*/
export const EVENTS_RECONNECT_BASE_MS = 1_000;
export const EVENTS_RECONNECT_MAX_MS = 30_000;
export const EVENTS_MAX_RECONNECT_ATTEMPTS = 15;
import { reconnectBackoffDelayMs } from '@hermes/shared'
export const EVENTS_RECONNECT_BASE_MS = 1_000
export const EVENTS_RECONNECT_MAX_MS = 30_000
export const EVENTS_MAX_RECONNECT_ATTEMPTS = 15
/** Bound ticket minting plus the WebSocket opening handshake. */
export const EVENTS_CONNECT_TIMEOUT_MS = 15_000;
export const EVENTS_CONNECT_TIMEOUT_MS = 15_000
/** Normal closure — the server said goodbye, don't chase it. */
const WS_CLOSE_NORMAL = 1000;
const WS_CLOSE_NORMAL = 1000
/** Ticket rejected / forbidden: retrying just burns tickets, user must reload. */
const WS_CLOSE_AUTH_CODES = new Set([4401, 4403]);
const WS_CLOSE_AUTH_CODES = new Set([4401, 4403])
/**
* Exponential backoff, 1s → 2s → 4s → … → 30s cap.
* Exponential backoff, 1s → 2s → 4s → … → 30s cap. Deterministic (no jitter)
* because the banner prints the exact delay.
*
* `attempt` is 0-based: attempt 0 is the first retry after the initial
* connection dropped.
*/
export function eventsReconnectDelayMs(attempt: number): number {
const exponent = Math.max(0, Math.trunc(attempt));
// 2 ** exponent overflows to Infinity long before it matters; Math.min
// still clamps correctly, but guard anyway so the delay stays a number.
const raw = EVENTS_RECONNECT_BASE_MS * 2 ** Math.min(exponent, 32);
return Math.min(raw, EVENTS_RECONNECT_MAX_MS);
return reconnectBackoffDelayMs(attempt, {
baseDelayMs: EVENTS_RECONNECT_BASE_MS,
capMs: EVENTS_RECONNECT_MAX_MS,
jitter: false
})
}
/**
@@ -42,32 +43,31 @@ export function eventsReconnectDelayMs(attempt: number): number {
*/
export function shouldRetryEventsClose(code: number | undefined): boolean {
if (code === undefined) {
return true;
return true
}
return code !== WS_CLOSE_NORMAL && !WS_CLOSE_AUTH_CODES.has(code);
return code !== WS_CLOSE_NORMAL && !WS_CLOSE_AUTH_CODES.has(code)
}
export function isEventsAuthRejection(code: number | undefined): boolean {
return code !== undefined && WS_CLOSE_AUTH_CODES.has(code);
return code !== undefined && WS_CLOSE_AUTH_CODES.has(code)
}
// The sidebar's banner is shared with `info.credential_warning` and with the
// JSON-RPC sidecar's errors, so the events socket may only clear a message it
// wrote itself. Everything this module can put in the banner is listed here.
export const EVENTS_DISCONNECTED_MESSAGE =
"events feed disconnected — the chat title may not update";
export const EVENTS_DISCONNECTED_MESSAGE = 'events feed disconnected — the chat title may not update'
export function eventsReconnectingMessage(delayMs: number): string {
return `events feed disconnected — reconnecting in ${Math.round(delayMs / 1000)}s…`;
return `events feed disconnected — reconnecting in ${Math.round(delayMs / 1000)}s…`
}
export function eventsRejectedMessage(code: number): string {
return `events feed rejected (${code}) — reload the page`;
return `events feed rejected (${code}) — reload the page`
}
export function eventsGaveUpMessage(): string {
return `events feed disconnected — gave up after ${EVENTS_MAX_RECONNECT_ATTEMPTS} attempts, reload the page`;
return `events feed disconnected — gave up after ${EVENTS_MAX_RECONNECT_ATTEMPTS} attempts, reload the page`
}
/**
@@ -77,8 +77,8 @@ export function eventsGaveUpMessage(): string {
*/
export function isEventsFeedMessage(message: string | null): boolean {
if (!message) {
return false;
return false
}
return message.startsWith("events feed ");
return message.startsWith('events feed ')
}

View File

@@ -0,0 +1,93 @@
/**
* `/api/events` subscriber for the dashboard chat sidebar.
*
* A notification-only view of the shared JSON-RPC client: the server never
* answers requests on this socket (subscribers don't speak), so replay is
* off and `request()` is not part of the surface. One instance lives for
* the component's whole life — the owner calls `connect()` again after a
* drop instead of building a new client — so the shared per-generation
* guards (stale-socket close, connect timeout, `error`+`close` collapsed
* into one `closed` transition) apply to every attempt.
*/
import { JsonRpcGatewayClient, type ConnectionState, type GatewayEvent, type GatewayEventName } from '@hermes/shared'
import { buildWsUrl } from '@/lib/api'
import { maybeReloadForLoopbackWsAuthFailure } from '@/lib/dashboard-auth-reload'
import { EVENTS_CONNECT_TIMEOUT_MS } from '@/lib/events-reconnect'
export type { ConnectionState, GatewayEvent, GatewayEventName }
type CloseHandler = (code: number | undefined) => void
export class EventsFeedClient extends JsonRpcGatewayClient {
private readonly closeHandlers = new Set<CloseHandler>()
private closeCode: number | null | undefined = undefined
constructor() {
super({
closedErrorMessage: 'events feed closed',
connectErrorMessage: 'events feed connection failed',
connectTimeoutMs: EVENTS_CONNECT_TIMEOUT_MS,
heartbeatDeadlineMs: 0,
heartbeatIntervalMs: 0,
onSocketClose: event => {
this.closeCode = event.code
// Loopback + stale token: the page reloads; nobody should retry.
return maybeReloadForLoopbackWsAuthFailure(event.code)
},
replay: false,
requestIdPrefix: 'e'
})
this.onState(state => {
if (state === 'closed' || state === 'error') {
const code = this.closeCode ?? undefined
this.closeCode = undefined
for (const handler of this.closeHandlers) {
handler(code)
}
}
})
}
/** Close code of the most recent drop; `null` before any socket was dialed. */
get lastCloseCode(): number | null | undefined {
return this.closeCode
}
/**
* Called once per generation that ends without opening or after opening:
* `code` is the WebSocket close code, or `undefined` when the handshake
* timed out / errored without a close frame.
*/
onClose(handler: CloseHandler): () => void {
this.closeHandlers.add(handler)
return () => this.closeHandlers.delete(handler)
}
/** Mint a fresh single-use ticket and dial `/api/events` for `channel`. */
async connect(channel: string): Promise<void> {
this.closeCode = null
// Cover ticket minting with the same deadline as the handshake: a stalled
// pre-socket request otherwise emits no close event and strands the retry
// loop at its last "reconnecting in …" banner.
const url = await withTimeout(buildWsUrl('/api/events', { channel }), EVENTS_CONNECT_TIMEOUT_MS)
await super.connect(url)
}
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('events feed ticket request timed out')), ms)
promise.then(
value => {
clearTimeout(timer)
resolve(value)
},
(error: unknown) => {
clearTimeout(timer)
reject(error instanceof Error ? error : new Error(String(error)))
}
)
})
}

View File

@@ -1,9 +0,0 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}

View File

@@ -1,192 +0,0 @@
// Lightweight fuzzy subsequence scorer for picker filtering.
//
// Matches a query as an ordered subsequence of the target (so `g4o` matches
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
// score is a better match. Returns the matched character indices so callers
// can highlight them.
//
// The scoring favours, in rough order: exact full match, prefix match, matches
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
// lower→upper case transition), contiguous runs, and earlier matches. This is
// intentionally simple — no external dependency — but good enough to make
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
//
// This is a logically identical copy of ui-tui/src/lib/fuzzy.ts (only prettier
// formatting differs); keep the two in sync. The TUI copy carries the vitest
// suite (this `web` package has no test runner), so behavioural changes should
// be validated there.
export interface FuzzyMatch {
/** Total score; higher is better. */
score: number;
/** Indices into the original (non-lowercased) target that were matched. */
positions: number[];
}
const WORD_BOUNDARY = /[-_/.\s]/;
function isBoundary(target: string, index: number): boolean {
if (index === 0) {
return true;
}
const prev = target[index - 1];
if (WORD_BOUNDARY.test(prev)) {
return true;
}
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
const cur = target[index];
return (
prev === prev.toLowerCase() &&
cur !== cur.toLowerCase() &&
cur === cur.toUpperCase()
);
}
/**
* Score a single query token against a target. Returns null when the token is
* not a subsequence of the target. An empty query scores 0 with no positions.
*/
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
if (!query) {
return { score: 0, positions: [] };
}
const lowerTarget = target.toLowerCase();
const lowerQuery = query.toLowerCase();
const positions: number[] = [];
let score = 0;
let prevIndex = -1;
let searchFrom = 0;
for (const ch of lowerQuery) {
const idx = lowerTarget.indexOf(ch, searchFrom);
if (idx < 0) {
return null;
}
positions.push(idx);
// Base point for the matched character.
score += 1;
// Contiguous with the previous match → strong bonus.
if (prevIndex >= 0 && idx === prevIndex + 1) {
score += 5;
} else if (prevIndex >= 0) {
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
score -= Math.min(idx - prevIndex - 1, 3);
}
// Word-boundary / start-of-string matches are meaningful.
if (isBoundary(target, idx)) {
score += 3;
}
// Matching the very first character of the target is the strongest signal.
if (idx === 0) {
score += 5;
}
prevIndex = idx;
searchFrom = idx + 1;
}
// Prefix bonus: the query matched a contiguous prefix of the target.
if (
positions.length &&
positions[0] === 0 &&
positions[positions.length - 1] === positions.length - 1
) {
score += 8;
}
// Exact full match dominates everything else.
if (lowerTarget === lowerQuery) {
score += 20;
}
// Slightly prefer shorter targets when scores are otherwise close, so a
// query that fully prefixes a short id beats the same prefix on a long one.
score -= lowerTarget.length * 0.01;
return { score, positions };
}
/**
* Score a target against a whitespace-separated, multi-token query. Every token
* must match (AND semantics); the result aggregates per-token scores and the
* union of matched positions. Returns null if any token fails to match.
*/
export function fuzzyScoreMulti(
target: string,
query: string,
): FuzzyMatch | null {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!tokens.length) {
return { score: 0, positions: [] };
}
let score = 0;
const positionSet = new Set<number>();
for (const token of tokens) {
const match = fuzzyScore(target, token);
if (!match) {
return null;
}
score += match.score;
for (const pos of match.positions) {
positionSet.add(pos);
}
}
return { score, positions: [...positionSet].sort((a, b) => a - b) };
}
export interface RankedItem<T> {
item: T;
score: number;
positions: number[];
}
/**
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
* items are dropped; matches are sorted by score (descending), ties broken by
* the original index so ordering is stable for equal scores. An empty query
* returns every item in original order with no positions.
*/
export function fuzzyRank<T>(
items: readonly T[],
query: string,
toText: (item: T) => string,
): RankedItem<T>[] {
const trimmed = query.trim();
if (!trimmed) {
return items.map((item) => ({ item, score: 0, positions: [] }));
}
const ranked: Array<RankedItem<T> & { index: number }> = [];
items.forEach((item, index) => {
const match = fuzzyScoreMulti(toText(item), trimmed);
if (match) {
ranked.push({ item, score: match.score, positions: match.positions, index });
}
});
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
return ranked.map(({ item, score, positions }) => ({ item, score, positions }));
}

View File

@@ -1,4 +1,4 @@
import { fuzzyScoreMulti } from "@/lib/fuzzy";
import { fuzzyScoreMulti } from "@hermes/shared";
/**
* True when `trimmedQuery` located the selected provider by name/slug but

View File

@@ -1,31 +0,0 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged — some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with ui-tui/src/lib/model-search-text.ts and
* hermes_cli/model_search.py. Behavioural tests live in the TUI package.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ["kimi-k3", "kimi"],
// OpenCode Zen serves the "Ox Alpha" stealth model under an opaque
// preview slug; let users find it by its public codename.
"x-preview-f-free": ["ox-alpha", "ox"],
};
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim();
if (!id) {
return model;
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()];
if (!aliases?.length) {
return id;
}
return `${id} ${aliases.join(" ")}`;
}

View File

@@ -1,123 +1,137 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from 'vitest'
import {
PTY_RECONNECT_BASE_MS,
PTY_RECONNECT_MAX_ATTEMPTS,
PTY_RECONNECT_MAX_MS,
ptyReconnectDelayMs,
shouldBlockPtyInput,
shouldReconnectPtyOnPageResume,
} from "./pty-reconnect";
shouldReconnectPtyOnPageResume
} from './pty-reconnect'
describe("shouldReconnectPtyOnPageResume", () => {
it("reconnects a missing socket when the active page becomes visible", () => {
describe('shouldReconnectPtyOnPageResume', () => {
it('reconnects a missing socket when the active page becomes visible', () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState: null,
ptyState: "reconnecting",
}),
).toBe(true);
});
ptyState: 'reconnecting'
})
).toBe(true)
})
it("reconnects closed or closing sockets on visible resume", () => {
it('reconnects closed or closing sockets on visible resume', () => {
for (const socketReadyState of [2, 3]) {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState,
ptyState: "reconnecting",
}),
).toBe(true);
ptyState: 'reconnecting'
})
).toBe(true)
}
});
})
it("does not reconnect an open socket on visible resume", () => {
it('does not reconnect an open socket on visible resume', () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState: 1,
ptyState: "open",
}),
).toBe(false);
});
ptyState: 'open'
})
).toBe(false)
})
it("reconnects a still-connecting socket when the page is already in reconnecting state", () => {
it('reconnects a still-connecting socket when the page is already in reconnecting state', () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState: 0,
ptyState: "reconnecting",
}),
).toBe(true);
});
ptyState: 'reconnecting'
})
).toBe(true)
})
it("does not reconnect while the page is hidden", () => {
it('does not reconnect while the page is hidden', () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "hidden",
visibilityState: 'hidden',
online: true,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
ptyState: 'reconnecting'
})
).toBe(false)
})
it("defers reconnect while offline", () => {
it('defers reconnect while offline', () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: false,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
ptyState: 'reconnecting'
})
).toBe(false)
})
it("does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)", () => {
it('does not fire a redundant reconnect while a connect is in flight (wsRef not yet assigned)', () => {
// The async socket-open IIFE has begun but not yet assigned wsRef, so
// socketReadyState reads null. Without the connectInFlight guard this
// would return true and double-connect.
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState: null,
ptyState: "connecting",
connectInFlight: true,
}),
).toBe(false);
});
ptyState: 'connecting',
connectInFlight: true
})
).toBe(false)
})
it("still reconnects an in-flight connect when the page already believes it is closed", () => {
it('still reconnects an in-flight connect when the page already believes it is closed', () => {
// A stuck attempt the user is actively trying to recover (manual reconnect
// or a closed state) must not be suppressed by the in-flight guard.
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
visibilityState: 'visible',
online: true,
socketReadyState: null,
ptyState: "closed",
connectInFlight: true,
}),
).toBe(true);
});
});
ptyState: 'closed',
connectInFlight: true
})
).toBe(true)
})
})
describe("shouldBlockPtyInput", () => {
it("allows input only while the PTY socket is open", () => {
expect(shouldBlockPtyInput("open")).toBe(false);
expect(shouldBlockPtyInput("connecting")).toBe(true);
expect(shouldBlockPtyInput("reconnecting")).toBe(true);
expect(shouldBlockPtyInput("closed")).toBe(true);
expect(shouldBlockPtyInput("ended")).toBe(true);
});
});
describe('shouldBlockPtyInput', () => {
it('allows input only while the PTY socket is open', () => {
expect(shouldBlockPtyInput('open')).toBe(false)
expect(shouldBlockPtyInput('connecting')).toBe(true)
expect(shouldBlockPtyInput('reconnecting')).toBe(true)
expect(shouldBlockPtyInput('closed')).toBe(true)
expect(shouldBlockPtyInput('ended')).toBe(true)
})
})
describe('ptyReconnectDelayMs', () => {
it('doubles from the base on each attempt and clamps at the cap', () => {
expect(Array.from({ length: PTY_RECONNECT_MAX_ATTEMPTS }, (_, i) => ptyReconnectDelayMs(i + 1))).toEqual([
250, 500, 1000, 2000, 3000
])
expect(ptyReconnectDelayMs(1)).toBe(PTY_RECONNECT_BASE_MS)
expect(ptyReconnectDelayMs(99)).toBe(PTY_RECONNECT_MAX_MS)
})
})

View File

@@ -1,29 +1,41 @@
export type PtyConnectionState =
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "ended";
import { reconnectBackoffDelayMs } from '@hermes/shared'
export const PTY_RECONNECT_INPUT_MESSAGE =
"Chat is reconnecting. Input will resume when connected.";
export type PtyConnectionState = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'ended'
export const PTY_RECONNECT_INPUT_MESSAGE = 'Chat is reconnecting. Input will resume when connected.'
// Minimum gap (ms) between page-resume-triggered reconnect attempts, so a
// burst of visibilitychange/pageshow/focus/online events on tab-return
// collapses into a single reconnect.
export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000;
export const PTY_RESUME_RECONNECT_THROTTLE_MS = 1000
// If a socket sits in WS_CONNECTING past this budget it is treated as wedged
// (e.g. a half-open mobile socket after a radio handoff — the NS-591 case)
// and force-closed so `onclose` → scheduleReconnect can recover it.
export const PTY_CONNECTING_TIMEOUT_MS = 8000;
export const PTY_CONNECTING_TIMEOUT_MS = 8000
// The same budget for the phase *before* the socket exists: in gated mode a
// connect first awaits a fresh single-use ticket. That request produces no
// WebSocket, so a rejection or a hang is invisible to both `onclose` and the
// CONNECTING timer above — the tab would sit on "connecting" forever with no
// retry. Bound it so the failure routes into the ordinary backoff instead.
export const PTY_TICKET_TIMEOUT_MS = 8000;
export const PTY_TICKET_TIMEOUT_MS = 8000
// Short ladder, tight cap: the PTY is the user's live terminal, so a transient
// drop must come back fast and a dead backend must stop chasing quickly (the
// banner offers a manual Reconnect). Deterministic so the banner can print it.
export const PTY_RECONNECT_BASE_MS = 250
export const PTY_RECONNECT_MAX_MS = 3000
export const PTY_RECONNECT_MAX_ATTEMPTS = 5
/** Delay before PTY reconnect `attempt` (1-based: ChatPage bumps its counter before scheduling). */
export function ptyReconnectDelayMs(attempt: number): number {
return reconnectBackoffDelayMs(attempt - 1, {
baseDelayMs: PTY_RECONNECT_BASE_MS,
capMs: PTY_RECONNECT_MAX_MS,
jitter: false
})
}
// How long after a resumed socket opens we keep suppressing ANSI erase codes
// (`ESC[K` / `ESC[X`) from the PTY stream. Ink's two-pass virtual scroll emits
@@ -33,21 +45,21 @@ export const PTY_TICKET_TIMEOUT_MS = 8000;
// session takes ~10-20s, so this is deliberately generous — over-running the
// window only costs a few stale cells on a buffer that is about to be
// repainted, while under-running it re-opens the blank-viewport bug.
export const PTY_RESUME_SANITIZE_WINDOW_MS = 30000;
export const PTY_RESUME_SANITIZE_WINDOW_MS = 30000
export interface PtyResumeReconnectInput {
isActive: boolean;
visibilityState?: DocumentVisibilityState;
online: boolean;
socketReadyState?: number | null;
ptyState: PtyConnectionState;
connectInFlight?: boolean;
isActive: boolean
visibilityState?: DocumentVisibilityState
online: boolean
socketReadyState?: number | null
ptyState: PtyConnectionState
connectInFlight?: boolean
}
const WS_CONNECTING = 0;
const WS_OPEN = 1;
const WS_CLOSING = 2;
const WS_CLOSED = 3;
const WS_CONNECTING = 0
const WS_OPEN = 1
const WS_CLOSING = 2
const WS_CLOSED = 3
export function shouldReconnectPtyOnPageResume({
isActive,
@@ -55,39 +67,35 @@ export function shouldReconnectPtyOnPageResume({
online,
socketReadyState,
ptyState,
connectInFlight,
connectInFlight
}: PtyResumeReconnectInput): boolean {
if (!isActive || !online || visibilityState === "hidden") {
return false;
if (!isActive || !online || visibilityState === 'hidden') {
return false
}
if (ptyState === "ended") {
return false;
if (ptyState === 'ended') {
return false
}
if (socketReadyState === WS_OPEN) {
return false;
return false
}
// A connect is mid-flight (the async socket-open IIFE is awaiting its
// ticket URL and hasn't assigned wsRef yet, or the socket is still
// CONNECTING on a non-stuck attempt). Don't fire a redundant reconnect
// into that window unless the tab already believes it is reconnecting or
// closed and needs a fresh attempt.
if (
(connectInFlight || socketReadyState === WS_CONNECTING) &&
ptyState !== "reconnecting" &&
ptyState !== "closed"
) {
return false;
if ((connectInFlight || socketReadyState === WS_CONNECTING) && ptyState !== 'reconnecting' && ptyState !== 'closed') {
return false
}
return (
socketReadyState === null ||
socketReadyState === undefined ||
socketReadyState === WS_CLOSING ||
socketReadyState === WS_CLOSED ||
ptyState === "reconnecting" ||
ptyState === "closed"
);
ptyState === 'reconnecting' ||
ptyState === 'closed'
)
}
export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean {
return ptyState !== "open";
return ptyState !== 'open'
}

View File

@@ -1,12 +1,18 @@
import {
DEFAULT_REASONING_EFFORT,
REASONING_EFFORT_VALUES,
type ReasoningEffortValue,
} from "@hermes/shared";
/**
* Pure reasoning-effort helpers shared by the dashboard ReasoningPicker.
*
* Kept DOM-free so the node-environment vitest harness can cover the
* resolution logic without loading React or the UI kit.
*
* Values mirror hermes_constants.VALID_REASONING_EFFORTS plus `none`
* (thinking-off). An empty/unset config value means the Hermes default,
* which is `medium`.
* Values come from @hermes/shared (hermes_constants.VALID_REASONING_EFFORTS
* plus `none`, thinking-off). An empty/unset config value means the Hermes
* default.
*/
export interface EffortOption {
@@ -14,25 +20,28 @@ export interface EffortOption {
label: string;
}
export const EFFORT_OPTIONS: ReadonlyArray<EffortOption> = [
{ value: "none", label: "Off (no thinking)" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra High" },
{ value: "max", label: "Max" },
{ value: "ultra", label: "Ultra" },
];
const EFFORT_LABELS: Record<ReasoningEffortValue, string> = {
none: "Off (no thinking)",
minimal: "Minimal",
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra High",
max: "Max",
ultra: "Ultra",
};
export const VALID_EFFORTS: ReadonlySet<string> = new Set(
EFFORT_OPTIONS.map((o) => o.value),
/** `none` first, then the seven levels ascending — the shared value order. */
export const EFFORT_OPTIONS: ReadonlyArray<EffortOption> = REASONING_EFFORT_VALUES.map(
(value) => ({ value, label: EFFORT_LABELS[value] }),
);
export const VALID_EFFORTS: ReadonlySet<string> = new Set(REASONING_EFFORT_VALUES);
/** Normalize a raw `agent.reasoning_effort` config value to a selectable
* option. Empty/unknown → `medium` (Hermes' default when unset). */
export function normalizeEffort(raw: unknown): string {
const value = String(raw ?? "").trim().toLowerCase();
if (!value) return "medium";
return VALID_EFFORTS.has(value) ? value : "medium";
if (!value) return DEFAULT_REASONING_EFFORT;
return VALID_EFFORTS.has(value) ? value : DEFAULT_REASONING_EFFORT;
}

View File

@@ -1,163 +0,0 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` — covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, …). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}

View File

@@ -44,10 +44,12 @@ import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer";
import {
PTY_CONNECTING_TIMEOUT_MS,
PTY_RECONNECT_INPUT_MESSAGE,
PTY_RECONNECT_MAX_ATTEMPTS,
PTY_RESUME_RECONNECT_THROTTLE_MS,
PTY_RESUME_SANITIZE_WINDOW_MS,
PTY_TICKET_TIMEOUT_MS,
type PtyConnectionState,
ptyReconnectDelayMs,
shouldBlockPtyInput,
shouldReconnectPtyOnPageResume,
} from "@/lib/pty-reconnect";
@@ -1151,9 +1153,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
if (reconnectTimerRef.current) {
return;
}
const attempt = Math.min(reconnectAttemptRef.current + 1, 5);
const attempt = Math.min(reconnectAttemptRef.current + 1, PTY_RECONNECT_MAX_ATTEMPTS);
reconnectAttemptRef.current = attempt;
const delayMs = Math.min(250 * 2 ** (attempt - 1), 3000);
const delayMs = ptyReconnectDelayMs(attempt);
setBanner(null);
setLastCloseCode(code);
setPtyState("reconnecting");

View File

@@ -13,7 +13,7 @@ import { api } from "@/lib/api";
import type {
CronJob,
CronDeliveryTarget,
ModelOptionsResponse,
ModelOptionsResult,
ProfileInfo,
SkillInfo,
ToolsetInfo,
@@ -129,7 +129,7 @@ interface CronJobEditorState extends CronJobFormState {
interface CronJobFormResources {
availableSkills: SkillInfo[];
availableToolsets: ToolsetInfo[];
modelOptions: ModelOptionsResponse | null;
modelOptions: ModelOptionsResult | null;
deliveryTargets: CronDeliveryTarget[];
}
@@ -197,7 +197,7 @@ function CronAdvancedFields({
idPrefix: string;
form: CronJobEditorState;
onChange: (form: CronJobEditorState) => void;
modelOptions: ModelOptionsResponse | null;
modelOptions: ModelOptionsResult | null;
availableToolsets: ToolsetInfo[];
}) {
const update = <K extends keyof CronJobEditorState,>(
@@ -604,7 +604,7 @@ export default function CronPage() {
// a job's current skills are always shown even if not in it.
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
const [availableToolsets, setAvailableToolsets] = useState<ToolsetInfo[]>([]);
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [modelOptions, setModelOptions] = useState<ModelOptionsResult | null>(null);
const resourceProfile = editJob ? getJobProfile(editJob) : createProfile;

View File

@@ -28,7 +28,7 @@ import {
DASHBOARD_MODAL_PANEL,
shouldCloseOuterModalOnEscape,
} from "@/lib/dashboard-modal-shell";
import { formatTokenCount } from "@/lib/format";
import { compactNumber } from "@hermes/shared";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
@@ -430,12 +430,12 @@ function ModelCard({
)}
{caps.context_window && caps.context_window > 0 && (
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.context_window)} ctx
{compactNumber(caps.context_window)} ctx
</span>
)}
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
<span className="text-xs text-text-secondary">
{formatTokenCount(caps.max_output_tokens)} out
{compactNumber(caps.max_output_tokens)} out
</span>
)}
</div>

View File

@@ -0,0 +1,29 @@
import { contrastRatio, THEME_PRESET_PALETTES, type ThemePresetPalette } from "@hermes/shared";
import { describe, expect, it } from "vitest";
import { BUILTIN_THEMES, webPresetFromShared } from "./presets";
// Every preset the dashboard shares with the desktop must render the shared
// table's palette, not a private copy — that is the whole point of the table.
// The second assertion keeps the projection honest: whatever slot the mapping
// picks as the dashboard's text/primary colour has to stay legible on the
// canvas it picks, so a future re-mapping cannot silently ship grey-on-grey.
describe("dashboard presets derive from the shared palette table", () => {
const shared = Object.keys(BUILTIN_THEMES).filter(
(name): name is keyof typeof THEME_PRESET_PALETTES => name in THEME_PRESET_PALETTES,
);
it("covers the presets both surfaces ship", () => {
expect(shared).toEqual(expect.arrayContaining(["cyberpunk", "ember", "midnight", "mono"]));
});
it.each(shared)("%s: canvas equals the shared background and the accent reads on it", (name) => {
const preset: ThemePresetPalette = THEME_PRESET_PALETTES[name];
const derived = webPresetFromShared(preset);
const palette = BUILTIN_THEMES[name].palette;
expect(palette.background.hex).toBe((preset.darkColors ?? preset.colors).background);
expect(palette.midground.hex).toBe(derived.midground.hex);
expect(contrastRatio(palette.midground.hex, palette.background.hex)).toBeGreaterThanOrEqual(3);
});
});

View File

@@ -1,4 +1,5 @@
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
import { parseColor, THEME_PRESET_PALETTES, type ThemePresetPalette } from "@hermes/shared";
import type { DashboardTheme, ThemePalette, ThemeTypography, ThemeLayout } from "./types";
/**
* Built-in dashboard themes.
@@ -9,6 +10,10 @@ import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
*
* Theme names must stay in sync with the backend's
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
*
* Presets that also ship on the desktop (midnight, ember, mono, cyberpunk)
* take their colours from `@hermes/shared` `THEME_PRESET_PALETTES` so both
* surfaces render one palette; only typography/layout/overrides live here.
*/
// ---------------------------------------------------------------------------
@@ -34,6 +39,28 @@ const DEFAULT_LAYOUT: ThemeLayout = {
density: "comfortable",
};
/**
* Project a shared (desktop-shaped) preset palette onto the dashboard's
* 3-slot model. The dashboard's `midground` is its text + primary-fill
* colour, which is the desktop's `primary`; its `warmGlow` is the brand
* accent stroke, which is the desktop's `midground` (falling back to `ring`).
* `foreground` stays the dashboard's invisible white overlay. Dark palettes
* are the dashboard's home turf, so a preset shipping `darkColors` is read
* from that side.
*/
export function webPresetFromShared(
preset: ThemePresetPalette,
): Omit<ThemePalette, "noiseOpacity"> {
const colors = preset.darkColors ?? preset.colors;
const [r, g, b] = parseColor(colors.midground ?? colors.ring) ?? [255, 255, 255];
return {
background: { hex: colors.background, alpha: 1 },
midground: { hex: colors.primary, alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: `rgba(${r}, ${g}, ${b}, 0.3)`,
};
}
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
@@ -59,10 +86,7 @@ export const midnightTheme: DashboardTheme = {
label: "Midnight",
description: "Deep blue-violet with cool accents",
palette: {
background: { hex: "#0a0a1f", alpha: 1 },
midground: { hex: "#d4c8ff", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(167, 139, 250, 0.32)",
...webPresetFromShared(THEME_PRESET_PALETTES.midnight),
noiseOpacity: 0.8,
},
typography: {
@@ -84,10 +108,7 @@ export const emberTheme: DashboardTheme = {
label: "Ember",
description: "Warm crimson and bronze — forge vibes",
palette: {
background: { hex: "#1a0a06", alpha: 1 },
midground: { hex: "#ffd8b0", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(249, 115, 22, 0.38)",
...webPresetFromShared(THEME_PRESET_PALETTES.ember),
noiseOpacity: 1,
},
typography: {
@@ -112,10 +133,7 @@ export const monoTheme: DashboardTheme = {
label: "Mono",
description: "Clean grayscale — minimal and focused",
palette: {
background: { hex: "#0e0e0e", alpha: 1 },
midground: { hex: "#eaeaea", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(255, 255, 255, 0.1)",
...webPresetFromShared(THEME_PRESET_PALETTES.mono),
noiseOpacity: 0.6,
},
typography: {
@@ -136,10 +154,7 @@ export const cyberpunkTheme: DashboardTheme = {
label: "Cyberpunk",
description: "Neon green on black — matrix terminal",
palette: {
background: { hex: "#040608", alpha: 1 },
midground: { hex: "#9bffcf", alpha: 1 },
foreground: { hex: "#ffffff", alpha: 0 },
warmGlow: "rgba(0, 255, 136, 0.22)",
...webPresetFromShared(THEME_PRESET_PALETTES.cyberpunk),
noiseOpacity: 1.2,
},
typography: {

View File

@@ -19,7 +19,9 @@
/* Path aliases */
"paths": {
"@/*": ["./src/*"],
"@hermes/shared": ["../apps/shared/src/index.ts"]
"@hermes/shared": ["../apps/shared/src/index.ts"],
"@hermes/shared/ansi": ["../apps/shared/src/ansi.ts"],
"@hermes/shared/i18n": ["../apps/shared/src/i18n.ts"]
},
/* Linting */

View File

@@ -20,5 +20,11 @@ export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
// The first test in a file pays env init + full module transform, and page
// suites (SessionsPage) legitimately run 3.5-4.5s on green CI runners —
// right against vitest's 5s default, so a loaded runner tips them into a
// timeout (main run 34600757569: 5079ms). Same headroom rationale as
// apps/desktop/vitest.config.ts; genuinely hung tests still fail.
testTimeout: 15_000,
},
});