Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { api, fetchJSON, setManagementProfile } from "./api";
|
||||
|
||||
const reloadMocks = vi.hoisted(() => ({
|
||||
attemptDashboardTokenReloadOnce: vi.fn(() => false),
|
||||
clearDashboardTokenReloadAttempt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./dashboard-auth-reload", () => ({
|
||||
attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce,
|
||||
clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt,
|
||||
}));
|
||||
|
||||
const SESSION_HEADER = "X-Hermes-Session-Token";
|
||||
|
||||
beforeEach(() => {
|
||||
reloadMocks.attemptDashboardTokenReloadOnce.mockReset();
|
||||
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false);
|
||||
reloadMocks.clearDashboardTokenReloadAttempt.mockReset();
|
||||
|
||||
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
|
||||
configurable: true,
|
||||
value: "stale-token",
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setManagementProfile("");
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonFetchMock(body: unknown = { ok: true }) {
|
||||
return vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("fetchJSON", () => {
|
||||
it("tries the one-shot reload path for loopback 401s", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
clone: () => ({
|
||||
json: async () => ({}),
|
||||
}),
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
text: async () => "Unauthorized",
|
||||
})),
|
||||
);
|
||||
reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true);
|
||||
|
||||
const pending = fetchJSON("/api/status");
|
||||
await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe(
|
||||
"pending",
|
||||
);
|
||||
|
||||
expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1);
|
||||
expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the reload latch after a successful response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
json: async () => ({ ok: true }),
|
||||
ok: true,
|
||||
status: 200,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api.getModelOptions", () => {
|
||||
it("requests a live model refresh when asked", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
|
||||
const fetchMock = jsonFetchMock({ providers: [] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.getModelOptions({ refresh: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/model/options?refresh=1&include_unconfigured=1",
|
||||
expect.objectContaining({ credentials: "include" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit profile scoping when refreshing", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
|
||||
const fetchMock = jsonFetchMock({ providers: [] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.getModelOptions({ profile: "default", refresh: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/model/options?profile=default&refresh=1&include_unconfigured=1",
|
||||
expect.objectContaining({ credentials: "include" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api OAuth helpers", () => {
|
||||
it("starts OAuth login in gated mode without requiring an injected session token", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
|
||||
const fetchMock = jsonFetchMock({
|
||||
flow: "device_code",
|
||||
session_id: "oauth-session",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.startOAuthLogin("openai-codex");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/providers/oauth/openai-codex/start",
|
||||
expect.objectContaining({
|
||||
body: "{}",
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
expect(headers.has(SESSION_HEADER)).toBe(false);
|
||||
});
|
||||
|
||||
it("still sends the injected session token for OAuth login in loopback mode", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "loopback-token" });
|
||||
const fetchMock = jsonFetchMock({
|
||||
flow: "device_code",
|
||||
session_id: "oauth-session",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.startOAuthLogin("openai-codex");
|
||||
|
||||
const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
|
||||
expect(headers.get(SESSION_HEADER)).toBe("loopback-token");
|
||||
});
|
||||
|
||||
it("runs provider auth mutations in gated mode via cookie auth", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
|
||||
const fetchMock = jsonFetchMock({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.disconnectOAuthProvider("anthropic");
|
||||
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
|
||||
await api.cancelOAuthSession("oauth-session");
|
||||
await api.revealEnvVar("OPENAI_API_KEY");
|
||||
|
||||
for (const call of fetchMock.mock.calls) {
|
||||
const init = call[1] as RequestInit;
|
||||
expect(init.credentials).toBe("include");
|
||||
expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every OAuth operation on the selected management profile", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetchMock = jsonFetchMock({
|
||||
flow: "device_code",
|
||||
session_id: "oauth-session",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("worker");
|
||||
|
||||
await api.getOAuthProviders();
|
||||
await api.disconnectOAuthProvider("anthropic");
|
||||
await api.startOAuthLogin("openai-codex");
|
||||
await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
|
||||
await api.pollOAuthSession("anthropic", "oauth-session");
|
||||
await api.cancelOAuthSession("oauth-session");
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
"/api/providers/oauth?profile=worker",
|
||||
"/api/providers/oauth/anthropic?profile=worker",
|
||||
"/api/providers/oauth/openai-codex/start?profile=worker",
|
||||
"/api/providers/oauth/anthropic/submit?profile=worker",
|
||||
"/api/providers/oauth/anthropic/poll/oauth-session?profile=worker",
|
||||
"/api/providers/oauth/sessions/oauth-session?profile=worker",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+2670
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { latchChatActivation } from "./chat-activation";
|
||||
|
||||
describe("latchChatActivation", () => {
|
||||
it("stays inactive while the chat tab has never been active", () => {
|
||||
// A dashboard sitting on /sessions, /system, … must not flip the latch,
|
||||
// so the persistently-mounted ChatPage never opens /api/pty (which would
|
||||
// trigger the TUI/agent bootstrap on every page).
|
||||
expect(latchChatActivation(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("activates when the chat tab becomes active", () => {
|
||||
expect(latchChatActivation(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("stays activated after the chat tab is left (sticky / persistence)", () => {
|
||||
// Once the user has opened /chat, the PTY must survive navigating away so
|
||||
// a running agent turn is not torn down on every tab switch.
|
||||
expect(latchChatActivation(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("stays activated while the chat tab remains active", () => {
|
||||
expect(latchChatActivation(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Chat PTY activation latch.
|
||||
*
|
||||
* The dashboard keeps `ChatPage` mounted persistently (just hidden with CSS)
|
||||
* on every route so the embedded chat PTY survives tab switches. The downside
|
||||
* is that the PTY-connect effect would otherwise open `/api/pty` — which spawns
|
||||
* the whole TUI + agent bootstrap (on a fresh checkout this prints
|
||||
* `Installing TUI dependencies…` and runs `npm install`) — the moment the
|
||||
* dashboard loads *any* page, even one the user never navigates the chat into.
|
||||
*
|
||||
* The fix is to only open the PTY once the chat tab has actually been active,
|
||||
* while keeping activation **sticky** so the PTY still persists across later
|
||||
* tab switches. This helper computes that latch: once `true`, it stays `true`.
|
||||
*/
|
||||
export function latchChatActivation(previous: boolean, isActive: boolean): boolean {
|
||||
return previous || isActive;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Tests for the sidecar ``session.create`` params built by ChatSidebar.
|
||||
*
|
||||
* The sidecar must opt its session into close_on_disconnect so the gateway
|
||||
* reaps the slash_worker on WS disconnect (the #21370/#21467 leak), and it
|
||||
* must pass the dashboard's selected profile so model/credential info
|
||||
* matches the PTY child under profile-scoped chat.
|
||||
*
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sidecarSessionCreateParams } from "@/components/ChatSidebar";
|
||||
|
||||
describe("sidecarSessionCreateParams", () => {
|
||||
it("opts into close_on_disconnect", () => {
|
||||
const params = sidecarSessionCreateParams();
|
||||
expect(params.close_on_disconnect).toBe(true);
|
||||
});
|
||||
|
||||
it("sets source to 'tool'", () => {
|
||||
const params = sidecarSessionCreateParams();
|
||||
expect(params.source).toBe("tool");
|
||||
});
|
||||
|
||||
it("forwards the profile when present", () => {
|
||||
const params = sidecarSessionCreateParams("work");
|
||||
expect(params.profile).toBe("work");
|
||||
});
|
||||
|
||||
it("omits profile when undefined", () => {
|
||||
const params = sidecarSessionCreateParams();
|
||||
expect(params).not.toHaveProperty("profile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title";
|
||||
|
||||
describe("normalizeSessionTitle", () => {
|
||||
it("trims non-empty session titles", () => {
|
||||
expect(normalizeSessionTitle(" Rename the dashboard ")).toBe(
|
||||
"Rename the dashboard",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats blank and non-string values as no title", () => {
|
||||
expect(normalizeSessionTitle(" ")).toBeNull();
|
||||
expect(normalizeSessionTitle(null)).toBeNull();
|
||||
expect(normalizeSessionTitle(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("titleFromSessionInfoPayload", () => {
|
||||
it("returns undefined when the payload has no title field", () => {
|
||||
expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined();
|
||||
expect(titleFromSessionInfoPayload(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when the title field is present but empty", () => {
|
||||
expect(titleFromSessionInfoPayload({ title: "" })).toBeNull();
|
||||
expect(titleFromSessionInfoPayload({ title: " " })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the normalized title when present", () => {
|
||||
expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe(
|
||||
"Live session title",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export function normalizeSessionTitle(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const title = raw.trim();
|
||||
return title ? title : null;
|
||||
}
|
||||
|
||||
export function titleFromSessionInfoPayload(
|
||||
payload: unknown,
|
||||
): string | null | undefined {
|
||||
if (!payload || typeof payload !== "object" || !("title" in payload)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizeSessionTitle((payload as { title?: unknown }).title);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
firstImageFromClipboard,
|
||||
imageFilesFromTransfer,
|
||||
transferMayContainImage,
|
||||
} from "./chatImagePaste";
|
||||
|
||||
// Minimal DataTransfer stand-ins. jsdom's DataTransfer doesn't let us seed
|
||||
// items/files, so we hand-roll the shape the helpers read.
|
||||
function makeItem(kind: string, type: string, file: File | null) {
|
||||
return { kind, type, getAsFile: () => file } as unknown as DataTransferItem;
|
||||
}
|
||||
|
||||
function makeData(opts: {
|
||||
items?: DataTransferItem[];
|
||||
files?: File[];
|
||||
}): DataTransfer {
|
||||
const items = opts.items ?? [];
|
||||
const files = opts.files ?? [];
|
||||
const itemList: Record<string | number, unknown> = { length: items.length };
|
||||
items.forEach((it, i) => {
|
||||
itemList[i] = it;
|
||||
});
|
||||
const fileList: Record<string | number, unknown> = { length: files.length };
|
||||
files.forEach((f, i) => {
|
||||
fileList[i] = f;
|
||||
});
|
||||
return {
|
||||
items: itemList,
|
||||
files: fileList,
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
const png = new File([new Uint8Array([1, 2, 3])], "x.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const gif = new File([new Uint8Array([4, 5])], "y.gif", {
|
||||
type: "image/gif",
|
||||
});
|
||||
|
||||
describe("firstImageFromClipboard", () => {
|
||||
it("returns null for null clipboard data", () => {
|
||||
expect(firstImageFromClipboard(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds an image via items[].getAsFile()", () => {
|
||||
const data = makeData({ items: [makeItem("file", "image/png", png)] });
|
||||
expect(firstImageFromClipboard(data)).toBe(png);
|
||||
});
|
||||
|
||||
it("ignores non-file and non-image items", () => {
|
||||
const data = makeData({
|
||||
items: [
|
||||
makeItem("string", "text/plain", null),
|
||||
makeItem("file", "application/pdf", new File([], "a.pdf")),
|
||||
],
|
||||
});
|
||||
expect(firstImageFromClipboard(data)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to files[] when items are absent (Safari/Firefox)", () => {
|
||||
const data = makeData({ files: [png] });
|
||||
expect(firstImageFromClipboard(data)).toBe(png);
|
||||
});
|
||||
|
||||
it("returns null when nothing image-like is present", () => {
|
||||
const data = makeData({
|
||||
files: [new File([], "notes.txt", { type: "text/plain" })],
|
||||
});
|
||||
expect(firstImageFromClipboard(data)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageFilesFromTransfer", () => {
|
||||
it("dedupes the same file when present in both items and files", () => {
|
||||
const data = makeData({
|
||||
items: [makeItem("file", "image/png", png)],
|
||||
files: [png, gif],
|
||||
});
|
||||
expect(imageFilesFromTransfer(data)).toEqual([png, gif]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferMayContainImage", () => {
|
||||
it("is true for image items even when type is empty (some browsers)", () => {
|
||||
const data = makeData({
|
||||
items: [makeItem("file", "", png)],
|
||||
});
|
||||
expect(transferMayContainImage(data)).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for text-only transfers", () => {
|
||||
const data = makeData({
|
||||
items: [makeItem("string", "text/plain", null)],
|
||||
});
|
||||
expect(transferMayContainImage(data)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { authedFetch } from "@/lib/api";
|
||||
|
||||
// Clipboard image MIME → file extension. Mirrors the set the TUI's /image
|
||||
// attach path and the gateway's image sniffer accept.
|
||||
const IMAGE_MIME_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
};
|
||||
|
||||
// Anthropic caps a single vision image at ~25 MB; reject earlier client-side
|
||||
// with a clear message rather than round-tripping a doomed upload.
|
||||
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export interface ChatImageUploadResult {
|
||||
/** Absolute path under HERMES_HOME/images the gateway wrote. */
|
||||
path: string;
|
||||
/** Byte size of the uploaded image. */
|
||||
bytes: number;
|
||||
/** Basename written on the server. */
|
||||
name: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
function imageFileKey(file: File): string {
|
||||
return `${file.name}\0${file.type}\0${file.size}\0${file.lastModified}`;
|
||||
}
|
||||
|
||||
function addImageFile(files: File[], seen: Set<string>, file: File | null) {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const key = imageFileKey(file);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
/** Pull every image file out of a DataTransfer (clipboard or drop). */
|
||||
export function imageFilesFromTransfer(
|
||||
data: DataTransfer | null,
|
||||
): File[] {
|
||||
if (!data) return [];
|
||||
const files: File[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (data.items?.length) {
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
addImageFile(files, seen, item.getAsFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.files?.length) {
|
||||
for (let i = 0; i < data.files.length; i++) {
|
||||
addImageFile(files, seen, data.files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Pull the first image blob out of a DataTransfer, or null if none present. */
|
||||
export function firstImageFromClipboard(
|
||||
data: DataTransfer | null,
|
||||
): File | null {
|
||||
return imageFilesFromTransfer(data)[0] ?? null;
|
||||
}
|
||||
|
||||
/** True when a drag payload may contain an image (for dragover preventDefault). */
|
||||
export function transferMayContainImage(data: DataTransfer | null): boolean {
|
||||
if (!data) return false;
|
||||
if (data.items?.length) {
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i];
|
||||
if (
|
||||
item.kind === "file" &&
|
||||
(!item.type || item.type.startsWith("image/"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.files?.length) {
|
||||
for (let i = 0; i < data.files.length; i++) {
|
||||
if (data.files[i].type.startsWith("image/")) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () =>
|
||||
reject(reader.error ?? new Error("image read failed"));
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (typeof result === "string") {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error("image read failed"));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a browser clipboard/drop image to ``HERMES_HOME/images`` via the
|
||||
* dedicated chat upload endpoint and return the absolute gateway path.
|
||||
*
|
||||
* The dashboard Chat tab is an xterm mirror of a TUI running INSIDE the
|
||||
* gateway. The container has no access to the browser's clipboard, so the
|
||||
* server-side ``clipboard.paste`` path can never see a pasted image.
|
||||
* Upload the bytes the browser already holds, then hand the path to the
|
||||
* TUI's ``/image`` command.
|
||||
*/
|
||||
export async function uploadChatImage(
|
||||
blob: Blob,
|
||||
profile = "",
|
||||
): Promise<ChatImageUploadResult> {
|
||||
if (blob.size === 0) throw new Error("clipboard image is empty");
|
||||
if (blob.size > MAX_IMAGE_BYTES) {
|
||||
const mb = Math.round(MAX_IMAGE_BYTES / (1024 * 1024));
|
||||
throw new Error(`image too large (max ${mb} MB)`);
|
||||
}
|
||||
|
||||
const mime = blob.type || "image/png";
|
||||
const ext = IMAGE_MIME_EXT[mime] || "png";
|
||||
const filename =
|
||||
blob instanceof File && blob.name
|
||||
? blob.name
|
||||
: `clipboard.${ext}`;
|
||||
const file =
|
||||
blob instanceof File
|
||||
? blob
|
||||
: new File([blob], filename, { type: mime });
|
||||
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
const qs = profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
||||
const res = await authedFetch(`/api/chat/image-upload${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
data_url: dataUrl,
|
||||
filename,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const uploaded = (await res.json()) as ChatImageUploadResult;
|
||||
if (!uploaded?.path) {
|
||||
throw new Error("image upload did not return a path");
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Regression guard: every dashboard copy action must go through
|
||||
// copyTextToClipboard (web/src/lib/clipboard.ts), which falls back to a
|
||||
// selection-based copy when the async Clipboard API is unavailable —
|
||||
// e.g. self-hosted dashboards served over plain HTTP on a LAN, where
|
||||
// window.isSecureContext is false and navigator.clipboard is undefined.
|
||||
//
|
||||
// Clipboard READS (paste paths) have no equivalent legacy fallback and are
|
||||
// exempt; they must feature-detect and fail soft instead.
|
||||
//
|
||||
// Ported from paperclipai/paperclip#10875.
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..");
|
||||
|
||||
// The helper itself is the single allowed writer.
|
||||
const ALLOWLIST = new Set(["lib/clipboard.ts"]);
|
||||
|
||||
function collectSourceFiles(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
collectSourceFiles(full, out);
|
||||
} else if (/\.(ts|tsx)$/.test(entry) && !/\.(test|spec)\.(ts|tsx)$/.test(entry)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("clipboard write regression guard", () => {
|
||||
it("web/src has no direct navigator.clipboard write calls outside lib/clipboard.ts", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of collectSourceFiles(SRC_ROOT)) {
|
||||
const rel = relative(SRC_ROOT, file).replace(/\\/g, "/");
|
||||
if (ALLOWLIST.has(rel)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
// Match writeText / write on navigator.clipboard (incl. optional
|
||||
// chaining); reads (read / readText) are intentionally not matched.
|
||||
const pattern = /navigator\.clipboard\??\s*\.\s*write(Text)?\s*[(.]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const line = source.slice(0, match.index).split("\n").length;
|
||||
offenders.push(`${rel}:${line}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
`Direct navigator.clipboard writes found — use copyTextToClipboard from "@/lib/clipboard" instead:\n${offenders.join("\n")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { copyTextToClipboard } from "./clipboard";
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
function setGlobal<K extends keyof typeof globalThis>(
|
||||
key: K,
|
||||
value: (typeof globalThis)[K] | undefined,
|
||||
) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setGlobal("navigator", originalNavigator);
|
||||
setGlobal("document", originalDocument);
|
||||
setGlobal("window", originalWindow);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("copyTextToClipboard", () => {
|
||||
it("uses navigator.clipboard when available", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
setGlobal(
|
||||
"navigator",
|
||||
{ clipboard: { writeText } } as unknown as Navigator,
|
||||
);
|
||||
setGlobal("document", undefined);
|
||||
|
||||
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
|
||||
});
|
||||
|
||||
it("falls back to selection copy when Clipboard API fails", async () => {
|
||||
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
|
||||
const appendChild = vi.fn();
|
||||
const removeChild = vi.fn();
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
const textarea = {
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
setSelectionRange: vi.fn(),
|
||||
style: {},
|
||||
value: "",
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
|
||||
setGlobal(
|
||||
"navigator",
|
||||
{ clipboard: { writeText } } as unknown as Navigator,
|
||||
);
|
||||
setGlobal("document", {
|
||||
body: { appendChild, removeChild },
|
||||
createElement: vi.fn(() => textarea),
|
||||
execCommand,
|
||||
getSelection: vi.fn(() => null),
|
||||
} as unknown as Document);
|
||||
|
||||
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("CODEX-1234");
|
||||
expect(textarea.value).toBe("CODEX-1234");
|
||||
expect(appendChild).toHaveBeenCalledWith(textarea);
|
||||
expect(textarea.select).toHaveBeenCalled();
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(removeChild).toHaveBeenCalledWith(textarea);
|
||||
});
|
||||
|
||||
it("uses selection copy directly in insecure browser contexts", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
const appendChild = vi.fn();
|
||||
const removeChild = vi.fn();
|
||||
const execCommand = vi.fn().mockReturnValue(true);
|
||||
const textarea = {
|
||||
focus: vi.fn(),
|
||||
select: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
setSelectionRange: vi.fn(),
|
||||
style: {},
|
||||
value: "",
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
|
||||
setGlobal(
|
||||
"navigator",
|
||||
{ clipboard: { writeText } } as unknown as Navigator,
|
||||
);
|
||||
setGlobal(
|
||||
"window",
|
||||
{ isSecureContext: false } as unknown as Window & typeof globalThis,
|
||||
);
|
||||
setGlobal("document", {
|
||||
body: { appendChild, removeChild },
|
||||
createElement: vi.fn(() => textarea),
|
||||
execCommand,
|
||||
getSelection: vi.fn(() => null),
|
||||
} as unknown as Document);
|
||||
|
||||
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
|
||||
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||
});
|
||||
|
||||
it("returns false when no copy mechanism is available", async () => {
|
||||
setGlobal("navigator", {} as Navigator);
|
||||
setGlobal("document", undefined);
|
||||
|
||||
await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
const clipboard =
|
||||
typeof navigator === "undefined" ? undefined : navigator.clipboard;
|
||||
const secureContext =
|
||||
typeof window === "undefined" ? true : window.isSecureContext;
|
||||
if (secureContext && clipboard?.writeText) {
|
||||
try {
|
||||
await clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall through to the selection-based copy path below.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-1000px";
|
||||
textarea.style.left = "-1000px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
const selection = document.getSelection();
|
||||
const ranges: Range[] = [];
|
||||
if (selection) {
|
||||
for (let i = 0; i < selection.rangeCount; i += 1) {
|
||||
ranges.push(selection.getRangeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
for (const range of ranges) {
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCronJobPayload,
|
||||
cronJobHasExecutionContent,
|
||||
cronJobFormFromJob,
|
||||
cronLastResult,
|
||||
splitCronList,
|
||||
type CronJobFormState,
|
||||
} from "./cron-job";
|
||||
import type { CronJob } from "./api";
|
||||
|
||||
function form(overrides: Partial<CronJobFormState> = {}): CronJobFormState {
|
||||
return {
|
||||
name: "",
|
||||
prompt: "prompt",
|
||||
schedule: "every 1h",
|
||||
deliver: "local",
|
||||
skills: [],
|
||||
provider: "",
|
||||
model: "",
|
||||
base_url: "",
|
||||
script: "",
|
||||
no_agent: false,
|
||||
context_from: "",
|
||||
continuity: false,
|
||||
enabled_toolsets: [],
|
||||
workdir: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("splitCronList", () => {
|
||||
it("normalizes comma and newline separated cron list fields", () => {
|
||||
expect(splitCronList(" web, terminal\nfile ,, ")).toEqual([
|
||||
"web",
|
||||
"terminal",
|
||||
"file",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCronJobPayload", () => {
|
||||
it("normalizes list fields and base URLs", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({
|
||||
base_url: "https://example.invalid/v1/",
|
||||
enabled_toolsets: ["web", ""],
|
||||
context_from: "upstream-a\nupstream-b",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
base_url: "https://example.invalid/v1",
|
||||
context_from: ["upstream-a", "upstream-b"],
|
||||
enabled_toolsets: ["web"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stores continuity as the reserved self entry", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({ continuity: true, context_from: "upstream-a" }),
|
||||
);
|
||||
|
||||
expect(payload.context_from).toEqual(["upstream-a", "self"]);
|
||||
});
|
||||
|
||||
it("continuity off strips any hand-typed self entry", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({ continuity: false, context_from: "SELF\nupstream-a" }),
|
||||
);
|
||||
|
||||
expect(payload.context_from).toEqual(["upstream-a"]);
|
||||
});
|
||||
|
||||
it("keeps clear operations explicit for update payloads", () => {
|
||||
const payload = buildCronJobPayload(form({ schedule: "every 2h" }));
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
schedule: "every 2h",
|
||||
provider: null,
|
||||
model: null,
|
||||
base_url: null,
|
||||
script: null,
|
||||
no_agent: false,
|
||||
context_from: null,
|
||||
enabled_toolsets: null,
|
||||
workdir: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronJobHasExecutionContent", () => {
|
||||
it("treats a script as execution content for agent-backed cron jobs", () => {
|
||||
const payload = buildCronJobPayload(
|
||||
form({ prompt: "", skills: [], script: "collect-status.py" }),
|
||||
);
|
||||
|
||||
expect(cronJobHasExecutionContent(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects payloads with no prompt, skills, or script", () => {
|
||||
const payload = buildCronJobPayload(form({ prompt: "", skills: [], script: "" }));
|
||||
|
||||
expect(cronJobHasExecutionContent(payload)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronJobFormFromJob", () => {
|
||||
it("preserves schedule fallback and editable list fields", () => {
|
||||
const job: CronJob = {
|
||||
id: "abc",
|
||||
enabled: true,
|
||||
schedule_display: "every 1h",
|
||||
context_from: ["upstream-a", "upstream-b"],
|
||||
enabled_toolsets: ["web"],
|
||||
};
|
||||
|
||||
expect(cronJobFormFromJob(job)).toMatchObject({
|
||||
schedule: "every 1h",
|
||||
context_from: "upstream-a\nupstream-b",
|
||||
continuity: false,
|
||||
enabled_toolsets: ["web"],
|
||||
});
|
||||
});
|
||||
|
||||
it("splits the stored self entry into the continuity toggle", () => {
|
||||
const job: CronJob = {
|
||||
id: "abc",
|
||||
enabled: true,
|
||||
schedule_display: "every 1h",
|
||||
context_from: ["self", "upstream-a"],
|
||||
};
|
||||
|
||||
expect(cronJobFormFromJob(job)).toMatchObject({
|
||||
context_from: "upstream-a",
|
||||
continuity: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers one-shot run_at over the human display string", () => {
|
||||
const job: CronJob = {
|
||||
id: "once-job",
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "once",
|
||||
run_at: "2026-02-03T14:00:00+08:00",
|
||||
},
|
||||
schedule_display: "once at 2026-02-03 14:00",
|
||||
};
|
||||
|
||||
expect(cronJobFormFromJob(job)).toMatchObject({
|
||||
schedule: "2026-02-03T14:00:00+08:00",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronLastResult", () => {
|
||||
it("renders nothing for a job that never ran", () => {
|
||||
expect(cronLastResult({ last_status: null })).toBeNull();
|
||||
expect(cronLastResult({ last_status: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("is green for ok with no detail", () => {
|
||||
expect(cronLastResult({ last_status: "ok", last_error: null })).toEqual({
|
||||
status: "ok",
|
||||
tone: "success",
|
||||
detail: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is amber for delivery_failed and explains it from last_delivery_error", () => {
|
||||
// The agent run succeeded (last_error is null for these runs); the reason
|
||||
// lives in last_delivery_error. Must never render as green or as "unknown".
|
||||
expect(
|
||||
cronLastResult({
|
||||
last_status: "delivery_failed",
|
||||
last_error: null,
|
||||
last_delivery_error: "telegram: 502 Bad Gateway",
|
||||
}),
|
||||
).toEqual({
|
||||
status: "delivery_failed",
|
||||
tone: "warning",
|
||||
detail: "telegram: 502 Bad Gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("is red for error and any unrecognised literal", () => {
|
||||
expect(cronLastResult({ last_status: "error", last_error: "boom" })).toEqual({
|
||||
status: "error",
|
||||
tone: "destructive",
|
||||
detail: "boom",
|
||||
});
|
||||
expect(cronLastResult({ last_status: "something_new" })?.tone).toBe("destructive");
|
||||
});
|
||||
|
||||
it("is amber for blocked_config (preflight refused to burn a run)", () => {
|
||||
expect(
|
||||
cronLastResult({ last_status: "blocked_config", last_error: "missing API key" }),
|
||||
).toEqual({ status: "blocked_config", tone: "warning", detail: "missing API key" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CronJob, CronJobMutation } from "./api";
|
||||
|
||||
export interface CronJobFormState {
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
deliver: string;
|
||||
skills: string[];
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
script: string;
|
||||
no_agent: boolean;
|
||||
context_from: string;
|
||||
continuity: boolean;
|
||||
enabled_toolsets: string[];
|
||||
workdir: string;
|
||||
}
|
||||
|
||||
/** Split a comma/newline list (or array) into trimmed, non-empty items. */
|
||||
export function splitCronList(value: unknown): string[] {
|
||||
const items = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value.split(/[\n,]/)
|
||||
: [];
|
||||
return items.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Trim to a non-empty string, or null. Optionally strip trailing slashes
|
||||
* (base URLs). Mirrors the backend's `_cron_optional_text`. */
|
||||
function optionalText(value: string, stripTrailingSlash = false): string | null {
|
||||
const text = stripTrailingSlash ? value.trim().replace(/\/+$/, "") : value.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/** Read a stored string field as a plain string ("" when absent). */
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** Build the create/update payload. Optional fields collapse to null so an
|
||||
* update explicitly clears them rather than leaving stale values. */
|
||||
export function buildCronJobPayload(form: CronJobFormState): CronJobMutation {
|
||||
// The `continuity` toggle is stored as the reserved "self" entry in
|
||||
// context_from (the job's own previous output). Users never type "self" —
|
||||
// the checkbox is the surface; strip any hand-typed variant first.
|
||||
const contextFrom = splitCronList(form.context_from).filter(
|
||||
(item) => item.toLowerCase() !== "self",
|
||||
);
|
||||
if (form.continuity) contextFrom.push("self");
|
||||
const enabledToolsets = form.enabled_toolsets.filter(Boolean);
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
prompt: form.prompt.trim(),
|
||||
schedule: form.schedule.trim(),
|
||||
deliver: form.deliver.trim() || "local",
|
||||
skills: form.skills.filter(Boolean),
|
||||
provider: optionalText(form.provider),
|
||||
model: optionalText(form.model),
|
||||
base_url: optionalText(form.base_url, true),
|
||||
script: optionalText(form.script),
|
||||
no_agent: Boolean(form.no_agent),
|
||||
context_from: contextFrom.length > 0 ? contextFrom : null,
|
||||
enabled_toolsets: enabledToolsets.length > 0 ? enabledToolsets : null,
|
||||
workdir: optionalText(form.workdir),
|
||||
};
|
||||
}
|
||||
|
||||
export function cronJobHasExecutionContent(
|
||||
job: Pick<CronJobMutation, "prompt" | "skills" | "script">,
|
||||
): boolean {
|
||||
const skills = Array.isArray(job.skills) ? job.skills.filter(Boolean) : [];
|
||||
return Boolean(asString(job.prompt).trim() || asString(job.script).trim() || skills.length);
|
||||
}
|
||||
|
||||
export function cronJobFormFromJob(job: CronJob): CronJobFormState {
|
||||
const storedRefs = splitCronList(job.context_from);
|
||||
// Raw store records carry the reserved "self" entry inside context_from;
|
||||
// tool/RPC-formatted records strip it and set an explicit continuity flag.
|
||||
const continuity =
|
||||
Boolean((job as { continuity?: boolean }).continuity) ||
|
||||
storedRefs.some((item) => item.toLowerCase() === "self");
|
||||
const externalRefs = storedRefs.filter((item) => item.toLowerCase() !== "self");
|
||||
return {
|
||||
name: asString(job.name),
|
||||
prompt: asString(job.prompt),
|
||||
schedule:
|
||||
asString(job.schedule?.expr) ||
|
||||
asString(job.schedule?.run_at) ||
|
||||
asString(job.schedule_display),
|
||||
deliver: asString(job.deliver) || "local",
|
||||
skills: Array.isArray(job.skills) ? job.skills.filter(Boolean) : [],
|
||||
provider: asString(job.provider),
|
||||
model: asString(job.model),
|
||||
base_url: asString(job.base_url),
|
||||
script: asString(job.script),
|
||||
no_agent: Boolean(job.no_agent),
|
||||
context_from: externalRefs.join("\n"),
|
||||
continuity,
|
||||
enabled_toolsets: splitCronList(job.enabled_toolsets),
|
||||
workdir: asString(job.workdir),
|
||||
};
|
||||
}
|
||||
|
||||
/** How a job's `last_status` should render. The scheduler writes a small,
|
||||
* closed set of literals; every literal maps to an explicit tone here so a
|
||||
* new status can never fall through to a neutral "unknown"-looking badge.
|
||||
* In particular `delivery_failed` (agent run succeeded, output never reached
|
||||
* the target) is amber, not green and not the same red as a run error, and
|
||||
* its detail lives in `last_delivery_error` (last_error is null for it). */
|
||||
export type CronLastResultTone = "success" | "warning" | "destructive";
|
||||
|
||||
export interface CronLastResult {
|
||||
status: string;
|
||||
tone: CronLastResultTone;
|
||||
/** Human detail to show next to the badge; null when nothing to add. */
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
const CRON_LAST_RESULT_TONE: Record<string, CronLastResultTone> = {
|
||||
ok: "success",
|
||||
delivery_failed: "warning",
|
||||
blocked_config: "warning",
|
||||
error: "destructive",
|
||||
};
|
||||
|
||||
export function cronLastResult(
|
||||
job: Pick<CronJob, "last_status" | "last_error" | "last_delivery_error">,
|
||||
): CronLastResult | null {
|
||||
const status = asString(job.last_status).trim();
|
||||
if (!status) return null;
|
||||
const tone = CRON_LAST_RESULT_TONE[status] ?? "destructive";
|
||||
if (status === "ok") return { status, tone, detail: null };
|
||||
const detail =
|
||||
status === "delivery_failed"
|
||||
? asString(job.last_delivery_error).trim() || asString(job.last_error).trim()
|
||||
: asString(job.last_error).trim() || asString(job.last_delivery_error).trim();
|
||||
return { status, tone, detail: detail || null };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCronTriggerController } from "@hermes/shared";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
describe("createCronTriggerController", () => {
|
||||
it("announces immediately and coalesces the same job while it is running", async () => {
|
||||
const request = deferred<string>();
|
||||
const order: string[] = [];
|
||||
const action = vi.fn(() => {
|
||||
order.push("action");
|
||||
return request.promise;
|
||||
});
|
||||
const onStarted = vi.fn(() => order.push("started"));
|
||||
const onRunningChange = vi.fn();
|
||||
const controller = createCronTriggerController(onRunningChange);
|
||||
|
||||
const first = controller.run("profile-a:job-1", action, onStarted);
|
||||
const duplicate = await controller.run("profile-a:job-1", action, onStarted);
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||
expect(order).toEqual(["started", "action"]);
|
||||
expect(onRunningChange).toHaveBeenNthCalledWith(1, "profile-a:job-1", true);
|
||||
expect(duplicate).toEqual({ started: false, value: null });
|
||||
|
||||
request.resolve("done");
|
||||
await expect(first).resolves.toEqual({ started: true, value: "done" });
|
||||
expect(onRunningChange).toHaveBeenLastCalledWith("profile-a:job-1", false);
|
||||
});
|
||||
|
||||
it("releases the job after failure so a retry can start", async () => {
|
||||
const request = deferred<void>();
|
||||
const controller = createCronTriggerController();
|
||||
|
||||
const failed = controller.run("job-1", () => request.promise);
|
||||
request.reject(new Error("failed"));
|
||||
|
||||
await expect(failed).rejects.toThrow("failed");
|
||||
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
|
||||
started: true,
|
||||
value: "retried",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the job when the immediate feedback callback fails", async () => {
|
||||
const controller = createCronTriggerController();
|
||||
|
||||
await expect(
|
||||
controller.run("job-1", async () => "not-called", () => {
|
||||
throw new Error("toast failed");
|
||||
}),
|
||||
).rejects.toThrow("toast failed");
|
||||
|
||||
await expect(controller.run("job-1", async () => "retried")).resolves.toEqual({
|
||||
started: true,
|
||||
value: "retried",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the same job id in different profiles to run concurrently", async () => {
|
||||
const defaultRequest = deferred<string>();
|
||||
const workRequest = deferred<string>();
|
||||
const defaultAction = vi.fn(() => defaultRequest.promise);
|
||||
const workAction = vi.fn(() => workRequest.promise);
|
||||
const controller = createCronTriggerController();
|
||||
|
||||
const defaultRun = controller.run("default:job-1", defaultAction);
|
||||
const workRun = controller.run("work:job-1", workAction);
|
||||
|
||||
expect(defaultAction).toHaveBeenCalledTimes(1);
|
||||
expect(workAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
defaultRequest.resolve("default");
|
||||
workRequest.resolve("work");
|
||||
|
||||
await expect(defaultRun).resolves.toEqual({ started: true, value: "default" });
|
||||
await expect(workRun).resolves.toEqual({ started: true, value: "work" });
|
||||
});
|
||||
|
||||
it("releases the job when the running-state callback fails", async () => {
|
||||
const controller = createCronTriggerController((_key, running) => {
|
||||
if (running) throw new Error("state callback failed");
|
||||
});
|
||||
|
||||
await expect(controller.run("job-1", async () => "not-called")).rejects.toThrow(
|
||||
"state callback failed",
|
||||
);
|
||||
expect(controller.isRunning("job-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the job before the stopped-state callback runs", async () => {
|
||||
const action = vi.fn(async () => "done");
|
||||
const controller = createCronTriggerController((_key, running) => {
|
||||
if (!running) throw new Error("stopped callback failed");
|
||||
});
|
||||
|
||||
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
|
||||
expect(controller.isRunning("job-1")).toBe(false);
|
||||
|
||||
await expect(controller.run("job-1", action)).rejects.toThrow("stopped callback failed");
|
||||
expect(action).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
attemptDashboardTokenReloadOnce,
|
||||
clearDashboardTokenReloadAttempt,
|
||||
maybeReloadForLoopbackWsAuthFailure,
|
||||
} from "./dashboard-auth-reload";
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("attemptDashboardTokenReloadOnce", () => {
|
||||
it("reloads once and latches the attempt", () => {
|
||||
const storage = makeStorage();
|
||||
const reload = vi.fn();
|
||||
|
||||
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears the latch when asked", () => {
|
||||
const storage = makeStorage();
|
||||
const reload = vi.fn();
|
||||
|
||||
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
|
||||
clearDashboardTokenReloadAttempt(storage);
|
||||
expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeReloadForLoopbackWsAuthFailure", () => {
|
||||
it("reloads once for loopback 4401 closes", () => {
|
||||
const storage = makeStorage();
|
||||
const reload = vi.fn();
|
||||
|
||||
expect(
|
||||
maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload),
|
||||
).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reload in gated mode or for other close codes", () => {
|
||||
const storage = makeStorage();
|
||||
const reload = vi.fn();
|
||||
|
||||
expect(
|
||||
maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload),
|
||||
).toBe(false);
|
||||
expect(
|
||||
maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload),
|
||||
).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
type StorageLike = Pick<Storage, "getItem" | "removeItem" | "setItem">;
|
||||
|
||||
const TOKEN_RELOAD_STORAGE_KEY = "hermes.tokenReloadAttempted";
|
||||
|
||||
function dashboardAuthRequired(): boolean {
|
||||
return typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__;
|
||||
}
|
||||
|
||||
function reloadDashboardWindow(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardSessionStorage(): StorageLike | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDashboardTokenReloadAttempt(
|
||||
storage: StorageLike | null = dashboardSessionStorage(),
|
||||
): void {
|
||||
try {
|
||||
storage?.removeItem(TOKEN_RELOAD_STORAGE_KEY);
|
||||
} catch {
|
||||
/* privacy mode / blocked storage — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function attemptDashboardTokenReloadOnce(
|
||||
storage: StorageLike | null = dashboardSessionStorage(),
|
||||
reload: () => void = reloadDashboardWindow,
|
||||
): boolean {
|
||||
let alreadyReloaded = false;
|
||||
try {
|
||||
alreadyReloaded =
|
||||
storage?.getItem(TOKEN_RELOAD_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
/* privacy mode / blocked storage — fall through */
|
||||
}
|
||||
if (alreadyReloaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
storage?.setItem(TOKEN_RELOAD_STORAGE_KEY, "1");
|
||||
} catch {
|
||||
/* privacy mode / blocked storage — best effort */
|
||||
}
|
||||
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function maybeReloadForLoopbackWsAuthFailure(
|
||||
code: number,
|
||||
authRequired = dashboardAuthRequired(),
|
||||
storage: StorageLike | null = dashboardSessionStorage(),
|
||||
reload: () => void = reloadDashboardWindow,
|
||||
): boolean {
|
||||
if (authRequired || code !== 4401) {
|
||||
return false;
|
||||
}
|
||||
return attemptDashboardTokenReloadOnce(storage, reload);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Injected by the server as `true`. The embedded TUI Chat surface
|
||||
* (`/chat`, `/api/ws`, `/api/pty`) is always enabled, so this is
|
||||
* effectively a constant; kept on `window` for any consumer that reads
|
||||
* it directly and for parity with the server's bootstrap script.
|
||||
*/
|
||||
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the dashboard's embedded TUI Chat surface is available.
|
||||
*
|
||||
* The embedded chat (`/chat` tab, `/api/ws` + `/api/pty` WebSockets) is now
|
||||
* an unconditional part of the dashboard — the desktop app and the in-browser
|
||||
* Chat tab both depend on it — so this always returns `true`. The function is
|
||||
* retained as a stable seam so call sites don't need to change if the surface
|
||||
* ever becomes conditional again.
|
||||
*/
|
||||
export function isDashboardEmbeddedChatEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_MODAL_BACKDROP,
|
||||
DASHBOARD_MODAL_PANEL,
|
||||
shouldCloseOuterModalOnEscape,
|
||||
} from "./dashboard-modal-shell";
|
||||
|
||||
describe("dashboard modal shell", () => {
|
||||
it("uses an opaque panel (bg-card), not the glass Card default", () => {
|
||||
expect(DASHBOARD_MODAL_PANEL).toMatch(/\bbg-card\b/);
|
||||
expect(DASHBOARD_MODAL_PANEL).not.toMatch(/bg-background-base\/\d+/);
|
||||
});
|
||||
|
||||
it("keeps the backdrop above page chrome (z-[100])", () => {
|
||||
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/z-\[100\]/);
|
||||
expect(DASHBOARD_MODAL_BACKDROP).toMatch(/\bbg-background\/85\b/);
|
||||
});
|
||||
|
||||
it("does not close the outer modal on Escape while a nested picker is open", () => {
|
||||
expect(shouldCloseOuterModalOnEscape(true)).toBe(false);
|
||||
expect(shouldCloseOuterModalOnEscape(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared dashboard dialog shell classes.
|
||||
*
|
||||
* Page `<Card>` defaults to `bg-background-base/80` (glass). That looks fine
|
||||
* on the page canvas, but as a modal panel it lets the Models page bleed
|
||||
* through and kills readability — especially on Cyberpunk / mobile.
|
||||
*
|
||||
* Modal panels must use opaque `bg-card`; backdrops use the same z-index
|
||||
* band as Auxiliary / Confirm dialogs so they sit above page chrome.
|
||||
*
|
||||
* Callers must `createPortal(..., document.body)` — `z-[100]` alone cannot
|
||||
* escape the dashboard column's `relative z-2` stacking context (see
|
||||
* ModelPickerDialog / ToolsetConfigDrawer).
|
||||
*/
|
||||
export const DASHBOARD_MODAL_BACKDROP =
|
||||
"fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4";
|
||||
|
||||
export const DASHBOARD_MODAL_PANEL =
|
||||
"relative w-full border border-border bg-card shadow-2xl";
|
||||
|
||||
/**
|
||||
* Outer modals that host a nested picker (e.g. MoA → ModelPickerDialog)
|
||||
* must ignore Escape while the picker is open; the picker owns that key.
|
||||
*/
|
||||
export function shouldCloseOuterModalOnEscape(
|
||||
nestedPickerOpen: boolean,
|
||||
): boolean {
|
||||
return !nestedPickerOpen;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EnvVarInfo } from "./api";
|
||||
import { removeDeletedEnvVarFromState } from "./env-state";
|
||||
|
||||
function envVar(overrides: Partial<EnvVarInfo> = {}): EnvVarInfo {
|
||||
return {
|
||||
is_set: true,
|
||||
redacted_value: "secr...alue",
|
||||
description: "",
|
||||
url: null,
|
||||
category: "provider",
|
||||
is_password: true,
|
||||
tools: [],
|
||||
advanced: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("removeDeletedEnvVarFromState", () => {
|
||||
it("removes a deleted custom key from dashboard state", () => {
|
||||
const vars = {
|
||||
WHATSAPP_DEBUG: envVar({ category: "custom", custom: true }),
|
||||
OPENAI_API_KEY: envVar(),
|
||||
};
|
||||
|
||||
const updated = removeDeletedEnvVarFromState(vars, "WHATSAPP_DEBUG");
|
||||
|
||||
expect(updated).not.toHaveProperty("WHATSAPP_DEBUG");
|
||||
expect(updated?.OPENAI_API_KEY).toBe(vars.OPENAI_API_KEY);
|
||||
});
|
||||
|
||||
it("keeps a catalog key available while marking it unset", () => {
|
||||
const vars = { OPENAI_API_KEY: envVar() };
|
||||
|
||||
const updated = removeDeletedEnvVarFromState(vars, "OPENAI_API_KEY");
|
||||
|
||||
expect(updated?.OPENAI_API_KEY).toEqual({
|
||||
...vars.OPENAI_API_KEY,
|
||||
is_set: false,
|
||||
redacted_value: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EnvVarInfo } from "./api";
|
||||
|
||||
/** Reconcile a successful DELETE /api/env response with the Keys page state. */
|
||||
export function removeDeletedEnvVarFromState(
|
||||
vars: Record<string, EnvVarInfo> | null,
|
||||
key: string,
|
||||
): Record<string, EnvVarInfo> | null {
|
||||
const info = vars?.[key];
|
||||
if (!vars || !info) return vars;
|
||||
|
||||
if (info.custom) {
|
||||
const updated = { ...vars };
|
||||
delete updated[key];
|
||||
return updated;
|
||||
}
|
||||
|
||||
return {
|
||||
...vars,
|
||||
[key]: { ...info, is_set: false, redacted_value: null },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
EVENTS_MAX_RECONNECT_ATTEMPTS,
|
||||
EVENTS_RECONNECT_BASE_MS,
|
||||
EVENTS_RECONNECT_MAX_MS,
|
||||
eventsGaveUpMessage,
|
||||
eventsReconnectDelayMs,
|
||||
eventsReconnectingMessage,
|
||||
eventsRejectedMessage,
|
||||
isEventsAuthRejection,
|
||||
isEventsFeedMessage,
|
||||
shouldRetryEventsClose,
|
||||
EVENTS_DISCONNECTED_MESSAGE,
|
||||
} from "./events-reconnect";
|
||||
|
||||
describe("eventsReconnectDelayMs", () => {
|
||||
it("doubles from the base delay", () => {
|
||||
expect(eventsReconnectDelayMs(0)).toBe(EVENTS_RECONNECT_BASE_MS);
|
||||
expect(eventsReconnectDelayMs(1)).toBe(EVENTS_RECONNECT_BASE_MS * 2);
|
||||
expect(eventsReconnectDelayMs(2)).toBe(EVENTS_RECONNECT_BASE_MS * 4);
|
||||
expect(eventsReconnectDelayMs(3)).toBe(EVENTS_RECONNECT_BASE_MS * 8);
|
||||
});
|
||||
|
||||
it("clamps at the cap and never exceeds it", () => {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt <= EVENTS_MAX_RECONNECT_ATTEMPTS + 5;
|
||||
attempt++
|
||||
) {
|
||||
expect(eventsReconnectDelayMs(attempt)).toBeLessThanOrEqual(
|
||||
EVENTS_RECONNECT_MAX_MS,
|
||||
);
|
||||
}
|
||||
expect(eventsReconnectDelayMs(99)).toBe(EVENTS_RECONNECT_MAX_MS);
|
||||
});
|
||||
|
||||
it("is monotonically non-decreasing", () => {
|
||||
let previous = 0;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const delay = eventsReconnectDelayMs(attempt);
|
||||
expect(delay).toBeGreaterThanOrEqual(previous);
|
||||
previous = delay;
|
||||
}
|
||||
});
|
||||
|
||||
it("stays finite for absurd attempt counts", () => {
|
||||
expect(Number.isFinite(eventsReconnectDelayMs(10_000))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRetryEventsClose", () => {
|
||||
it("retries transient drops", () => {
|
||||
// 1005 (no status) and 1006 (abnormal) are what a killed gateway and a
|
||||
// dropped network produce respectively.
|
||||
for (const code of [1001, 1005, 1006, 1011, 1012, 1013]) {
|
||||
expect(shouldRetryEventsClose(code)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry a normal closure", () => {
|
||||
expect(shouldRetryEventsClose(1000)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry auth rejections", () => {
|
||||
expect(shouldRetryEventsClose(4401)).toBe(false);
|
||||
expect(shouldRetryEventsClose(4403)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries when the code is missing", () => {
|
||||
expect(shouldRetryEventsClose(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("never both retries and reports an auth rejection", () => {
|
||||
for (const code of [1000, 1005, 1006, 4401, 4403, 4500]) {
|
||||
expect(shouldRetryEventsClose(code) && isEventsAuthRejection(code)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEventsFeedMessage", () => {
|
||||
it("recognizes every message this module can surface", () => {
|
||||
expect(isEventsFeedMessage(EVENTS_DISCONNECTED_MESSAGE)).toBe(true);
|
||||
expect(isEventsFeedMessage(eventsReconnectingMessage(4_000))).toBe(true);
|
||||
expect(isEventsFeedMessage(eventsRejectedMessage(4401))).toBe(true);
|
||||
expect(isEventsFeedMessage(eventsGaveUpMessage())).toBe(true);
|
||||
});
|
||||
|
||||
it("does not claim messages owned by other banner sources", () => {
|
||||
// The banner is shared with info.credential_warning and the JSON-RPC
|
||||
// sidecar — clearing those on an events reconnect would hide a real
|
||||
// problem from the user.
|
||||
expect(isEventsFeedMessage("ANTHROPIC_API_KEY is not set")).toBe(false);
|
||||
expect(isEventsFeedMessage("WebSocket connection failed")).toBe(false);
|
||||
expect(isEventsFeedMessage("gateway not connected")).toBe(false);
|
||||
expect(isEventsFeedMessage(null)).toBe(false);
|
||||
expect(isEventsFeedMessage("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnect message copy", () => {
|
||||
it("renders the delay in whole seconds", () => {
|
||||
expect(eventsReconnectingMessage(1_000)).toContain("1s");
|
||||
expect(eventsReconnectingMessage(30_000)).toContain("30s");
|
||||
});
|
||||
|
||||
it("names the close code in the rejection message", () => {
|
||||
expect(eventsRejectedMessage(4403)).toContain("4403");
|
||||
});
|
||||
|
||||
it("does not reference the tools box removed in #51737", () => {
|
||||
const messages = [
|
||||
EVENTS_DISCONNECTED_MESSAGE,
|
||||
eventsReconnectingMessage(1_000),
|
||||
eventsRejectedMessage(4401),
|
||||
eventsGaveUpMessage(),
|
||||
];
|
||||
for (const message of messages) {
|
||||
expect(message).not.toContain("tool calls");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Reconnect policy for the ChatSidebar `/api/events` subscriber socket.
|
||||
*
|
||||
* Pure helpers, no DOM: the component owns the socket and the timer, this
|
||||
* module owns the arithmetic and the "is this close code worth retrying"
|
||||
* 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;
|
||||
/** Bound ticket minting plus the WebSocket opening handshake. */
|
||||
export const EVENTS_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Normal closure — the server said goodbye, don't chase it. */
|
||||
const WS_CLOSE_NORMAL = 1000;
|
||||
/** Ticket rejected / forbidden: retrying just burns tickets, user must reload. */
|
||||
const WS_CLOSE_AUTH_CODES = new Set([4401, 4403]);
|
||||
|
||||
/**
|
||||
* Exponential backoff, 1s → 2s → 4s → … → 30s cap.
|
||||
*
|
||||
* `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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a close code should trigger a retry.
|
||||
*
|
||||
* Auth rejections are terminal (the banner tells the user to reload) and a
|
||||
* normal 1000 close is intentional. Everything else — gateway restart,
|
||||
* network drop, 1005/1006, proxy timeout — is worth retrying.
|
||||
*/
|
||||
export function shouldRetryEventsClose(code: number | undefined): boolean {
|
||||
if (code === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 function eventsReconnectingMessage(delayMs: number): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
export function eventsGaveUpMessage(): string {
|
||||
return `events feed disconnected — gave up after ${EVENTS_MAX_RECONNECT_ATTEMPTS} attempts, reload the page`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `message` is one this module produced, i.e. safe to clear on a
|
||||
* successful reconnect. Guards against stomping a `credential_warning` or a
|
||||
* sidecar error that happens to be showing when the feed recovers.
|
||||
*/
|
||||
export function isEventsFeedMessage(message: string | null): boolean {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return message.startsWith("events feed ");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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 }));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayClient } from "./gatewayClient";
|
||||
|
||||
const reloadMocks = vi.hoisted(() => ({
|
||||
maybeReloadForLoopbackWsAuthFailure: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("./dashboard-auth-reload", () => ({
|
||||
maybeReloadForLoopbackWsAuthFailure:
|
||||
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
|
||||
}));
|
||||
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = [];
|
||||
static OPEN = 1;
|
||||
|
||||
listeners = new Map<string, Array<(event: EventLike) => void>>();
|
||||
readyState = 0;
|
||||
url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, cb: (event: EventLike) => void) {
|
||||
const list = this.listeners.get(type) ?? [];
|
||||
list.push(cb);
|
||||
this.listeners.set(type, list);
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
emit(type: string, event: EventLike) {
|
||||
for (const cb of this.listeners.get(type) ?? []) {
|
||||
cb(event);
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListener(type: string, cb: (event: EventLike) => void) {
|
||||
const list = this.listeners.get(type) ?? [];
|
||||
this.listeners.set(
|
||||
type,
|
||||
list.filter((item) => item !== cb),
|
||||
);
|
||||
}
|
||||
|
||||
send() {}
|
||||
}
|
||||
|
||||
type EventLike = {
|
||||
code?: number;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.instances = [];
|
||||
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockClear();
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket);
|
||||
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
|
||||
configurable: true,
|
||||
value: "stale-token",
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("GatewayClient", () => {
|
||||
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
|
||||
reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
|
||||
const gw = new GatewayClient();
|
||||
const connectPromise = gw.connect();
|
||||
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
socket.readyState = 1;
|
||||
socket.emit("open", {});
|
||||
await connectPromise;
|
||||
|
||||
socket.emit("close", { code: 4401 });
|
||||
|
||||
expect(
|
||||
reloadMocks.maybeReloadForLoopbackWsAuthFailure,
|
||||
).toHaveBeenCalledWith(4401);
|
||||
expect(gw.connectionState).toBe("open");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
|
||||
*
|
||||
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
|
||||
* drives over stdio. The server-side transport abstraction
|
||||
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
|
||||
* onto either stdout or a WebSocket depending on how the client connected.
|
||||
*
|
||||
* const gw = new GatewayClient()
|
||||
* await gw.connect()
|
||||
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
|
||||
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
|
||||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
import {
|
||||
JsonRpcGatewayClient,
|
||||
buildHermesWebSocketUrl,
|
||||
type ConnectionState,
|
||||
type GatewayEvent,
|
||||
type GatewayEventName,
|
||||
} from "@hermes/shared";
|
||||
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
|
||||
|
||||
export type { ConnectionState, GatewayEvent, GatewayEventName };
|
||||
|
||||
export class GatewayClient extends JsonRpcGatewayClient {
|
||||
constructor() {
|
||||
super({
|
||||
closedErrorMessage: "WebSocket closed",
|
||||
connectErrorMessage: "WebSocket connection failed",
|
||||
notConnectedErrorMessage: "gateway not connected",
|
||||
onSocketClose: (event) => maybeReloadForLoopbackWsAuthFailure(event.code),
|
||||
requestIdPrefix: "w",
|
||||
});
|
||||
}
|
||||
|
||||
async connect(token?: string): Promise<void> {
|
||||
if (this.connectionState === "open" || this.connectionState === "connecting") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
|
||||
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
|
||||
// override path.
|
||||
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
|
||||
if (!authParam[1]) {
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard server",
|
||||
);
|
||||
}
|
||||
|
||||
await super.connect(
|
||||
buildHermesWebSocketUrl({
|
||||
authParam,
|
||||
basePath: HERMES_BASE_PATH,
|
||||
path: "/api/ws",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeKeyboardInset,
|
||||
KEYBOARD_INSET_MIN_PX,
|
||||
shouldPinScroll,
|
||||
} from "./keyboard-inset";
|
||||
|
||||
describe("computeKeyboardInset", () => {
|
||||
it("returns 0 when visualViewport is unavailable", () => {
|
||||
expect(computeKeyboardInset(null, 800)).toBe(0);
|
||||
expect(computeKeyboardInset(undefined, 800)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when no keyboard is showing (vv fills layout)", () => {
|
||||
expect(computeKeyboardInset({ height: 800, offsetTop: 0 }, 800)).toBe(0);
|
||||
});
|
||||
|
||||
it("measures the obscured region below the visual viewport", () => {
|
||||
// 800px layout, keyboard eats 320px: vv.height = 480.
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 800)).toBe(320);
|
||||
});
|
||||
|
||||
it("accounts for visual-viewport offsetTop (iOS keyboard scroll)", () => {
|
||||
// iOS nudged the visual viewport down 40px; keyboard covers the rest.
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: 40 }, 800)).toBe(
|
||||
280,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores small deltas from collapsing browser chrome", () => {
|
||||
// URL bar show/hide produces deltas well under a real keyboard height.
|
||||
const delta = KEYBOARD_INSET_MIN_PX - 1;
|
||||
expect(
|
||||
computeKeyboardInset({ height: 800 - delta, offsetTop: 0 }, 800),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts insets at exactly the threshold", () => {
|
||||
expect(
|
||||
computeKeyboardInset(
|
||||
{ height: 800 - KEYBOARD_INSET_MIN_PX, offsetTop: 0 },
|
||||
800,
|
||||
),
|
||||
).toBe(KEYBOARD_INSET_MIN_PX);
|
||||
});
|
||||
|
||||
it("never goes negative when vv is larger than layout height", () => {
|
||||
// Rotation / zoom races can transiently report vv.height > innerHeight.
|
||||
expect(computeKeyboardInset({ height: 900, offsetTop: 0 }, 800)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for degenerate layout heights", () => {
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, 0)).toBe(0);
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, -1)).toBe(0);
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: 0 }, NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for non-finite viewport values", () => {
|
||||
expect(computeKeyboardInset({ height: NaN, offsetTop: 0 }, 800)).toBe(0);
|
||||
expect(computeKeyboardInset({ height: 480, offsetTop: NaN }, 800)).toBe(0);
|
||||
});
|
||||
|
||||
it("rounds fractional geometry to whole pixels", () => {
|
||||
// iOS reports fractional vv heights under pinch zoom.
|
||||
expect(
|
||||
computeKeyboardInset({ height: 479.5, offsetTop: 0.25 }, 800),
|
||||
).toBe(320);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPinScroll", () => {
|
||||
it("pins while a keyboard inset is active", () => {
|
||||
expect(shouldPinScroll(320)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not pin without a keyboard", () => {
|
||||
expect(shouldPinScroll(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Soft-keyboard inset math for the dashboard chat terminal (NS-434).
|
||||
*
|
||||
* Problem: when the on-screen keyboard opens on mobile, neither iOS Safari
|
||||
* nor Android Chrome (default `interactive-widget=resizes-visual`) shrinks
|
||||
* the *layout* viewport — the keyboard just overlays it. Our app shell is
|
||||
* `h-dvh`, so the terminal host's bounding box doesn't change, `fit()`
|
||||
* computes the same (cols, rows), and the PTY never re-lays-out. The Ink
|
||||
* input line — drawn at the bottom of the grid — ends up hidden underneath
|
||||
* the keyboard.
|
||||
*
|
||||
* The only reliable signal is `window.visualViewport`: its `height` shrinks
|
||||
* to the visible region above the keyboard, and `offsetTop` reflects any
|
||||
* visual-viewport scroll (iOS nudges the page when an input focuses). The
|
||||
* keyboard inset is the part of the layout viewport below the visual one:
|
||||
*
|
||||
* inset = layoutHeight - vv.height - vv.offsetTop
|
||||
*
|
||||
* ChatPage applies this as bottom padding on the terminal wrapper, which
|
||||
* shrinks the xterm host → ResizeObserver refit → `term.onResize` sends
|
||||
* `RESIZE` to the PTY → Ink redraws the input line above the keyboard.
|
||||
*
|
||||
* We also set `interactive-widget=resizes-content` in the viewport meta so
|
||||
* Android Chrome 108+ resizes the layout viewport natively; there the inset
|
||||
* computes ≈ 0 and this path is a harmless no-op. iOS ignores the directive
|
||||
* and takes the JS path.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Insets smaller than this are treated as 0. Collapsing browser chrome
|
||||
* (URL bar show/hide) produces small transient height deltas that would
|
||||
* otherwise thrash the terminal grid; real soft keyboards are ≥ ~150px.
|
||||
*/
|
||||
export const KEYBOARD_INSET_MIN_PX = 80;
|
||||
|
||||
export interface ViewportGeometry {
|
||||
/** `visualViewport.height` — visible height above the keyboard. */
|
||||
height: number;
|
||||
/** `visualViewport.offsetTop` — visual viewport's offset into layout. */
|
||||
offsetTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height (px) of the layout viewport currently obscured by the soft
|
||||
* keyboard, or 0 when no keyboard is showing / the signal is unusable.
|
||||
*/
|
||||
export function computeKeyboardInset(
|
||||
viewport: ViewportGeometry | null | undefined,
|
||||
layoutHeightPx: number,
|
||||
): number {
|
||||
if (!viewport || !Number.isFinite(layoutHeightPx) || layoutHeightPx <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const { height, offsetTop } = viewport;
|
||||
if (!Number.isFinite(height) || !Number.isFinite(offsetTop)) return 0;
|
||||
const inset = Math.round(layoutHeightPx - height - offsetTop);
|
||||
return inset >= KEYBOARD_INSET_MIN_PX ? inset : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page scroll should be pinned back to the top.
|
||||
*
|
||||
* The dashboard shell is a fixed `h-dvh` column and must never scroll, but
|
||||
* iOS Safari auto-scrolls the *page* when a focused input would sit under
|
||||
* the keyboard (xterm's hidden textarea triggers this). Pin whenever a
|
||||
* keyboard is present so the terminal chrome stays put; the terminal's own
|
||||
* scrollback handles content visibility.
|
||||
*/
|
||||
export function shouldPinScroll(nextInsetPx: number): boolean {
|
||||
return nextInsetPx > 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyLine } from "./log-classify";
|
||||
|
||||
const TS = "2026-07-26 13:07:45,228";
|
||||
|
||||
describe("classifyLine", () => {
|
||||
it("classifies by the structured level token", () => {
|
||||
expect(classifyLine(`${TS} INFO run_agent: client created`)).toBe("info");
|
||||
expect(classifyLine(`${TS} DEBUG registry: scanned 12 tools`)).toBe(
|
||||
"debug",
|
||||
);
|
||||
expect(classifyLine(`${TS} WARNING hermes_state: WAL disabled`)).toBe(
|
||||
"warning",
|
||||
);
|
||||
expect(classifyLine(`${TS} ERROR gateway: connect failed`)).toBe("error");
|
||||
expect(classifyLine(`${TS} CRITICAL agent: giving up`)).toBe("error");
|
||||
});
|
||||
|
||||
it("does NOT flag INFO lines whose payload mentions errors", () => {
|
||||
// Regression: these rendered red with the old substring heuristic.
|
||||
expect(
|
||||
classifyLine(
|
||||
`${TS} INFO tui_gateway.ws: ws closed peer=127.0.0.1:43212 parse_errors=0 dispatch_crashes=0 send_failures=1`,
|
||||
),
|
||||
).toBe("info");
|
||||
expect(classifyLine(`${TS} INFO logs: rotating errors.log`)).toBe("info");
|
||||
expect(
|
||||
classifyLine(`${TS} INFO retry: recovered from FatalError subclass`),
|
||||
).toBe("info");
|
||||
});
|
||||
|
||||
it("does NOT let payload text spoof a higher level", () => {
|
||||
expect(
|
||||
classifyLine(`${TS} INFO chat: user said "ERROR ERROR ERROR"`),
|
||||
).toBe("info");
|
||||
expect(classifyLine(`${TS} DEBUG probe: WARNING string seen`)).toBe(
|
||||
"debug",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to word-boundary matching for untimestamped lines", () => {
|
||||
expect(classifyLine("ValueError: bad input")).toBe("info"); // no bare token
|
||||
expect(classifyLine("ERROR: something broke")).toBe("error");
|
||||
expect(classifyLine("Traceback (most recent call last):")).toBe("error");
|
||||
expect(classifyLine(" WARNING low disk")).toBe("warning");
|
||||
expect(classifyLine("errors=0 all good")).toBe("info");
|
||||
expect(classifyLine("wrote to errors.log")).toBe("info");
|
||||
expect(classifyLine("just a plain line")).toBe("info");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Log-line level classification for the dashboard Logs page.
|
||||
*
|
||||
* Prefers the structured level token emitted by hermes_logging
|
||||
* ("2026-07-26 13:07:45,228 INFO …"); falls back to word-boundary matching
|
||||
* for untimestamped lines (tracebacks, wrapped continuations). Plain
|
||||
* substring matching is deliberately avoided — INFO lines carrying payloads
|
||||
* like "parse_errors=0" or paths like "errors.log" must not render red.
|
||||
*/
|
||||
|
||||
export type LogLevel = "error" | "warning" | "info" | "debug";
|
||||
|
||||
// Level token as emitted by hermes_logging, anchored to the line head so
|
||||
// payload text can't spoof the level.
|
||||
const LEVEL_TOKEN_RE =
|
||||
/^\d{4}-\d{2}-\d{2}[ T][\d:,.]+\s+(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\b/;
|
||||
|
||||
export function classifyLine(line: string): LogLevel {
|
||||
const token = LEVEL_TOKEN_RE.exec(line)?.[1];
|
||||
if (token) {
|
||||
if (token === "ERROR" || token === "CRITICAL" || token === "FATAL")
|
||||
return "error";
|
||||
if (token === "WARNING" || token === "WARN") return "warning";
|
||||
if (token === "DEBUG") return "debug";
|
||||
return "info";
|
||||
}
|
||||
const upper = line.toUpperCase();
|
||||
if (
|
||||
/\b(ERROR|CRITICAL|FATAL)\b/.test(upper) ||
|
||||
upper.startsWith("TRACEBACK (")
|
||||
)
|
||||
return "error";
|
||||
if (/\b(WARNING|WARN)\b/.test(upper)) return "warning";
|
||||
if (/\bDEBUG\b/.test(upper)) return "debug";
|
||||
return "info";
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { completeMcpDashboardOAuth } from "./mcp-dashboard-oauth";
|
||||
|
||||
describe("completeMcpDashboardOAuth", () => {
|
||||
it("opens the authorization URL in the dashboard browser and polls to approval", async () => {
|
||||
const authWindow = { location: { href: "" }, opener: {} } as unknown as Window;
|
||||
const open = vi.fn().mockReturnValue(authWindow);
|
||||
const start = vi.fn().mockResolvedValue({
|
||||
flow_id: "flow-1",
|
||||
server_name: "reports",
|
||||
status: "authorization_required",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
});
|
||||
const status = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
flow_id: "flow-1",
|
||||
server_name: "reports",
|
||||
status: "authorization_required",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
tools: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
flow_id: "flow-1",
|
||||
server_name: "reports",
|
||||
status: "approved",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
tools: [{ name: "list_reports", description: "List reports" }],
|
||||
});
|
||||
|
||||
const result = await completeMcpDashboardOAuth({
|
||||
serverName: "reports",
|
||||
start,
|
||||
status,
|
||||
open,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
"about:blank",
|
||||
"_blank",
|
||||
);
|
||||
expect(authWindow.opener).toBeNull();
|
||||
expect(authWindow.location.href).toBe("https://idp.example/authorize");
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
expect(result.status).toBe("approved");
|
||||
});
|
||||
|
||||
it("surfaces a terminal OAuth error", async () => {
|
||||
const close = vi.fn();
|
||||
await expect(
|
||||
completeMcpDashboardOAuth({
|
||||
serverName: "reports",
|
||||
start: async () => ({
|
||||
flow_id: "flow-2",
|
||||
server_name: "reports",
|
||||
status: "error",
|
||||
authorization_url: null,
|
||||
error: "registration denied",
|
||||
}),
|
||||
status: vi.fn(),
|
||||
open: vi.fn().mockReturnValue({ location: { href: "" }, close }),
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("registration denied");
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails before starting when the browser blocks the popup", async () => {
|
||||
const start = vi.fn();
|
||||
await expect(
|
||||
completeMcpDashboardOAuth({
|
||||
serverName: "reports",
|
||||
start,
|
||||
status: vi.fn(),
|
||||
open: vi.fn().mockReturnValue(null),
|
||||
}),
|
||||
).rejects.toThrow("popup was blocked");
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when the authorization window closes before approval", async () => {
|
||||
const authWindow = { location: { href: "" }, opener: {}, closed: false } as unknown as Window;
|
||||
const status = vi.fn().mockImplementation(async () => {
|
||||
Object.defineProperty(authWindow, "closed", { value: true });
|
||||
return {
|
||||
flow_id: "flow-closed",
|
||||
server_name: "reports",
|
||||
status: "authorization_required",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeMcpDashboardOAuth({
|
||||
serverName: "reports",
|
||||
start: async () => ({
|
||||
flow_id: "flow-closed",
|
||||
server_name: "reports",
|
||||
status: "authorization_required",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
}),
|
||||
status,
|
||||
open: vi.fn().mockReturnValue(authWindow),
|
||||
sleep: async () => {},
|
||||
}),
|
||||
).rejects.toThrow("authorization window was closed");
|
||||
});
|
||||
|
||||
it("retries a transient status failure", async () => {
|
||||
const authWindow = { location: { href: "" }, opener: {}, closed: false } as unknown as Window;
|
||||
const status = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary network failure"))
|
||||
.mockResolvedValueOnce({
|
||||
flow_id: "flow-retry",
|
||||
server_name: "reports",
|
||||
status: "approved",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const result = await completeMcpDashboardOAuth({
|
||||
serverName: "reports",
|
||||
start: async () => ({
|
||||
flow_id: "flow-retry",
|
||||
server_name: "reports",
|
||||
status: "authorization_required",
|
||||
authorization_url: "https://idp.example/authorize",
|
||||
error: null,
|
||||
}),
|
||||
status,
|
||||
open: vi.fn().mockReturnValue(authWindow),
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("approved");
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { McpOAuthFlow } from "./api";
|
||||
|
||||
type CompleteOptions = {
|
||||
serverName: string;
|
||||
start: (name: string) => Promise<McpOAuthFlow>;
|
||||
status: (flowId: string) => Promise<McpOAuthFlow>;
|
||||
open: (url?: string | URL, target?: string, features?: string) => unknown;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
maxPollFailures?: number;
|
||||
};
|
||||
|
||||
const defaultSleep = (milliseconds: number) =>
|
||||
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
|
||||
export async function completeMcpDashboardOAuth({
|
||||
serverName,
|
||||
start,
|
||||
status,
|
||||
open,
|
||||
sleep = defaultSleep,
|
||||
maxPollFailures = 3,
|
||||
}: CompleteOptions): Promise<McpOAuthFlow> {
|
||||
// Open synchronously from the click handler, before the first await. Browsers
|
||||
// otherwise classify the later OAuth popup as unsolicited and block it.
|
||||
const authWindow = open("about:blank", "_blank") as Window | null;
|
||||
if (!authWindow) {
|
||||
throw new Error("OAuth popup was blocked — allow popups for this dashboard and retry");
|
||||
}
|
||||
authWindow.opener = null;
|
||||
let started: McpOAuthFlow;
|
||||
try {
|
||||
started = await start(serverName);
|
||||
if (started.status === "error") {
|
||||
throw new Error(started.error || "OAuth failed to start");
|
||||
}
|
||||
if (!started.authorization_url) {
|
||||
throw new Error("OAuth server did not provide an authorization URL");
|
||||
}
|
||||
authWindow.location.href = started.authorization_url;
|
||||
} catch (error) {
|
||||
authWindow.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let pollFailures = 0;
|
||||
for (;;) {
|
||||
let current: McpOAuthFlow;
|
||||
try {
|
||||
current = await status(started.flow_id);
|
||||
pollFailures = 0;
|
||||
} catch (error) {
|
||||
pollFailures += 1;
|
||||
if (pollFailures >= maxPollFailures) throw error;
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
if (current.status === "approved") return current;
|
||||
if (current.status === "error") {
|
||||
throw new Error(current.error || "OAuth authorization failed");
|
||||
}
|
||||
if (authWindow.closed) {
|
||||
throw new Error("OAuth authorization window was closed before completion");
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildMcpServerCreate, emptyMcpServerDraft } from "./mcp-server-create";
|
||||
|
||||
describe("buildMcpServerCreate", () => {
|
||||
it("builds an HTTP Bearer request without stdio fields", () => {
|
||||
const server = buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: " Linear ",
|
||||
url: " https://mcp.linear.app/mcp ",
|
||||
httpAuth: "header",
|
||||
bearerToken: "Bearer secret-token",
|
||||
command: "ignored",
|
||||
args: "--ignored",
|
||||
env: "IGNORED=value",
|
||||
});
|
||||
|
||||
expect(server).toEqual({
|
||||
name: "Linear",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
auth: "header",
|
||||
bearer_token: "Bearer secret-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds OAuth and unauthenticated HTTP requests without a token", () => {
|
||||
expect(
|
||||
buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "oauth",
|
||||
url: "https://example.com/mcp",
|
||||
httpAuth: "oauth",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "oauth",
|
||||
url: "https://example.com/mcp",
|
||||
auth: "oauth",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "public",
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
).toEqual({
|
||||
name: "public",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses stdio arguments and environment assignments", () => {
|
||||
const server = buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "local",
|
||||
transport: "stdio",
|
||||
command: " uvx ",
|
||||
args: "mcp-server, --debug",
|
||||
env: "API_KEY=secret\nURL=https://example.com?a=b\nINVALID",
|
||||
});
|
||||
|
||||
expect(server).toEqual({
|
||||
name: "local",
|
||||
command: "uvx",
|
||||
args: ["mcp-server", "--debug"],
|
||||
env: {
|
||||
API_KEY: "secret",
|
||||
URL: "https://example.com?a=b",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing transport fields and Bearer tokens", () => {
|
||||
expect(() => buildMcpServerCreate(emptyMcpServerDraft())).toThrow(
|
||||
"Name required",
|
||||
);
|
||||
expect(() =>
|
||||
buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "remote",
|
||||
}),
|
||||
).toThrow("URL required");
|
||||
expect(() =>
|
||||
buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
httpAuth: "header",
|
||||
}),
|
||||
).toThrow("Bearer token required");
|
||||
expect(() =>
|
||||
buildMcpServerCreate({
|
||||
...emptyMcpServerDraft(),
|
||||
name: "local",
|
||||
transport: "stdio",
|
||||
}),
|
||||
).toThrow("Command required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { McpHttpAuth, McpServerCreate } from "@/lib/api";
|
||||
|
||||
export type McpTransport = "http" | "stdio";
|
||||
|
||||
export interface McpServerDraft {
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
url: string;
|
||||
httpAuth: McpHttpAuth;
|
||||
bearerToken: string;
|
||||
command: string;
|
||||
args: string;
|
||||
env: string;
|
||||
}
|
||||
|
||||
export function emptyMcpServerDraft(): McpServerDraft {
|
||||
return {
|
||||
name: "",
|
||||
transport: "http",
|
||||
url: "",
|
||||
httpAuth: "none",
|
||||
bearerToken: "",
|
||||
command: "",
|
||||
args: "",
|
||||
env: "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnv(raw: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const rawLine of raw.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator === -1) continue;
|
||||
const key = line.slice(0, separator).trim();
|
||||
const value = line.slice(separator + 1).trim();
|
||||
if (key) env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildMcpServerCreate(draft: McpServerDraft): McpServerCreate {
|
||||
const name = draft.name.trim();
|
||||
if (!name) throw new Error("Name required");
|
||||
|
||||
if (draft.transport === "http") {
|
||||
const url = draft.url.trim();
|
||||
if (!url) throw new Error("URL required");
|
||||
if (draft.httpAuth === "header" && !draft.bearerToken.trim()) {
|
||||
throw new Error("Bearer token required");
|
||||
}
|
||||
|
||||
const server: McpServerCreate = { name, url };
|
||||
if (draft.httpAuth !== "none") server.auth = draft.httpAuth;
|
||||
if (draft.httpAuth === "header") {
|
||||
server.bearer_token = draft.bearerToken;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
const command = draft.command.trim();
|
||||
if (!command) throw new Error("Command required");
|
||||
|
||||
const server: McpServerCreate = { name, command };
|
||||
const args = parseArgs(draft.args);
|
||||
if (args.length) server.args = args;
|
||||
const env = parseEnv(draft.env);
|
||||
if (Object.keys(env).length) server.env = env;
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { queryMatchesProviderOnly } from "./model-picker-filter";
|
||||
|
||||
describe("queryMatchesProviderOnly", () => {
|
||||
it("returns true when the query finds the provider but no model id (issue #65374)", () => {
|
||||
// Reproduces the exact case from the issue: typing "aws" locates the
|
||||
// "AWS Build" provider, but none of its Claude model ids contain "aws".
|
||||
const provider = { name: "AWS Build", slug: "aws-build" };
|
||||
const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
|
||||
|
||||
expect(queryMatchesProviderOnly(provider, models, "aws")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the query also matches a model id — keeps normal filtering", () => {
|
||||
const provider = { name: "AWS Build", slug: "aws-build" };
|
||||
const models = ["claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5"];
|
||||
|
||||
expect(queryMatchesProviderOnly(provider, models, "sonnet")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the query does not match the provider at all", () => {
|
||||
const provider = { name: "AWS Build", slug: "aws-build" };
|
||||
const models = ["claude-sonnet-4.5"];
|
||||
|
||||
expect(queryMatchesProviderOnly(provider, models, "openrouter")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty query", () => {
|
||||
const provider = { name: "AWS Build", slug: "aws-build" };
|
||||
const models = ["claude-sonnet-4.5"];
|
||||
|
||||
expect(queryMatchesProviderOnly(provider, models, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no selected provider", () => {
|
||||
expect(queryMatchesProviderOnly(null, ["claude-sonnet-4.5"], "aws")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fuzzyScoreMulti } from "@/lib/fuzzy";
|
||||
|
||||
/**
|
||||
* True when `trimmedQuery` located the selected provider by name/slug but
|
||||
* matches none of its models by id — the case where a single search box
|
||||
* filtering both the provider and model columns would otherwise leave the
|
||||
* model pane empty even though the user just successfully found the
|
||||
* provider they were looking for.
|
||||
*/
|
||||
export function queryMatchesProviderOnly(
|
||||
selectedProvider: { name: string; slug: string } | null,
|
||||
models: readonly string[],
|
||||
trimmedQuery: string,
|
||||
): boolean {
|
||||
if (!trimmedQuery || !selectedProvider) return false;
|
||||
|
||||
const matchesProvider =
|
||||
fuzzyScoreMulti(`${selectedProvider.name} ${selectedProvider.slug}`, trimmedQuery) !=
|
||||
null;
|
||||
const matchesAnyModel = models.some((m) => fuzzyScoreMulti(m, trimmedQuery) != null);
|
||||
|
||||
return matchesProvider && !matchesAnyModel;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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(" ")}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const clone = structuredClone(obj);
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = clone;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPtyCompositionForwarder } from "./pty-composition";
|
||||
|
||||
describe("createPtyCompositionForwarder", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("forwards committed dead-key text when xterm emits no onData", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ä");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("ä");
|
||||
});
|
||||
|
||||
it("leaves xterm's committed input alone when it arrives before the fallback", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ä");
|
||||
forwarder.noteTerminalData("äx");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a pending composition after unrelated terminal data", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ä");
|
||||
forwarder.noteTerminalData("x");
|
||||
vi.advanceTimersByTime(15);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("ä");
|
||||
});
|
||||
|
||||
it("forwards a pending composition when unrelated data precedes matching chunks", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ab");
|
||||
forwarder.noteTerminalData("x");
|
||||
forwarder.noteTerminalData("a");
|
||||
forwarder.noteTerminalData("b");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith("ab");
|
||||
});
|
||||
|
||||
it("cancels a pending composition when matching text arrives in clean chunks", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ab");
|
||||
forwarder.noteTerminalData("a");
|
||||
forwarder.noteTerminalData("b");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores ESC/SGR data while matching composition chunks", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ab");
|
||||
forwarder.noteTerminalData("a");
|
||||
forwarder.noteTerminalData("\x1b[<0;10;10M");
|
||||
forwarder.noteTerminalData("b");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a second composition after the first fallback completes", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ä");
|
||||
vi.runAllTimers();
|
||||
forwarder.onCompositionEnd("ö");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).toHaveBeenNthCalledWith(1, "ä");
|
||||
expect(send).toHaveBeenNthCalledWith(2, "ö");
|
||||
});
|
||||
|
||||
it("preserves an earlier rapid composition before scheduling the next", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("a");
|
||||
forwarder.onCompositionEnd("ä");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).toHaveBeenNthCalledWith(1, "a");
|
||||
expect(send).toHaveBeenNthCalledWith(2, "ä");
|
||||
});
|
||||
|
||||
it("cancels a pending composition on disposal", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("ä");
|
||||
forwarder.dispose();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send an empty cancelled composition", () => {
|
||||
vi.useFakeTimers();
|
||||
const send = vi.fn();
|
||||
const forwarder = createPtyCompositionForwarder(send);
|
||||
|
||||
forwarder.onCompositionEnd("");
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Delays an IME/dead-key commit just long enough for xterm to emit onData.
|
||||
*
|
||||
* xterm is authoritative when it emits the commit. Browsers/layouts where it
|
||||
* does not emit onData still forward the compositionend text on the next turn.
|
||||
*/
|
||||
export function createPtyCompositionForwarder(send: (data: string) => void) {
|
||||
let pending: string | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let matchedTerminalPrefix = "";
|
||||
let sawUnrelatedTerminalData = false;
|
||||
|
||||
const clearPending = () => {
|
||||
pending = null;
|
||||
matchedTerminalPrefix = "";
|
||||
sawUnrelatedTerminalData = false;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onCompositionEnd(data: string | null) {
|
||||
if (!data) return;
|
||||
// Preserve rapid consecutive commits instead of discarding the first.
|
||||
const previous = pending;
|
||||
clearPending();
|
||||
if (previous) send(previous);
|
||||
pending = data;
|
||||
timer = setTimeout(() => {
|
||||
const committed = pending;
|
||||
clearPending();
|
||||
if (committed) send(committed);
|
||||
}, 16);
|
||||
},
|
||||
noteTerminalData(data: string) {
|
||||
if (!pending || data.startsWith("\x1b") || sawUnrelatedTerminalData) return;
|
||||
|
||||
// xterm may split committed text across callbacks, but only a clean,
|
||||
// leading match is authoritative. Once unrelated data arrives, retain
|
||||
// the fallback even if later callbacks happen to spell the composition.
|
||||
const observed = matchedTerminalPrefix + data;
|
||||
if (observed.startsWith(pending)) {
|
||||
clearPending();
|
||||
} else if (pending.startsWith(observed)) {
|
||||
matchedTerminalPrefix = observed;
|
||||
} else {
|
||||
sawUnrelatedTerminalData = true;
|
||||
}
|
||||
},
|
||||
dispose: clearPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
resolvePtyKeyboardShortcut,
|
||||
sendPtyShortcutSequence,
|
||||
} from './pty-keyboard-shortcuts'
|
||||
import type { PtyConnectionState } from './pty-reconnect'
|
||||
|
||||
const key = (overrides: Partial<KeyboardEvent> = {}) =>
|
||||
({
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
key: '',
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
}) as KeyboardEvent
|
||||
|
||||
describe('resolvePtyKeyboardShortcut', () => {
|
||||
it('copies terminal selection with bare Ctrl+C on non-macOS', () => {
|
||||
expect(
|
||||
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, true),
|
||||
).toBe('copy')
|
||||
})
|
||||
|
||||
it('preserves Ctrl+C interrupt when nothing is selected', () => {
|
||||
expect(
|
||||
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, false),
|
||||
).toBe('pass')
|
||||
})
|
||||
|
||||
it('maps Ctrl+Backspace to backward word deletion', () => {
|
||||
expect(
|
||||
resolvePtyKeyboardShortcut(
|
||||
key({ ctrlKey: true, key: 'Backspace' }),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
).toBe('delete-word-backward')
|
||||
})
|
||||
|
||||
it('maps Ctrl+Delete to forward word deletion', () => {
|
||||
expect(
|
||||
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'Delete' }), false, false),
|
||||
).toBe('delete-word-forward')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendPtyShortcutSequence', () => {
|
||||
const socket = (readyState: number = WebSocket.OPEN) => ({
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
}) as unknown as Pick<WebSocket, 'readyState' | 'send'> & { send: ReturnType<typeof vi.fn> }
|
||||
|
||||
it('sends the sequence when the socket and PTY state are open', () => {
|
||||
const ws = socket()
|
||||
|
||||
expect(sendPtyShortcutSequence(ws, 'open', '\x17')).toBe(true)
|
||||
expect(ws.send).toHaveBeenCalledOnce()
|
||||
expect(ws.send).toHaveBeenCalledWith('\x17')
|
||||
})
|
||||
|
||||
it.each<PtyConnectionState>(['connecting', 'reconnecting', 'closed', 'ended'])(
|
||||
'blocks shortcut bytes while the PTY state is %s',
|
||||
(state) => {
|
||||
const ws = socket()
|
||||
|
||||
expect(sendPtyShortcutSequence(ws, state, '\x1bd')).toBe(false)
|
||||
expect(ws.send).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('blocks shortcut bytes when the socket is missing or not open', () => {
|
||||
const ws = socket(WebSocket.CLOSING)
|
||||
|
||||
expect(sendPtyShortcutSequence(null, 'open', '\x17')).toBe(false)
|
||||
expect(sendPtyShortcutSequence(ws, 'open', '\x17')).toBe(false)
|
||||
expect(ws.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { shouldBlockPtyInput, type PtyConnectionState } from "./pty-reconnect";
|
||||
|
||||
const WS_OPEN = 1;
|
||||
|
||||
export type PtyKeyboardShortcut =
|
||||
| "copy"
|
||||
| "delete-word-backward"
|
||||
| "delete-word-forward"
|
||||
| "pass";
|
||||
|
||||
export function resolvePtyKeyboardShortcut(
|
||||
ev: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey">,
|
||||
isMac: boolean,
|
||||
hasTerminalSelection: boolean,
|
||||
): PtyKeyboardShortcut {
|
||||
const key = ev.key.toLowerCase();
|
||||
const copyPressed = isMac
|
||||
? ev.metaKey && !ev.ctrlKey
|
||||
: ev.ctrlKey && !ev.altKey && !ev.metaKey;
|
||||
|
||||
if (copyPressed && key === "c" && hasTerminalSelection) {
|
||||
return "copy";
|
||||
}
|
||||
|
||||
if (
|
||||
ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
ev.key === "Backspace"
|
||||
) {
|
||||
return "delete-word-backward";
|
||||
}
|
||||
|
||||
if (
|
||||
ev.ctrlKey &&
|
||||
!ev.shiftKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
ev.key === "Delete"
|
||||
) {
|
||||
return "delete-word-forward";
|
||||
}
|
||||
|
||||
return "pass";
|
||||
}
|
||||
|
||||
// Guarded sender for shortcut escape sequences. Applies the same gate as the
|
||||
// term.onData path (socket OPEN + shouldBlockPtyInput) so a reconnecting or
|
||||
// closed session never receives shortcut bytes.
|
||||
export function sendPtyShortcutSequence(
|
||||
ws: Pick<WebSocket, "readyState" | "send"> | null,
|
||||
ptyState: PtyConnectionState,
|
||||
sequence: string,
|
||||
): boolean {
|
||||
if (!ws || ws.readyState !== WS_OPEN || shouldBlockPtyInput(ptyState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ws.send(sequence);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
normalizePtyMobileInput,
|
||||
shouldTreatInputAsMobileReplacement,
|
||||
updatePtyInputLine,
|
||||
} from "./pty-mobile-input";
|
||||
|
||||
describe("shouldTreatInputAsMobileReplacement", () => {
|
||||
it("recognizes explicit browser replacement input", () => {
|
||||
expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true);
|
||||
expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true);
|
||||
expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats multi-character mobile insertText as replacement-like", () => {
|
||||
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true);
|
||||
expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false);
|
||||
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePtyMobileInput", () => {
|
||||
it("turns a Gboard full-line suggestion into a line replacement", () => {
|
||||
const result = normalizePtyMobileInput(
|
||||
"hello my name is Kain Kain",
|
||||
"hello my name is kain",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.normalized).toBe(true);
|
||||
expect(result.nextLine).toBe("hello my name is Kain");
|
||||
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
|
||||
});
|
||||
|
||||
it("turns a Gboard last-word suggestion into a last-word replacement", () => {
|
||||
const result = normalizePtyMobileInput("Kain", "hello my name is kain", true);
|
||||
|
||||
expect(result.normalized).toBe(true);
|
||||
expect(result.nextLine).toBe("hello my name is Kain");
|
||||
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
|
||||
});
|
||||
|
||||
it("does not normalize ordinary appends when replacement is not active", () => {
|
||||
const result = normalizePtyMobileInput(
|
||||
"hello my name is Kain Kain",
|
||||
"hello my name is kain",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.normalized).toBe(false);
|
||||
expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain");
|
||||
});
|
||||
|
||||
it("does not normalize control input", () => {
|
||||
const result = normalizePtyMobileInput("\r", "hello", true);
|
||||
|
||||
expect(result.normalized).toBe(false);
|
||||
expect(result.nextLine).toBe("");
|
||||
expect(result.data).toBe("\r");
|
||||
});
|
||||
|
||||
it("does not collapse legitimate single-letter reduplication", () => {
|
||||
// "a a" is a plausible thing to type; the >=2-char guard keeps the
|
||||
// duplicate-final-word collapse from eating it inside the window.
|
||||
const result = normalizePtyMobileInput("a a", "a", true);
|
||||
|
||||
expect(result.normalized).toBe(false);
|
||||
expect(result.data).toBe("a a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePtyInputLine", () => {
|
||||
it("tracks printable text, delete, and submit", () => {
|
||||
expect(updatePtyInputLine("", "abc")).toBe("abc");
|
||||
expect(updatePtyInputLine("abc", "\x7f")).toBe("ab");
|
||||
expect(updatePtyInputLine("abc", "\r")).toBe("");
|
||||
});
|
||||
|
||||
it("resets tracking on escape sequences instead of appending their payload", () => {
|
||||
// Left-arrow arrives as one CSI chunk; the tracker cannot model cursor
|
||||
// moves, so it must disarm rather than record "hello[D".
|
||||
expect(updatePtyInputLine("hello", "\x1b[D")).toBe("");
|
||||
expect(updatePtyInputLine("hello", "\x1b[H")).toBe("");
|
||||
expect(updatePtyInputLine("hello", "\x1bOP")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePtyMobileInput after cursor movement", () => {
|
||||
it("does not emit a replacement against a tracker reset by arrow keys", () => {
|
||||
// Simulate: type "hello my name is kain", press left-arrow, then a
|
||||
// Gboard suggestion arrives. The tracker reset means no replacement
|
||||
// heuristic can fire against a stale line snapshot.
|
||||
const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D");
|
||||
const result = normalizePtyMobileInput("Kain", afterArrow, true);
|
||||
|
||||
expect(result.normalized).toBe(false);
|
||||
expect(result.data).toBe("Kain");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
const DELETE = "\x7f";
|
||||
|
||||
// How long (ms) after a mobile IME / replacement event we treat subsequent
|
||||
// terminal input as a candidate line-replacement rather than a plain append.
|
||||
// Exported so the ChatPage integration and tests share one tunable value.
|
||||
export const MOBILE_REPLACEMENT_WINDOW_MS = 350;
|
||||
|
||||
function chars(text: string): string[] {
|
||||
return Array.from(text);
|
||||
}
|
||||
|
||||
function removeLastChar(text: string): string {
|
||||
const c = chars(text);
|
||||
c.pop();
|
||||
return c.join("");
|
||||
}
|
||||
|
||||
|
||||
function isPlainText(data: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex -- terminal data may contain control chars
|
||||
return !/[\x00-\x1f\x7f]/.test(data);
|
||||
}
|
||||
|
||||
function lastWordMatch(line: string): RegExpMatchArray | null {
|
||||
return line.match(/^(.*?)(\S+)(\s*)$/u);
|
||||
}
|
||||
|
||||
function collapseDuplicatedFinalWord(text: string, previousLine: string): string {
|
||||
const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u);
|
||||
if (!match) return text;
|
||||
|
||||
const [, prefix, first, , second, trailing] = match;
|
||||
if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text;
|
||||
// Only collapse a duplication the tracked line already ended with — i.e.
|
||||
// Gboard re-emitted the final word. Requiring a >=2-char word avoids
|
||||
// eating legitimate single-letter reduplication ("a a", "i i") that a
|
||||
// user may genuinely type inside the replacement window.
|
||||
if (first.length < 2) return text;
|
||||
if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) {
|
||||
return text;
|
||||
}
|
||||
return `${prefix}${first}${trailing}`;
|
||||
}
|
||||
|
||||
function replacementLineForMobileInput(
|
||||
currentLine: string,
|
||||
incoming: string,
|
||||
): string | null {
|
||||
if (!currentLine || currentLine.length < 2 || !incoming) return null;
|
||||
|
||||
const currentLower = currentLine.toLocaleLowerCase();
|
||||
const incomingLower = incoming.toLocaleLowerCase();
|
||||
|
||||
if (incomingLower.startsWith(currentLower)) {
|
||||
return collapseDuplicatedFinalWord(incoming, currentLine);
|
||||
}
|
||||
|
||||
const word = lastWordMatch(currentLine);
|
||||
if (!word) return null;
|
||||
|
||||
const [, prefix, last, trailing] = word;
|
||||
if (trailing) return null;
|
||||
|
||||
const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? "";
|
||||
if (
|
||||
incomingFirst &&
|
||||
incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase()
|
||||
) {
|
||||
return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldTreatInputAsMobileReplacement(
|
||||
inputType: string | undefined,
|
||||
data: string | null | undefined,
|
||||
isMobileLike: boolean,
|
||||
): boolean {
|
||||
if (
|
||||
inputType === "insertReplacementText" ||
|
||||
inputType === "insertFromComposition" ||
|
||||
inputType === "insertCompositionText"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1;
|
||||
}
|
||||
|
||||
export function updatePtyInputLine(currentLine: string, data: string): string {
|
||||
// Escape sequences (arrow keys, home/end, function keys, paste guards)
|
||||
// move the cursor or edit the line in ways this flat tracker cannot
|
||||
// model — and the per-char loop below would append their printable
|
||||
// payload (e.g. the "[D" of a left-arrow) as if it were typed text.
|
||||
// Reset instead: an unknown cursor position must disarm replacement
|
||||
// normalization until the user starts a fresh, cleanly-tracked line.
|
||||
if (data.includes("\x1b")) {
|
||||
return "";
|
||||
}
|
||||
let next = currentLine;
|
||||
for (const ch of chars(data)) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
next = "";
|
||||
} else if (ch === DELETE || ch === "\b") {
|
||||
next = removeLastChar(next);
|
||||
} else if (ch === "\x15") {
|
||||
next = "";
|
||||
} else if (isPlainText(ch)) {
|
||||
next += ch;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function normalizePtyMobileInput(
|
||||
data: string,
|
||||
currentLine: string,
|
||||
replacementActive: boolean,
|
||||
): { data: string; nextLine: string; normalized: boolean } {
|
||||
if (replacementActive && isPlainText(data)) {
|
||||
const replacementLine = replacementLineForMobileInput(currentLine, data);
|
||||
if (replacementLine !== null) {
|
||||
return {
|
||||
data: DELETE.repeat(chars(currentLine).length) + replacementLine,
|
||||
nextLine: replacementLine,
|
||||
normalized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
nextLine: updatePtyInputLine(currentLine, data),
|
||||
normalized: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
shouldBlockPtyInput,
|
||||
shouldReconnectPtyOnPageResume,
|
||||
} from "./pty-reconnect";
|
||||
|
||||
describe("shouldReconnectPtyOnPageResume", () => {
|
||||
it("reconnects a missing socket when the active page becomes visible", () => {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "visible",
|
||||
online: true,
|
||||
socketReadyState: null,
|
||||
ptyState: "reconnecting",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reconnects closed or closing sockets on visible resume", () => {
|
||||
for (const socketReadyState of [2, 3]) {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "visible",
|
||||
online: true,
|
||||
socketReadyState,
|
||||
ptyState: "reconnecting",
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reconnect an open socket on visible resume", () => {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "visible",
|
||||
online: true,
|
||||
socketReadyState: 1,
|
||||
ptyState: "open",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reconnects a still-connecting socket when the page is already in reconnecting state", () => {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "visible",
|
||||
online: true,
|
||||
socketReadyState: 0,
|
||||
ptyState: "reconnecting",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reconnect while the page is hidden", () => {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "hidden",
|
||||
online: true,
|
||||
socketReadyState: 3,
|
||||
ptyState: "reconnecting",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defers reconnect while offline", () => {
|
||||
expect(
|
||||
shouldReconnectPtyOnPageResume({
|
||||
isActive: true,
|
||||
visibilityState: "visible",
|
||||
online: false,
|
||||
socketReadyState: 3,
|
||||
ptyState: "reconnecting",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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",
|
||||
online: true,
|
||||
socketReadyState: null,
|
||||
ptyState: "connecting",
|
||||
connectInFlight: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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",
|
||||
online: true,
|
||||
socketReadyState: null,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// them while replaying a long session; past that replay they are legitimate
|
||||
// in-place redraws (spinners, progress bars, status lines) and must reach
|
||||
// xterm or stale glyphs are left on screen. The replay of a 200+ message
|
||||
// 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 interface PtyResumeReconnectInput {
|
||||
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;
|
||||
|
||||
export function shouldReconnectPtyOnPageResume({
|
||||
isActive,
|
||||
visibilityState,
|
||||
online,
|
||||
socketReadyState,
|
||||
ptyState,
|
||||
connectInFlight,
|
||||
}: PtyResumeReconnectInput): boolean {
|
||||
if (!isActive || !online || visibilityState === "hidden") {
|
||||
return false;
|
||||
}
|
||||
if (ptyState === "ended") {
|
||||
return false;
|
||||
}
|
||||
if (socketReadyState === WS_OPEN) {
|
||||
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;
|
||||
}
|
||||
return (
|
||||
socketReadyState === null ||
|
||||
socketReadyState === undefined ||
|
||||
socketReadyState === WS_CLOSING ||
|
||||
socketReadyState === WS_CLOSED ||
|
||||
ptyState === "reconnecting" ||
|
||||
ptyState === "closed"
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean {
|
||||
return ptyState !== "open";
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PtyResumeSanitizer } from "./pty-resume-sanitizer";
|
||||
import {
|
||||
PTY_RESUME_LOADING_MAX_MS,
|
||||
shouldFinishResumeHydrationOnChunk,
|
||||
shouldShowResumeLoadingOverlay,
|
||||
} from "./pty-resume-loading";
|
||||
|
||||
describe("shouldFinishResumeHydrationOnChunk", () => {
|
||||
it("finishes on the first non-empty chunk", () => {
|
||||
expect(shouldFinishResumeHydrationOnChunk("")).toBe(false);
|
||||
expect(shouldFinishResumeHydrationOnChunk("hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a positive hard-cap timeout for wedged resumes", () => {
|
||||
expect(PTY_RESUME_LOADING_MAX_MS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resume hydration gate over the real sanitizer", () => {
|
||||
// Regression: the gate must key off the payload actually written to xterm,
|
||||
// not the raw frame. The sanitizer collapses an erase-only / all-newline /
|
||||
// partial-CSI resume frame to "", so a nonempty raw first frame would
|
||||
// otherwise clear the wait notice while the terminal is still blank.
|
||||
const ESC = String.fromCharCode(27);
|
||||
const CRLF = String.fromCharCode(13, 10);
|
||||
const VISIBLE = `Hello world${CRLF}`;
|
||||
|
||||
const controlOnlyFirstFrames: Array<[string, string]> = [
|
||||
["erase-only", `${ESC}[2K`],
|
||||
["all-newline", `${CRLF}${CRLF}`],
|
||||
["partial-CSI", `${ESC}[`],
|
||||
];
|
||||
|
||||
it.each(controlOnlyFirstFrames)(
|
||||
"keeps hydrating on a %s first frame, then finishes on visible replay",
|
||||
(_label, firstFrame) => {
|
||||
const sanitizer = new PtyResumeSanitizer();
|
||||
|
||||
// Raw first frame is nonempty, but nothing is written to xterm...
|
||||
expect(firstFrame.length).toBeGreaterThan(0);
|
||||
const firstRendered = sanitizer.next(firstFrame);
|
||||
expect(firstRendered).toBe("");
|
||||
expect(shouldFinishResumeHydrationOnChunk(firstRendered)).toBe(false);
|
||||
|
||||
// ...so the notice only clears once real replay output arrives.
|
||||
const secondRendered = sanitizer.next(VISIBLE);
|
||||
expect(secondRendered.length).toBeGreaterThan(0);
|
||||
expect(shouldFinishResumeHydrationOnChunk(secondRendered)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("shouldShowResumeLoadingOverlay", () => {
|
||||
it("shows while a resume target is connecting or open and still hydrating", () => {
|
||||
expect(
|
||||
shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget: true,
|
||||
ptyState: "connecting",
|
||||
hydrating: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget: true,
|
||||
ptyState: "open",
|
||||
hydrating: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides when there is no resume target", () => {
|
||||
expect(
|
||||
shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget: false,
|
||||
ptyState: "connecting",
|
||||
hydrating: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides once hydration finishes", () => {
|
||||
expect(
|
||||
shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget: true,
|
||||
ptyState: "open",
|
||||
hydrating: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defers to reconnect / closed / ended overlays", () => {
|
||||
for (const ptyState of ["reconnecting", "closed", "ended"] as const) {
|
||||
expect(
|
||||
shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget: true,
|
||||
ptyState,
|
||||
hydrating: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PtyConnectionState } from "@/lib/pty-reconnect";
|
||||
|
||||
/**
|
||||
* Hard cap so a wedged resume (never gets PTY payload) cannot leave the
|
||||
* wait notice up forever.
|
||||
*/
|
||||
export const PTY_RESUME_LOADING_MAX_MS = 30000;
|
||||
|
||||
export const PTY_RESUME_LOADING_MESSAGE =
|
||||
"Please wait while the conversation loads…";
|
||||
|
||||
export interface ResumeLoadingOverlayInput {
|
||||
hasResumeTarget: boolean;
|
||||
ptyState: PtyConnectionState;
|
||||
hydrating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a wait notice only while a resumed chat is still blank. Once the
|
||||
* first real PTY payload arrives the terminal has something to show, so
|
||||
* the notice hides and history can stream in underneath.
|
||||
*
|
||||
* Reconnect / ended / closed states keep their own overlays and must not
|
||||
* stack this one on top.
|
||||
*/
|
||||
export function shouldShowResumeLoadingOverlay({
|
||||
hasResumeTarget,
|
||||
ptyState,
|
||||
hydrating,
|
||||
}: ResumeLoadingOverlayInput): boolean {
|
||||
if (!hasResumeTarget || !hydrating) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
ptyState === "reconnecting" ||
|
||||
ptyState === "closed" ||
|
||||
ptyState === "ended"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return ptyState === "connecting" || ptyState === "open";
|
||||
}
|
||||
|
||||
/** First non-empty PTY chunk means the blank window is over. */
|
||||
export function shouldFinishResumeHydrationOnChunk(chunkText: string): boolean {
|
||||
return chunkText.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPtyFilters, PtyResumeSanitizer } from "./pty-resume-sanitizer";
|
||||
|
||||
/**
|
||||
* Fixture note — why most cases use CRLF, not LF.
|
||||
*
|
||||
* `hermes_cli/pty_bridge.py` spawns the agent through
|
||||
* `ptyprocess.PtyProcess.spawn()` and never puts the PTY into raw mode, so the
|
||||
* line discipline runs with ONLCR: every LF the child writes reaches the
|
||||
* master (and therefore xterm) as CRLF. Verified against a real PTY:
|
||||
*
|
||||
* child writes b"A" + b"\n"*5 + b"B" -> master reads b"A\r\n\r\n\r\n\r\n\r\nB"
|
||||
*
|
||||
* Tests that assert burst collapsing therefore use `\r\n`; an LF-only fixture
|
||||
* would pass while the production path did nothing.
|
||||
*/
|
||||
const CRLF = "\r\n";
|
||||
|
||||
/** Split a string into fixed-size frames, mimicking chunked `os.read` output. */
|
||||
function frames(s: string, size: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Feed every frame through a sanitizer and concatenate output + flush. */
|
||||
function drain(sanitizer: PtyResumeSanitizer, chunks: string[]): string {
|
||||
return chunks.map((c) => sanitizer.next(c)).join("") + sanitizer.flush();
|
||||
}
|
||||
|
||||
describe("applyPtyFilters", () => {
|
||||
it("passes through normal text unchanged", () => {
|
||||
const input = "hello world\r\nfoo bar\r\n";
|
||||
expect(applyPtyFilters(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("collapses pathological CRLF bursts (real PTY output)", () => {
|
||||
const burst = "a" + CRLF.repeat(1000) + "b";
|
||||
expect(applyPtyFilters(burst)).toBe("a\r\n\r\nb");
|
||||
});
|
||||
|
||||
it("collapses pathological LF-only bursts (raw-mode PTY)", () => {
|
||||
const burst = "a" + "\n".repeat(100) + "b";
|
||||
expect(applyPtyFilters(burst)).toBe("a\r\n\r\nb");
|
||||
});
|
||||
|
||||
it("leaves short blank-line runs untouched", () => {
|
||||
const short = `a${CRLF}${CRLF}${CRLF}b`;
|
||||
expect(applyPtyFilters(short)).toBe(short);
|
||||
});
|
||||
|
||||
it("strips erase-line CSI sequences", () => {
|
||||
expect(applyPtyFilters("hello\x1b[K world")).toBe("hello world");
|
||||
expect(applyPtyFilters("foo\x1b[2Kbar")).toBe("foobar");
|
||||
expect(applyPtyFilters("\x1b[K")).toBe("");
|
||||
});
|
||||
|
||||
it("strips erase-char CSI sequences", () => {
|
||||
expect(applyPtyFilters("abc\x1b[3Xdef")).toBe("abcdef");
|
||||
expect(applyPtyFilters("\x1b[X")).toBe("");
|
||||
});
|
||||
|
||||
it("leaves normal SGR sequences untouched", () => {
|
||||
const input = "\x1b[31mred text\x1b[0m\r\n";
|
||||
expect(applyPtyFilters(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(applyPtyFilters("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves erase codes when erase-stripping is disabled", () => {
|
||||
// Post-resume in-place redraw must keep ESC[K or stale glyphs remain.
|
||||
const spinner = "Loading 10%\r\x1b[KLoading 20%\r\x1b[KDone";
|
||||
expect(applyPtyFilters(spinner, false)).toBe(spinner);
|
||||
});
|
||||
|
||||
it("still collapses bursts when erase-stripping is disabled", () => {
|
||||
const burst = "a" + CRLF.repeat(200) + "b";
|
||||
expect(applyPtyFilters(burst, false)).toBe("a\r\n\r\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PtyResumeSanitizer — stateful frame handling", () => {
|
||||
it("processes a complete chunk in one frame", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
// The trailing newline run is held back in case it continues next frame.
|
||||
expect(s.next("hello\r\n")).toBe("hello");
|
||||
expect(s.flush()).toBe("\r\n");
|
||||
});
|
||||
|
||||
it("buffers a trailing partial escape and resolves in next frame", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("hello\x1b[")).toBe("hello");
|
||||
expect(s.next("2K world\r\n")).toBe(" world");
|
||||
expect(s.flush()).toBe("\r\n");
|
||||
});
|
||||
|
||||
it("buffers bare \\x1b and resolves on completion", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("before\x1b")).toBe("before");
|
||||
expect(s.next("[Kafter")).toBe("after");
|
||||
});
|
||||
|
||||
it("buffers \\x1b[\\d+ prefix and resolves", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("x\x1b[4")).toBe("x");
|
||||
expect(s.next("2Ky")).toBe("y");
|
||||
});
|
||||
|
||||
it("passes through when no partial escape is buffered", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("chunk1 ")).toBe("chunk1 ");
|
||||
expect(s.next("chunk2 ")).toBe("chunk2 ");
|
||||
});
|
||||
|
||||
it("drops a buffered partial escape on flush", () => {
|
||||
// An unterminated CSI must never reach xterm: it would leave the parser
|
||||
// in an "in-escape" state and swallow output after reconnect.
|
||||
const s = new PtyResumeSanitizer();
|
||||
s.next("trailing\x1b[");
|
||||
expect(s.flush()).toBe("");
|
||||
});
|
||||
|
||||
it("resets state across instances", () => {
|
||||
const a = new PtyResumeSanitizer();
|
||||
a.next("\x1b[");
|
||||
const b = new PtyResumeSanitizer();
|
||||
// "[K" without an ESC prefix is literal text, not a CSI code.
|
||||
expect(b.next("[Khello")).toBe("[Khello");
|
||||
expect(a.flush()).toBe("");
|
||||
});
|
||||
|
||||
it("handles empty chunk without disturbing pending buffer", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
s.next("before\x1b[");
|
||||
expect(s.next("")).toBe("");
|
||||
expect(s.next("2Kafter")).toBe("after");
|
||||
});
|
||||
|
||||
it("handles consecutive split escapes across three frames", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("a\x1b")).toBe("a");
|
||||
expect(s.next("[")).toBe("");
|
||||
expect(s.next("2Kb")).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PtyResumeSanitizer — cross-frame blank-line bursts", () => {
|
||||
it("collapses a burst contained in a single frame", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(drain(s, ["start" + CRLF.repeat(60) + "end"])).toBe(
|
||||
"start\r\n\r\nend",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a burst split across 64KiB-scale frames", () => {
|
||||
// bridge.read() does os.read(fd, 65536); a 3000-row burst spans frames.
|
||||
const burst = "START" + CRLF.repeat(3000) + "END";
|
||||
const s = new PtyResumeSanitizer();
|
||||
const out = drain(s, frames(burst, 4096));
|
||||
expect(out).toBe("START\r\n\r\nEND");
|
||||
});
|
||||
|
||||
it("collapses a burst delivered as many small partial reads", () => {
|
||||
const burst = "START" + CRLF.repeat(3000) + "END";
|
||||
const s = new PtyResumeSanitizer();
|
||||
const out = drain(s, frames(burst, 40));
|
||||
expect(out).toBe("START\r\n\r\nEND");
|
||||
});
|
||||
|
||||
it("collapses sub-threshold fragments that sum past the threshold", () => {
|
||||
// 49 + 49 rows individually duck the 50 threshold but total 98 blank rows.
|
||||
const s = new PtyResumeSanitizer();
|
||||
const out = drain(s, [CRLF.repeat(49), CRLF.repeat(49)]);
|
||||
expect(out).toBe("\r\n\r\n");
|
||||
});
|
||||
|
||||
it("does not merge blank runs separated by real content", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
const out = drain(s, [CRLF.repeat(40), "text", CRLF.repeat(40)]);
|
||||
expect(out).toBe(`${CRLF.repeat(40)}text${CRLF.repeat(40)}`);
|
||||
});
|
||||
|
||||
it("emits a buffered trailing newline run on flush", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.next("done" + CRLF)).toBe("done");
|
||||
expect(s.flush()).toBe(CRLF);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PtyResumeSanitizer — bounded erase suppression", () => {
|
||||
it("suppresses erase codes while the resume window is open", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
expect(s.isSuppressingErase).toBe(true);
|
||||
expect(s.next("a\x1b[Kb")).toBe("ab");
|
||||
});
|
||||
|
||||
it("stops suppressing erase codes once the window closes", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
s.endEraseSuppression();
|
||||
expect(s.isSuppressingErase).toBe(false);
|
||||
expect(s.next("a\x1b[Kb")).toBe("a\x1b[Kb");
|
||||
});
|
||||
|
||||
it("keeps collapsing bursts after the window closes", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
s.endEraseSuppression();
|
||||
expect(drain(s, ["x" + CRLF.repeat(80) + "y"])).toBe("x\r\n\r\ny");
|
||||
});
|
||||
|
||||
it("preserves a post-resume spinner redraw verbatim", () => {
|
||||
const s = new PtyResumeSanitizer();
|
||||
s.endEraseSuppression();
|
||||
const spinner = "Loading 10%\r\x1b[KLoading 20%\r\x1b[KDone";
|
||||
expect(drain(s, [spinner])).toBe(spinner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* PTY resume output sanitizer — strips pathological ANSI sequences that
|
||||
* Ink's two-pass virtual scroll emits during session resume.
|
||||
*
|
||||
* Three properties of the real pipeline drive this design:
|
||||
*
|
||||
* 1. **The PTY is in cooked mode (ONLCR).** `hermes_cli/pty_bridge.py` spawns
|
||||
* via `ptyprocess.PtyProcess.spawn()` and never calls `setraw()`, so the
|
||||
* line discipline rewrites every LF the child writes as CRLF. A burst
|
||||
* therefore arrives as `\r\n\r\n\r\n…`, never as bare `\n\n\n…`, and any
|
||||
* filter matching `\n{50,}` would never fire in production.
|
||||
*
|
||||
* 2. **Reads are chunked, not message-framed.** `bridge.read()` does
|
||||
* `os.read(fd, 65536)` per drain tick and `pty_session.py` forwards each
|
||||
* read as its own binary WebSocket frame. A CSI escape, a UTF-8 code
|
||||
* point, *and* a long blank-line run can each straddle a frame boundary,
|
||||
* so all three need trailing-state buffering to survive reassembly.
|
||||
*
|
||||
* 3. **Erase codes are only pathological during the resume replay.** Once the
|
||||
* replay has settled, `ESC[K` / `ESC[X` are exactly how a TUI clears stale
|
||||
* glyphs for spinners, progress bars, and status lines. Stripping them
|
||||
* forever corrupts normal interactive output, so suppression is bounded to
|
||||
* a short window after connect (see PTY_RESUME_SANITIZE_WINDOW_MS).
|
||||
*/
|
||||
|
||||
/** A blank-line run: CRLF (real PTY, cooked mode) or bare LF (raw-mode PTY). */
|
||||
const BLANK_LINE_BURST = /(?:\r?\n){50,}/g;
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
|
||||
const ERASE_LINE = /\x1b\[\d*K/g;
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
|
||||
const ERASE_CHAR = /\x1b\[\d*X/g;
|
||||
|
||||
/** Still-incomplete trailing escape: "\x1b", "\x1b[", "\x1b[\d*". */
|
||||
// eslint-disable-next-line no-control-regex -- intentional ESC byte in ANSI sequence parser
|
||||
const PARTIAL_ESC = /^\x1b(?:\[\d*)?$/;
|
||||
|
||||
/**
|
||||
* A trailing run of newlines that may continue into the next frame. Held back
|
||||
* so a burst split across frames still meets the collapse threshold instead of
|
||||
* slipping through as sub-threshold fragments. A lone trailing `\r` is
|
||||
* included: a frame boundary can fall between the CR and LF of a CRLF pair,
|
||||
* and emitting the CR early would break one run into two shorter ones.
|
||||
*
|
||||
* Always matches (possibly empty, at end of input).
|
||||
*/
|
||||
const TRAILING_NEWLINES = /(?:\r?\n)*\r?$/;
|
||||
|
||||
/** Collapsed form of a pathological burst: one blank row, CRLF for xterm. */
|
||||
const COLLAPSED_BURST = "\r\n\r\n";
|
||||
|
||||
/**
|
||||
* Apply all suppression rules to a safely-completed string.
|
||||
*
|
||||
* @param stripErase When false, `ESC[K` / `ESC[X` are preserved. Blank-line
|
||||
* burst collapsing still applies — a thousand-row burst is pathological
|
||||
* whenever it appears, but erase codes are legitimate once resume settles.
|
||||
* Exported for focused unit-testing of filter behaviour.
|
||||
*/
|
||||
export function applyPtyFilters(input: string, stripErase = true): string {
|
||||
const collapsed = input.replace(BLANK_LINE_BURST, COLLAPSED_BURST);
|
||||
if (!stripErase) return collapsed;
|
||||
return collapsed.replace(ERASE_LINE, "").replace(ERASE_CHAR, "");
|
||||
}
|
||||
|
||||
/** Stateful chunk processor that guards against cross-frame split sequences. */
|
||||
export class PtyResumeSanitizer {
|
||||
#pending = "";
|
||||
#stripErase = true;
|
||||
|
||||
/**
|
||||
* Stop stripping erase codes while continuing to collapse blank-line bursts.
|
||||
* Called when the resume-replay window closes so that ordinary interactive
|
||||
* redraws (spinners, progress bars, status lines) keep their `ESC[K`.
|
||||
*/
|
||||
endEraseSuppression(): void {
|
||||
this.#stripErase = false;
|
||||
}
|
||||
|
||||
/** True while erase-code stripping is still active. */
|
||||
get isSuppressingErase(): boolean {
|
||||
return this.#stripErase;
|
||||
}
|
||||
|
||||
/** Feed one decoded WebSocket frame payload. Returns the sanitized output. */
|
||||
next(chunk: string): string {
|
||||
const combined = this.#pending + chunk;
|
||||
if (combined === "") {
|
||||
this.#pending = "";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Hold back a trailing partial escape so a CSI split across frames is
|
||||
// still recognised once its terminating byte arrives.
|
||||
const lastEsc = combined.lastIndexOf("\x1b");
|
||||
if (lastEsc !== -1 && PARTIAL_ESC.test(combined.slice(lastEsc))) {
|
||||
this.#pending = combined.slice(lastEsc);
|
||||
return applyPtyFilters(combined.slice(0, lastEsc), this.#stripErase);
|
||||
}
|
||||
|
||||
// Hold back a trailing newline run so a burst spanning frames accumulates
|
||||
// to the collapse threshold instead of leaking through in fragments.
|
||||
// TRAILING_NEWLINES always matches; an empty match at end of input means
|
||||
// the frame does not end mid-run and nothing needs to be held back.
|
||||
const trailing = TRAILING_NEWLINES.exec(combined) as RegExpExecArray;
|
||||
if (trailing.index === 0) {
|
||||
// The whole frame is newlines — keep accumulating, emit nothing yet.
|
||||
this.#pending = combined;
|
||||
return "";
|
||||
}
|
||||
this.#pending = trailing[0];
|
||||
return applyPtyFilters(combined.slice(0, trailing.index), this.#stripErase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain buffered state at end of stream.
|
||||
*
|
||||
* A buffered *partial escape* is dropped rather than written: `#pending` only
|
||||
* ever holds an incomplete sequence, and emitting one would leave xterm's
|
||||
* parser in an "in-escape" state that swallows subsequent output after
|
||||
* reconnect. A buffered *newline run* is safe and is emitted (collapsed).
|
||||
*/
|
||||
flush(): string {
|
||||
const last = this.#pending;
|
||||
this.#pending = "";
|
||||
if (last === "" || last.includes("\x1b")) return "";
|
||||
return applyPtyFilters(last, this.#stripErase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isViewportPinnedToBottom,
|
||||
parseResumeControlMessage,
|
||||
shouldFollowPtyOutput,
|
||||
} from "./pty-scroll";
|
||||
|
||||
describe("isViewportPinnedToBottom", () => {
|
||||
it("is pinned when the viewport sits on the bottom row", () => {
|
||||
// xterm reports viewportY === baseY when the newest output is on screen.
|
||||
expect(isViewportPinnedToBottom({ viewportY: 120, baseY: 120 })).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the pin once the user scrolls up into the backlog", () => {
|
||||
// Scrolling up drops viewportY below baseY — the user is reading history,
|
||||
// so the resume replay must not yank them back down (#59591 follow-up).
|
||||
expect(isViewportPinnedToBottom({ viewportY: 40, baseY: 120 })).toBe(false);
|
||||
});
|
||||
|
||||
it("stays pinned if viewportY overshoots baseY while rows are trimmed", () => {
|
||||
// scrollback eviction can momentarily push viewportY past baseY.
|
||||
expect(isViewportPinnedToBottom({ viewportY: 121, baseY: 120 })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a fresh 0x0 buffer as pinned", () => {
|
||||
expect(isViewportPinnedToBottom({ viewportY: 0, baseY: 0 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFollowPtyOutput", () => {
|
||||
it("follows replayed output while resuming and stuck to the bottom", () => {
|
||||
// The core #59591 fix: scroll to bottom as each replay chunk commits.
|
||||
expect(shouldFollowPtyOutput("sess-123", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops following once the user has scrolled up mid-replay", () => {
|
||||
expect(shouldFollowPtyOutput("sess-123", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow a fresh (non-resume) session", () => {
|
||||
// Fresh chats start empty; forcing scroll would fight normal cursor output.
|
||||
expect(shouldFollowPtyOutput(null, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not follow a fresh session even when stickToBottom is true", () => {
|
||||
expect(shouldFollowPtyOutput(null, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an empty resume param as non-resume", () => {
|
||||
expect(shouldFollowPtyOutput("", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseResumeControlMessage", () => {
|
||||
it("extracts the id from a resume control frame", () => {
|
||||
// #93518: the implicit active-session fallback has no `?resume=` on the
|
||||
// URL, so the server names the session it resolved in a control frame.
|
||||
expect(
|
||||
parseResumeControlMessage('{"type":"resume","id":"sess-123"}'),
|
||||
).toBe("sess-123");
|
||||
});
|
||||
|
||||
it("ignores plain ANSI banner text sent as a text frame", () => {
|
||||
// pty_ws also sends "Chat unavailable: ..." error banners as text
|
||||
// frames; those must keep rendering into the terminal, not get
|
||||
// swallowed as a (mis-parsed) control message.
|
||||
expect(
|
||||
parseResumeControlMessage("\r\n\x1b[31mChat unavailable: x\x1b[0m\r\n"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores JSON of the wrong shape", () => {
|
||||
expect(parseResumeControlMessage('{"type":"other","id":"x"}')).toBeNull();
|
||||
expect(parseResumeControlMessage('{"type":"resume"}')).toBeNull();
|
||||
expect(parseResumeControlMessage('{"type":"resume","id":""}')).toBeNull();
|
||||
expect(parseResumeControlMessage("null")).toBeNull();
|
||||
expect(parseResumeControlMessage('"resume"')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Dashboard chat resume-scroll helpers.
|
||||
*
|
||||
* When a chat session is resumed (`/chat?resume=<id>`) the PTY backend replays
|
||||
* the entire scrollback over the WebSocket the instant it opens. xterm.js writes
|
||||
* those bytes into its buffer but leaves the viewport wherever it was (e.g. the
|
||||
* top of a fresh terminal), so the transcript looks truncated until something
|
||||
* else forces a re-render. See #59591.
|
||||
*
|
||||
* The fix pins the viewport to the bottom *as each replayed chunk commits* (via
|
||||
* xterm's `write` callback) instead of guessing with a fixed double-rAF delay,
|
||||
* and releases that pin the moment the user scrolls up so their manual review of
|
||||
* the backlog is never yanked back down.
|
||||
*
|
||||
* These two decisions are pulled out here as pure functions so they can be
|
||||
* unit-tested without a live terminal; `ChatPage` wires them to the real xterm
|
||||
* instance (see `term.onScroll` and `ws.onmessage`).
|
||||
*/
|
||||
|
||||
/** The subset of xterm's active `IBuffer` these helpers need. */
|
||||
export interface TerminalViewportPosition {
|
||||
/** Row index of the top of the current viewport. */
|
||||
viewportY: number;
|
||||
/** Row index of the viewport top when scrolled fully to the bottom. */
|
||||
baseY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the viewport is scrolled to (or past) the bottom, i.e. the latest
|
||||
* output is on screen. xterm reports `viewportY === baseY` at the bottom; the
|
||||
* `>=` also covers the transient overshoot while scrollback rows are trimmed.
|
||||
*/
|
||||
export function isViewportPinnedToBottom(
|
||||
buffer: TerminalViewportPosition,
|
||||
): boolean {
|
||||
return buffer.viewportY >= buffer.baseY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a freshly written PTY output chunk should scroll the terminal to the
|
||||
* bottom afterwards. We only auto-follow while resuming a session (the replay
|
||||
* case) and only while the user hasn't scrolled up to read the backlog. A fresh
|
||||
* (non-resume) session returns `false` so normal cursor output is never fought.
|
||||
*/
|
||||
export function shouldFollowPtyOutput(
|
||||
resumeParam: string | null,
|
||||
stickToBottom: boolean,
|
||||
): boolean {
|
||||
return Boolean(resumeParam) && stickToBottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* When `pty_ws` falls back to the per-channel active-session file (no
|
||||
* `?resume=` on the URL), the server sends a one-off JSON text frame naming
|
||||
* the session it resolved before any PTY replay bytes arrive, since the
|
||||
* frontend has no other way to learn a replay is happening (#93518). PTY
|
||||
* output itself always arrives as binary frames, so any text frame is a
|
||||
* candidate; this returns the resume id on a match and `null` for anything
|
||||
* else (including the plain ANSI error banners `pty_ws` still sends as text
|
||||
* on failure, which must keep rendering into the terminal as before).
|
||||
*/
|
||||
export function parseResumeControlMessage(data: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.type === "resume" &&
|
||||
typeof parsed.id === "string" &&
|
||||
parsed.id
|
||||
) {
|
||||
return parsed.id;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON — an ANSI banner or other plain-text frame */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EFFORT_OPTIONS,
|
||||
VALID_EFFORTS,
|
||||
normalizeEffort,
|
||||
} from "./reasoning-effort";
|
||||
|
||||
describe("normalizeEffort", () => {
|
||||
it("treats empty/unset as the Hermes default (medium)", () => {
|
||||
expect(normalizeEffort("")).toBe("medium");
|
||||
expect(normalizeEffort(null)).toBe("medium");
|
||||
expect(normalizeEffort(undefined)).toBe("medium");
|
||||
expect(normalizeEffort(" ")).toBe("medium");
|
||||
});
|
||||
|
||||
it("passes through every valid effort level", () => {
|
||||
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) {
|
||||
expect(normalizeEffort(level)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case- and whitespace-insensitive", () => {
|
||||
expect(normalizeEffort("HIGH")).toBe("high");
|
||||
expect(normalizeEffort(" XHigh ")).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("falls back to medium for unknown values", () => {
|
||||
expect(normalizeEffort("turbo")).toBe("medium");
|
||||
expect(normalizeEffort(42)).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EFFORT_OPTIONS", () => {
|
||||
it("every option value is in VALID_EFFORTS (no orphan labels)", () => {
|
||||
for (const opt of EFFORT_OPTIONS) {
|
||||
expect(VALID_EFFORTS.has(opt.value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers the real reasoning levels plus thinking-off", () => {
|
||||
// Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'.
|
||||
const values = new Set(EFFORT_OPTIONS.map((o) => o.value));
|
||||
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) {
|
||||
expect(values.has(level)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
export interface EffortOption {
|
||||
value: string;
|
||||
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" },
|
||||
];
|
||||
|
||||
export const VALID_EFFORTS: ReadonlySet<string> = new Set(
|
||||
EFFORT_OPTIONS.map((o) => o.value),
|
||||
);
|
||||
|
||||
/** 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";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePageTitle } from "./resolve-page-title";
|
||||
import type { Translations } from "@/i18n/types";
|
||||
|
||||
// Minimal translations stub — only the fields resolvePageTitle touches.
|
||||
const t = {
|
||||
app: {
|
||||
webUi: "Web UI",
|
||||
nav: {
|
||||
analytics: "Analytics",
|
||||
chat: "Chat",
|
||||
config: "Config",
|
||||
cron: "Cron",
|
||||
documentation: "Documentation",
|
||||
keys: "Keys",
|
||||
logs: "Logs",
|
||||
models: "Models",
|
||||
profiles: "Profiles",
|
||||
plugins: "Plugins",
|
||||
sessions: "Sessions",
|
||||
skills: "Skills",
|
||||
},
|
||||
},
|
||||
} as unknown as Translations;
|
||||
|
||||
describe("resolvePageTitle", () => {
|
||||
it("uses i18n nav keys for translated routes", () => {
|
||||
expect(resolvePageTitle("/sessions", t, [])).toBe("Sessions");
|
||||
expect(resolvePageTitle("/env", t, [])).toBe("Keys");
|
||||
});
|
||||
|
||||
it("renders initialisms and literal labels correctly", () => {
|
||||
// Regression: the naive capitalize fallback produced "Mcp".
|
||||
expect(resolvePageTitle("/mcp", t, [])).toBe("MCP");
|
||||
expect(resolvePageTitle("/system", t, [])).toBe("System");
|
||||
expect(resolvePageTitle("/channels", t, [])).toBe("Channels");
|
||||
expect(resolvePageTitle("/webhooks", t, [])).toBe("Webhooks");
|
||||
expect(resolvePageTitle("/pairing", t, [])).toBe("Pairing");
|
||||
expect(resolvePageTitle("/files", t, [])).toBe("Files");
|
||||
});
|
||||
|
||||
it("prefers plugin tab labels", () => {
|
||||
expect(
|
||||
resolvePageTitle("/kanban", t, [{ path: "/kanban", label: "Kanban" }]),
|
||||
).toBe("Kanban");
|
||||
});
|
||||
|
||||
it("falls back to capitalized path segment for unknown routes", () => {
|
||||
expect(resolvePageTitle("/whatever", t, [])).toBe("Whatever");
|
||||
});
|
||||
|
||||
it("treats root as sessions and trailing slashes as equivalent", () => {
|
||||
expect(resolvePageTitle("/", t, [])).toBe("Sessions");
|
||||
expect(resolvePageTitle("/mcp/", t, [])).toBe("MCP");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Translations } from "@/i18n/types";
|
||||
|
||||
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
||||
"/chat": "chat",
|
||||
"/sessions": "sessions",
|
||||
"/analytics": "analytics",
|
||||
"/models": "models",
|
||||
"/logs": "logs",
|
||||
"/cron": "cron",
|
||||
"/skills": "skills",
|
||||
"/plugins": "plugins",
|
||||
"/profiles": "profiles",
|
||||
"/config": "config",
|
||||
"/env": "keys",
|
||||
"/docs": "documentation",
|
||||
};
|
||||
|
||||
// Built-in routes without an i18n nav key. Keep these in sync with the
|
||||
// sidebar labels in App.tsx — the naive capitalize fallback below mangles
|
||||
// initialisms ("/mcp" → "Mcp") and can't match multi-word labels.
|
||||
const BUILTIN_LITERAL: Record<string, string> = {
|
||||
"/files": "Files",
|
||||
"/mcp": "MCP",
|
||||
"/channels": "Channels",
|
||||
"/webhooks": "Webhooks",
|
||||
"/pairing": "Pairing",
|
||||
"/system": "System",
|
||||
};
|
||||
|
||||
export function resolvePageTitle(
|
||||
pathname: string,
|
||||
t: Translations,
|
||||
pluginTabs: { path: string; label: string }[],
|
||||
): string {
|
||||
const normalized = pathname.replace(/\/$/, "") || "/";
|
||||
if (normalized === "/") {
|
||||
return t.app.nav.sessions;
|
||||
}
|
||||
const plugin = pluginTabs.find((p) => p.path === normalized);
|
||||
if (plugin) {
|
||||
return plugin.label;
|
||||
}
|
||||
const key = BUILTIN[normalized];
|
||||
if (key) {
|
||||
return t.app.nav[key];
|
||||
}
|
||||
const literal = BUILTIN_LITERAL[normalized];
|
||||
if (literal) {
|
||||
return literal;
|
||||
}
|
||||
// Derive title from pathname: "/profiles" → "Profiles"
|
||||
const segment = normalized.slice(1);
|
||||
if (segment) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
}
|
||||
return t.app.webUi;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildScheduleString,
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
parseScheduleString,
|
||||
} from "./schedule";
|
||||
|
||||
describe("parseScheduleString", () => {
|
||||
it("parses recurring interval strings", () => {
|
||||
expect(parseScheduleString("every 30m")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 30,
|
||||
intervalUnit: "minutes",
|
||||
});
|
||||
expect(parseScheduleString("every 2h")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 2,
|
||||
intervalUnit: "hours",
|
||||
});
|
||||
expect(parseScheduleString("every 1d")).toMatchObject({
|
||||
mode: "interval",
|
||||
intervalValue: 1,
|
||||
intervalUnit: "days",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses ISO timestamps into once mode", () => {
|
||||
expect(parseScheduleString("2026-02-03T14:00:00")).toMatchObject({
|
||||
mode: "once",
|
||||
onceAt: "2026-02-03T14:00",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00")).toMatchObject({
|
||||
mode: "once",
|
||||
onceAt: "2026-02-03T14:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses daily cron expressions", () => {
|
||||
expect(parseScheduleString("0 9 * * *")).toMatchObject({
|
||||
mode: "daily",
|
||||
timeOfDay: "09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses weekly cron expressions", () => {
|
||||
expect(parseScheduleString("30 14 * * 1,3,5")).toMatchObject({
|
||||
mode: "weekly",
|
||||
timeOfDay: "14:30",
|
||||
weekdays: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes cron Sunday 7 into the builder's Sunday 0", () => {
|
||||
expect(parseScheduleString("30 14 * * 1,7")).toMatchObject({
|
||||
mode: "weekly",
|
||||
timeOfDay: "14:30",
|
||||
weekdays: [1, 0],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses monthly cron expressions", () => {
|
||||
expect(parseScheduleString("0 9 15 * *")).toMatchObject({
|
||||
mode: "monthly",
|
||||
timeOfDay: "09:00",
|
||||
dayOfMonth: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to custom for unsupported schedule strings", () => {
|
||||
expect(parseScheduleString("0 9 * * 1-5")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 * * 1-5",
|
||||
});
|
||||
expect(parseScheduleString("@daily")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "@daily",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00:00Z")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "2026-02-03T14:00:00Z",
|
||||
});
|
||||
expect(parseScheduleString("2026-02-03T14:00:00+08:00")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "2026-02-03T14:00:00+08:00",
|
||||
});
|
||||
expect(parseScheduleString("0 9 * * 1,8")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 * * 1,8",
|
||||
});
|
||||
expect(parseScheduleString("0 9 1,15 * *")).toMatchObject({
|
||||
mode: "custom",
|
||||
custom: "0 9 1,15 * *",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the default state for empty input", () => {
|
||||
expect(parseScheduleString("")).toEqual(DEFAULT_SCHEDULE_STATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScheduleString round-trip", () => {
|
||||
it("rebuilds the schedule string from parsed state", () => {
|
||||
const cases: [string, string][] = [
|
||||
["every 30m", "every 30m"],
|
||||
["every 2h", "every 2h"],
|
||||
["every 1d", "every 1d"],
|
||||
["0 9 * * *", "0 9 * * *"],
|
||||
["30 14 * * 1,3,5", "30 14 * * 1,3,5"],
|
||||
["30 14 * * 1,7", "30 14 * * 0,1"],
|
||||
["0 9 15 * *", "0 9 15 * *"],
|
||||
["0 9 1,15 * *", "0 9 1,15 * *"],
|
||||
["2026-02-03T14:00:00", "2026-02-03T14:00:00"],
|
||||
["2026-02-03T14:00", "2026-02-03T14:00:00"],
|
||||
["2026-02-03T14:00:00Z", "2026-02-03T14:00:00Z"],
|
||||
["2026-02-03T14:00:00+08:00", "2026-02-03T14:00:00+08:00"],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
const state = parseScheduleString(input);
|
||||
expect(buildScheduleString(state)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Schedule builder helpers for the cron page.
|
||||
*
|
||||
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
|
||||
* surprisingly broad set of string formats:
|
||||
*
|
||||
* - Duration (one-shot): "30m", "2h", "1d"
|
||||
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
|
||||
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
|
||||
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
|
||||
*
|
||||
* Power users can hand-type any of those, but for everyone else the
|
||||
* dashboard now offers a human-readable picker. This module is the
|
||||
* pure logic layer behind that picker:
|
||||
*
|
||||
* - {@link buildScheduleString} turns the picker's structured state
|
||||
* into one of the strings above.
|
||||
* - {@link describeSchedule} goes the other way: takes the structured
|
||||
* schedule shape the API returns (``CronJob.schedule``) and produces
|
||||
* a human-readable sentence for the job list. It recognises common
|
||||
* cron-expression shapes (daily/weekly/monthly) so users don't have
|
||||
* to parse "30 14 * * 1,3,5" by eye.
|
||||
*
|
||||
* Kept dependency-free and locale-string-driven so it tree-shakes
|
||||
* cleanly and is testable in isolation if we ever wire up vitest here.
|
||||
*/
|
||||
|
||||
/** Picker modes — each renders a different set of inputs in the UI but
|
||||
* all funnel through {@link buildScheduleString} to a backend-compatible
|
||||
* string. ``custom`` is the escape hatch for power users who still want
|
||||
* to type a raw cron expression. */
|
||||
export type ScheduleMode =
|
||||
| "interval"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "once"
|
||||
| "custom";
|
||||
|
||||
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
|
||||
export type IntervalUnit = "minutes" | "hours" | "days";
|
||||
|
||||
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
|
||||
* croniter expects on the backend (no need to remap on submit). */
|
||||
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
|
||||
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
|
||||
|
||||
export interface ScheduleBuilderState {
|
||||
/** Index of which "custom" radio is selected. */
|
||||
mode: ScheduleMode;
|
||||
|
||||
/** Interval mode: positive integer, paired with ``intervalUnit``. */
|
||||
intervalValue: number;
|
||||
intervalUnit: IntervalUnit;
|
||||
|
||||
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
|
||||
timeOfDay: string;
|
||||
|
||||
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
|
||||
* still valid — we send "*" for the day-of-week cron field. */
|
||||
weekdays: Weekday[];
|
||||
|
||||
/** Monthly mode: 1..31 (no support for "last day of month" sugar — the
|
||||
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
|
||||
dayOfMonth: number;
|
||||
|
||||
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
|
||||
onceAt: string;
|
||||
|
||||
/** Custom mode: raw user-typed cron expression. Stored separately so
|
||||
* flipping between modes doesn't erase the user's work. */
|
||||
custom: string;
|
||||
}
|
||||
|
||||
/** Default state — "every 30 minutes" is the most-common-cron-pattern
|
||||
* starting point and avoids forcing the user to pick everything from
|
||||
* scratch. */
|
||||
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
|
||||
mode: "interval",
|
||||
intervalValue: 30,
|
||||
intervalUnit: "minutes",
|
||||
timeOfDay: "09:00",
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
dayOfMonth: 1,
|
||||
onceAt: "",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
|
||||
minutes: "m",
|
||||
hours: "h",
|
||||
days: "d",
|
||||
};
|
||||
|
||||
/** Build the schedule string from picker state. Returns ``""`` when the
|
||||
* state is incomplete enough that the backend would 400 — the caller
|
||||
* uses that to disable the Submit button.
|
||||
*
|
||||
* Why we lean on the broad parse_schedule grammar instead of always
|
||||
* emitting cron expressions: interval syntax ("every 30m") survives a
|
||||
* backend without ``croniter`` installed and renders more readably in
|
||||
* the job list. We only emit raw cron when the picker truly needs the
|
||||
* cron field expressiveness (specific weekdays, specific day-of-month). */
|
||||
export function buildScheduleString(state: ScheduleBuilderState): string {
|
||||
switch (state.mode) {
|
||||
case "interval": {
|
||||
const n = Math.floor(state.intervalValue);
|
||||
if (!Number.isFinite(n) || n < 1) return "";
|
||||
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
|
||||
}
|
||||
case "daily": {
|
||||
const parsed = parseTimeOfDay(state.timeOfDay);
|
||||
if (!parsed) return "";
|
||||
return `${parsed.minute} ${parsed.hour} * * *`;
|
||||
}
|
||||
case "weekly": {
|
||||
const parsed = parseTimeOfDay(state.timeOfDay);
|
||||
if (!parsed) return "";
|
||||
// Empty weekday selection → "*" (every day) rather than a backend
|
||||
// 400. The Daily mode is the cleaner choice for that, but if the
|
||||
// user toggles all days off in Weekly mode we still emit a valid
|
||||
// expression instead of breaking the submit.
|
||||
const days =
|
||||
state.weekdays.length === 0
|
||||
? "*"
|
||||
: [...state.weekdays].sort((a, b) => a - b).join(",");
|
||||
return `${parsed.minute} ${parsed.hour} * * ${days}`;
|
||||
}
|
||||
case "monthly": {
|
||||
const parsed = parseTimeOfDay(state.timeOfDay);
|
||||
if (!parsed) return "";
|
||||
const dom = Math.floor(state.dayOfMonth);
|
||||
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
|
||||
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
|
||||
}
|
||||
case "once": {
|
||||
const v = state.onceAt.trim();
|
||||
if (!v) return "";
|
||||
// <input type=datetime-local> already emits the
|
||||
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
|
||||
// Append ":00" so the backend's regex hits the "T" branch and
|
||||
// the seconds component lines up with isoformat() output.
|
||||
return v.length === 16 ? `${v}:00` : v;
|
||||
}
|
||||
case "custom":
|
||||
return state.custom.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse schedules emitted by buildScheduleString; unknown strings stay custom. */
|
||||
export function parseScheduleString(
|
||||
schedule: string,
|
||||
): ScheduleBuilderState {
|
||||
const trimmed = schedule.trim();
|
||||
if (!trimmed) return { ...DEFAULT_SCHEDULE_STATE };
|
||||
|
||||
// ISO timestamp (one-shot).
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(trimmed)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "once",
|
||||
onceAt: trimmed.slice(0, 16),
|
||||
};
|
||||
}
|
||||
|
||||
// Recurring interval.
|
||||
const intervalMatch = /^every\s+(\d+)\s*([mhd])$/i.exec(trimmed);
|
||||
if (intervalMatch) {
|
||||
const value = Number.parseInt(intervalMatch[1], 10);
|
||||
const suffix = intervalMatch[2].toLowerCase();
|
||||
const unit: IntervalUnit =
|
||||
suffix === "d" ? "days" : suffix === "h" ? "hours" : "minutes";
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "interval",
|
||||
intervalValue: Number.isFinite(value) && value > 0 ? value : 1,
|
||||
intervalUnit: unit,
|
||||
};
|
||||
}
|
||||
|
||||
// 5-field cron expression.
|
||||
const parsedCron = parseSimpleCronExpression(trimmed);
|
||||
if (parsedCron) {
|
||||
if (parsedCron.mode === "daily") {
|
||||
return { ...DEFAULT_SCHEDULE_STATE, mode: "daily", timeOfDay: parsedCron.time };
|
||||
}
|
||||
if (parsedCron.mode === "weekly") {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "weekly",
|
||||
timeOfDay: parsedCron.time,
|
||||
weekdays: parsedCron.weekdays ?? [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_STATE,
|
||||
mode: "monthly",
|
||||
timeOfDay: parsedCron.time,
|
||||
dayOfMonth: parsedCron.dayOfMonth ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: preserve the raw string in custom mode.
|
||||
return { ...DEFAULT_SCHEDULE_STATE, mode: "custom", custom: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: recognise the simple, well-shaped 5-field cron patterns
|
||||
* that both the human-readable describer and the schedule builder care
|
||||
* about. Returns a structured result or ``null`` when the expression has
|
||||
* ranges, steps, per-month rules, or other complexity.
|
||||
*/
|
||||
function parseSimpleCronExpression(
|
||||
expr: string,
|
||||
): { mode: "daily" | "weekly" | "monthly"; time: string; weekdays?: Weekday[]; dayOfMonth?: number } | null {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [minField, hourField, domField, monField, dowField] = parts;
|
||||
|
||||
if (monField !== "*") return null;
|
||||
|
||||
const isLiteralOrList = (f: string) => /^\d+(,\d+)*$|^\*$/.test(f);
|
||||
if (
|
||||
!isLiteralOrList(minField) ||
|
||||
!isLiteralOrList(hourField) ||
|
||||
!isLiteralOrList(domField) ||
|
||||
!isLiteralOrList(dowField)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (minField === "*" || hourField === "*") return null;
|
||||
|
||||
const minutes = minField.split(",").map((n) => parseInt(n, 10));
|
||||
const hours = hourField.split(",").map((n) => parseInt(n, 10));
|
||||
if (minutes.length !== 1 || hours.length !== 1) return null;
|
||||
if (
|
||||
!Number.isFinite(minutes[0]) ||
|
||||
!Number.isFinite(hours[0]) ||
|
||||
hours[0] < 0 ||
|
||||
hours[0] > 23 ||
|
||||
minutes[0] < 0 ||
|
||||
minutes[0] > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
|
||||
|
||||
const domAll = domField === "*";
|
||||
const dowAll = dowField === "*";
|
||||
|
||||
if (domAll && dowAll) {
|
||||
return { mode: "daily", time };
|
||||
}
|
||||
|
||||
if (domAll && !dowAll) {
|
||||
const weekdays: Weekday[] = [];
|
||||
for (const part of dowField.split(",")) {
|
||||
const day = parseInt(part, 10);
|
||||
if (!Number.isFinite(day) || day < 0 || day > 7) return null;
|
||||
const normalized = (day === 7 ? 0 : day) as Weekday;
|
||||
if (!weekdays.includes(normalized)) weekdays.push(normalized);
|
||||
}
|
||||
if (weekdays.length === 0) return null;
|
||||
return { mode: "weekly", time, weekdays };
|
||||
}
|
||||
|
||||
if (!domAll && dowAll) {
|
||||
if (!/^\d+$/.test(domField)) return null;
|
||||
const dom = parseInt(domField, 10);
|
||||
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
|
||||
return { mode: "monthly", time, dayOfMonth: dom };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
|
||||
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
|
||||
const [hh, mm] = value.split(":");
|
||||
const hour = parseInt(hh, 10);
|
||||
const minute = parseInt(mm, 10);
|
||||
if (
|
||||
!Number.isFinite(hour) ||
|
||||
!Number.isFinite(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
/** Translation surface the human-readable describer needs. Passing it
|
||||
* in (instead of importing ``useI18n``) keeps the helper pure and
|
||||
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
|
||||
export interface ScheduleDescribeStrings {
|
||||
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
|
||||
none: string;
|
||||
/** "Every {n} minute(s)" — caller pluralises via {n}. */
|
||||
everyMinutes: string;
|
||||
everyHours: string;
|
||||
everyDays: string;
|
||||
/** "Daily at {time}" */
|
||||
dailyAt: string;
|
||||
/** "Weekly on {days} at {time}" */
|
||||
weeklyAt: string;
|
||||
/** "Monthly on the {day} at {time}" */
|
||||
monthlyAt: string;
|
||||
/** "Once at {time}" */
|
||||
onceAt: string;
|
||||
/** Weekday short names indexed 0..6 (Sunday-first). */
|
||||
weekdaysShort: [string, string, string, string, string, string, string];
|
||||
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
|
||||
* don't use English ordinals, just return ``String(day)``. */
|
||||
ordinal: (day: number) => string;
|
||||
}
|
||||
|
||||
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
|
||||
export interface ScheduleLike {
|
||||
kind?: string;
|
||||
expr?: string;
|
||||
minutes?: number;
|
||||
run_at?: string;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
/** Human-readable description of a stored schedule.
|
||||
*
|
||||
* Prefers a structured render over the raw ``display`` string so cron
|
||||
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
|
||||
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
|
||||
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
|
||||
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
|
||||
* macros that we'd misrepresent if we tried to "humanize"). */
|
||||
export function describeSchedule(
|
||||
schedule: ScheduleLike | undefined,
|
||||
fallbackDisplay: string | undefined,
|
||||
strings: ScheduleDescribeStrings,
|
||||
): string {
|
||||
if (!schedule) return fallbackDisplay || strings.none;
|
||||
|
||||
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
|
||||
return describeInterval(schedule.minutes, strings);
|
||||
}
|
||||
|
||||
if (schedule.kind === "once" && schedule.run_at) {
|
||||
return strings.onceAt.replace(
|
||||
"{time}",
|
||||
formatIsoLocal(schedule.run_at, false),
|
||||
);
|
||||
}
|
||||
|
||||
if (schedule.kind === "cron" && schedule.expr) {
|
||||
const cronDesc = describeCronExpression(schedule.expr, strings);
|
||||
if (cronDesc) return cronDesc;
|
||||
}
|
||||
|
||||
// Try the raw expression as a last attempt — for legacy jobs stored
|
||||
// without ``kind``, the ``schedule_display`` field often *is* the cron
|
||||
// expression.
|
||||
if (fallbackDisplay) {
|
||||
const cronDesc = describeCronExpression(fallbackDisplay, strings);
|
||||
if (cronDesc) return cronDesc;
|
||||
return fallbackDisplay;
|
||||
}
|
||||
if (schedule.display) return schedule.display;
|
||||
if (schedule.expr) return schedule.expr;
|
||||
return strings.none;
|
||||
}
|
||||
|
||||
function describeInterval(
|
||||
minutes: number,
|
||||
strings: ScheduleDescribeStrings,
|
||||
): string {
|
||||
if (minutes <= 0) return strings.none;
|
||||
if (minutes % 1440 === 0) {
|
||||
return strings.everyDays.replace("{n}", String(minutes / 1440));
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
return strings.everyHours.replace("{n}", String(minutes / 60));
|
||||
}
|
||||
return strings.everyMinutes.replace("{n}", String(minutes));
|
||||
}
|
||||
|
||||
/** Recognise the common, well-shaped cron patterns and return a
|
||||
* human sentence for them. Returns ``null`` when the expression has any
|
||||
* ranges, steps, or other complexity that would be misleading to
|
||||
* "humanize" — caller falls back to displaying the raw expression so
|
||||
* the user sees what's actually scheduled.
|
||||
*
|
||||
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
|
||||
* 6-field ``minute hour dom month dow year`` form, but humanising those
|
||||
* by destructuring only the first five fields would silently drop the
|
||||
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
|
||||
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
|
||||
* the raw-string fallback in {@link describeSchedule}. */
|
||||
function describeCronExpression(
|
||||
expr: string,
|
||||
strings: ScheduleDescribeStrings,
|
||||
): string | null {
|
||||
const parsed = parseSimpleCronExpression(expr);
|
||||
if (!parsed) return null;
|
||||
|
||||
if (parsed.mode === "daily") {
|
||||
return strings.dailyAt.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
if (parsed.mode === "weekly") {
|
||||
const labels = (parsed.weekdays ?? [])
|
||||
.map((d) => strings.weekdaysShort[d])
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return strings.weeklyAt
|
||||
.replace("{days}", labels)
|
||||
.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
return strings.monthlyAt
|
||||
.replace("{day}", strings.ordinal(parsed.dayOfMonth ?? 1))
|
||||
.replace("{time}", parsed.time);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
/** Format an ISO date for inline display. Drops the seconds + TZ
|
||||
* suffix so the cron list stays compact. Falls back to the raw string
|
||||
* if Date parsing fails. */
|
||||
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = pad2(d.getMonth() + 1);
|
||||
const dd = pad2(d.getDate());
|
||||
const hh = pad2(d.getHours());
|
||||
const mi = pad2(d.getMinutes());
|
||||
if (includeSeconds) {
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
|
||||
* Most non-English locales should just return ``String(day)`` from
|
||||
* their ``ordinal`` override. */
|
||||
export function englishOrdinal(day: number): string {
|
||||
const d = Math.floor(day);
|
||||
if (!Number.isFinite(d) || d < 1) return String(day);
|
||||
const lastTwo = d % 100;
|
||||
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
|
||||
switch (d % 10) {
|
||||
case 1:
|
||||
return `${d}st`;
|
||||
case 2:
|
||||
return `${d}nd`;
|
||||
case 3:
|
||||
return `${d}rd`;
|
||||
default:
|
||||
return `${d}th`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { importSummary, parseImportSessions } from "./session-import";
|
||||
|
||||
describe("parseImportSessions", () => {
|
||||
it("accepts a single exported session", () => {
|
||||
expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([
|
||||
{ id: "session-1", messages: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts arrays and wrapped session exports", () => {
|
||||
const sessions = [{ id: "one" }, { id: "two" }];
|
||||
expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions);
|
||||
expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions);
|
||||
});
|
||||
|
||||
it("accepts JSONL session exports", () => {
|
||||
expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([
|
||||
{ id: "one" },
|
||||
{ id: "two" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects empty files and non-object entries", () => {
|
||||
expect(() => parseImportSessions(" \n")).toThrow("File is empty");
|
||||
expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow(
|
||||
"Expected exported session JSON or JSONL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importSummary", () => {
|
||||
it("includes skipped and detached counts only when present", () => {
|
||||
expect(
|
||||
importSummary({
|
||||
ok: true,
|
||||
imported: 2,
|
||||
skipped: 1,
|
||||
detached: 1,
|
||||
imported_ids: ["one", "two"],
|
||||
skipped_ids: ["existing"],
|
||||
errors: [],
|
||||
}),
|
||||
).toBe("2 imported; 1 skipped; 1 detached from missing parents");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SessionImportResponse } from "@/lib/api";
|
||||
|
||||
export type ImportableSession = Record<string, unknown>;
|
||||
|
||||
function normalizeImportSessions(value: unknown): ImportableSession[] {
|
||||
const candidate =
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Array.isArray((value as { sessions?: unknown }).sessions)
|
||||
? (value as { sessions: unknown[] }).sessions
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
|
||||
const sessions = candidate.filter(
|
||||
(item): item is ImportableSession =>
|
||||
!!item && typeof item === "object" && !Array.isArray(item),
|
||||
);
|
||||
if (sessions.length !== candidate.length) {
|
||||
throw new Error("Expected exported session JSON or JSONL");
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export function parseImportSessions(text: string): ImportableSession[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("File is empty");
|
||||
|
||||
try {
|
||||
return normalizeImportSessions(JSON.parse(trimmed));
|
||||
} catch (jsonError) {
|
||||
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim());
|
||||
if (lines.length <= 1) throw jsonError;
|
||||
return normalizeImportSessions(lines.map((line) => JSON.parse(line)));
|
||||
}
|
||||
}
|
||||
|
||||
export function importSummary(result: SessionImportResponse): string {
|
||||
const parts = [`${result.imported} imported`];
|
||||
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
||||
if (result.detached > 0) {
|
||||
parts.push(`${result.detached} detached from missing parents`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatSessionPruneResult } from './session-prune'
|
||||
|
||||
describe('formatSessionPruneResult', () => {
|
||||
it('reports open sessions skipped by the prune safety guard', () => {
|
||||
expect(formatSessionPruneResult({ removed: 0, skipped_open: 2 })).toBe(
|
||||
'Pruned 0 sessions. Skipped 2 open sessions; prune only removes ended sessions.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the existing success message when nothing was skipped', () => {
|
||||
expect(formatSessionPruneResult({ removed: 1, skipped_open: 0 })).toBe('Pruned 1 session')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
interface SessionPruneResult {
|
||||
removed: number
|
||||
skipped_open: number
|
||||
}
|
||||
|
||||
export function formatSessionPruneResult(result: SessionPruneResult): string {
|
||||
const removed = `Pruned ${result.removed} session${result.removed === 1 ? '' : 's'}`
|
||||
if (!result.skipped_open) return removed
|
||||
|
||||
return `${removed}. Skipped ${result.skipped_open} open session${result.skipped_open === 1 ? '' : 's'}; prune only removes ended sessions.`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldRefreshSessions } from "./session-refresh";
|
||||
|
||||
describe("shouldRefreshSessions", () => {
|
||||
it("returns false on the first poll (no baseline yet)", () => {
|
||||
expect(shouldRefreshSessions(null, "s2")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the current response has no sessions", () => {
|
||||
expect(shouldRefreshSessions("s1", null)).toBe(false);
|
||||
expect(shouldRefreshSessions(null, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the newest session id is unchanged", () => {
|
||||
expect(shouldRefreshSessions("s1", "s1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a new session appears at the head of the list", () => {
|
||||
expect(shouldRefreshSessions("s1", "s2")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Decide whether the paginated sessions list should be silently
|
||||
* re-fetched after an overview poll.
|
||||
*
|
||||
* The dashboard's FastAPI server and a terminal CLI are separate
|
||||
* processes that share the same SQLite session DB. There is no
|
||||
* inter-process push channel, so the Sessions page polls the 50 newest
|
||||
* sessions every few seconds (the "overview" poll). When that poll
|
||||
* surfaces a session id at the head of the list that we have not seen
|
||||
* before, a new session was created in another process and the
|
||||
* paginated list is stale — refresh it.
|
||||
*
|
||||
* Returns false on the very first poll (no baseline yet) and when
|
||||
* either id is null (empty DB / transient empty response), so we never
|
||||
* trigger a spurious reload on mount or while the DB is empty.
|
||||
*/
|
||||
export function shouldRefreshSessions(
|
||||
prevNewestId: string | null,
|
||||
currentNewestId: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
prevNewestId !== null &&
|
||||
currentNewestId !== null &&
|
||||
prevNewestId !== currentNewestId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Mondwest font only — use on layout shells; do not force normal-case here or `text-display` chrome (Segmented, badges) stops uppercasing. */
|
||||
export const themedFont = "font-mondwest";
|
||||
|
||||
/** Mondwest body copy — sentence-case themed text (not uppercase chrome). */
|
||||
export const themedBody = "font-mondwest normal-case";
|
||||
|
||||
/** Mondwest brand chrome — uppercase section headers and nav labels. */
|
||||
export const themedChrome = "font-mondwest text-display";
|
||||
|
||||
/** Relative time from a Unix epoch timestamp (seconds). */
|
||||
export function timeAgo(ts: number): string {
|
||||
const delta = Date.now() / 1000 - ts;
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
if (delta < 172800) return "yesterday";
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
/** Relative time from an ISO-8601 timestamp string. */
|
||||
export function isoTimeAgo(iso: string): string {
|
||||
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (delta < 0 || Number.isNaN(delta)) return "unknown";
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
Reference in new issue
Block a user