Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions web/src/lib/chat-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import {
buildChatAttachScope,
buildResumeInChatUrl,
RESUME_NONCE_PARAM,
} from "./chat-resume";

function parseResumeUrl(url: string) {
const parsed = new URL(url, "https://example.test");
return {
pathname: parsed.pathname,
resume: parsed.searchParams.get("resume"),
resumeNonce: parsed.searchParams.get(RESUME_NONCE_PARAM),
};
}

describe("buildResumeInChatUrl", () => {
it("keeps the selected session id and stamps a reopen nonce", () => {
const parsed = parseResumeUrl(buildResumeInChatUrl("sess-123"));

expect(parsed.pathname).toBe("/chat");
expect(parsed.resume).toBe("sess-123");
expect(parsed.resumeNonce).toMatch(/^[a-z0-9]+-[a-z0-9]+$/);
});

it("generates a fresh nonce for repeated reopen clicks", () => {
const first = parseResumeUrl(buildResumeInChatUrl("sess-123"));
const second = parseResumeUrl(buildResumeInChatUrl("sess-123"));

expect(first.resume).toBe("sess-123");
expect(second.resume).toBe("sess-123");
expect(first.resumeNonce).not.toBe(second.resumeNonce);
});
});

describe("buildChatAttachScope", () => {
it("scopes keep-alive attachment tokens to the selected session", () => {
expect(buildChatAttachScope("sess-123", null)).toBe("default::sess-123");
expect(buildChatAttachScope("sess-456", null)).toBe("default::sess-456");
});

it("keeps fresh chat and profile-scoped chat separate", () => {
expect(buildChatAttachScope(null, null)).toBe("default::fresh");
expect(buildChatAttachScope("sess-123", "ops")).toBe("ops::sess-123");
});
});
16 changes: 16 additions & 0 deletions web/src/lib/chat-resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const RESUME_NONCE_PARAM = "resumeNonce";

export function buildResumeInChatUrl(sessionId: string): string {
const params = new URLSearchParams({
resume: sessionId,
[RESUME_NONCE_PARAM]: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
});
return `/chat?${params.toString()}`;
}

export function buildChatAttachScope(
sessionId: string | null,
profile: string | null | undefined,
): string {
return `${profile ?? "default"}::${sessionId ?? "fresh"}`;
}
48 changes: 32 additions & 16 deletions web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,33 +54,43 @@ import {
transferMayContainImage,
uploadChatImage,
} from "@/lib/chatImagePaste";
import { buildChatAttachScope, RESUME_NONCE_PARAM } from "@/lib/chat-resume";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";

// Stable per-browser token identifying THIS chat tab's keep-alive PTY session.
// Sent as ?attach=; lets a refresh/disconnect reattach to the same live process
// instead of spawning a fresh one. Per-localStorage, so other devices can't grab it.
// ``rotate`` mints a new token — used when the user explicitly starts a fresh
// session so the old keep-alive PTY is NOT reattached (the registry reaps it).
const PTY_ATTACH_TOKEN_KEY = "hermes.pty.token.chat";
function ptyAttachToken(rotate = false): string {
// Stable per-tab token identifying the current keep-alive PTY session for a
// specific chat scope. Sent as ?attach=; lets a refresh/disconnect reattach to
// the same live process instead of spawning a fresh one. The scope includes
// the selected resume target so switching sessions cannot accidentally
// reattach to a previous session's PTY.
const PTY_ATTACH_TOKEN_KEY_PREFIX = "hermes.pty.token.chat";
function ptyAttachToken(scope: string, rotate = false): string {
const key = `${PTY_ATTACH_TOKEN_KEY_PREFIX}.${encodeURIComponent(scope)}`;
let t = "";
if (!rotate) {
try {
t = window.localStorage.getItem(PTY_ATTACH_TOKEN_KEY) ?? "";
t = window.sessionStorage.getItem(key) ?? "";
} catch {
/* private mode / storage blocked */
try {
t = window.localStorage.getItem(key) ?? "";
} catch {
/* private mode / storage blocked */
}
}
}
if (!t) {
const a = new Uint8Array(16);
crypto.getRandomValues(a);
t = Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
try {
window.localStorage.setItem(PTY_ATTACH_TOKEN_KEY, t);
window.sessionStorage.setItem(key, t);
} catch {
/* ignore */
try {
window.localStorage.setItem(key, t);
} catch {
/* ignore */
}
}
}
return t;
Expand Down Expand Up @@ -291,16 +301,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// param changes do NOT remount the component. Resume-in-chat from the
// Sessions page relies on `/chat?resume=<id>` changing at runtime, so we must
// treat the current resume target as part of the PTY identity and rebuild the
// terminal session when it changes.
// terminal session when it changes. `resumeNonce` lets the Sessions page
// force a reopen even when the user clicks the already-active session.
const resumeParam = searchParams.get("resume");
const resumeNonce = searchParams.get(RESUME_NONCE_PARAM);
// Profile-scoped chat: spawn the PTY under the globally selected
// management profile. Changing it remounts the terminal (key below /
// effect dep) so the user explicitly starts a fresh scoped session.
const { profile: scopedProfile } = useProfileScope();
const channel = useMemo(
() => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`),
const attachScope = useMemo(
() => buildChatAttachScope(resumeParam, scopedProfile),
[resumeParam, scopedProfile],
);
const channel = useMemo(
() => generateChannelId(`${resumeParam ?? ""}\0${resumeNonce ?? ""}\0${scopedProfile}`),
[resumeParam, resumeNonce, scopedProfile],
);
const titleScope = `${channel}\0${reconnectNonce}`;
const sessionTitle =
sessionTitleState.scope === titleScope ? sessionTitleState.title : null;
Expand Down Expand Up @@ -907,7 +923,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// Keep-alive identity: reattach to this tab's living PTY across
// refresh/transient drops. A forced-fresh start rotates the token so
// the previous keep-alive PTY is not reattached (registry reaps it).
params.attach = ptyAttachToken(forceFresh);
params.attach = ptyAttachToken(attachScope, forceFresh);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
// skills, memory, and sessions (see web_server._resolve_chat_argv).
Expand Down Expand Up @@ -1165,7 +1181,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
reconnectTimerRef.current = null;
}
};
}, [channel, clearReconnectTimer, resumeParam, scopedProfile, reconnectNonce]);
}, [attachScope, channel, clearReconnectTimer, resumeParam, scopedProfile, reconnectNonce]);

// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/SessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { buildResumeInChatUrl } from "@/lib/chat-resume";
import { PluginSlot } from "@/plugins";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";

Expand Down Expand Up @@ -442,7 +443,7 @@ function SessionRow({
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
navigate(buildResumeInChatUrl(session.id));
}}
>
<Play />
Expand Down
Loading