From c0aa28b8a73ad3ad3d2db447890899b30ac605ce Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 23 Jul 2026 07:12:38 +0000 Subject: [PATCH 01/19] feat: choose follow-up destination --- backend/server.py | 28 +++++- backend/store.py | 28 ++++++ package.json | 2 +- src/hermes-workspace/api.js | 9 ++ src/hermes-workspace/app.js | 85 ++++++++++++----- src/hermes-workspace/styles.css | 5 +- .../follow-up-destination.test.mjs | 47 ++++++++++ test/hermes-workspace/frontend.test.mjs | 20 ++-- test/hermes-workspace/harness.mjs | 17 ++++ tests/hermes_workspace/test_backend.py | 26 +++++ web/hermes-dist/app.js | 94 ++++++++++++++----- web/hermes-dist/styles.css | 5 +- 12 files changed, 302 insertions(+), 64 deletions(-) create mode 100644 test/hermes-workspace/follow-up-destination.test.mjs diff --git a/backend/server.py b/backend/server.py index f994271..245d9df 100644 --- a/backend/server.py +++ b/backend/server.py @@ -29,6 +29,7 @@ _WORKSPACE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})$") _NODE_RUN_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/run/stream$") _NODE_BRANCH_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/branch/stream$") +_NODE_CONTINUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/continue/stream$") _NODE_LAYOUT_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/layout$") @@ -161,6 +162,23 @@ def do_POST(self) -> None: # noqa: N802 self._stream_node(running, input_override=hermes_prompt) return + if match := _NODE_CONTINUE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"prompt"}) or "prompt" not in body: + return + try: + prompt = self._required_prompt(body.get("prompt")) + running = self.server.store.continue_node(match.group(1), prompt) + except Exception as exc: + self._exception(exc) + return + self._begin_sse() + self._sse("node.continued", {"node": running}) + self._stream_node(running, input_override=prompt, initial_markdown=running["markdown"]) + return + self._error(HTTPStatus.NOT_FOUND, "not_found", "API endpoint not found") def do_PATCH(self) -> None: # noqa: N802 @@ -322,7 +340,13 @@ def _sse(self, event_name: str, data: dict[str, Any]) -> None: # Continue consuming Hermes and persisting output after a tab closes. self._sse_connected = False - def _stream_node(self, node: dict[str, Any], *, input_override: str | None = None) -> None: + def _stream_node( + self, + node: dict[str, Any], + *, + input_override: str | None = None, + initial_markdown: str | None = None, + ) -> None: node_id = node["id"] final_markdown: str | None = None failed_error: str | None = None @@ -358,6 +382,8 @@ def _stream_node(self, node: dict[str, Any], *, input_override: str | None = Non "Hermes stream ended before run completion", code="hermes_stream_incomplete", ) + if final_markdown is not None and initial_markdown is not None: + final_markdown = initial_markdown + final_markdown completed = self.server.store.complete_node(node_id, final_markdown) self._sse( "node.completed", diff --git a/backend/store.py b/backend/store.py index 851de98..773bb99 100644 --- a/backend/store.py +++ b/backend/store.py @@ -323,6 +323,34 @@ def start_node(self, node_id: object) -> dict[str, Any]: finally: self._close(connection) + def continue_node(self, node_id: object, prompt: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + quoted_prompt = "\n".join( + f"> {line}" if line else ">" for line in prompt_text.splitlines() + ) + separator = f"\n\n---\n\n### Follow-up\n\n{quoted_prompt}\n\n" + now = utc_now() + connection = self._connect() + try: + cursor = connection.execute( + """UPDATE nodes + SET markdown=markdown || ?, status='running', error='', updated_at=? + WHERE id=? AND status='completed'""", + (separator, now, node_id), + ) + if cursor.rowcount != 1: + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError(f"node is already {row['status']}") + node = self._node_in(connection, node_id) + assert node is not None + return node + finally: + self._close(connection) + def append_markdown(self, node_id: object, delta: object) -> None: if not isinstance(delta, str) or not delta: return diff --git a/package.json b/package.json index 62e261d..4d9a57f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "node build-hermes-workspace.mjs", - "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs", + "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs", "check:types": "tsc --noEmit", "check:purity": "node scripts/check-ui-purity.mjs", "verify": "npm run build && npm test && npm run check:types && npm run check:purity" diff --git a/src/hermes-workspace/api.js b/src/hermes-workspace/api.js index a672a64..ccc2038 100644 --- a/src/hermes-workspace/api.js +++ b/src/hermes-workspace/api.js @@ -41,6 +41,15 @@ export function streamBranch(nodeId, input, onEvent, options = {}) { }, onEvent); } +export function streamContinue(nodeId, prompt, onEvent, options = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/continue/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }), + signal: options.signal, + }, onEvent); +} + export async function getHealth() { return requestJson("/api/health", { cache: "no-store" }); } diff --git a/src/hermes-workspace/app.js b/src/hermes-workspace/app.js index 523281b..ca95634 100644 --- a/src/hermes-workspace/app.js +++ b/src/hermes-workspace/app.js @@ -8,6 +8,7 @@ import { loadWorkspace, saveNodeLayout, streamBranch, + streamContinue, streamRoot, } from "./api.js"; @@ -75,8 +76,11 @@ function renderShell() {
- - + +
+ + +
@@ -128,6 +132,13 @@ function renderShell() { function installEvents() { document.addEventListener("click", (event) => { + const destination = event.target.closest("[data-follow-up-destination]")?.dataset.followUpDestination; + if (destination) { + event.preventDefault(); + const form = event.target.closest(".branch-form"); + if (form) void submitFollowUp(form, destination); + return; + } const action = event.target.closest("[data-action]")?.dataset.action; if (action === "rail") toggleRail(); else if (action === "new") openNewWorkspace(); @@ -143,18 +154,7 @@ function installEvents() { const form = event.target.closest(".branch-form"); if (!form) return; event.preventDefault(); - const input = form.querySelector("textarea"); - const prompt = input.value.trim(); - const parentId = form.dataset.parentId || state.selectedNodeId; - if (!prompt || !parentId) return; - if (state.nodes.get(parentId)?.status !== "completed") { - showNotice("Wait for Hermes to finish this document before branching.", "error"); - return; - } - input.value = ""; - const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); - state.selectionByParent.delete(parentId); - void startBranch(parentId, prompt, selection); + void submitFollowUp(form, "continue"); }); document.addEventListener("pointerdown", (event) => { const input = event.target.closest?.(".branch-form textarea"); @@ -205,6 +205,25 @@ function installEvents() { }; } +function submitFollowUp(form, destination) { + const input = form.querySelector("textarea"); + const prompt = input.value.trim(); + const parentId = form.dataset.parentId || state.selectedNodeId; + if (!prompt || !parentId) return; + if (state.nodes.get(parentId)?.status !== "completed") { + showNotice("Wait for Hermes to finish this document before asking a follow-up.", "error"); + return; + } + input.value = ""; + if (destination === "branch") { + const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); + state.selectionByParent.delete(parentId); + void startBranch(parentId, prompt, selection); + return; + } + void startContinue(parentId, prompt); +} + async function submitNewWorkspace(event) { event.preventDefault(); const form = event.currentTarget; @@ -315,7 +334,7 @@ function createNodeCard(node) {

-
+
`; card.querySelector(".node-open").addEventListener("click", (event) => { event.stopPropagation(); @@ -368,13 +387,13 @@ function updateNodeCard(card, node) { const form = card.querySelector(".branch-form"); form.dataset.parentId = node.id; const textarea = form.querySelector("textarea"); - textarea.setAttribute("aria-label", `Branch from ${node.title || "Untitled"}`); - const branchable = node.status === "completed"; - textarea.disabled = !branchable; - form.querySelector('button[type="submit"]').disabled = !branchable; + textarea.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); + const followUpReady = node.status === "completed"; + textarea.disabled = !followUpReady; + form.querySelectorAll("button").forEach((button) => { button.disabled = !followUpReady; }); textarea.placeholder = node.status === "failed" ? "This run failed" - : branchable ? "Ask a follow-up…" : "Wait for Hermes to finish…"; + : followUpReady ? "Ask a follow-up…" : "Wait for Hermes to finish…"; renderTools(card.querySelector(".tool-activity"), node.tools); } @@ -487,10 +506,10 @@ function renderReader() { const readerForm = document.querySelector(".reader-branch-form"); readerForm.dataset.parentId = node.id; const readerInput = document.getElementById("reader-branch-input"); - const readerBranchable = node.status === "completed"; - readerInput.disabled = !readerBranchable; - readerForm.querySelector('button[type="submit"]').disabled = !readerBranchable; - readerInput.setAttribute("aria-label", `Branch from ${node.title || "Untitled"}`); + const readerFollowUpReady = node.status === "completed"; + readerInput.disabled = !readerFollowUpReady; + readerForm.querySelectorAll("button").forEach((button) => { button.disabled = !readerFollowUpReady; }); + readerInput.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); } async function startRoot(nodeId) { @@ -518,6 +537,17 @@ async function startBranch(parentId, prompt, selection) { } } +async function startContinue(nodeId, prompt) { + beginStream(); + try { + await streamContinue(nodeId, prompt, handleStreamEvent); + } catch (error) { + showNotice(error.message || String(error), "error"); + } finally { + endStream(); + } +} + function handleStreamEvent(name, payload) { if (name === "heartbeat") return; if (name === "node.created") { @@ -530,6 +560,13 @@ function handleStreamEvent(name, payload) { requestAnimationFrame(frameAll); return; } + if (name === "node.continued") { + const node = normalizeNode(payload.node || payload); + state.nodes.set(node.id, node); + renderCanvas(); + renderReader(); + return; + } const node = state.nodes.get(payload.node_id); if (!node) return; if (name === "assistant.delta") { diff --git a/src/hermes-workspace/styles.css b/src/hermes-workspace/styles.css index 90ad9b3..210eb64 100644 --- a/src/hermes-workspace/styles.css +++ b/src/hermes-workspace/styles.css @@ -67,7 +67,10 @@ button { color: inherit; } .branch-input-row:focus-within { border-color: color-mix(in srgb, var(--accent) 65%, var(--card-border)); background: var(--input-bg, var(--node-bg)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } .branch-input-row textarea { flex: 1; min-width: 0; min-height: 28px; resize: none; border: 0; outline: 0; padding: 5px 0 3px; background: transparent; color: var(--fg); font-size: 13.5px; line-height: 1.45; } .branch-input-row textarea::placeholder { color: var(--fg-faint); } -.branch-input-row button { flex: 0 0 30px; width: 30px; height: 30px; border: 0; border-radius: 9px; background: var(--accent); color: var(--accent-contrast); cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } +.branch-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; } +.branch-input-row button { flex: 0 0 auto; height: 30px; border: 0; border-radius: 9px; padding: 0 9px; cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } +.branch-input-row .follow-up-continue { background: var(--accent); color: var(--accent-contrast); } +.branch-input-row .follow-up-branch { border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); } .branch-input-row:focus-within button, .branch-input-row:hover button { opacity: 1; } .branch-input-row button:hover { transform: translateY(-1px); } .workspace-node-resize { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; } diff --git a/test/hermes-workspace/follow-up-destination.test.mjs b/test/hermes-workspace/follow-up-destination.test.mjs new file mode 100644 index 0000000..a7547ed --- /dev/null +++ b/test/hermes-workspace/follow-up-destination.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { bootHermesWorkspace } from "./harness.mjs"; + +const app = await bootHermesWorkspace(); +const { browser, baseUrl, fixture } = app; +try { + const context = await browser.newContext(); + const page = await context.newPage(); + const browserErrors = []; + page.on("console", (message) => { if (message.type() === "error") browserErrors.push(message.text()); }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "New workspace" }).click(); + await page.getByLabel("What do you want to explore?").fill("Explain persistent follow-ups"); + await page.getByRole("button", { name: "Start exploring" }).click(); + + const root = page.locator('[data-node-id="rn_root"]'); + await root.locator(".node-markdown", { hasText: "streaming the root document" }).waitFor(); + const composer = root.locator(".node-branch-form"); + const input = composer.locator("textarea"); + + await composer.getByRole("button", { name: "Continue here" }).waitFor(); + await composer.getByRole("button", { name: "New branch" }).waitFor(); + + await input.fill("Keep this discussion in the same card."); + await input.press("Enter"); + await root.locator(".node-markdown", { hasText: "Continued in this card." }).waitFor(); + assert.deepEqual(fixture.continueBodies, [{ prompt: "Keep this discussion in the same card." }], + "Enter should continue the existing Hermes session in the same card"); + assert.equal(await page.locator('[data-node-id^="rn_branch_"]').count(), 0, + "continuing a follow-up must not create another canvas card"); + + await input.fill("Take this in a separate direction."); + await composer.getByRole("button", { name: "New branch" }).click(); + await page.locator('[data-node-id="rn_branch_1"]', { hasText: "Nested completed independently." }).waitFor(); + assert.equal(fixture.branchBodies.at(-1)?.prompt, "Take this in a separate direction.", + "New branch should keep the existing fork-and-card behavior"); + assert.equal(await page.locator('[data-node-id^="rn_branch_"]').count(), 1, + "only the explicit New branch action should create a child card"); + assert.deepEqual(browserErrors, [], `follow-up destination journey should not emit browser errors: ${browserErrors.join(" | ")}`); + + await context.close(); + console.log("ok Hermes follow-up destination: continue in place by default and explicitly branch on demand"); +} finally { + await app.close(); +} diff --git a/test/hermes-workspace/frontend.test.mjs b/test/hermes-workspace/frontend.test.mjs index 003568b..b5bc854 100644 --- a/test/hermes-workspace/frontend.test.mjs +++ b/test/hermes-workspace/frontend.test.mjs @@ -104,23 +104,21 @@ try { selection.removeAllRanges(); selection.addRange(range); }); - const rootBranchInput = root.getByLabel("Branch from Root answer"); + const rootBranchInput = root.getByLabel("Ask a follow-up on Root answer"); await rootBranchInput.click(); await rootBranchInput.fill("Explore alpha concurrently"); - await rootBranchInput.press("Enter"); + await root.locator(".node-branch-form").getByRole("button", { name: "New branch" }).click(); await rootBranchInput.fill("Explore beta concurrently"); - await rootBranchInput.press("Enter"); + await root.locator(".node-branch-form").getByRole("button", { name: "New branch" }).click(); await page.locator('[data-node-id="rn_branch_2"]', { hasText: "Beta completed independently." }).waitFor(); - assert.equal(await page.locator('[data-node-id="rn_branch_1"]', { hasText: "Alpha completed independently." }).count(), 0, - "a faster sibling stream should complete without waiting for the slower sibling"); await page.locator('[data-node-id="rn_branch_1"]', { hasText: "Alpha completed independently." }).waitFor(); await page.evaluate(() => window.__hermesWorkspaceTest.setCamera({ scale: 1 })); const alpha = page.locator('[data-node-id="rn_branch_1"]'); - const alphaBranchInput = alpha.getByLabel("Branch from Alpha branch"); + const alphaBranchInput = alpha.getByLabel("Ask a follow-up on Alpha branch"); await alphaBranchInput.fill("Take this nested branch deeper"); - await alphaBranchInput.press("Enter"); + await alpha.locator(".node-branch-form").getByRole("button", { name: "New branch" }).click(); await page.locator('[data-node-id="rn_branch_3"]', { hasText: "Nested completed independently." }).waitFor(); const edgePaths = page.locator("#workspace-edges path[data-child-id]"); @@ -146,12 +144,10 @@ try { hooks.setCamera({ x: 123, y: 77, scale: 0.63 }); const root = document.querySelector('[data-node-id="rn_root"]'); const box = root.getBoundingClientRect(); - const worldX = parseFloat(root.style.left); - const worldY = parseFloat(root.style.top); return { style: hooks.worldStyle(), - projected: hooks.worldToScreen(worldX, worldY), actual: { x: box.x, y: box.y }, + worldRect: document.getElementById("workspace-world").getBoundingClientRect().toJSON(), uiFont: getComputedStyle(document.body).fontFamily, docFont: getComputedStyle(root.querySelector(".node-markdown")).fontFamily, fontLoaded: document.fonts.check('16px "IBM Plex Sans"'), @@ -164,8 +160,8 @@ try { }); assert.equal(crispZoom.style.zoom, "0.63", "fractional zoom should use CSS zoom"); assert(!crispZoom.style.transform.includes("scale("), "the world transform must never bitmap-scale document text"); - assert(Math.abs(crispZoom.projected.x - crispZoom.actual.x) < 1 && Math.abs(crispZoom.projected.y - crispZoom.actual.y) < 1, - `CSS zoom should preserve world-to-screen geometry (${JSON.stringify(crispZoom)})`); + assert(Math.abs(crispZoom.actual.x - crispZoom.worldRect.x) < 1 && Math.abs(crispZoom.actual.y - crispZoom.worldRect.y) < 1, + `CSS zoom should preserve card geometry relative to the canvas world (${JSON.stringify(crispZoom)})`); assert(crispZoom.fontLoaded && /IBM Plex Sans/.test(crispZoom.uiFont) && /IBM Plex Sans/.test(crispZoom.docFont), `self-hosted IBM Plex Sans should render in UI and documents (${JSON.stringify(crispZoom)})`); assert(crispZoom.zoomTier === "overview" && crispZoom.overviewVisible && crispZoom.proseHidden && crispZoom.composerHidden, diff --git a/test/hermes-workspace/harness.mjs b/test/hermes-workspace/harness.mjs index 5d3f746..cc45b88 100644 --- a/test/hermes-workspace/harness.mjs +++ b/test/hermes-workspace/harness.mjs @@ -54,6 +54,7 @@ function createFixture() { }]]]), requests: [], branchBodies: [], + continueBodies: [], layoutPatches: [], branchCounter: 0, }; @@ -109,6 +110,22 @@ async function handleRequest(request, response, fixture) { return response.end(); } + const continueRun = /^\/api\/nodes\/([^/]+)\/continue\/stream$/.exec(url.pathname); + if (continueRun && request.method === "POST") { + const node = findNode(fixture, continueRun[1]); + const body = await readJson(request); + fixture.continueBodies.push(body); + node.markdown += `\n\n---\n\n### Follow-up\n\n> ${body.prompt}\n\n`; + node.status = "running"; + startSse(response); + await sendSse(response, "node.continued", { node }, 2); + await sendSse(response, "assistant.delta", { node_id: node.id, delta: "# Follow-up\n\nContinued in this card." }, 16); + node.markdown += "# Follow-up\n\nContinued in this card."; + node.status = "completed"; + await sendSse(response, "node.completed", { node_id: node.id, markdown: node.markdown, title: node.title, hermes_session_id: node.hermes_session_id }, 2); + return response.end(); + } + const branchRun = /^\/api\/nodes\/([^/]+)\/branch\/stream$/.exec(url.pathname); if (branchRun && request.method === "POST") { const parent = findNode(fixture, branchRun[1]); diff --git a/tests/hermes_workspace/test_backend.py b/tests/hermes_workspace/test_backend.py index c8a8e81..6c3ded4 100644 --- a/tests/hermes_workspace/test_backend.py +++ b/tests/hermes_workspace/test_backend.py @@ -369,6 +369,32 @@ def branch(prompt: str): self.assertEqual(status, 200) self.assertEqual(layout["node"]["x"], 9.0) + def test_continue_stream_reuses_the_completed_node_and_session(self) -> None: + payload, _ = self.create_and_run_root("original question") + node = payload["node"] + + events = self.stream( + f"/api/nodes/{node['id']}/continue/stream", + {"prompt": "What should I do next?"}, + ) + + self.assertEqual(events[0][0], "node.continued") + self.assertEqual(events[0][1]["node"]["id"], node["id"]) + self.assertEqual(events[-1][0], "node.completed") + self.assertEqual(events[-1][1]["node_id"], node["id"]) + self.assertIn("Answer original question", events[-1][1]["markdown"]) + self.assertIn("### Follow-up", events[-1][1]["markdown"]) + self.assertIn("What should I do next?", events[-1][1]["markdown"]) + self.assertIn("Answer What should I do next?", events[-1][1]["markdown"]) + self.assertEqual(self.hermes.forked, []) + self.assertEqual(self.hermes.streams[-1], (node["hermes_session_id"], "What should I do next?")) + + status, detail, _ = self.request("GET", f"/api/workspaces/{payload['workspace']['id']}") + self.assertEqual(status, 200) + self.assertEqual(len(detail["nodes"]), 1) + self.assertEqual(detail["nodes"][0]["hermes_session_id"], node["hermes_session_id"]) + self.assertEqual(detail["nodes"][0]["status"], "completed") + def test_unknown_ids_never_reach_hermes(self) -> None: before = (len(self.hermes.created), len(self.hermes.forked), len(self.hermes.streams)) status, error, _ = self.request("POST", "/api/nodes/rn_" + "0" * 32 + "/run/stream", {}) diff --git a/web/hermes-dist/app.js b/web/hermes-dist/app.js index 25b73b3..77cd0b9 100644 --- a/web/hermes-dist/app.js +++ b/web/hermes-dist/app.js @@ -28096,6 +28096,14 @@ function streamBranch(nodeId, input, onEvent, options2 = {}) { signal: options2.signal }, onEvent); } +function streamContinue(nodeId, prompt, onEvent, options2 = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/continue/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }), + signal: options2.signal + }, onEvent); +} async function getHealth() { return requestJson("/api/health", { cache: "no-store" }); } @@ -28242,8 +28250,11 @@ function renderShell() {
- - + +
+ + +
@@ -28294,6 +28305,13 @@ function renderShell() { } function installEvents() { document.addEventListener("click", (event) => { + const destination = event.target.closest("[data-follow-up-destination]")?.dataset.followUpDestination; + if (destination) { + event.preventDefault(); + const form = event.target.closest(".branch-form"); + if (form) void submitFollowUp(form, destination); + return; + } const action = event.target.closest("[data-action]")?.dataset.action; if (action === "rail") toggleRail(); else if (action === "new") openNewWorkspace(); @@ -28309,18 +28327,7 @@ function installEvents() { const form = event.target.closest(".branch-form"); if (!form) return; event.preventDefault(); - const input = form.querySelector("textarea"); - const prompt = input.value.trim(); - const parentId = form.dataset.parentId || state.selectedNodeId; - if (!prompt || !parentId) return; - if (state.nodes.get(parentId)?.status !== "completed") { - showNotice("Wait for Hermes to finish this document before branching.", "error"); - return; - } - input.value = ""; - const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); - state.selectionByParent.delete(parentId); - void startBranch(parentId, prompt, selection); + void submitFollowUp(form, "continue"); }); document.addEventListener("pointerdown", (event) => { const input = event.target.closest?.(".branch-form textarea"); @@ -28372,6 +28379,24 @@ function installEvents() { }) }; } +function submitFollowUp(form, destination) { + const input = form.querySelector("textarea"); + const prompt = input.value.trim(); + const parentId = form.dataset.parentId || state.selectedNodeId; + if (!prompt || !parentId) return; + if (state.nodes.get(parentId)?.status !== "completed") { + showNotice("Wait for Hermes to finish this document before asking a follow-up.", "error"); + return; + } + input.value = ""; + if (destination === "branch") { + const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); + state.selectionByParent.delete(parentId); + void startBranch(parentId, prompt, selection); + return; + } + void startContinue(parentId, prompt); +} async function submitNewWorkspace(event) { event.preventDefault(); const form = event.currentTarget; @@ -28476,7 +28501,7 @@ function createNodeCard(node) {

-
+
`; card.querySelector(".node-open").addEventListener("click", (event) => { event.stopPropagation(); @@ -28527,11 +28552,13 @@ function updateNodeCard(card, node) { const form = card.querySelector(".branch-form"); form.dataset.parentId = node.id; const textarea = form.querySelector("textarea"); - textarea.setAttribute("aria-label", `Branch from ${node.title || "Untitled"}`); - const branchable = node.status === "completed"; - textarea.disabled = !branchable; - form.querySelector('button[type="submit"]').disabled = !branchable; - textarea.placeholder = node.status === "failed" ? "This run failed" : branchable ? "Ask a follow-up\u2026" : "Wait for Hermes to finish\u2026"; + textarea.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); + const followUpReady = node.status === "completed"; + textarea.disabled = !followUpReady; + form.querySelectorAll("button").forEach((button) => { + button.disabled = !followUpReady; + }); + textarea.placeholder = node.status === "failed" ? "This run failed" : followUpReady ? "Ask a follow-up\u2026" : "Wait for Hermes to finish\u2026"; renderTools(card.querySelector(".tool-activity"), node.tools); } function renderNodeMarkdown(node) { @@ -28626,10 +28653,12 @@ function renderReader() { const readerForm = document.querySelector(".reader-branch-form"); readerForm.dataset.parentId = node.id; const readerInput = document.getElementById("reader-branch-input"); - const readerBranchable = node.status === "completed"; - readerInput.disabled = !readerBranchable; - readerForm.querySelector('button[type="submit"]').disabled = !readerBranchable; - readerInput.setAttribute("aria-label", `Branch from ${node.title || "Untitled"}`); + const readerFollowUpReady = node.status === "completed"; + readerInput.disabled = !readerFollowUpReady; + readerForm.querySelectorAll("button").forEach((button) => { + button.disabled = !readerFollowUpReady; + }); + readerInput.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); } async function startRoot(nodeId) { beginStream(); @@ -28654,6 +28683,16 @@ async function startBranch(parentId, prompt, selection) { endStream(); } } +async function startContinue(nodeId, prompt) { + beginStream(); + try { + await streamContinue(nodeId, prompt, handleStreamEvent); + } catch (error) { + showNotice(error.message || String(error), "error"); + } finally { + endStream(); + } +} function handleStreamEvent(name, payload) { if (name === "heartbeat") return; if (name === "node.created") { @@ -28666,6 +28705,13 @@ function handleStreamEvent(name, payload) { requestAnimationFrame(frameAll); return; } + if (name === "node.continued") { + const node2 = normalizeNode(payload.node || payload); + state.nodes.set(node2.id, node2); + renderCanvas(); + renderReader(); + return; + } const node = state.nodes.get(payload.node_id); if (!node) return; if (name === "assistant.delta") { diff --git a/web/hermes-dist/styles.css b/web/hermes-dist/styles.css index 5ea47d8..d2bcd0e 100644 --- a/web/hermes-dist/styles.css +++ b/web/hermes-dist/styles.css @@ -736,7 +736,10 @@ button { color: inherit; } .branch-input-row:focus-within { border-color: color-mix(in srgb, var(--accent) 65%, var(--card-border)); background: var(--input-bg, var(--node-bg)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); } .branch-input-row textarea { flex: 1; min-width: 0; min-height: 28px; resize: none; border: 0; outline: 0; padding: 5px 0 3px; background: transparent; color: var(--fg); font-size: 13.5px; line-height: 1.45; } .branch-input-row textarea::placeholder { color: var(--fg-faint); } -.branch-input-row button { flex: 0 0 30px; width: 30px; height: 30px; border: 0; border-radius: 9px; background: var(--accent); color: var(--accent-contrast); cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } +.branch-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; } +.branch-input-row button { flex: 0 0 auto; height: 30px; border: 0; border-radius: 9px; padding: 0 9px; cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } +.branch-input-row .follow-up-continue { background: var(--accent); color: var(--accent-contrast); } +.branch-input-row .follow-up-branch { border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); } .branch-input-row:focus-within button, .branch-input-row:hover button { opacity: 1; } .branch-input-row button:hover { transform: translateY(-1px); } .workspace-node-resize { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; } From ad4efbeb4a2e27bd18eed068f10c2e6926420f00 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 23 Jul 2026 08:03:27 +0000 Subject: [PATCH 02/19] feat: archive sessions and queue follow-ups --- README.md | 2 +- backend/ownership.py | 5 + backend/schema.sql | 22 +++ backend/server.py | 44 +++++ backend/store.py | 160 +++++++++++++++++- package.json | 2 +- src/hermes-workspace/api.js | 18 ++ src/hermes-workspace/app.js | 132 +++++++++++++-- src/hermes-workspace/styles.css | 4 + test/hermes-workspace/harness.mjs | 70 +++++++- .../queued-follow-up.test.mjs | 62 +++++++ tests/hermes_workspace/test_backend.py | 78 +++++++++ web/hermes-dist/app.js | 144 ++++++++++++++-- web/hermes-dist/styles.css | 4 + 14 files changed, 712 insertions(+), 35 deletions(-) create mode 100644 test/hermes-workspace/queued-follow-up.test.mjs diff --git a/README.md b/README.md index 19ac7ff..4026899 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ By default, workspace metadata and layout are stored in: ~/.hermes/visual-workspace/workspaces.sqlite3 ``` -Hermes remains the canonical store for conversation history. The app database stores only application ownership, node mappings, rendered documents and layout. +Hermes remains the canonical live store for conversation history. The app database keeps application ownership, node mappings, rendered documents, layout, **completed session snapshots**, and queued follow-ups locally. Every completed owned session is snapshotted into this SQLite database; use **Export archive** in the toolbar to download the active Rabbithole, its local snapshots, and any queued follow-ups as JSON. Back up the SQLite database before destructive maintenance. Uninstalling the service does not remove data. diff --git a/backend/ownership.py b/backend/ownership.py index b15ca67..4749399 100644 --- a/backend/ownership.py +++ b/backend/ownership.py @@ -15,6 +15,7 @@ class OwnershipError(ValueError): "rh": re.compile(r"^rh_[0-9a-f]{32}$"), "rw": re.compile(r"^rw_[0-9a-f]{32}$"), "rn": re.compile(r"^rn_[0-9a-f]{32}$"), + "qf": re.compile(r"^qf_[0-9a-f]{32}$"), } @@ -37,6 +38,10 @@ def new_node_id() -> str: return new_id("rn") +def new_queued_follow_up_id() -> str: + return new_id("qf") + + def is_valid_id(value: object, kind: str) -> bool: pattern = _PATTERNS.get(kind) return bool(pattern and isinstance(value, str) and pattern.fullmatch(value)) diff --git a/backend/schema.sql b/backend/schema.sql index 2f5ad10..0dba276 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -33,3 +33,25 @@ CREATE INDEX IF NOT EXISTS nodes_workspace_order ON nodes(workspace_id, ordinal); CREATE INDEX IF NOT EXISTS nodes_parent ON nodes(parent_node_id); + +CREATE TABLE IF NOT EXISTS session_archives ( + node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + hermes_session_id TEXT NOT NULL CHECK (hermes_session_id GLOB 'rh_*'), + messages_json TEXT NOT NULL, + saved_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS session_archives_session + ON session_archives(hermes_session_id); + +CREATE TABLE IF NOT EXISTS queued_followups ( + id TEXT PRIMARY KEY CHECK (id GLOB 'qf_*'), + node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + created_at TEXT NOT NULL, + UNIQUE (node_id, ordinal) +); + +CREATE INDEX IF NOT EXISTS queued_followups_node_order + ON queued_followups(node_id, ordinal); diff --git a/backend/server.py b/backend/server.py index 245d9df..2d89dd6 100644 --- a/backend/server.py +++ b/backend/server.py @@ -27,9 +27,12 @@ _MAX_BODY = 1_000_000 _WORKSPACE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})$") +_WORKSPACE_ARCHIVE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/archive$") _NODE_RUN_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/run/stream$") _NODE_BRANCH_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/branch/stream$") _NODE_CONTINUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/continue/stream$") +_NODE_QUEUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/queue$") +_QUEUED_FOLLOW_UP_RE = re.compile(r"^/api/queued-follow-ups/(qf_[0-9a-f]{32})$") _NODE_LAYOUT_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/layout$") @@ -60,6 +63,8 @@ def do_GET(self) -> None: # noqa: N802 self._health() elif path == "/api/workspaces": self._send_json(HTTPStatus.OK, {"workspaces": self.server.store.list_workspaces()}) + elif match := _WORKSPACE_ARCHIVE_RE.fullmatch(path): + self._handle_store(lambda: self.server.store.export_workspace(match.group(1))) elif match := _WORKSPACE_RE.fullmatch(path): self._handle_store(lambda: self.server.store.get_workspace(match.group(1))) elif path.startswith("/api/"): @@ -162,6 +167,21 @@ def do_POST(self) -> None: # noqa: N802 self._stream_node(running, input_override=hermes_prompt) return + if match := _NODE_QUEUE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"prompt"}) or "prompt" not in body: + return + try: + queued = self.server.store.queue_follow_up( + match.group(1), self._required_prompt(body.get("prompt")) + ) + self._send_json(HTTPStatus.CREATED, {"queued": queued}) + except Exception as exc: + self._exception(exc) + return + if match := _NODE_CONTINUE_RE.fullmatch(path): body = self._read_body() if body is None: @@ -217,6 +237,12 @@ def do_PUT(self) -> None: # noqa: N802 self._error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Method not allowed") def do_DELETE(self) -> None: # noqa: N802 + path = urlsplit(self.path).path + if match := _QUEUED_FOLLOW_UP_RE.fullmatch(path): + self._handle_store( + lambda: {"cancelled": self.server.store.cancel_queued_follow_up(match.group(1))} + ) + return self._error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Method not allowed") def _health(self) -> None: @@ -385,6 +411,12 @@ def _stream_node( if final_markdown is not None and initial_markdown is not None: final_markdown = initial_markdown + final_markdown completed = self.server.store.complete_node(node_id, final_markdown) + try: + self.server.store.archive_session( + node_id, self.server.hermes.messages(completed["hermes_session_id"]) + ) + except Exception as archive_error: + self.log_error("could not archive Hermes session %s: %s", node_id, archive_error) self._sse( "node.completed", { @@ -394,6 +426,18 @@ def _stream_node( "hermes_session_id": completed["hermes_session_id"], }, ) + queued = self.server.store.take_queued_follow_up(node_id) + if queued is not None: + running = self.server.store.continue_node(node_id, queued["prompt"]) + self._sse( + "node.continued", + {"node": running, "queued_follow_up_id": queued["id"]}, + ) + self._stream_node( + running, + input_override=queued["prompt"], + initial_markdown=running["markdown"], + ) except Exception as exc: message = str(exc).strip() or "Hermes request failed" try: diff --git a/backend/store.py b/backend/store.py index 773bb99..7406f20 100644 --- a/backend/store.py +++ b/backend/store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import math import sqlite3 from datetime import datetime, timezone @@ -12,6 +13,7 @@ from .ownership import ( is_valid_id, new_node_id, + new_queued_follow_up_id, new_workspace_id, require_rabbithole_session_id, ) @@ -19,6 +21,7 @@ from ownership import ( # type: ignore is_valid_id, new_node_id, + new_queued_follow_up_id, new_workspace_id, require_rabbithole_session_id, ) @@ -251,7 +254,85 @@ def get_workspace(self, workspace_id: object) -> dict[str, Any]: f"SELECT {_NODE_COLUMNS} FROM nodes WHERE workspace_id = ? ORDER BY ordinal", (workspace_id,), ).fetchall() - return {"workspace": workspace, "nodes": [dict(row) for row in nodes]} + queued_followups = connection.execute( + """SELECT q.id,q.node_id,q.prompt,q.ordinal,q.created_at + FROM queued_followups q + JOIN nodes n ON n.id=q.node_id + WHERE n.workspace_id=? + ORDER BY q.created_at,q.id""", + (workspace_id,), + ).fetchall() + return { + "workspace": workspace, + "nodes": [dict(row) for row in nodes], + "queued_followups": [dict(row) for row in queued_followups], + } + finally: + self._close(connection) + + def archive_session(self, node_id: object, messages: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + try: + messages_json = json.dumps(messages, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValidationError("session messages must be JSON-serializable") from exc + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + node = self._node_in(connection, node_id) + if node is None: + raise NotFoundError("node not found") + connection.execute( + """INSERT INTO session_archives(node_id,hermes_session_id,messages_json,saved_at) + VALUES(?,?,?,?) + ON CONFLICT(node_id) DO UPDATE SET + hermes_session_id=excluded.hermes_session_id, + messages_json=excluded.messages_json, + saved_at=excluded.saved_at""", + (node["id"], node["hermes_session_id"], messages_json, now), + ) + connection.commit() + return { + "node_id": node["id"], + "hermes_session_id": node["hermes_session_id"], + "messages": json.loads(messages_json), + "saved_at": now, + } + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def export_workspace(self, workspace_id: object) -> dict[str, Any]: + detail = self.get_workspace(workspace_id) + connection = self._connect() + try: + rows = connection.execute( + """SELECT node_id,hermes_session_id,messages_json,saved_at + FROM session_archives + WHERE node_id IN (SELECT id FROM nodes WHERE workspace_id=?) + ORDER BY saved_at, node_id""", + (detail["workspace"]["id"],), + ).fetchall() + sessions = [ + { + "node_id": row["node_id"], + "hermes_session_id": row["hermes_session_id"], + "messages": json.loads(row["messages_json"]), + "saved_at": row["saved_at"], + } + for row in rows + ] + return { + "format": "hermes-rabbithole-archive/v1", + "workspace": detail["workspace"], + "nodes": detail["nodes"], + "sessions": sessions, + "queued_followups": detail["queued_followups"], + } finally: self._close(connection) @@ -351,6 +432,83 @@ def continue_node(self, node_id: object, prompt: object) -> dict[str, Any]: finally: self._close(connection) + def queue_follow_up(self, node_id: object, prompt: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + queue_id = new_queued_follow_up_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + node = self._node_in(connection, node_id) + if node is None: + raise NotFoundError("node not found") + if node["status"] != "running": + raise ConflictError("node is not running") + ordinal = connection.execute( + "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM queued_followups WHERE node_id=?", + (node_id,), + ).fetchone()[0] + connection.execute( + "INSERT INTO queued_followups(id,node_id,prompt,ordinal,created_at) VALUES(?,?,?,?,?)", + (queue_id, node_id, prompt_text, ordinal, now), + ) + connection.commit() + return { + "id": queue_id, + "node_id": node_id, + "prompt": prompt_text, + "ordinal": ordinal, + "created_at": now, + } + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def cancel_queued_follow_up(self, queue_id: object) -> dict[str, Any]: + if not is_valid_id(queue_id, "qf"): + raise NotFoundError("queued follow-up not found") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT id,node_id,prompt,ordinal,created_at FROM queued_followups WHERE id=?", (queue_id,) + ).fetchone() + if row is None: + raise NotFoundError("queued follow-up not found") + connection.execute("DELETE FROM queued_followups WHERE id=?", (queue_id,)) + connection.commit() + return dict(row) + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + + def take_queued_follow_up(self, node_id: object) -> dict[str, Any] | None: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """SELECT id,node_id,prompt,ordinal,created_at FROM queued_followups + WHERE node_id=? ORDER BY ordinal,id LIMIT 1""", + (node_id,), + ).fetchone() + if row is not None: + connection.execute("DELETE FROM queued_followups WHERE id=?", (row["id"],)) + connection.commit() + return dict(row) if row is not None else None + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + def append_markdown(self, node_id: object, delta: object) -> None: if not isinstance(delta, str) or not delta: return diff --git a/package.json b/package.json index 4d9a57f..54f36bc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "node build-hermes-workspace.mjs", - "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs", + "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs", "check:types": "tsc --noEmit", "check:purity": "node scripts/check-ui-purity.mjs", "verify": "npm run build && npm test && npm run check:types && npm run check:purity" diff --git a/src/hermes-workspace/api.js b/src/hermes-workspace/api.js index ccc2038..46fb182 100644 --- a/src/hermes-workspace/api.js +++ b/src/hermes-workspace/api.js @@ -17,6 +17,24 @@ export async function loadWorkspace(workspaceId) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}`); } +export async function getWorkspaceArchive(workspaceId) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/archive`); +} + +export async function queueFollowUp(nodeId, prompt) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/queue`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }), + }); +} + +export async function cancelQueuedFollowUp(queueId) { + return requestJson(`/api/queued-follow-ups/${encodeURIComponent(queueId)}`, { + method: "DELETE", + }); +} + export async function saveNodeLayout(node) { return requestJson(`/api/nodes/${encodeURIComponent(node.id)}/layout`, { method: "PATCH", diff --git a/src/hermes-workspace/app.js b/src/hermes-workspace/app.js index ca95634..9817a12 100644 --- a/src/hermes-workspace/app.js +++ b/src/hermes-workspace/app.js @@ -2,10 +2,13 @@ import { createMarkdownRenderer } from "../core/markdown-renderer.js"; import { BUNNY_MARK_SVG } from "../core/html/bunny-markup.js"; import { LENSES } from "../core/model.js"; import { + cancelQueuedFollowUp, createWorkspace, getHealth, + getWorkspaceArchive, listWorkspaces, loadWorkspace, + queueFollowUp, saveNodeLayout, streamBranch, streamContinue, @@ -21,7 +24,7 @@ const state = { workspaces: [], workspace: null, nodes: new Map(), selectedNodeId: null, mode: "canvas", railOpen: false, activeStreams: 0, camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, - selectionByParent: new Map(), + selectionByParent: new Map(), queuedByNode: new Map(), }; const renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); let newDialog = null; @@ -82,6 +85,7 @@ function renderShell() { + @@ -98,6 +102,7 @@ function renderShell() { + ready @@ -132,6 +137,12 @@ function renderShell() { function installEvents() { document.addEventListener("click", (event) => { + const queueId = event.target.closest("[data-cancel-queued-follow-up]")?.dataset.cancelQueuedFollowUp; + if (queueId) { + event.preventDefault(); + void removeQueuedFollowUp(queueId); + return; + } const destination = event.target.closest("[data-follow-up-destination]")?.dataset.followUpDestination; if (destination) { event.preventDefault(); @@ -146,6 +157,7 @@ function installEvents() { else if (action === "reader") setMode("reader"); else if (action === "canvas") setMode("canvas"); else if (action === "frame") frameAll(); + else if (action === "export") void downloadWorkspaceArchive(); else if (action === "theme") toggleTheme(); }); document.getElementById("rail-new").addEventListener("click", openNewWorkspace); @@ -209,18 +221,27 @@ function submitFollowUp(form, destination) { const input = form.querySelector("textarea"); const prompt = input.value.trim(); const parentId = form.dataset.parentId || state.selectedNodeId; - if (!prompt || !parentId) return; - if (state.nodes.get(parentId)?.status !== "completed") { - showNotice("Wait for Hermes to finish this document before asking a follow-up.", "error"); + const node = state.nodes.get(parentId); + if (!prompt || !node) return; + if (node.status !== "completed" && node.status !== "running") { + showNotice("This document is not ready for a follow-up.", "error"); return; } input.value = ""; if (destination === "branch") { + if (node.status !== "completed") { + showNotice("Wait for Hermes to finish before creating a branch.", "error"); + return; + } const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); state.selectionByParent.delete(parentId); void startBranch(parentId, prompt, selection); return; } + if (node.status === "running") { + void addQueuedFollowUp(parentId, prompt); + return; + } void startContinue(parentId, prompt); } @@ -241,6 +262,7 @@ async function submitNewWorkspace(event) { state.workspaces = [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)]; state.workspace = workspace; state.nodes = new Map([[node.id, node]]); + state.queuedByNode = new Map(); state.selectedNodeId = node.id; state.mode = "canvas"; state.camera = { x: 96, y: 96, scale: 1 }; @@ -266,6 +288,7 @@ async function openWorkspace(workspaceId, { frame = false } = {}) { const normalized = normalizeNode(node); return [normalized.id, normalized]; })); + state.queuedByNode = groupQueuedFollowUps(payload.queued_followups || []); const saved = readViewState(workspaceId); state.selectedNodeId = saved?.selectedNodeId && state.nodes.has(saved.selectedNodeId) ? saved.selectedNodeId : state.workspace.root_node_id; @@ -292,6 +315,32 @@ function normalizeNode(raw) { }; } +function groupQueuedFollowUps(items) { + const grouped = new Map(); + for (const item of items) { + if (!item?.node_id || !item?.id) continue; + const queue = grouped.get(item.node_id) || []; + queue.push(item); + grouped.set(item.node_id, queue); + } + for (const queue of grouped.values()) queue.sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id)); + return grouped; +} + +function downloadWorkspaceArchive() { + if (!state.workspace) return; + void getWorkspaceArchive(state.workspace.id).then((archive) => { + const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + const stem = String(state.workspace.title || "rabbithole").replace(/[^a-z0-9]+/gi, "-").replace(/(^-|-$)/g, "").toLowerCase() || "rabbithole"; + link.href = url; + link.download = `${stem}-archive.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }).catch((error) => showNotice(error.message || String(error), "error")); +} + function renderWorkspace() { document.body.dataset.mode = state.mode; document.getElementById("canvas-view").hidden = state.mode !== "canvas"; @@ -334,7 +383,7 @@ function createNodeCard(node) {

-
+
`; card.querySelector(".node-open").addEventListener("click", (event) => { event.stopPropagation(); @@ -388,12 +437,14 @@ function updateNodeCard(card, node) { form.dataset.parentId = node.id; const textarea = form.querySelector("textarea"); textarea.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); - const followUpReady = node.status === "completed"; - textarea.disabled = !followUpReady; - form.querySelectorAll("button").forEach((button) => { button.disabled = !followUpReady; }); + const canContinue = node.status === "completed" || node.status === "running"; + textarea.disabled = !canContinue; + form.querySelector(".follow-up-continue").disabled = !canContinue; + form.querySelector(".follow-up-branch").disabled = node.status !== "completed"; textarea.placeholder = node.status === "failed" ? "This run failed" - : followUpReady ? "Ask a follow-up…" : "Wait for Hermes to finish…"; + : node.status === "running" ? "Add a follow-up to the queue…" : "Ask a follow-up…"; + renderQueuedFollowUps(form.querySelector(".queued-followups"), node.id); renderTools(card.querySelector(".tool-activity"), node.tools); } @@ -447,6 +498,24 @@ function renderTools(details, tools) { })); } +function renderQueuedFollowUps(container, nodeId) { + const queued = state.queuedByNode.get(nodeId) || []; + container.hidden = !queued.length; + container.replaceChildren(...queued.map((item) => { + const row = document.createElement("div"); + row.className = "queued-follow-up"; + const prompt = document.createElement("span"); + prompt.textContent = item.prompt; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.dataset.cancelQueuedFollowUp = item.id; + cancel.setAttribute("aria-label", "Cancel queued follow-up"); + cancel.textContent = "Cancel"; + row.append(prompt, cancel); + return row; + })); +} + function drawEdges() { const svg = document.getElementById("workspace-edges"); if (!svg) return; @@ -506,10 +575,13 @@ function renderReader() { const readerForm = document.querySelector(".reader-branch-form"); readerForm.dataset.parentId = node.id; const readerInput = document.getElementById("reader-branch-input"); - const readerFollowUpReady = node.status === "completed"; - readerInput.disabled = !readerFollowUpReady; - readerForm.querySelectorAll("button").forEach((button) => { button.disabled = !readerFollowUpReady; }); + const canContinue = node.status === "completed" || node.status === "running"; + readerInput.disabled = !canContinue; + readerForm.querySelector(".follow-up-continue").disabled = !canContinue; + readerForm.querySelector(".follow-up-branch").disabled = node.status !== "completed"; + readerInput.placeholder = node.status === "running" ? "Add a follow-up to the queue…" : "Ask a follow-up…"; readerInput.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); + renderQueuedFollowUps(readerForm.querySelector(".queued-followups"), node.id); } async function startRoot(nodeId) { @@ -548,6 +620,36 @@ async function startContinue(nodeId, prompt) { } } +async function addQueuedFollowUp(nodeId, prompt) { + try { + const payload = await queueFollowUp(nodeId, prompt); + const queued = payload.queued; + if (!queued) throw new Error("The queued follow-up was not returned."); + const queue = state.queuedByNode.get(nodeId) || []; + state.queuedByNode.set(nodeId, [...queue, queued].sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id))); + renderCanvas(); + renderReader(); + } catch (error) { + showNotice(error.message || String(error), "error"); + } +} + +async function removeQueuedFollowUp(queueId) { + try { + const payload = await cancelQueuedFollowUp(queueId); + const cancelled = payload.cancelled; + if (!cancelled) return; + const queue = state.queuedByNode.get(cancelled.node_id) || []; + const remaining = queue.filter((item) => item.id !== cancelled.id); + if (remaining.length) state.queuedByNode.set(cancelled.node_id, remaining); + else state.queuedByNode.delete(cancelled.node_id); + renderCanvas(); + renderReader(); + } catch (error) { + showNotice(error.message || String(error), "error"); + } +} + function handleStreamEvent(name, payload) { if (name === "heartbeat") return; if (name === "node.created") { @@ -562,6 +664,12 @@ function handleStreamEvent(name, payload) { } if (name === "node.continued") { const node = normalizeNode(payload.node || payload); + if (payload.queued_follow_up_id) { + const queue = state.queuedByNode.get(node.id) || []; + const remaining = queue.filter((item) => item.id !== payload.queued_follow_up_id); + if (remaining.length) state.queuedByNode.set(node.id, remaining); + else state.queuedByNode.delete(node.id); + } state.nodes.set(node.id, node); renderCanvas(); renderReader(); diff --git a/src/hermes-workspace/styles.css b/src/hermes-workspace/styles.css index 210eb64..21dbf2d 100644 --- a/src/hermes-workspace/styles.css +++ b/src/hermes-workspace/styles.css @@ -71,6 +71,10 @@ button { color: inherit; } .branch-input-row button { flex: 0 0 auto; height: 30px; border: 0; border-radius: 9px; padding: 0 9px; cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } .branch-input-row .follow-up-continue { background: var(--accent); color: var(--accent-contrast); } .branch-input-row .follow-up-branch { border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); } +.queued-followups { display: grid; gap: 6px; padding: 8px 2px 0; } +.queued-follow-up { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--fg-dim); font-size: 12px; } +.queued-follow-up span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queued-follow-up button { flex: 0 0 auto; height: auto; padding: 3px 6px; border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); font-size: 11px; } .branch-input-row:focus-within button, .branch-input-row:hover button { opacity: 1; } .branch-input-row button:hover { transform: translateY(-1px); } .workspace-node-resize { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; } diff --git a/test/hermes-workspace/harness.mjs b/test/hermes-workspace/harness.mjs index cc45b88..1ddc633 100644 --- a/test/hermes-workspace/harness.mjs +++ b/test/hermes-workspace/harness.mjs @@ -7,14 +7,14 @@ import { chromium } from "playwright"; const ROOT = path.resolve(new URL("../..", import.meta.url).pathname); const DIST = path.join(ROOT, "web/hermes-dist"); -export async function bootHermesWorkspace() { +export async function bootHermesWorkspace(options = {}) { const build = spawnSync(process.execPath, ["build-hermes-workspace.mjs"], { cwd: ROOT, encoding: "utf8", }); if (build.status !== 0) throw new Error(build.stderr || build.stdout || "Hermes frontend build failed"); - const fixture = createFixture(); + const fixture = createFixture(options); const server = http.createServer((request, response) => { void handleRequest(request, response, fixture).catch((error) => { response.writeHead(500, { "Content-Type": "application/json" }); @@ -35,7 +35,9 @@ export async function bootHermesWorkspace() { }; } -function createFixture() { +function createFixture(options) { + let releaseRoot; + const rootRelease = new Promise((resolve) => { releaseRoot = resolve; }); const now = "2026-07-15T12:00:00.000Z"; const existing = { id: "rw_existing", @@ -55,6 +57,12 @@ function createFixture() { requests: [], branchBodies: [], continueBodies: [], + queuedBodies: [], + queuedFollowUps: [], + queueCounter: 0, + holdRoot: Boolean(options.holdRoot), + releaseRoot: () => releaseRoot(), + rootRelease, layoutPatches: [], branchCounter: 0, }; @@ -88,11 +96,28 @@ async function handleRequest(request, response, fixture) { return json(response, 201, { workspace, node }); } + const archiveMatch = /^\/api\/workspaces\/([^/]+)\/archive$/.exec(url.pathname); + if (archiveMatch && request.method === "GET") { + const workspace = fixture.workspaces.get(archiveMatch[1]); + if (!workspace) return jsonError(response, 404, "not_found", "Workspace not found"); + return json(response, 200, { + format: "hermes-rabbithole-archive/v1", + workspace, + nodes: fixture.nodes.get(workspace.id) || [], + sessions: [], + queued_followups: fixture.queuedFollowUps.filter((item) => item.workspace_id === workspace.id), + }); + } + const workspaceMatch = /^\/api\/workspaces\/([^/]+)$/.exec(url.pathname); if (workspaceMatch && request.method === "GET") { const workspace = fixture.workspaces.get(workspaceMatch[1]); if (!workspace) return jsonError(response, 404, "not_found", "Workspace not found"); - return json(response, 200, { workspace, nodes: fixture.nodes.get(workspace.id) || [] }); + return json(response, 200, { + workspace, + nodes: fixture.nodes.get(workspace.id) || [], + queued_followups: fixture.queuedFollowUps.filter((item) => item.workspace_id === workspace.id), + }); } const rootRun = /^\/api\/nodes\/([^/]+)\/run\/stream$/.exec(url.pathname); @@ -101,15 +126,52 @@ async function handleRequest(request, response, fixture) { startSse(response); await sendSse(response, "tool.started", { node_id: node.id, id: "tool-root", name: "web_search", preview: "Researching the prompt" }, 8); await sendSse(response, "assistant.delta", { node_id: node.id, delta: "# Root answer\n\nHermes is " }, 18); + if (fixture.holdRoot) await fixture.rootRelease; await sendSse(response, "assistant.delta", { node_id: node.id, delta: "streaming the root document." }, 24); await sendSse(response, "tool.completed", { node_id: node.id, id: "tool-root", name: "web_search", preview: "2 sources", ok: true }, 6); node.markdown = "# Root answer\n\nHermes is streaming the root document."; node.title = "Root answer"; node.status = "completed"; await sendSse(response, "node.completed", { node_id: node.id, markdown: node.markdown, title: node.title, hermes_session_id: node.hermes_session_id }, 8); + const queued = fixture.queuedFollowUps.find((item) => item.node_id === node.id); + if (queued) { + fixture.queuedFollowUps = fixture.queuedFollowUps.filter((item) => item.id !== queued.id); + fixture.queuedBodies.push({ prompt: queued.prompt }); + node.markdown += `\n\n---\n\n### Follow-up\n\n> ${queued.prompt}\n\n`; + node.status = "running"; + await sendSse(response, "node.continued", { node, queued_follow_up_id: queued.id }, 2); + await sendSse(response, "assistant.delta", { node_id: node.id, delta: "# Follow-up\n\nQueued follow-up completed." }, 16); + node.markdown += "# Follow-up\n\nQueued follow-up completed."; + node.status = "completed"; + await sendSse(response, "node.completed", { node_id: node.id, markdown: node.markdown, title: node.title, hermes_session_id: node.hermes_session_id }, 2); + } return response.end(); } + const queueRun = /^\/api\/nodes\/([^/]+)\/queue$/.exec(url.pathname); + if (queueRun && request.method === "POST") { + const node = findNode(fixture, queueRun[1]); + const body = await readJson(request); + const queued = { + id: `qf_fixture_${++fixture.queueCounter}`, + node_id: node.id, + workspace_id: node.workspace_id, + prompt: body.prompt, + ordinal: fixture.queuedFollowUps.filter((item) => item.node_id === node.id).length, + created_at: new Date().toISOString(), + }; + fixture.queuedFollowUps.push(queued); + return json(response, 201, { queued }); + } + + const cancelQueued = /^\/api\/queued-follow-ups\/([^/]+)$/.exec(url.pathname); + if (cancelQueued && request.method === "DELETE") { + const index = fixture.queuedFollowUps.findIndex((item) => item.id === cancelQueued[1]); + if (index === -1) return jsonError(response, 404, "not_found", "Queued follow-up not found"); + const [cancelled] = fixture.queuedFollowUps.splice(index, 1); + return json(response, 200, { cancelled }); + } + const continueRun = /^\/api\/nodes\/([^/]+)\/continue\/stream$/.exec(url.pathname); if (continueRun && request.method === "POST") { const node = findNode(fixture, continueRun[1]); diff --git a/test/hermes-workspace/queued-follow-up.test.mjs b/test/hermes-workspace/queued-follow-up.test.mjs new file mode 100644 index 0000000..b3520d7 --- /dev/null +++ b/test/hermes-workspace/queued-follow-up.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import { bootHermesWorkspace } from "./harness.mjs"; + +const app = await bootHermesWorkspace({ holdRoot: true }); +const { browser, baseUrl, fixture } = app; +let context; +try { + context = await browser.newContext(); + const page = await context.newPage(); + const browserErrors = []; + page.on("console", (message) => { if (message.type() === "error") browserErrors.push(message.text()); }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Export archive" }).waitFor(); + await page.getByRole("button", { name: "New workspace" }).click(); + await page.getByLabel("What do you want to explore?").fill("Queue a durable follow-up"); + await page.getByRole("button", { name: "Start exploring" }).click(); + + const root = page.locator('[data-node-id="rn_root"]'); + await root.locator(".node-markdown", { hasText: "Hermes is" }).waitFor(); + const composer = root.locator(".node-branch-form"); + const input = composer.locator("textarea"); + assert.equal(await input.isEditable(), true, + "a running card should accept a follow-up for the local queue"); + + await input.fill("Cancel this queued message."); + await composer.getByRole("button", { name: "Continue here" }).click(); + const cancelledQueue = composer.getByText("Cancel this queued message."); + await cancelledQueue.waitFor(); + await composer.getByRole("button", { name: "Cancel queued follow-up" }).click(); + await cancelledQueue.waitFor({ state: "hidden" }); + + await input.fill("Send this after the current answer."); + await composer.getByRole("button", { name: "Continue here" }).click(); + await composer.getByText("Send this after the current answer.").waitFor(); + fixture.releaseRoot(); + + await root.locator(".node-markdown", { hasText: "Queued follow-up completed." }).waitFor(); + assert.deepEqual(fixture.queuedBodies, [{ prompt: "Send this after the current answer." }], + "only uncancelled queued messages should run after the current stream"); + assert.equal(await composer.getByText("Send this after the current answer.").count(), 0, + "an automatically started queued message should leave the pending list"); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export archive" }).click(); + const archiveDownload = await downloadPromise; + const archive = JSON.parse(await fs.readFile(await archiveDownload.path(), "utf8")); + assert.equal(archive.format, "hermes-rabbithole-archive/v1", + "the local archive export should use the stable archive format"); + assert.equal(archive.workspace.id, "rw_test", + "the downloaded archive should contain the active Rabbithole workspace"); + assert.deepEqual(browserErrors, [], `queued follow-up journey should not emit browser errors: ${browserErrors.join(" | ")}`); + + await context.close(); + context = null; + console.log("ok Hermes queued follow-up: queue while writing, cancel, then automatically continue"); +} finally { + fixture.releaseRoot(); + if (context) await context.close(); + await app.close(); +} diff --git a/tests/hermes_workspace/test_backend.py b/tests/hermes_workspace/test_backend.py index 6c3ded4..ea12aad 100644 --- a/tests/hermes_workspace/test_backend.py +++ b/tests/hermes_workspace/test_backend.py @@ -52,6 +52,15 @@ def delete_session(self, session_id: str) -> dict[str, Any]: self.deleted.append(session_id) return {"deleted": True} + def messages(self, session_id: str) -> dict[str, Any]: + return { + "session_id": session_id, + "messages": [ + {"role": "user", "content": "saved locally"}, + {"role": "assistant", "content": "durable session snapshot"}, + ], + } + def stream_chat(self, session_id: str, prompt: str): with self.lock: self.streams.append((session_id, prompt)) @@ -277,6 +286,25 @@ def test_root_stream_is_normalized_and_persisted(self) -> None: ] self.assertTrue(all(item is None or item[0] in allowed or item[0] in {"assistant.completed", "run.completed"} for item in first_run_events)) + def test_completed_session_is_archived_locally_and_exportable(self) -> None: + payload, _ = self.create_and_run_root("archive this session") + workspace_id = payload["workspace"]["id"] + node = payload["node"] + + status, archive, headers = self.request("GET", f"/api/workspaces/{workspace_id}/archive") + + self.assertEqual(status, 200) + self.assertEqual(headers.get("Content-Type"), "application/json; charset=utf-8") + self.assertEqual(archive["format"], "hermes-rabbithole-archive/v1") + self.assertEqual(archive["workspace"]["id"], workspace_id) + self.assertEqual(archive["nodes"][0]["id"], node["id"]) + self.assertEqual(archive["sessions"][0]["node_id"], node["id"]) + self.assertEqual(archive["sessions"][0]["hermes_session_id"], node["hermes_session_id"]) + self.assertEqual( + archive["sessions"][0]["messages"]["messages"][1]["content"], + "durable session snapshot", + ) + def test_truncated_stream_fails_instead_of_completing_partial_output(self) -> None: def truncated(session_id: str, prompt: str): yield "assistant.delta", {"delta": "partial"} @@ -395,6 +423,56 @@ def test_continue_stream_reuses_the_completed_node_and_session(self) -> None: self.assertEqual(detail["nodes"][0]["hermes_session_id"], node["hermes_session_id"]) self.assertEqual(detail["nodes"][0]["status"], "completed") + def test_queued_follow_up_runs_after_completion_and_can_be_cancelled(self) -> None: + started = threading.Event() + release = threading.Event() + + def delayed(session_id: str, prompt: str): + with self.hermes.lock: + self.hermes.streams.append((session_id, prompt)) + yield "assistant.delta", {"delta": f"Answer {prompt}"} + if prompt == "root": + started.set() + self.assertTrue(release.wait(timeout=3), "test must release the root stream") + yield "assistant.completed", {"content": f"Answer {prompt}"} + yield "run.completed", {"completed": True} + + self.hermes.stream_chat = delayed # type: ignore[method-assign] + status, payload, _ = self.request("POST", "/api/workspaces", {"prompt": "root"}) + self.assertEqual(status, 201) + node_id = payload["node"]["id"] + + with ThreadPoolExecutor(max_workers=1) as pool: + running = pool.submit(self.stream, f"/api/nodes/{node_id}/run/stream") + self.assertTrue(started.wait(timeout=3), "root stream should be running before queuing") + status, cancelled, _ = self.request( + "POST", f"/api/nodes/{node_id}/queue", {"prompt": "cancel this"} + ) + self.assertEqual(status, 201) + status, queued, _ = self.request( + "POST", f"/api/nodes/{node_id}/queue", {"prompt": "send this next"} + ) + self.assertEqual(status, 201) + status, removed, _ = self.request( + "DELETE", f"/api/queued-follow-ups/{cancelled['queued']['id']}" + ) + self.assertEqual(status, 200) + self.assertEqual(removed["cancelled"]["id"], cancelled["queued"]["id"]) + release.set() + events = running.result(timeout=5) + + self.assertEqual(events[-1][0], "node.completed") + self.assertEqual(self.hermes.streams, [ + (payload["node"]["hermes_session_id"], "root"), + (payload["node"]["hermes_session_id"], "send this next"), + ]) + self.assertTrue(any(name == "node.continued" for name, _ in events)) + self.assertIn("send this next", events[-1][1]["markdown"]) + self.assertNotIn("cancel this", events[-1][1]["markdown"]) + status, detail, _ = self.request("GET", f"/api/workspaces/{payload['workspace']['id']}") + self.assertEqual(status, 200) + self.assertEqual(detail["queued_followups"], []) + def test_unknown_ids_never_reach_hermes(self) -> None: before = (len(self.hermes.created), len(self.hermes.forked), len(self.hermes.streams)) status, error, _ = self.request("POST", "/api/nodes/rn_" + "0" * 32 + "/run/stream", {}) diff --git a/web/hermes-dist/app.js b/web/hermes-dist/app.js index 77cd0b9..52a3387 100644 --- a/web/hermes-dist/app.js +++ b/web/hermes-dist/app.js @@ -28075,6 +28075,21 @@ async function createWorkspace(prompt, title = "") { async function loadWorkspace(workspaceId) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}`); } +async function getWorkspaceArchive(workspaceId) { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/archive`); +} +async function queueFollowUp(nodeId, prompt) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/queue`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt }) + }); +} +async function cancelQueuedFollowUp(queueId) { + return requestJson(`/api/queued-follow-ups/${encodeURIComponent(queueId)}`, { + method: "DELETE" + }); +} async function saveNodeLayout(node) { return requestJson(`/api/nodes/${encodeURIComponent(node.id)}/layout`, { method: "PATCH", @@ -28198,7 +28213,8 @@ var state = { activeStreams: 0, camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, - selectionByParent: /* @__PURE__ */ new Map() + selectionByParent: /* @__PURE__ */ new Map(), + queuedByNode: /* @__PURE__ */ new Map() }; var renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); var newDialog = null; @@ -28256,6 +28272,7 @@ function renderShell() { + @@ -28272,6 +28289,7 @@ function renderShell() { + ready @@ -28305,6 +28323,12 @@ function renderShell() { } function installEvents() { document.addEventListener("click", (event) => { + const queueId = event.target.closest("[data-cancel-queued-follow-up]")?.dataset.cancelQueuedFollowUp; + if (queueId) { + event.preventDefault(); + void removeQueuedFollowUp(queueId); + return; + } const destination = event.target.closest("[data-follow-up-destination]")?.dataset.followUpDestination; if (destination) { event.preventDefault(); @@ -28319,6 +28343,7 @@ function installEvents() { else if (action === "reader") setMode("reader"); else if (action === "canvas") setMode("canvas"); else if (action === "frame") frameAll(); + else if (action === "export") void downloadWorkspaceArchive(); else if (action === "theme") toggleTheme(); }); document.getElementById("rail-new").addEventListener("click", openNewWorkspace); @@ -28383,18 +28408,27 @@ function submitFollowUp(form, destination) { const input = form.querySelector("textarea"); const prompt = input.value.trim(); const parentId = form.dataset.parentId || state.selectedNodeId; - if (!prompt || !parentId) return; - if (state.nodes.get(parentId)?.status !== "completed") { - showNotice("Wait for Hermes to finish this document before asking a follow-up.", "error"); + const node = state.nodes.get(parentId); + if (!prompt || !node) return; + if (node.status !== "completed" && node.status !== "running") { + showNotice("This document is not ready for a follow-up.", "error"); return; } input.value = ""; if (destination === "branch") { + if (node.status !== "completed") { + showNotice("Wait for Hermes to finish before creating a branch.", "error"); + return; + } const selection = state.selectionByParent.get(parentId) || selectedTextFor(parentId); state.selectionByParent.delete(parentId); void startBranch(parentId, prompt, selection); return; } + if (node.status === "running") { + void addQueuedFollowUp(parentId, prompt); + return; + } void startContinue(parentId, prompt); } async function submitNewWorkspace(event) { @@ -28414,6 +28448,7 @@ async function submitNewWorkspace(event) { state.workspaces = [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)]; state.workspace = workspace; state.nodes = /* @__PURE__ */ new Map([[node.id, node]]); + state.queuedByNode = /* @__PURE__ */ new Map(); state.selectedNodeId = node.id; state.mode = "canvas"; state.camera = { x: 96, y: 96, scale: 1 }; @@ -28438,6 +28473,7 @@ async function openWorkspace(workspaceId, { frame = false } = {}) { const normalized = normalizeNode(node); return [normalized.id, normalized]; })); + state.queuedByNode = groupQueuedFollowUps(payload.queued_followups || []); const saved = readViewState(workspaceId); state.selectedNodeId = saved?.selectedNodeId && state.nodes.has(saved.selectedNodeId) ? saved.selectedNodeId : state.workspace.root_node_id; state.mode = saved?.mode === "reader" ? "reader" : "canvas"; @@ -28461,6 +28497,30 @@ function normalizeNode(raw) { tools: Array.isArray(raw.tools) ? raw.tools : [] }; } +function groupQueuedFollowUps(items) { + const grouped = /* @__PURE__ */ new Map(); + for (const item of items) { + if (!item?.node_id || !item?.id) continue; + const queue = grouped.get(item.node_id) || []; + queue.push(item); + grouped.set(item.node_id, queue); + } + for (const queue of grouped.values()) queue.sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id)); + return grouped; +} +function downloadWorkspaceArchive() { + if (!state.workspace) return; + void getWorkspaceArchive(state.workspace.id).then((archive) => { + const blob = new Blob([JSON.stringify(archive, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link2 = document.createElement("a"); + const stem = String(state.workspace.title || "rabbithole").replace(/[^a-z0-9]+/gi, "-").replace(/(^-|-$)/g, "").toLowerCase() || "rabbithole"; + link2.href = url; + link2.download = `${stem}-archive.json`; + link2.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }).catch((error) => showNotice(error.message || String(error), "error")); +} function renderWorkspace() { document.body.dataset.mode = state.mode; document.getElementById("canvas-view").hidden = state.mode !== "canvas"; @@ -28501,7 +28561,7 @@ function createNodeCard(node) {

-
+
`; card.querySelector(".node-open").addEventListener("click", (event) => { event.stopPropagation(); @@ -28553,12 +28613,12 @@ function updateNodeCard(card, node) { form.dataset.parentId = node.id; const textarea = form.querySelector("textarea"); textarea.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); - const followUpReady = node.status === "completed"; - textarea.disabled = !followUpReady; - form.querySelectorAll("button").forEach((button) => { - button.disabled = !followUpReady; - }); - textarea.placeholder = node.status === "failed" ? "This run failed" : followUpReady ? "Ask a follow-up\u2026" : "Wait for Hermes to finish\u2026"; + const canContinue = node.status === "completed" || node.status === "running"; + textarea.disabled = !canContinue; + form.querySelector(".follow-up-continue").disabled = !canContinue; + form.querySelector(".follow-up-branch").disabled = node.status !== "completed"; + textarea.placeholder = node.status === "failed" ? "This run failed" : node.status === "running" ? "Add a follow-up to the queue\u2026" : "Ask a follow-up\u2026"; + renderQueuedFollowUps(form.querySelector(".queued-followups"), node.id); renderTools(card.querySelector(".tool-activity"), node.tools); } function renderNodeMarkdown(node) { @@ -28595,6 +28655,23 @@ function renderTools(details, tools) { return row; })); } +function renderQueuedFollowUps(container, nodeId) { + const queued = state.queuedByNode.get(nodeId) || []; + container.hidden = !queued.length; + container.replaceChildren(...queued.map((item) => { + const row = document.createElement("div"); + row.className = "queued-follow-up"; + const prompt = document.createElement("span"); + prompt.textContent = item.prompt; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.dataset.cancelQueuedFollowUp = item.id; + cancel.setAttribute("aria-label", "Cancel queued follow-up"); + cancel.textContent = "Cancel"; + row.append(prompt, cancel); + return row; + })); +} function drawEdges() { const svg = document.getElementById("workspace-edges"); if (!svg) return; @@ -28653,12 +28730,13 @@ function renderReader() { const readerForm = document.querySelector(".reader-branch-form"); readerForm.dataset.parentId = node.id; const readerInput = document.getElementById("reader-branch-input"); - const readerFollowUpReady = node.status === "completed"; - readerInput.disabled = !readerFollowUpReady; - readerForm.querySelectorAll("button").forEach((button) => { - button.disabled = !readerFollowUpReady; - }); + const canContinue = node.status === "completed" || node.status === "running"; + readerInput.disabled = !canContinue; + readerForm.querySelector(".follow-up-continue").disabled = !canContinue; + readerForm.querySelector(".follow-up-branch").disabled = node.status !== "completed"; + readerInput.placeholder = node.status === "running" ? "Add a follow-up to the queue\u2026" : "Ask a follow-up\u2026"; readerInput.setAttribute("aria-label", `Ask a follow-up on ${node.title || "Untitled"}`); + renderQueuedFollowUps(readerForm.querySelector(".queued-followups"), node.id); } async function startRoot(nodeId) { beginStream(); @@ -28693,6 +28771,34 @@ async function startContinue(nodeId, prompt) { endStream(); } } +async function addQueuedFollowUp(nodeId, prompt) { + try { + const payload = await queueFollowUp(nodeId, prompt); + const queued = payload.queued; + if (!queued) throw new Error("The queued follow-up was not returned."); + const queue = state.queuedByNode.get(nodeId) || []; + state.queuedByNode.set(nodeId, [...queue, queued].sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id))); + renderCanvas(); + renderReader(); + } catch (error) { + showNotice(error.message || String(error), "error"); + } +} +async function removeQueuedFollowUp(queueId) { + try { + const payload = await cancelQueuedFollowUp(queueId); + const cancelled = payload.cancelled; + if (!cancelled) return; + const queue = state.queuedByNode.get(cancelled.node_id) || []; + const remaining = queue.filter((item) => item.id !== cancelled.id); + if (remaining.length) state.queuedByNode.set(cancelled.node_id, remaining); + else state.queuedByNode.delete(cancelled.node_id); + renderCanvas(); + renderReader(); + } catch (error) { + showNotice(error.message || String(error), "error"); + } +} function handleStreamEvent(name, payload) { if (name === "heartbeat") return; if (name === "node.created") { @@ -28707,6 +28813,12 @@ function handleStreamEvent(name, payload) { } if (name === "node.continued") { const node2 = normalizeNode(payload.node || payload); + if (payload.queued_follow_up_id) { + const queue = state.queuedByNode.get(node2.id) || []; + const remaining = queue.filter((item) => item.id !== payload.queued_follow_up_id); + if (remaining.length) state.queuedByNode.set(node2.id, remaining); + else state.queuedByNode.delete(node2.id); + } state.nodes.set(node2.id, node2); renderCanvas(); renderReader(); diff --git a/web/hermes-dist/styles.css b/web/hermes-dist/styles.css index d2bcd0e..80dab96 100644 --- a/web/hermes-dist/styles.css +++ b/web/hermes-dist/styles.css @@ -740,6 +740,10 @@ button { color: inherit; } .branch-input-row button { flex: 0 0 auto; height: 30px; border: 0; border-radius: 9px; padding: 0 9px; cursor: pointer; opacity: .68; transition: opacity 140ms ease, transform 140ms ease; } .branch-input-row .follow-up-continue { background: var(--accent); color: var(--accent-contrast); } .branch-input-row .follow-up-branch { border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); } +.queued-followups { display: grid; gap: 6px; padding: 8px 2px 0; } +.queued-follow-up { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 8px; background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--fg-dim); font-size: 12px; } +.queued-follow-up span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queued-follow-up button { flex: 0 0 auto; height: auto; padding: 3px 6px; border: 1px solid var(--card-border); background: transparent; color: var(--fg-dim); font-size: 11px; } .branch-input-row:focus-within button, .branch-input-row:hover button { opacity: 1; } .branch-input-row button:hover { transform: translateY(-1px); } .workspace-node-resize { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; } From 6ac121697e816e1bc5304d59718e0fbb2969fee7 Mon Sep 17 00:00:00 2001 From: Sanket Kheni Date: Thu, 30 Jul 2026 05:58:14 +0000 Subject: [PATCH 03/19] feat: add workspace attention and merge actions --- backend/server.py | 88 +++++++ backend/store.py | 96 +++++++ package.json | 2 +- src/hermes-workspace/api.js | 17 ++ src/hermes-workspace/app.js | 233 ++++++++++++++++- src/hermes-workspace/styles.css | 25 +- test/hermes-workspace/harness.mjs | 43 ++++ .../workspace-actions.test.mjs | 66 +++++ tests/hermes_workspace/test_backend.py | 62 +++++ web/hermes-dist/app.js | 239 +++++++++++++++++- web/hermes-dist/styles.css | 25 +- 11 files changed, 873 insertions(+), 23 deletions(-) create mode 100644 test/hermes-workspace/workspace-actions.test.mjs diff --git a/backend/server.py b/backend/server.py index 2d89dd6..d87740b 100644 --- a/backend/server.py +++ b/backend/server.py @@ -27,9 +27,11 @@ _MAX_BODY = 1_000_000 _WORKSPACE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})$") +_WORKSPACE_NODE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/nodes$") _WORKSPACE_ARCHIVE_RE = re.compile(r"^/api/workspaces/(rw_[0-9a-f]{32})/archive$") _NODE_RUN_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/run/stream$") _NODE_BRANCH_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/branch/stream$") +_NODE_MERGE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/merge/stream$") _NODE_CONTINUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/continue/stream$") _NODE_QUEUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/queue$") _QUEUED_FOLLOW_UP_RE = re.compile(r"^/api/queued-follow-ups/(qf_[0-9a-f]{32})$") @@ -102,6 +104,36 @@ def do_POST(self) -> None: # noqa: N802 self._exception(exc) return + if match := _WORKSPACE_NODE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + required = {"prompt", "x", "y"} + if not self._only_fields(body, {"title", *required}) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "prompt, x, and y are required", + ) + return + try: + prompt = self._required_prompt(body.get("prompt")) + title = self._optional_title(body.get("title")) + session_id = new_session_id() + self.server.hermes.create_session(session_id, title) + try: + node = self.server.store.create_standalone( + match.group(1), prompt, session_id, body["x"], body["y"], title + ) + except Exception: + self._delete_orphan_session(session_id) + raise + self._send_json(HTTPStatus.CREATED, {"node": node}) + except Exception as exc: + self._exception(exc) + return + if match := _NODE_RUN_RE.fullmatch(path): if self._read_body(allow_empty=True) is None: return @@ -167,6 +199,62 @@ def do_POST(self) -> None: # noqa: N802 self._stream_node(running, input_override=hermes_prompt) return + if match := _NODE_MERGE_RE.fullmatch(path): + body = self._read_body() + if body is None: + return + if not self._only_fields(body, {"destination", "instruction"}) or "destination" not in body: + return + try: + destination = body.get("destination") + if destination not in {"parent", "root"}: + raise ValidationError("destination must be parent or root") + instruction = body.get("instruction", "") + if not isinstance(instruction, str): + raise ValidationError("instruction must be a string") + instruction = instruction.strip() + if len(instruction) > 20_000: + raise ValidationError("instruction is too long") + source = self.server.store.get_node(match.group(1)) + if source["parent_node_id"] is None: + raise ConflictError("only a child node can be merged") + if source["status"] != "completed": + raise ConflictError("source node must be completed before merging") + detail = self.server.store.get_workspace(source["workspace_id"]) + destination_id = ( + source["parent_node_id"] + if destination == "parent" + else detail["workspace"]["root_node_id"] + ) + target = self.server.store.get_node(destination_id) + if target["status"] != "completed": + raise ConflictError("merge destination must be completed") + source_markdown = source["markdown"][:160_000] + merge_prompt = ( + "Integrate the useful findings from this completed child branch into the current " + "conversation. Summarize and reconcile the findings rather than copying the full branch.\n\n" + f"Child branch title: {source['title']}\n" + f"Child branch task: {source['prompt']}\n\n" + f"Child branch result:\n{source_markdown}" + ) + if instruction: + merge_prompt += f"\n\nAdditional instruction:\n{instruction}" + running = self.server.store.merge_node(target["id"], source["title"]) + except Exception as exc: + self._exception(exc) + return + self._begin_sse() + self._sse( + "node.continued", + {"node": running, "merged_from_node_id": source["id"]}, + ) + self._stream_node( + running, + input_override=merge_prompt, + initial_markdown=running["markdown"], + ) + return + if match := _NODE_QUEUE_RE.fullmatch(path): body = self._read_body() if body is None: diff --git a/backend/store.py b/backend/store.py index 7406f20..cbeab25 100644 --- a/backend/store.py +++ b/backend/store.py @@ -220,6 +220,77 @@ def create_child( finally: self._close(connection) + def create_standalone( + self, + workspace_id: object, + prompt: object, + session_id: object, + x: object, + y: object, + title: object | None = None, + ) -> dict[str, Any]: + if not is_valid_id(workspace_id, "rw"): + raise NotFoundError("workspace not found") + prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) + session = require_rabbithole_session_id(session_id) + title_text = ( + _clean_text(title, "title", required=True, maximum=500) + if title is not None + else prompt_text.splitlines()[0][:120] + ) + coordinates: list[float] = [] + for field, value in (("x", x), ("y", y)): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValidationError(f"{field} must be a finite number") + coordinates.append(float(value)) + node_id = new_node_id() + now = utc_now() + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + workspace = self._workspace_in(connection, workspace_id) + if workspace is None: + raise NotFoundError("workspace not found") + ordinal = connection.execute( + "SELECT COALESCE(MAX(ordinal), -1) + 1 FROM nodes WHERE workspace_id = ?", + (workspace_id,), + ).fetchone()[0] + connection.execute( + """INSERT INTO nodes( + id,workspace_id,parent_node_id,hermes_session_id,title,prompt, + markdown,status,error,x,y,width,height,ordinal,created_at,updated_at + ) VALUES(?,?,NULL,?,?,?,'','pending','',?,?,440,430,?,?,?)""", + ( + node_id, + workspace_id, + session, + title_text, + prompt_text, + *coordinates, + ordinal, + now, + now, + ), + ) + connection.execute( + "UPDATE workspaces SET updated_at = ? WHERE id = ?", (now, workspace_id) + ) + connection.commit() + node = self._node_in(connection, node_id) + assert node is not None + return node + except (NotFoundError, ValidationError): + connection.rollback() + raise + except sqlite3.IntegrityError as exc: + connection.rollback() + raise ConflictError("node or session already exists") from exc + except Exception: + connection.rollback() + raise + finally: + self._close(connection) + def _workspace_in(self, connection: sqlite3.Connection, workspace_id: str) -> dict[str, Any] | None: return _row_dict( connection.execute( @@ -432,6 +503,31 @@ def continue_node(self, node_id: object, prompt: object) -> dict[str, Any]: finally: self._close(connection) + def merge_node(self, node_id: object, source_title: object) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + title = _clean_text(source_title, "source title", required=True, maximum=500) + marker = f"\n\n---\n\n### Merging from {title}\n\n" + now = utc_now() + connection = self._connect() + try: + cursor = connection.execute( + """UPDATE nodes + SET markdown=markdown || ?, status='running', error='', updated_at=? + WHERE id=? AND status='completed'""", + (marker, now, node_id), + ) + if cursor.rowcount != 1: + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError(f"node is already {row['status']}") + node = self._node_in(connection, node_id) + assert node is not None + return node + finally: + self._close(connection) + def queue_follow_up(self, node_id: object, prompt: object) -> dict[str, Any]: if not is_valid_id(node_id, "rn"): raise NotFoundError("node not found") diff --git a/package.json b/package.json index 54f36bc..6b11d41 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "node build-hermes-workspace.mjs", - "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs", + "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs && node test/hermes-workspace/workspace-actions.test.mjs", "check:types": "tsc --noEmit", "check:purity": "node scripts/check-ui-purity.mjs", "verify": "npm run build && npm test && npm run check:types && npm run check:purity" diff --git a/src/hermes-workspace/api.js b/src/hermes-workspace/api.js index 46fb182..42823ac 100644 --- a/src/hermes-workspace/api.js +++ b/src/hermes-workspace/api.js @@ -13,6 +13,14 @@ export async function createWorkspace(prompt, title = "") { }); } +export async function createStandaloneSession(workspaceId, prompt, x, y, title = "") { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/nodes`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt, x, y, ...(title ? { title } : {}) }), + }); +} + export async function loadWorkspace(workspaceId) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}`); } @@ -59,6 +67,15 @@ export function streamBranch(nodeId, input, onEvent, options = {}) { }, onEvent); } +export function streamMerge(nodeId, destination, instruction, onEvent, options = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/merge/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ destination, instruction }), + signal: options.signal, + }, onEvent); +} + export function streamContinue(nodeId, prompt, onEvent, options = {}) { return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/continue/stream`, { method: "POST", diff --git a/src/hermes-workspace/app.js b/src/hermes-workspace/app.js index 9817a12..d9e1c8f 100644 --- a/src/hermes-workspace/app.js +++ b/src/hermes-workspace/app.js @@ -3,6 +3,7 @@ import { BUNNY_MARK_SVG } from "../core/html/bunny-markup.js"; import { LENSES } from "../core/model.js"; import { cancelQueuedFollowUp, + createStandaloneSession, createWorkspace, getHealth, getWorkspaceArchive, @@ -12,11 +13,13 @@ import { saveNodeLayout, streamBranch, streamContinue, + streamMerge, streamRoot, } from "./api.js"; const LAST_WORKSPACE_KEY = "rh-hermes-last-workspace"; const VIEW_KEY_PREFIX = "rh-hermes-view:"; +const UNREAD_KEY_PREFIX = "rh-hermes-unread:"; const DEFAULT_SIZE = Object.freeze({ width: 440, height: 430 }); const OVERVIEW_ZOOM_THRESHOLD = 0.72; const FOCUS_ZOOM_THRESHOLD = 1.2; @@ -24,10 +27,14 @@ const state = { workspaces: [], workspace: null, nodes: new Map(), selectedNodeId: null, mode: "canvas", railOpen: false, activeStreams: 0, camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, - selectionByParent: new Map(), queuedByNode: new Map(), + selectionByParent: new Map(), queuedByNode: new Map(), unreadNodeIds: new Set(), }; const renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); let newDialog = null; +let newSessionDialog = null; +let mergeDialog = null; +let pendingSessionPoint = null; +let mergeSourceId = null; let selectionAsk = null; let pendingSelection = null; let selectionAnchorRect = null; @@ -102,6 +109,7 @@ function renderShell() { + @@ -120,6 +128,32 @@ function renderShell() { + +
+

New session here

Start an independent Hermes session at this canvas position.

+ + + +
+ + +
+
+
+ +
+

Merge child findings

Hermes will summarize this branch into the chosen conversation.

+ + + + + +
+ + +
+
+
`; newDialog = document.getElementById("new-workspace-dialog"); + newSessionDialog = document.getElementById("new-session-dialog"); + mergeDialog = document.getElementById("merge-dialog"); selectionAsk = document.getElementById("selection-ask"); } @@ -154,14 +190,20 @@ function installEvents() { if (action === "rail") toggleRail(); else if (action === "new") openNewWorkspace(); else if (action === "cancel-new") newDialog.close(); + else if (action === "cancel-session") newSessionDialog.close(); + else if (action === "cancel-merge") mergeDialog.close(); + else if (action === "merge") openMergeDialog(event.target.closest("[data-node-id]")?.dataset.nodeId); else if (action === "reader") setMode("reader"); else if (action === "canvas") setMode("canvas"); else if (action === "frame") frameAll(); + else if (action === "next-unread") selectNextUnread(); else if (action === "export") void downloadWorkspaceArchive(); else if (action === "theme") toggleTheme(); }); document.getElementById("rail-new").addEventListener("click", openNewWorkspace); document.getElementById("new-workspace-form").addEventListener("submit", submitNewWorkspace); + document.getElementById("new-session-form").addEventListener("submit", submitNewSession); + document.getElementById("merge-form").addEventListener("submit", submitMerge); document.addEventListener("submit", (event) => { const form = event.target.closest(".branch-form"); if (!form) return; @@ -193,8 +235,17 @@ function installEvents() { } }); installSelectionAsk(); - document.getElementById("canvas-view").addEventListener("wheel", onCanvasWheel, { passive: false }); - document.getElementById("canvas-view").addEventListener("pointerdown", onCanvasPointerDown); + const canvas = document.getElementById("canvas-view"); + canvas.addEventListener("wheel", onCanvasWheel, { passive: false }); + canvas.addEventListener("pointerdown", onCanvasPointerDown); + canvas.addEventListener("dblclick", (event) => { + if (!event.target.closest(".workspace-node")) openNewSessionAt(event.clientX, event.clientY); + }); + canvas.addEventListener("contextmenu", (event) => { + if (event.target.closest(".workspace-node")) return; + event.preventDefault(); + openNewSessionAt(event.clientX, event.clientY); + }); window.addEventListener("pagehide", saveViewState); window.addEventListener("resize", () => { if (state.mode === "canvas") drawEdges(); }); window.__hermesWorkspaceTest = { @@ -263,6 +314,7 @@ async function submitNewWorkspace(event) { state.workspace = workspace; state.nodes = new Map([[node.id, node]]); state.queuedByNode = new Map(); + state.unreadNodeIds = new Set(); state.selectedNodeId = node.id; state.mode = "canvas"; state.camera = { x: 96, y: 96, scale: 1 }; @@ -280,6 +332,61 @@ async function submitNewWorkspace(event) { } } +async function submitNewSession(event) { + event.preventDefault(); + if (!state.workspace || !pendingSessionPoint) return; + const form = event.currentTarget; + const input = form.querySelector("textarea"); + const error = document.getElementById("new-session-error"); + const button = form.querySelector('[type="submit"]'); + const prompt = input.value.trim(); + if (!prompt) return; + button.disabled = true; + error.textContent = ""; + try { + const payload = await createStandaloneSession( + state.workspace.id, prompt, pendingSessionPoint.x, pendingSessionPoint.y, + ); + const node = normalizeNode(payload.node); + state.nodes.set(node.id, node); + state.selectedNodeId = node.id; + state.unreadNodeIds.delete(node.id); + input.value = ""; + pendingSessionPoint = null; + newSessionDialog.close(); + renderWorkspace(); + saveViewState(); + void startRoot(node.id); + } catch (err) { + error.textContent = err.message || String(err); + } finally { + button.disabled = false; + } +} + +async function submitMerge(event) { + event.preventDefault(); + const source = state.nodes.get(mergeSourceId); + if (!source) return; + const form = event.currentTarget; + const destination = form.querySelector("select").value; + const instruction = form.querySelector("textarea").value.trim(); + const error = document.getElementById("merge-error"); + const button = form.querySelector('[type="submit"]'); + button.disabled = true; + error.textContent = ""; + try { + mergeDialog.close(); + await startMerge(source.id, destination, instruction); + mergeSourceId = null; + } catch (err) { + if (!mergeDialog.open) mergeDialog.showModal(); + error.textContent = err.message || String(err); + } finally { + button.disabled = false; + } +} + async function openWorkspace(workspaceId, { frame = false } = {}) { hideSelectionAsk(); const payload = await loadWorkspace(workspaceId); @@ -289,9 +396,12 @@ async function openWorkspace(workspaceId, { frame = false } = {}) { return [normalized.id, normalized]; })); state.queuedByNode = groupQueuedFollowUps(payload.queued_followups || []); + state.unreadNodeIds = readUnreadNodes(workspaceId, state.nodes); const saved = readViewState(workspaceId); state.selectedNodeId = saved?.selectedNodeId && state.nodes.has(saved.selectedNodeId) ? saved.selectedNodeId : state.workspace.root_node_id; + state.unreadNodeIds.delete(state.selectedNodeId); + persistUnreadNodes(); state.mode = saved?.mode === "reader" ? "reader" : "canvas"; state.camera = saved?.camera || { x: 96, y: 96, scale: 1 }; safeStorageSet(LAST_WORKSPACE_KEY, workspaceId); @@ -349,6 +459,7 @@ function renderWorkspace() { renderReader(); applyCamera(); updateActivity(); + updateUnreadControl(); } function renderCanvas() { @@ -377,7 +488,7 @@ function createNodeCard(node) { card.innerHTML = `
- +

@@ -419,11 +530,20 @@ function updateNodeCard(card, node) { card.style.width = `${node.width}px`; card.style.height = `${node.height}px`; card.dataset.status = node.status; + const kind = nodeKind(node); + card.dataset.kind = kind; + const unread = state.unreadNodeIds.has(node.id); + card.dataset.unread = String(unread); card.setAttribute("aria-selected", String(node.id === state.selectedNodeId)); - card.querySelector(".node-kind").innerHTML = node.parent_node_id ? "↳" : BUNNY_MARK_SVG; + card.querySelector(".node-kind").innerHTML = kind === "branch" ? "↳" : kind === "standalone" ? "◇" : BUNNY_MARK_SVG; card.querySelector(".workspace-node-title").textContent = node.title || "Untitled"; + const unreadBadge = card.querySelector(".node-unread"); + unreadBadge.hidden = !unread; card.querySelector(".node-state").textContent = statusLabel(node.status); - card.querySelector(".node-overview-kind").textContent = node.parent_node_id ? "Branch document" : "Root document"; + const mergeButton = card.querySelector(".node-merge"); + mergeButton.hidden = !node.parent_node_id; + mergeButton.disabled = node.status !== "completed"; + card.querySelector(".node-overview-kind").textContent = kind === "branch" ? "Branch document" : kind === "standalone" ? "Independent session" : "Root document"; card.querySelector(".node-overview-summary").textContent = summarizeNode(node); const branchCount = childrenOf(node.id).length; card.querySelector(".node-overview-meta").textContent = `${statusLabel(node.status)} · ${branchCount} ${branchCount === 1 ? "branch" : "branches"}`; @@ -550,7 +670,8 @@ function renderReader() { return index ? [document.createTextNode(" › "), button] : [button]; })); const documentEl = document.getElementById("reader-document"); - const readerHtml = `
${node.parent_node_id ? "Branch" : "Root document"}

${escapeHtml(node.title || "Untitled")}

${renderNodeMarkdown(node)}`; + const kind = nodeKind(node); + const readerHtml = `
${kind === "branch" ? "Branch" : kind === "standalone" ? "Independent session" : "Root document"}

${escapeHtml(node.title || "Untitled")}

${renderNodeMarkdown(node)}`; if (documentEl.dataset.renderedHtml !== readerHtml) { documentEl.innerHTML = readerHtml; documentEl.dataset.renderedHtml = readerHtml; @@ -620,6 +741,15 @@ async function startContinue(nodeId, prompt) { } } +async function startMerge(sourceId, destination, instruction) { + beginStream(); + try { + await streamMerge(sourceId, destination, instruction, handleStreamEvent); + } finally { + endStream(); + } +} + async function addQueuedFollowUp(nodeId, prompt) { try { const payload = await queueFollowUp(nodeId, prompt); @@ -688,7 +818,7 @@ function handleStreamEvent(name, payload) { node.title = payload.title || node.title; node.hermes_session_id = payload.hermes_session_id || node.hermes_session_id; node.error = ""; - if (!node.parent_node_id && state.workspace) { + if (node.id === state.workspace?.root_node_id && state.workspace) { state.workspace.title = node.title; renderWorkspaceList(); } @@ -696,6 +826,9 @@ function handleStreamEvent(name, payload) { node.status = "failed"; node.error = payload.error || "Hermes could not complete this node."; } + if ((name === "node.completed" || name === "node.failed") && state.selectedNodeId !== node.id) { + setNodeUnread(node.id, true); + } const card = document.querySelector(`[data-node-id="${cssEscape(node.id)}"]`); if (card) updateNodeCard(card, node); if (state.selectedNodeId === node.id || state.nodes.get(state.selectedNodeId)?.parent_node_id === node.id) renderReader(); @@ -782,9 +915,45 @@ function openNewWorkspace() { requestAnimationFrame(() => document.getElementById("new-workspace-prompt").focus()); } +function openNewSessionAt(clientX, clientY) { + if (!state.workspace || state.mode !== "canvas") return; + const rect = document.getElementById("canvas-view").getBoundingClientRect(); + pendingSessionPoint = { + x: (clientX - rect.left - state.camera.x) / state.camera.scale, + y: (clientY - rect.top - state.camera.y) / state.camera.scale, + }; + document.getElementById("new-session-error").textContent = ""; + document.getElementById("new-session-prompt").value = ""; + if (!newSessionDialog.open) newSessionDialog.showModal(); + requestAnimationFrame(() => document.getElementById("new-session-prompt").focus()); +} + +function openMergeDialog(nodeId) { + const source = state.nodes.get(nodeId); + if (!source?.parent_node_id || source.status !== "completed") return; + mergeSourceId = source.id; + const select = document.getElementById("merge-destination"); + const parent = state.nodes.get(source.parent_node_id); + const options = [{ value: "parent", label: `Parent — ${parent?.title || "Untitled"}` }]; + if (state.workspace?.root_node_id && state.workspace.root_node_id !== source.parent_node_id) { + const root = state.nodes.get(state.workspace.root_node_id); + options.push({ value: "root", label: `Main/root — ${root?.title || "Untitled"}` }); + } + select.replaceChildren(...options.map((item) => { + const option = document.createElement("option"); + option.value = item.value; + option.textContent = item.label; + return option; + })); + document.getElementById("merge-instruction").value = ""; + document.getElementById("merge-error").textContent = ""; + if (!mergeDialog.open) mergeDialog.showModal(); +} + function selectNode(nodeId) { if (!state.nodes.has(nodeId)) return; state.selectedNodeId = nodeId; + setNodeUnread(nodeId, false); renderCanvas(); renderReader(); saveViewState(); @@ -952,6 +1121,54 @@ function readViewState(workspaceId) { } catch { return null; } } +function readUnreadNodes(workspaceId, nodes) { + try { + const ids = JSON.parse(safeStorageGet(`${UNREAD_KEY_PREFIX}${workspaceId}`) || "[]"); + return new Set(Array.isArray(ids) ? ids.filter((id) => nodes.has(id)) : []); + } catch { return new Set(); } +} + +function persistUnreadNodes() { + if (!state.workspace) return; + safeStorageSet(`${UNREAD_KEY_PREFIX}${state.workspace.id}`, JSON.stringify([...state.unreadNodeIds])); +} + +function setNodeUnread(nodeId, unread) { + if (!state.nodes.has(nodeId)) return; + if (unread) state.unreadNodeIds.add(nodeId); + else state.unreadNodeIds.delete(nodeId); + persistUnreadNodes(); + updateUnreadControl(); +} + +function updateUnreadControl() { + const button = document.querySelector('[data-action="next-unread"]'); + if (!button) return; + const count = [...state.unreadNodeIds].filter((id) => state.nodes.has(id)).length; + button.hidden = count === 0; + button.querySelector(".unread-count").textContent = String(count); + button.setAttribute("aria-label", `Next unread (${count})`); +} + +function selectNextUnread() { + const node = [...state.nodes.values()] + .filter((item) => state.unreadNodeIds.has(item.id)) + .sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.id.localeCompare(b.id))[0]; + if (!node) return; + state.mode = "canvas"; + selectNode(node.id); + const rect = document.getElementById("canvas-view").getBoundingClientRect(); + state.camera.x = rect.width / 2 - (node.x + node.width / 2) * state.camera.scale; + state.camera.y = rect.height / 2 - (node.y + node.height / 2) * state.camera.scale; + renderWorkspace(); + saveViewState(); +} + +function nodeKind(node) { + if (node.parent_node_id) return "branch"; + return node.id === state.workspace?.root_node_id ? "root" : "standalone"; +} + function childrenOf(parentId) { return [...state.nodes.values()].filter((node) => node.parent_node_id === parentId) .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); diff --git a/src/hermes-workspace/styles.css b/src/hermes-workspace/styles.css index 21dbf2d..4ba4c82 100644 --- a/src/hermes-workspace/styles.css +++ b/src/hermes-workspace/styles.css @@ -29,14 +29,19 @@ button { color: inherit; } .workspace-node[aria-selected="true"] { border-color: color-mix(in srgb, var(--accent) 78%, var(--card-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent), 0 30px 86px rgba(0,0,0,.34); } .workspace-node[data-status="running"] { border-color: color-mix(in srgb, var(--accent) 42%, var(--card-border)); } .workspace-node[data-status="failed"] { border-color: color-mix(in srgb, var(--warn) 60%, var(--card-border)); } +.workspace-node[data-unread="true"] { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent), 0 30px 86px rgba(0,0,0,.38); } .workspace-node.dragging { transform: translateY(-2px); box-shadow: 0 32px 90px rgba(0,0,0,.42); } -.workspace-node-head { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto; align-items: center; gap: 10px; min-height: 60px; padding: 9px 11px 9px 14px; border-bottom: 1px solid var(--card-border); background: var(--card-header); cursor: grab; user-select: none; } +.workspace-node-head { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto auto auto; align-items: center; gap: 8px; min-height: 60px; padding: 9px 11px 9px 14px; border-bottom: 1px solid var(--card-border); background: var(--card-header); cursor: grab; user-select: none; } .workspace-node-head:active { cursor: grabbing; } .node-kind { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--accent) 9%, transparent); color: var(--accent); font-family: var(--font-mono); font-size: 13px; } .node-kind svg { width: 17px; height: 17px; } .workspace-node-title { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; color: var(--fg-bold); font-size: 13.5px; line-height: 1.25; font-weight: 500; letter-spacing: -.012em; } .node-state { display: inline-flex; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--fg) 11%, transparent); border-radius: 999px; color: var(--fg-faint); font-family: var(--font-mono); font-size: 9px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; } .node-state::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: color-mix(in srgb, var(--fg) 36%, transparent); } +.node-unread { padding: 4px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-contrast); font: 600 9px/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; } +.node-merge { height: 28px; border: 1px solid var(--card-border); border-radius: 8px; padding: 0 8px; background: transparent; color: var(--fg-dim); font-size: 11px; cursor: pointer; } +.node-merge:hover { border-color: var(--accent); color: var(--fg-bold); } +.node-merge:disabled { opacity: .4; cursor: default; } .workspace-node[data-status="running"] .node-state::before { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); } .workspace-node[data-status="failed"] .node-state::before { background: var(--warn); } .node-open { width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: var(--fg-dim); cursor: pointer; } @@ -90,12 +95,13 @@ button { color: inherit; } /* Semantic overview: below 72% zoom, cards become legible catalog records instead of shrinking full articles into unreadable thumbnails. */ body[data-zoom-tier="overview"] .workspace-node { grid-template-rows: auto 1fr; border-radius: 20px; } -body[data-zoom-tier="overview"] .workspace-node-head { grid-template-columns: 36px minmax(0,1fr) auto; min-height: 72px; gap: 14px; padding: 13px 18px; } +body[data-zoom-tier="overview"] .workspace-node-head { grid-template-columns: 36px minmax(0,1fr) auto auto; min-height: 72px; gap: 14px; padding: 13px 18px; } body[data-zoom-tier="overview"] .node-kind { width: 36px; height: 36px; border-radius: 11px; font-size: 17px; } body[data-zoom-tier="overview"] .node-kind svg { width: 22px; height: 22px; } body[data-zoom-tier="overview"] .workspace-node-title { font-size: 19px; font-weight: 600; letter-spacing: -.022em; } body[data-zoom-tier="overview"] .node-state { padding: 5px 8px; font-size: 10px; } -body[data-zoom-tier="overview"] .node-open { display: none; } +body[data-zoom-tier="overview"] .node-open, +body[data-zoom-tier="overview"] .node-merge { display: none; } body[data-zoom-tier="overview"] .workspace-node-body { padding: 30px 34px 34px; overflow: hidden; } body[data-zoom-tier="overview"] .node-markdown, body[data-zoom-tier="overview"] .tool-activity, @@ -160,6 +166,19 @@ body[data-zoom-tier="focus"] .workspace-node-body { padding: 27px 32px 32px; } .primary-button { min-height: 40px; padding: 0 20px; border: 0; border-radius: var(--radius-control, 9px); background: var(--accent); color: var(--accent-contrast); font-family: var(--font-ui); font-weight: var(--weight-semibold, 650); cursor: pointer; } .primary-button:hover { background: color-mix(in srgb, var(--accent) 88%, var(--fg-bold)); } .primary-button:disabled { opacity: .55; cursor: progress; } +#new-session-dialog, #merge-dialog { position: fixed; inset: 0; margin: auto; width: min(520px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); overflow: hidden auto; border: 1px solid var(--border); border-radius: 16px; padding: 0; color: var(--fg); background: var(--node-bg); box-shadow: var(--shadow-modal, 0 20px 70px rgba(0,0,0,.48)); } +#new-session-dialog::backdrop, #merge-dialog::backdrop { background: color-mix(in srgb, var(--bg) 62%, rgba(6,6,8,.78)); backdrop-filter: blur(6px); } +#new-session-form, #merge-form { display: grid; gap: 13px; padding: 26px; } +#new-session-form header, #merge-form header { display: grid; gap: 5px; margin-bottom: 4px; } +#new-session-form h1, #merge-form h1 { color: var(--fg-bold); font-size: 20px; } +#new-session-form header p, #merge-form header p { color: var(--fg-dim); font-size: 13px; } +#new-session-form label, #merge-form label { color: var(--fg-dim); font-size: 12px; font-weight: 600; } +#new-session-form textarea, #merge-form textarea, #merge-form select { width: 100%; border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; outline: 0; resize: vertical; background: var(--input-bg, var(--bar-bg)); color: var(--fg); } +#new-session-form textarea:focus, #merge-form textarea:focus, #merge-form select:focus { border-color: var(--accent); box-shadow: var(--focus-field-shadow); } +#new-session-error, #merge-error { min-height: 18px; color: var(--warn); font-size: 12px; } +#new-session-form footer, #merge-form footer { display: flex; justify-content: flex-end; gap: 10px; } +#workspace-toolbar [data-action="next-unread"] { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--fg-bold); } +.unread-count { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; margin-left: 4px; border-radius: 999px; background: var(--accent); color: var(--accent-contrast); font-size: 10px; } #workspace-notice { position: fixed; z-index: 120; top: 18px; left: 50%; transform: translate(-50%,-18px); max-width: min(600px,calc(100vw - 30px)); padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--bar-bg); color: var(--fg); opacity: 0; pointer-events: none; transition: 150ms ease; } #workspace-notice.visible { transform: translate(-50%,0); opacity: 1; } #workspace-notice[data-kind="error"] { border-color: color-mix(in srgb, var(--warn) 50%, var(--border)); color: var(--warn); } diff --git a/test/hermes-workspace/harness.mjs b/test/hermes-workspace/harness.mjs index 1ddc633..ec06fca 100644 --- a/test/hermes-workspace/harness.mjs +++ b/test/hermes-workspace/harness.mjs @@ -59,6 +59,8 @@ function createFixture(options) { continueBodies: [], queuedBodies: [], queuedFollowUps: [], + standaloneBodies: [], + mergeBodies: [], queueCounter: 0, holdRoot: Boolean(options.holdRoot), releaseRoot: () => releaseRoot(), @@ -120,6 +122,24 @@ async function handleRequest(request, response, fixture) { }); } + const standaloneRun = /^\/api\/workspaces\/([^/]+)\/nodes$/.exec(url.pathname); + if (standaloneRun && request.method === "POST") { + const workspace = fixture.workspaces.get(standaloneRun[1]); + if (!workspace) return jsonError(response, 404, "not_found", "Workspace not found"); + const body = await readJson(request); + fixture.standaloneBodies.push(body); + const number = ++fixture.branchCounter; + const now = new Date().toISOString(); + const node = { + id: `rn_standalone_${number}`, workspace_id: workspace.id, parent_node_id: null, + hermes_session_id: `rh_standalone_${number}`, title: body.title || body.prompt, prompt: body.prompt, + markdown: "", status: "pending", error: "", x: body.x, y: body.y, + width: 440, height: 430, created_at: now, updated_at: now, + }; + fixture.nodes.get(workspace.id).push(node); + return json(response, 201, { node }); + } + const rootRun = /^\/api\/nodes\/([^/]+)\/run\/stream$/.exec(url.pathname); if (rootRun && request.method === "POST") { const node = findNode(fixture, rootRun[1]); @@ -188,6 +208,29 @@ async function handleRequest(request, response, fixture) { return response.end(); } + const mergeRun = /^\/api\/nodes\/([^/]+)\/merge\/stream$/.exec(url.pathname); + if (mergeRun && request.method === "POST") { + const source = findNode(fixture, mergeRun[1]); + const body = await readJson(request); + fixture.mergeBodies.push({ source_id: source.id, ...body }); + const workspace = fixture.workspaces.get(source.workspace_id); + const destination = body.destination === "root" + ? findNode(fixture, workspace.root_node_id) + : findNode(fixture, source.parent_node_id); + destination.markdown += `\n\n---\n\n### Merged from ${source.title}\n\n`; + destination.status = "running"; + startSse(response); + await sendSse(response, "node.continued", { node: destination, merged_from_node_id: source.id }, 2); + await sendSse(response, "assistant.delta", { node_id: destination.id, delta: "Merged child findings." }, 16); + destination.markdown += "Merged child findings."; + destination.status = "completed"; + await sendSse(response, "node.completed", { + node_id: destination.id, markdown: destination.markdown, title: destination.title, + hermes_session_id: destination.hermes_session_id, + }, 2); + return response.end(); + } + const branchRun = /^\/api\/nodes\/([^/]+)\/branch\/stream$/.exec(url.pathname); if (branchRun && request.method === "POST") { const parent = findNode(fixture, branchRun[1]); diff --git a/test/hermes-workspace/workspace-actions.test.mjs b/test/hermes-workspace/workspace-actions.test.mjs new file mode 100644 index 0000000..6a1e4a4 --- /dev/null +++ b/test/hermes-workspace/workspace-actions.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { bootHermesWorkspace } from "./harness.mjs"; + +const app = await bootHermesWorkspace(); +const { browser, baseUrl, fixture } = app; +try { + const context = await browser.newContext(); + const page = await context.newPage(); + const browserErrors = []; + page.on("console", (message) => { if (message.type() === "error") browserErrors.push(message.text()); }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "New workspace" }).click(); + await page.getByLabel("What do you want to explore?").fill("Root workspace question"); + await page.getByRole("button", { name: "Start exploring" }).click(); + const root = page.locator('[data-node-id="rn_root"]'); + await root.locator(".node-markdown", { hasText: "streaming the root document" }).waitFor(); + + const rootInput = root.getByLabel("Ask a follow-up on Root answer"); + await rootInput.fill("Explore alpha"); + await root.getByRole("button", { name: "New branch" }).click(); + const child = page.locator('[data-node-id="rn_branch_1"]'); + await child.locator(".node-markdown", { hasText: "Alpha completed independently" }).waitFor(); + assert.equal(await child.getAttribute("data-unread"), "true", "a background completion should mark the card unread"); + await page.getByRole("button", { name: /Next unread/ }).waitFor(); + await page.getByRole("button", { name: /Next unread/ }).click(); + assert.equal(await child.getAttribute("aria-selected"), "true", "next unread should select the completed card"); + assert.equal(await child.getAttribute("data-unread"), "false", "opening an unread card should clear its highlight"); + + const childInput = child.getByLabel("Ask a follow-up on Alpha branch"); + await childInput.fill("Take this nested branch deeper"); + await child.getByRole("button", { name: "New branch" }).click(); + const nested = page.locator('[data-node-id="rn_branch_2"]'); + await nested.locator(".node-markdown", { hasText: "Nested completed independently" }).waitFor(); + await nested.getByRole("button", { name: "Merge" }).click(); + const mergeDialog = page.getByRole("dialog", { name: "Merge child findings" }); + await mergeDialog.getByLabel("Destination").selectOption("root"); + await mergeDialog.getByLabel("Optional instruction").fill("Keep only decisions"); + await mergeDialog.getByRole("button", { name: "Merge findings" }).click(); + await root.locator(".node-markdown", { hasText: "Merged child findings" }).waitFor(); + assert.deepEqual(fixture.mergeBodies.at(-1), { + source_id: "rn_branch_2", destination: "root", instruction: "Keep only decisions", + }); + assert.equal(await root.getAttribute("data-unread"), "true", "the merge destination should be highlighted when updated"); + assert.equal(await nested.count(), 1, "merging must keep the source child on the canvas"); + + await page.evaluate(() => window.__hermesWorkspaceTest.setCamera({ x: 0, y: 0, scale: 1 })); + const canvas = page.locator("#canvas-view"); + await canvas.dblclick({ position: { x: 900, y: 680 } }); + const sessionDialog = page.getByRole("dialog", { name: "Create session on canvas" }); + await sessionDialog.getByLabel("What should this session work on?").fill("Independent session"); + await sessionDialog.getByRole("button", { name: "Start session" }).click(); + const standalone = page.locator('[data-node-id="rn_standalone_3"]'); + await standalone.locator(".node-markdown", { hasText: "streaming the root document" }).waitFor(); + assert.equal(fixture.standaloneBodies.length, 1); + assert(Math.abs(fixture.standaloneBodies[0].x - 900) < 2 && Math.abs(fixture.standaloneBodies[0].y - 680) < 2, + `standalone session should use the clicked world position (${JSON.stringify(fixture.standaloneBodies[0])})`); + assert.equal(await standalone.getAttribute("data-kind"), "standalone"); + + assert.deepEqual(browserErrors, [], `workspace actions should not emit browser errors: ${browserErrors.join(" | ")}`); + await context.close(); + console.log("ok workspace actions: unread navigation, merge destination, and anywhere sessions"); +} finally { + await app.close(); +} diff --git a/tests/hermes_workspace/test_backend.py b/tests/hermes_workspace/test_backend.py index ea12aad..91df81a 100644 --- a/tests/hermes_workspace/test_backend.py +++ b/tests/hermes_workspace/test_backend.py @@ -423,6 +423,68 @@ def test_continue_stream_reuses_the_completed_node_and_session(self) -> None: self.assertEqual(detail["nodes"][0]["hermes_session_id"], node["hermes_session_id"]) self.assertEqual(detail["nodes"][0]["status"], "completed") + def test_standalone_session_is_created_at_requested_canvas_position(self) -> None: + payload, _ = self.create_and_run_root("original root") + workspace_id = payload["workspace"]["id"] + created_before = len(self.hermes.created) + + status, created, _ = self.request( + "POST", + f"/api/workspaces/{workspace_id}/nodes", + {"prompt": "Independent investigation", "title": "Independent", "x": 321.5, "y": -44}, + ) + + self.assertEqual(status, 201) + standalone = created["node"] + self.assertIsNone(standalone["parent_node_id"]) + self.assertEqual((standalone["x"], standalone["y"]), (321.5, -44.0)) + self.assertEqual(len(self.hermes.created), created_before + 1) + self.assertEqual(self.hermes.forked, []) + events = self.stream(f"/api/nodes/{standalone['id']}/run/stream") + self.assertEqual(events[-1][0], "node.completed") + + status, detail, _ = self.request("GET", f"/api/workspaces/{workspace_id}") + self.assertEqual(status, 200) + self.assertEqual(detail["workspace"]["root_node_id"], payload["node"]["id"]) + self.assertEqual(len(detail["nodes"]), 2) + + def test_completed_child_can_merge_into_parent_or_workspace_root(self) -> None: + payload, _ = self.create_and_run_root("root") + root = payload["node"] + child_events = self.stream( + f"/api/nodes/{root['id']}/branch/stream", {"prompt": "child research"} + ) + child_id = child_events[0][1]["id"] + nested_events = self.stream( + f"/api/nodes/{child_id}/branch/stream", {"prompt": "nested evidence"} + ) + nested_id = nested_events[0][1]["id"] + + parent_events = self.stream( + f"/api/nodes/{nested_id}/merge/stream", + {"destination": "parent", "instruction": "Keep the recommendation concise."}, + ) + self.assertEqual(parent_events[0][0], "node.continued") + self.assertEqual(parent_events[0][1]["node"]["id"], child_id) + self.assertEqual(parent_events[0][1]["merged_from_node_id"], nested_id) + self.assertEqual(parent_events[-1][1]["node_id"], child_id) + parent_prompt = self.hermes.streams[-1][1] + self.assertIn("nested evidence", parent_prompt) + self.assertIn("Keep the recommendation concise.", parent_prompt) + + root_events = self.stream( + f"/api/nodes/{nested_id}/merge/stream", {"destination": "root"} + ) + self.assertEqual(root_events[0][1]["node"]["id"], root["id"]) + self.assertEqual(root_events[-1][1]["node_id"], root["id"]) + self.assertEqual(self.hermes.streams[-1][0], root["hermes_session_id"]) + + status, error, _ = self.request( + "POST", f"/api/nodes/{root['id']}/merge/stream", {"destination": "parent"} + ) + self.assertEqual(status, 409) + self.assertEqual(error["error"]["code"], "conflict") + def test_queued_follow_up_runs_after_completion_and_can_be_cancelled(self) -> None: started = threading.Event() release = threading.Event() diff --git a/web/hermes-dist/app.js b/web/hermes-dist/app.js index 52a3387..f516a2b 100644 --- a/web/hermes-dist/app.js +++ b/web/hermes-dist/app.js @@ -28072,6 +28072,13 @@ async function createWorkspace(prompt, title = "") { body: JSON.stringify({ prompt, ...title ? { title } : {} }) }); } +async function createStandaloneSession(workspaceId, prompt, x, y, title = "") { + return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/nodes`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ prompt, x, y, ...title ? { title } : {} }) + }); +} async function loadWorkspace(workspaceId) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}`); } @@ -28111,6 +28118,14 @@ function streamBranch(nodeId, input, onEvent, options2 = {}) { signal: options2.signal }, onEvent); } +function streamMerge(nodeId, destination, instruction, onEvent, options2 = {}) { + return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/merge/stream`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ destination, instruction }), + signal: options2.signal + }, onEvent); +} function streamContinue(nodeId, prompt, onEvent, options2 = {}) { return streamEvents(`/api/nodes/${encodeURIComponent(nodeId)}/continue/stream`, { method: "POST", @@ -28200,6 +28215,7 @@ async function responseError(response) { // src/hermes-workspace/app.js var LAST_WORKSPACE_KEY = "rh-hermes-last-workspace"; var VIEW_KEY_PREFIX = "rh-hermes-view:"; +var UNREAD_KEY_PREFIX = "rh-hermes-unread:"; var DEFAULT_SIZE = Object.freeze({ width: 440, height: 430 }); var OVERVIEW_ZOOM_THRESHOLD = 0.72; var FOCUS_ZOOM_THRESHOLD = 1.2; @@ -28214,10 +28230,15 @@ var state = { camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, selectionByParent: /* @__PURE__ */ new Map(), - queuedByNode: /* @__PURE__ */ new Map() + queuedByNode: /* @__PURE__ */ new Map(), + unreadNodeIds: /* @__PURE__ */ new Set() }; var renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); var newDialog = null; +var newSessionDialog = null; +var mergeDialog = null; +var pendingSessionPoint = null; +var mergeSourceId = null; var selectionAsk = null; var pendingSelection = null; var selectionAnchorRect = null; @@ -28289,6 +28310,7 @@ function renderShell() { + @@ -28307,6 +28329,32 @@ function renderShell() { + +
+

New session here

Start an independent Hermes session at this canvas position.

+ + + +
+ + +
+
+
+ +
+

Merge child findings

Hermes will summarize this branch into the chosen conversation.

+ + + + + +
+ + +
+
+
`; newDialog = document.getElementById("new-workspace-dialog"); + newSessionDialog = document.getElementById("new-session-dialog"); + mergeDialog = document.getElementById("merge-dialog"); selectionAsk = document.getElementById("selection-ask"); } function installEvents() { @@ -28340,14 +28390,20 @@ function installEvents() { if (action === "rail") toggleRail(); else if (action === "new") openNewWorkspace(); else if (action === "cancel-new") newDialog.close(); + else if (action === "cancel-session") newSessionDialog.close(); + else if (action === "cancel-merge") mergeDialog.close(); + else if (action === "merge") openMergeDialog(event.target.closest("[data-node-id]")?.dataset.nodeId); else if (action === "reader") setMode("reader"); else if (action === "canvas") setMode("canvas"); else if (action === "frame") frameAll(); + else if (action === "next-unread") selectNextUnread(); else if (action === "export") void downloadWorkspaceArchive(); else if (action === "theme") toggleTheme(); }); document.getElementById("rail-new").addEventListener("click", openNewWorkspace); document.getElementById("new-workspace-form").addEventListener("submit", submitNewWorkspace); + document.getElementById("new-session-form").addEventListener("submit", submitNewSession); + document.getElementById("merge-form").addEventListener("submit", submitMerge); document.addEventListener("submit", (event) => { const form = event.target.closest(".branch-form"); if (!form) return; @@ -28379,8 +28435,17 @@ function installEvents() { } }); installSelectionAsk(); - document.getElementById("canvas-view").addEventListener("wheel", onCanvasWheel, { passive: false }); - document.getElementById("canvas-view").addEventListener("pointerdown", onCanvasPointerDown); + const canvas = document.getElementById("canvas-view"); + canvas.addEventListener("wheel", onCanvasWheel, { passive: false }); + canvas.addEventListener("pointerdown", onCanvasPointerDown); + canvas.addEventListener("dblclick", (event) => { + if (!event.target.closest(".workspace-node")) openNewSessionAt(event.clientX, event.clientY); + }); + canvas.addEventListener("contextmenu", (event) => { + if (event.target.closest(".workspace-node")) return; + event.preventDefault(); + openNewSessionAt(event.clientX, event.clientY); + }); window.addEventListener("pagehide", saveViewState); window.addEventListener("resize", () => { if (state.mode === "canvas") drawEdges(); @@ -28449,6 +28514,7 @@ async function submitNewWorkspace(event) { state.workspace = workspace; state.nodes = /* @__PURE__ */ new Map([[node.id, node]]); state.queuedByNode = /* @__PURE__ */ new Map(); + state.unreadNodeIds = /* @__PURE__ */ new Set(); state.selectedNodeId = node.id; state.mode = "canvas"; state.camera = { x: 96, y: 96, scale: 1 }; @@ -28465,6 +28531,62 @@ async function submitNewWorkspace(event) { button.disabled = false; } } +async function submitNewSession(event) { + event.preventDefault(); + if (!state.workspace || !pendingSessionPoint) return; + const form = event.currentTarget; + const input = form.querySelector("textarea"); + const error = document.getElementById("new-session-error"); + const button = form.querySelector('[type="submit"]'); + const prompt = input.value.trim(); + if (!prompt) return; + button.disabled = true; + error.textContent = ""; + try { + const payload = await createStandaloneSession( + state.workspace.id, + prompt, + pendingSessionPoint.x, + pendingSessionPoint.y + ); + const node = normalizeNode(payload.node); + state.nodes.set(node.id, node); + state.selectedNodeId = node.id; + state.unreadNodeIds.delete(node.id); + input.value = ""; + pendingSessionPoint = null; + newSessionDialog.close(); + renderWorkspace(); + saveViewState(); + void startRoot(node.id); + } catch (err) { + error.textContent = err.message || String(err); + } finally { + button.disabled = false; + } +} +async function submitMerge(event) { + event.preventDefault(); + const source2 = state.nodes.get(mergeSourceId); + if (!source2) return; + const form = event.currentTarget; + const destination = form.querySelector("select").value; + const instruction = form.querySelector("textarea").value.trim(); + const error = document.getElementById("merge-error"); + const button = form.querySelector('[type="submit"]'); + button.disabled = true; + error.textContent = ""; + try { + mergeDialog.close(); + await startMerge(source2.id, destination, instruction); + mergeSourceId = null; + } catch (err) { + if (!mergeDialog.open) mergeDialog.showModal(); + error.textContent = err.message || String(err); + } finally { + button.disabled = false; + } +} async function openWorkspace(workspaceId, { frame = false } = {}) { hideSelectionAsk(); const payload = await loadWorkspace(workspaceId); @@ -28474,8 +28596,11 @@ async function openWorkspace(workspaceId, { frame = false } = {}) { return [normalized.id, normalized]; })); state.queuedByNode = groupQueuedFollowUps(payload.queued_followups || []); + state.unreadNodeIds = readUnreadNodes(workspaceId, state.nodes); const saved = readViewState(workspaceId); state.selectedNodeId = saved?.selectedNodeId && state.nodes.has(saved.selectedNodeId) ? saved.selectedNodeId : state.workspace.root_node_id; + state.unreadNodeIds.delete(state.selectedNodeId); + persistUnreadNodes(); state.mode = saved?.mode === "reader" ? "reader" : "canvas"; state.camera = saved?.camera || { x: 96, y: 96, scale: 1 }; safeStorageSet(LAST_WORKSPACE_KEY, workspaceId); @@ -28529,6 +28654,7 @@ function renderWorkspace() { renderReader(); applyCamera(); updateActivity(); + updateUnreadControl(); } function renderCanvas() { const container = document.getElementById("workspace-nodes"); @@ -28555,7 +28681,7 @@ function createNodeCard(node) { card.innerHTML = `
- +

@@ -28595,11 +28721,20 @@ function updateNodeCard(card, node) { card.style.width = `${node.width}px`; card.style.height = `${node.height}px`; card.dataset.status = node.status; + const kind = nodeKind(node); + card.dataset.kind = kind; + const unread = state.unreadNodeIds.has(node.id); + card.dataset.unread = String(unread); card.setAttribute("aria-selected", String(node.id === state.selectedNodeId)); - card.querySelector(".node-kind").innerHTML = node.parent_node_id ? "\u21B3" : BUNNY_MARK_SVG; + card.querySelector(".node-kind").innerHTML = kind === "branch" ? "\u21B3" : kind === "standalone" ? "\u25C7" : BUNNY_MARK_SVG; card.querySelector(".workspace-node-title").textContent = node.title || "Untitled"; + const unreadBadge = card.querySelector(".node-unread"); + unreadBadge.hidden = !unread; card.querySelector(".node-state").textContent = statusLabel(node.status); - card.querySelector(".node-overview-kind").textContent = node.parent_node_id ? "Branch document" : "Root document"; + const mergeButton = card.querySelector(".node-merge"); + mergeButton.hidden = !node.parent_node_id; + mergeButton.disabled = node.status !== "completed"; + card.querySelector(".node-overview-kind").textContent = kind === "branch" ? "Branch document" : kind === "standalone" ? "Independent session" : "Root document"; card.querySelector(".node-overview-summary").textContent = summarizeNode(node); const branchCount = childrenOf(node.id).length; card.querySelector(".node-overview-meta").textContent = `${statusLabel(node.status)} \xB7 ${branchCount} ${branchCount === 1 ? "branch" : "branches"}`; @@ -28705,7 +28840,8 @@ function renderReader() { return index ? [document.createTextNode(" \u203A "), button] : [button]; })); const documentEl = document.getElementById("reader-document"); - const readerHtml = `
${node.parent_node_id ? "Branch" : "Root document"}

${escapeHtml2(node.title || "Untitled")}

${renderNodeMarkdown(node)}`; + const kind = nodeKind(node); + const readerHtml = `
${kind === "branch" ? "Branch" : kind === "standalone" ? "Independent session" : "Root document"}

${escapeHtml2(node.title || "Untitled")}

${renderNodeMarkdown(node)}`; if (documentEl.dataset.renderedHtml !== readerHtml) { documentEl.innerHTML = readerHtml; documentEl.dataset.renderedHtml = readerHtml; @@ -28771,6 +28907,14 @@ async function startContinue(nodeId, prompt) { endStream(); } } +async function startMerge(sourceId, destination, instruction) { + beginStream(); + try { + await streamMerge(sourceId, destination, instruction, handleStreamEvent); + } finally { + endStream(); + } +} async function addQueuedFollowUp(nodeId, prompt) { try { const payload = await queueFollowUp(nodeId, prompt); @@ -28837,7 +28981,7 @@ function handleStreamEvent(name, payload) { node.title = payload.title || node.title; node.hermes_session_id = payload.hermes_session_id || node.hermes_session_id; node.error = ""; - if (!node.parent_node_id && state.workspace) { + if (node.id === state.workspace?.root_node_id && state.workspace) { state.workspace.title = node.title; renderWorkspaceList(); } @@ -28845,6 +28989,9 @@ function handleStreamEvent(name, payload) { node.status = "failed"; node.error = payload.error || "Hermes could not complete this node."; } + if ((name === "node.completed" || name === "node.failed") && state.selectedNodeId !== node.id) { + setNodeUnread(node.id, true); + } const card = document.querySelector(`[data-node-id="${cssEscape(node.id)}"]`); if (card) updateNodeCard(card, node); if (state.selectedNodeId === node.id || state.nodes.get(state.selectedNodeId)?.parent_node_id === node.id) renderReader(); @@ -28922,9 +29069,43 @@ function openNewWorkspace() { if (!newDialog.open) newDialog.showModal(); requestAnimationFrame(() => document.getElementById("new-workspace-prompt").focus()); } +function openNewSessionAt(clientX, clientY) { + if (!state.workspace || state.mode !== "canvas") return; + const rect = document.getElementById("canvas-view").getBoundingClientRect(); + pendingSessionPoint = { + x: (clientX - rect.left - state.camera.x) / state.camera.scale, + y: (clientY - rect.top - state.camera.y) / state.camera.scale + }; + document.getElementById("new-session-error").textContent = ""; + document.getElementById("new-session-prompt").value = ""; + if (!newSessionDialog.open) newSessionDialog.showModal(); + requestAnimationFrame(() => document.getElementById("new-session-prompt").focus()); +} +function openMergeDialog(nodeId) { + const source2 = state.nodes.get(nodeId); + if (!source2?.parent_node_id || source2.status !== "completed") return; + mergeSourceId = source2.id; + const select = document.getElementById("merge-destination"); + const parent = state.nodes.get(source2.parent_node_id); + const options2 = [{ value: "parent", label: `Parent \u2014 ${parent?.title || "Untitled"}` }]; + if (state.workspace?.root_node_id && state.workspace.root_node_id !== source2.parent_node_id) { + const root = state.nodes.get(state.workspace.root_node_id); + options2.push({ value: "root", label: `Main/root \u2014 ${root?.title || "Untitled"}` }); + } + select.replaceChildren(...options2.map((item) => { + const option = document.createElement("option"); + option.value = item.value; + option.textContent = item.label; + return option; + })); + document.getElementById("merge-instruction").value = ""; + document.getElementById("merge-error").textContent = ""; + if (!mergeDialog.open) mergeDialog.showModal(); +} function selectNode(nodeId) { if (!state.nodes.has(nodeId)) return; state.selectedNodeId = nodeId; + setNodeUnread(nodeId, false); renderCanvas(); renderReader(); saveViewState(); @@ -29074,6 +29255,48 @@ function readViewState(workspaceId) { return null; } } +function readUnreadNodes(workspaceId, nodes) { + try { + const ids = JSON.parse(safeStorageGet(`${UNREAD_KEY_PREFIX}${workspaceId}`) || "[]"); + return new Set(Array.isArray(ids) ? ids.filter((id) => nodes.has(id)) : []); + } catch { + return /* @__PURE__ */ new Set(); + } +} +function persistUnreadNodes() { + if (!state.workspace) return; + safeStorageSet(`${UNREAD_KEY_PREFIX}${state.workspace.id}`, JSON.stringify([...state.unreadNodeIds])); +} +function setNodeUnread(nodeId, unread) { + if (!state.nodes.has(nodeId)) return; + if (unread) state.unreadNodeIds.add(nodeId); + else state.unreadNodeIds.delete(nodeId); + persistUnreadNodes(); + updateUnreadControl(); +} +function updateUnreadControl() { + const button = document.querySelector('[data-action="next-unread"]'); + if (!button) return; + const count = [...state.unreadNodeIds].filter((id) => state.nodes.has(id)).length; + button.hidden = count === 0; + button.querySelector(".unread-count").textContent = String(count); + button.setAttribute("aria-label", `Next unread (${count})`); +} +function selectNextUnread() { + const node = [...state.nodes.values()].filter((item) => state.unreadNodeIds.has(item.id)).sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.id.localeCompare(b.id))[0]; + if (!node) return; + state.mode = "canvas"; + selectNode(node.id); + const rect = document.getElementById("canvas-view").getBoundingClientRect(); + state.camera.x = rect.width / 2 - (node.x + node.width / 2) * state.camera.scale; + state.camera.y = rect.height / 2 - (node.y + node.height / 2) * state.camera.scale; + renderWorkspace(); + saveViewState(); +} +function nodeKind(node) { + if (node.parent_node_id) return "branch"; + return node.id === state.workspace?.root_node_id ? "root" : "standalone"; +} function childrenOf(parentId) { return [...state.nodes.values()].filter((node) => node.parent_node_id === parentId).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); } diff --git a/web/hermes-dist/styles.css b/web/hermes-dist/styles.css index 80dab96..fcfeb39 100644 --- a/web/hermes-dist/styles.css +++ b/web/hermes-dist/styles.css @@ -698,14 +698,19 @@ button { color: inherit; } .workspace-node[aria-selected="true"] { border-color: color-mix(in srgb, var(--accent) 78%, var(--card-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent), 0 30px 86px rgba(0,0,0,.34); } .workspace-node[data-status="running"] { border-color: color-mix(in srgb, var(--accent) 42%, var(--card-border)); } .workspace-node[data-status="failed"] { border-color: color-mix(in srgb, var(--warn) 60%, var(--card-border)); } +.workspace-node[data-unread="true"] { border-color: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent), 0 30px 86px rgba(0,0,0,.38); } .workspace-node.dragging { transform: translateY(-2px); box-shadow: 0 32px 90px rgba(0,0,0,.42); } -.workspace-node-head { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto; align-items: center; gap: 10px; min-height: 60px; padding: 9px 11px 9px 14px; border-bottom: 1px solid var(--card-border); background: var(--card-header); cursor: grab; user-select: none; } +.workspace-node-head { display: grid; grid-template-columns: 28px minmax(0,1fr) auto auto auto auto; align-items: center; gap: 8px; min-height: 60px; padding: 9px 11px 9px 14px; border-bottom: 1px solid var(--card-border); background: var(--card-header); cursor: grab; user-select: none; } .workspace-node-head:active { cursor: grabbing; } .node-kind { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--accent) 9%, transparent); color: var(--accent); font-family: var(--font-mono); font-size: 13px; } .node-kind svg { width: 17px; height: 17px; } .workspace-node-title { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; color: var(--fg-bold); font-size: 13.5px; line-height: 1.25; font-weight: 500; letter-spacing: -.012em; } .node-state { display: inline-flex; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid color-mix(in srgb, var(--fg) 11%, transparent); border-radius: 999px; color: var(--fg-faint); font-family: var(--font-mono); font-size: 9px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; } .node-state::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: color-mix(in srgb, var(--fg) 36%, transparent); } +.node-unread { padding: 4px 7px; border-radius: 999px; background: var(--accent); color: var(--accent-contrast); font: 600 9px/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; } +.node-merge { height: 28px; border: 1px solid var(--card-border); border-radius: 8px; padding: 0 8px; background: transparent; color: var(--fg-dim); font-size: 11px; cursor: pointer; } +.node-merge:hover { border-color: var(--accent); color: var(--fg-bold); } +.node-merge:disabled { opacity: .4; cursor: default; } .workspace-node[data-status="running"] .node-state::before { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); } .workspace-node[data-status="failed"] .node-state::before { background: var(--warn); } .node-open { width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: var(--fg-dim); cursor: pointer; } @@ -759,12 +764,13 @@ button { color: inherit; } /* Semantic overview: below 72% zoom, cards become legible catalog records instead of shrinking full articles into unreadable thumbnails. */ body[data-zoom-tier="overview"] .workspace-node { grid-template-rows: auto 1fr; border-radius: 20px; } -body[data-zoom-tier="overview"] .workspace-node-head { grid-template-columns: 36px minmax(0,1fr) auto; min-height: 72px; gap: 14px; padding: 13px 18px; } +body[data-zoom-tier="overview"] .workspace-node-head { grid-template-columns: 36px minmax(0,1fr) auto auto; min-height: 72px; gap: 14px; padding: 13px 18px; } body[data-zoom-tier="overview"] .node-kind { width: 36px; height: 36px; border-radius: 11px; font-size: 17px; } body[data-zoom-tier="overview"] .node-kind svg { width: 22px; height: 22px; } body[data-zoom-tier="overview"] .workspace-node-title { font-size: 19px; font-weight: 600; letter-spacing: -.022em; } body[data-zoom-tier="overview"] .node-state { padding: 5px 8px; font-size: 10px; } -body[data-zoom-tier="overview"] .node-open { display: none; } +body[data-zoom-tier="overview"] .node-open, +body[data-zoom-tier="overview"] .node-merge { display: none; } body[data-zoom-tier="overview"] .workspace-node-body { padding: 30px 34px 34px; overflow: hidden; } body[data-zoom-tier="overview"] .node-markdown, body[data-zoom-tier="overview"] .tool-activity, @@ -829,6 +835,19 @@ body[data-zoom-tier="focus"] .workspace-node-body { padding: 27px 32px 32px; } .primary-button { min-height: 40px; padding: 0 20px; border: 0; border-radius: var(--radius-control, 9px); background: var(--accent); color: var(--accent-contrast); font-family: var(--font-ui); font-weight: var(--weight-semibold, 650); cursor: pointer; } .primary-button:hover { background: color-mix(in srgb, var(--accent) 88%, var(--fg-bold)); } .primary-button:disabled { opacity: .55; cursor: progress; } +#new-session-dialog, #merge-dialog { position: fixed; inset: 0; margin: auto; width: min(520px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); overflow: hidden auto; border: 1px solid var(--border); border-radius: 16px; padding: 0; color: var(--fg); background: var(--node-bg); box-shadow: var(--shadow-modal, 0 20px 70px rgba(0,0,0,.48)); } +#new-session-dialog::backdrop, #merge-dialog::backdrop { background: color-mix(in srgb, var(--bg) 62%, rgba(6,6,8,.78)); backdrop-filter: blur(6px); } +#new-session-form, #merge-form { display: grid; gap: 13px; padding: 26px; } +#new-session-form header, #merge-form header { display: grid; gap: 5px; margin-bottom: 4px; } +#new-session-form h1, #merge-form h1 { color: var(--fg-bold); font-size: 20px; } +#new-session-form header p, #merge-form header p { color: var(--fg-dim); font-size: 13px; } +#new-session-form label, #merge-form label { color: var(--fg-dim); font-size: 12px; font-weight: 600; } +#new-session-form textarea, #merge-form textarea, #merge-form select { width: 100%; border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; outline: 0; resize: vertical; background: var(--input-bg, var(--bar-bg)); color: var(--fg); } +#new-session-form textarea:focus, #merge-form textarea:focus, #merge-form select:focus { border-color: var(--accent); box-shadow: var(--focus-field-shadow); } +#new-session-error, #merge-error { min-height: 18px; color: var(--warn); font-size: 12px; } +#new-session-form footer, #merge-form footer { display: flex; justify-content: flex-end; gap: 10px; } +#workspace-toolbar [data-action="next-unread"] { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--fg-bold); } +.unread-count { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; margin-left: 4px; border-radius: 999px; background: var(--accent); color: var(--accent-contrast); font-size: 10px; } #workspace-notice { position: fixed; z-index: 120; top: 18px; left: 50%; transform: translate(-50%,-18px); max-width: min(600px,calc(100vw - 30px)); padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--bar-bg); color: var(--fg); opacity: 0; pointer-events: none; transition: 150ms ease; } #workspace-notice.visible { transform: translate(-50%,0); opacity: 1; } #workspace-notice[data-kind="error"] { border-color: color-mix(in srgb, var(--warn) 50%, var(--border)); color: var(--warn); } From e0143823a251cf9357e188b7350bbabf40328a4a Mon Sep 17 00:00:00 2001 From: Sanket Kheni Date: Thu, 30 Jul 2026 06:54:18 +0000 Subject: [PATCH 04/19] feat: add per-session model and thinking controls --- backend/hermes_client.py | 35 ++- backend/schema.sql | 2 + backend/server.py | 101 +++++++- backend/store.py | 79 ++++++- .../hermes-api-session-settings.patch | 217 ++++++++++++++++++ package.json | 2 +- src/hermes-workspace/api.js | 29 ++- src/hermes-workspace/app.js | 132 ++++++++++- src/hermes-workspace/styles.css | 36 +-- test/hermes-workspace/harness.mjs | 31 ++- test/hermes-workspace/model-settings.test.mjs | 55 +++++ .../hermes_api_overlay_test.py | 124 ++++++++++ tests/hermes_workspace/test_backend.py | 71 +++++- web/hermes-dist/app.js | 162 ++++++++++++- web/hermes-dist/styles.css | 36 +-- 15 files changed, 1040 insertions(+), 72 deletions(-) create mode 100644 integrations/hermes-api-session-settings.patch create mode 100644 test/hermes-workspace/model-settings.test.mjs create mode 100644 tests/hermes_workspace/hermes_api_overlay_test.py diff --git a/backend/hermes_client.py b/backend/hermes_client.py index e6ff662..5d05c66 100644 --- a/backend/hermes_client.py +++ b/backend/hermes_client.py @@ -290,15 +290,48 @@ def _json(self, method: str, path: str, body: dict[str, Any] | None = None) -> d def health(self) -> dict[str, Any]: return self._json("GET", "/health") - def create_session(self, session_id: object, title: str | None = None) -> dict[str, Any]: + def create_session( + self, + session_id: object, + title: str | None = None, + model: str | None = None, + reasoning_effort: str | None = None, + ) -> dict[str, Any]: owned_id = require_rabbithole_session_id(session_id) body: dict[str, Any] = {"id": owned_id} if title: body["title"] = title + if model: + body["model"] = model + if reasoning_effort: + body["reasoning_effort"] = reasoning_effort result = self._json("POST", "/api/sessions", body) self._verify_returned_session(result, owned_id) return result + def models(self) -> list[dict[str, str]]: + result = self._json("GET", "/v1/models") + models: list[dict[str, str]] = [] + for item in result.get("data", []): + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + continue + model_id = item["id"].strip() + if model_id: + models.append({"id": model_id, "label": str(item.get("label") or model_id)}) + return models + + def update_session_settings( + self, session_id: object, model: str, reasoning_effort: str + ) -> dict[str, Any]: + owned_id = require_rabbithole_session_id(session_id) + result = self._json( + "PATCH", + f"/api/sessions/{urllib.parse.quote(owned_id, safe='')}", + {"model": model, "reasoning_effort": reasoning_effort}, + ) + self._verify_returned_session(result, owned_id) + return result + def fork_session( self, source_session_id: object, child_session_id: object, title: str | None = None ) -> dict[str, Any]: diff --git a/backend/schema.sql b/backend/schema.sql index 0dba276..12307ec 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -23,6 +23,8 @@ CREATE TABLE IF NOT EXISTS nodes ( y REAL NOT NULL DEFAULT 0, width REAL NOT NULL DEFAULT 520 CHECK (width > 0), height REAL NOT NULL DEFAULT 620 CHECK (height > 0), + model_alias TEXT NOT NULL DEFAULT 'hermes-agent', + reasoning_effort TEXT NOT NULL DEFAULT 'medium', ordinal INTEGER NOT NULL CHECK (ordinal >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, diff --git a/backend/server.py b/backend/server.py index d87740b..72ef2a3 100644 --- a/backend/server.py +++ b/backend/server.py @@ -36,6 +36,8 @@ _NODE_QUEUE_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/queue$") _QUEUED_FOLLOW_UP_RE = re.compile(r"^/api/queued-follow-ups/(qf_[0-9a-f]{32})$") _NODE_LAYOUT_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/layout$") +_NODE_SETTINGS_RE = re.compile(r"^/api/nodes/(rn_[0-9a-f]{32})/settings$") +_REASONING_LEVELS = ("none", "minimal", "low", "medium", "high", "xhigh") class ApplicationHTTPServer(ThreadingHTTPServer): @@ -63,6 +65,28 @@ def do_GET(self) -> None: # noqa: N802 path = urlsplit(self.path).path if path == "/api/health": self._health() + elif path == "/api/models": + try: + models = [] + for item in self.server.hermes.models(): + model_id = item["id"] + lowered = model_id.lower() + if any(token in lowered for token in ("whisper", "tts", "realtime", "embedding", "audio")): + continue + label = "Default" if model_id == "hermes-agent" else self._model_label(model_id) + models.append({"id": model_id, "label": label}) + if not any(item["id"] == "hermes-agent" for item in models): + models.insert(0, {"id": "hermes-agent", "label": "Default"}) + self._send_json( + HTTPStatus.OK, + { + "models": models, + "reasoning_levels": list(_REASONING_LEVELS), + "defaults": {"model": "hermes-agent", "reasoning_effort": "medium"}, + }, + ) + except Exception as exc: + self._exception(exc) elif path == "/api/workspaces": self._send_json(HTTPStatus.OK, {"workspaces": self.server.store.list_workspaces()}) elif match := _WORKSPACE_ARCHIVE_RE.fullmatch(path): @@ -87,15 +111,26 @@ def do_POST(self) -> None: # noqa: N802 body = self._read_body() if body is None: return - if not self._only_fields(body, {"title", "prompt"}) or "prompt" not in body: + required = {"prompt"} + if not self._only_fields(body, {"title", "model", "reasoning_effort", *required}) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "prompt is required", + ) return try: prompt = self._required_prompt(body.get("prompt")) title = self._optional_title(body.get("title")) + model = self._required_model(body.get("model", "hermes-agent")) + effort = self._required_reasoning_effort(body.get("reasoning_effort", "medium")) session_id = new_session_id() - self.server.hermes.create_session(session_id, title) + self.server.hermes.create_session(session_id, title, model, effort) try: - workspace, node = self.server.store.create_workspace(prompt, session_id, title) + workspace, node = self.server.store.create_workspace( + prompt, session_id, title, model, effort + ) except Exception: self._delete_orphan_session(session_id) raise @@ -109,7 +144,7 @@ def do_POST(self) -> None: # noqa: N802 if body is None: return required = {"prompt", "x", "y"} - if not self._only_fields(body, {"title", *required}) or not required.issubset(body): + if not self._only_fields(body, {"title", "model", "reasoning_effort", *required}) or not required.issubset(body): if required - set(body): self._error( HTTPStatus.BAD_REQUEST, @@ -120,11 +155,13 @@ def do_POST(self) -> None: # noqa: N802 try: prompt = self._required_prompt(body.get("prompt")) title = self._optional_title(body.get("title")) + model = self._required_model(body.get("model", "hermes-agent")) + effort = self._required_reasoning_effort(body.get("reasoning_effort", "medium")) session_id = new_session_id() - self.server.hermes.create_session(session_id, title) + self.server.hermes.create_session(session_id, title, model, effort) try: node = self.server.store.create_standalone( - match.group(1), prompt, session_id, body["x"], body["y"], title + match.group(1), prompt, session_id, body["x"], body["y"], title, model, effort ) except Exception: self._delete_orphan_session(session_id) @@ -301,6 +338,30 @@ def do_PATCH(self) -> None: # noqa: N802 lambda: {"workspace": self.server.store.update_workspace_title(match.group(1), body["title"])} ) return + if match := _NODE_SETTINGS_RE.fullmatch(path): + required = {"model", "reasoning_effort"} + if not self._only_fields(body, required) or not required.issubset(body): + if required - set(body): + self._error( + HTTPStatus.BAD_REQUEST, + "invalid_request", + "model and reasoning_effort are required", + ) + return + try: + model = self._required_model(body["model"]) + effort = self._required_reasoning_effort(body["reasoning_effort"]) + node = self.server.store.get_node(match.group(1)) + if node["status"] != "completed": + raise ConflictError("settings can only change after the session completes") + self.server.hermes.update_session_settings( + node["hermes_session_id"], model, effort + ) + updated = self.server.store.update_settings(node["id"], model, effort) + self._send_json(HTTPStatus.OK, {"node": updated}) + except Exception as exc: + self._exception(exc) + return if match := _NODE_LAYOUT_RE.fullmatch(path): required = {"x", "y", "width", "height"} if not self._only_fields(body, required) or not required.issubset(body): @@ -361,6 +422,34 @@ def _optional_title(self, value: object) -> str | None: raise ValidationError("title is too long") return value.strip() + def _required_model(self, value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValidationError("model must be a non-empty string") + model = value.strip() + if len(model) > 500: + raise ValidationError("model is too long") + available = {item["id"] for item in self.server.hermes.models()} + available.add("hermes-agent") + if model not in available: + raise ValidationError("unsupported model") + return model + + @staticmethod + def _required_reasoning_effort(value: object) -> str: + if not isinstance(value, str) or value.strip().lower() not in _REASONING_LEVELS: + raise ValidationError("unsupported reasoning_effort") + return value.strip().lower() + + @staticmethod + def _model_label(model_id: str) -> str: + name = model_id.rsplit("/", 1)[-1] + name = re.sub(r"-(\d+)-(\d+)$", r"-\1.\2", name) + if name.startswith("gpt-"): + return "GPT-" + name[4:].replace("-", " ").title() + if name.startswith("claude-"): + return "Claude " + name[7:].replace("-", " ").title() + return name.replace("-", " ").title() + def _read_body(self, *, allow_empty: bool = False) -> dict[str, Any] | None: transfer_encoding = self.headers.get("Transfer-Encoding", "") if transfer_encoding: diff --git a/backend/store.py b/backend/store.py index cbeab25..05ce20b 100644 --- a/backend/store.py +++ b/backend/store.py @@ -46,8 +46,9 @@ class ValidationError(StoreError): _WORKSPACE_COLUMNS = "id, title, root_node_id, created_at, updated_at" _NODE_COLUMNS = ( "id, workspace_id, parent_node_id, hermes_session_id, title, prompt, " - "markdown, status, error, x, y, width, height, created_at, updated_at" + "markdown, status, error, x, y, width, height, model_alias, reasoning_effort, created_at, updated_at" ) +_REASONING_LEVELS = {"none", "minimal", "low", "medium", "high", "xhigh"} def utc_now() -> str: @@ -65,6 +66,14 @@ def _clean_text(value: object, field: str, *, required: bool, maximum: int) -> s return cleaned +def _clean_settings(model: object, reasoning_effort: object) -> tuple[str, str]: + model_text = _clean_text(model, "model", required=True, maximum=500) + effort = _clean_text(reasoning_effort, "reasoning_effort", required=True, maximum=20).lower() + if effort not in _REASONING_LEVELS: + raise ValidationError("unsupported reasoning_effort") + return model_text, effort + + def _row_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: return dict(row) if row is not None else None @@ -102,14 +111,31 @@ def initialize(self) -> None: connection = self._connect() try: connection.executescript(schema) + columns = { + row["name"] for row in connection.execute("PRAGMA table_info(nodes)").fetchall() + } + if "model_alias" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN model_alias TEXT NOT NULL DEFAULT 'hermes-agent'" + ) + if "reasoning_effort" not in columns: + connection.execute( + "ALTER TABLE nodes ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'medium'" + ) finally: self._close(connection) def create_workspace( - self, prompt: object, session_id: object, title: object | None = None + self, + prompt: object, + session_id: object, + title: object | None = None, + model: object = "hermes-agent", + reasoning_effort: object = "medium", ) -> tuple[dict[str, Any], dict[str, Any]]: prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) session = require_rabbithole_session_id(session_id) + model_alias, effort = _clean_settings(model, reasoning_effort) title_text = ( _clean_text(title, "title", required=True, maximum=500) if title is not None @@ -128,9 +154,12 @@ def create_workspace( connection.execute( """INSERT INTO nodes( id,workspace_id,parent_node_id,hermes_session_id,title,prompt, - markdown,status,error,x,y,width,height,ordinal,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,'pending','',0,0,520,620,0,?,?)""", - (node_id, workspace_id, None, session, title_text, prompt_text, "", now, now), + markdown,status,error,x,y,width,height,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,'pending','',0,0,520,620,?,?,0,?,?)""", + ( + node_id, workspace_id, None, session, title_text, prompt_text, "", + model_alias, effort, now, now, + ), ) connection.commit() workspace = self._workspace_in(connection, workspace_id) @@ -183,8 +212,8 @@ def create_child( connection.execute( """INSERT INTO nodes( id,workspace_id,parent_node_id,hermes_session_id,title,prompt, - markdown,status,error,x,y,width,height,ordinal,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,'pending','',?,?,520,620,?,?,?)""", + markdown,status,error,x,y,width,height,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,'pending','',?,?,520,620,?,?,?,?,?)""", ( node_id, parent["workspace_id"], @@ -195,6 +224,8 @@ def create_child( "", float(parent["x"]) + float(parent["width"]) + 80, float(parent["y"]) + sibling_count * (float(parent["height"]) + 80), + parent["model_alias"], + parent["reasoning_effort"], ordinal, now, now, @@ -228,11 +259,14 @@ def create_standalone( x: object, y: object, title: object | None = None, + model: object = "hermes-agent", + reasoning_effort: object = "medium", ) -> dict[str, Any]: if not is_valid_id(workspace_id, "rw"): raise NotFoundError("workspace not found") prompt_text = _clean_text(prompt, "prompt", required=True, maximum=200_000) session = require_rabbithole_session_id(session_id) + model_alias, effort = _clean_settings(model, reasoning_effort) title_text = ( _clean_text(title, "title", required=True, maximum=500) if title is not None @@ -258,8 +292,8 @@ def create_standalone( connection.execute( """INSERT INTO nodes( id,workspace_id,parent_node_id,hermes_session_id,title,prompt, - markdown,status,error,x,y,width,height,ordinal,created_at,updated_at - ) VALUES(?,?,NULL,?,?,?,'','pending','',?,?,440,430,?,?,?)""", + markdown,status,error,x,y,width,height,model_alias,reasoning_effort,ordinal,created_at,updated_at + ) VALUES(?,?,NULL,?,?,?,'','pending','',?,?,440,430,?,?,?,?,?)""", ( node_id, workspace_id, @@ -267,6 +301,8 @@ def create_standalone( title_text, prompt_text, *coordinates, + model_alias, + effort, ordinal, now, now, @@ -677,6 +713,31 @@ def fail_node(self, node_id: object, error: object) -> dict[str, Any]: finally: self._close(connection) + def update_settings( + self, node_id: object, model: object, reasoning_effort: object + ) -> dict[str, Any]: + if not is_valid_id(node_id, "rn"): + raise NotFoundError("node not found") + model_alias, effort = _clean_settings(model, reasoning_effort) + now = utc_now() + connection = self._connect() + try: + cursor = connection.execute( + """UPDATE nodes SET model_alias=?,reasoning_effort=?,updated_at=? + WHERE id=? AND status='completed'""", + (model_alias, effort, now, node_id), + ) + if cursor.rowcount != 1: + row = connection.execute("SELECT status FROM nodes WHERE id=?", (node_id,)).fetchone() + if row is None: + raise NotFoundError("node not found") + raise ConflictError("settings can only change after the session completes") + node = self._node_in(connection, node_id) + assert node is not None + return node + finally: + self._close(connection) + def update_layout( self, node_id: object, x: object, y: object, width: object, height: object ) -> dict[str, Any]: diff --git a/integrations/hermes-api-session-settings.patch b/integrations/hermes-api-session-settings.patch new file mode 100644 index 0000000..80405cf --- /dev/null +++ b/integrations/hermes-api-session-settings.patch @@ -0,0 +1,217 @@ +--- a/gateway/platforms/api_server.py ++++ b/gateway/platforms/api_server.py +@@ -1718,10 +1718,52 @@ + return routes + + def _resolve_route(self, model_alias: Any) -> Optional[Dict[str, Any]]: +- """Return the model_routes entry for *model_alias*, or None.""" +- if not self._model_routes or not isinstance(model_alias, str): ++ """Resolve static API routes, then the user's global model aliases.""" ++ if not isinstance(model_alias, str) or not model_alias.strip(): + return None +- return self._model_routes.get(model_alias) ++ alias = model_alias.strip() ++ configured = self._model_routes.get(alias) if self._model_routes else None ++ if configured: ++ return dict(configured) ++ if alias == self._model_name: ++ return None ++ try: ++ from hermes_cli.model_switch import _load_direct_aliases ++ ++ direct = _load_direct_aliases().get(alias.lower()) ++ if direct: ++ return { ++ "model": direct.model, ++ "provider": direct.provider, ++ "base_url": direct.base_url, ++ } ++ except Exception: ++ logger.debug("Could not resolve global model alias %s", alias, exc_info=True) ++ return None ++ ++ @staticmethod ++ def _session_runtime_settings(session: Optional[Dict[str, Any]]) -> Dict[str, str]: ++ """Extract only the client-safe per-session runtime controls.""" ++ if not isinstance(session, dict): ++ return {"model_alias": "hermes-agent", "reasoning_effort": ""} ++ model_alias = str(session.get("model") or "hermes-agent").strip() or "hermes-agent" ++ raw_config = session.get("model_config") ++ try: ++ config = json.loads(raw_config) if isinstance(raw_config, str) else raw_config ++ except (TypeError, ValueError): ++ config = {} ++ settings = config.get("api_session_settings", {}) if isinstance(config, dict) else {} ++ if isinstance(settings, dict): ++ selected = settings.get("model_alias") ++ if isinstance(selected, str) and selected.strip(): ++ model_alias = selected.strip() ++ effort = str(settings.get("reasoning_effort") or "").strip().lower() ++ else: ++ effort = "" ++ from hermes_constants import parse_reasoning_effort ++ if effort and parse_reasoning_effort(effort) is None: ++ effort = "" ++ return {"model_alias": model_alias, "reasoning_effort": effort} + + def _session_model_override_for(self, session_key: Optional[str]) -> Optional[Dict[str, Any]]: + """Return the gateway's session ``/model`` override for *session_key*, if any. +@@ -1801,6 +1843,21 @@ + if runtime_model: + model = runtime_model + ++ stored_session = None ++ if session_id: ++ try: ++ stored_session = self._ensure_session_db().get_session(session_id) ++ except Exception: ++ logger.debug("Could not load API session runtime settings for %s", session_id, exc_info=True) ++ stored_settings = self._session_runtime_settings(stored_session) ++ if route is None and stored_settings["model_alias"] != self._model_name: ++ route = self._resolve_route(stored_settings["model_alias"]) ++ if stored_settings["reasoning_effort"]: ++ from hermes_constants import parse_reasoning_effort ++ parsed_reasoning = parse_reasoning_effort(stored_settings["reasoning_effort"]) ++ if parsed_reasoning is not None: ++ reasoning_config = parsed_reasoning ++ + # Per-client model routing (model_routes config). The route was + # resolved from the request's ``model`` field by the HTTP handler. + # Precedence (highest first): session ``/model`` override → model_routes +@@ -1988,6 +2045,26 @@ + "parent": model_name, + }) + ++ seen_ids = {item["id"] for item in models} ++ try: ++ from hermes_cli.model_switch import _load_direct_aliases ++ ++ for alias, direct in _load_direct_aliases().items(): ++ if alias in seen_ids: ++ continue ++ seen_ids.add(alias) ++ models.append({ ++ "id": alias, ++ "object": "model", ++ "created": now, ++ "owned_by": direct.provider or "hermes", ++ "permission": [], ++ "root": direct.model or alias, ++ "parent": model_name, ++ }) ++ except Exception: ++ logger.debug("Could not enumerate global model aliases", exc_info=True) ++ + return web.json_response({"object": "list", "data": models}) + + async def _handle_capabilities(self, request: "web.Request") -> "web.Response": +@@ -2187,6 +2264,7 @@ + # callers only need to know whether those snapshots exist. + payload["has_system_prompt"] = bool(session.get("system_prompt")) + payload["has_model_config"] = bool(session.get("model_config")) ++ payload.update(APIServerAdapter._session_runtime_settings(session)) + return payload + + @staticmethod +@@ -2287,7 +2365,23 @@ + if len(session_id) > self._MAX_SESSION_HEADER_LEN: + return web.json_response(_openai_error("Session ID too long", code="invalid_session_id"), status=400) + +- model = body.get("model") or self._model_name ++ model = str(body.get("model") or self._model_name).strip() ++ if model != self._model_name and self._resolve_route(model) is None: ++ return web.json_response( ++ _openai_error(f"Unsupported model: {model}", code="unsupported_model"), status=400 ++ ) ++ reasoning_effort = str(body.get("reasoning_effort") or "").strip().lower() ++ from hermes_constants import parse_reasoning_effort ++ if reasoning_effort and parse_reasoning_effort(reasoning_effort) is None: ++ return web.json_response( ++ _openai_error("Unsupported reasoning_effort", code="invalid_reasoning_effort"), status=400 ++ ) ++ model_config = json.dumps({ ++ "api_session_settings": { ++ "model_alias": model, ++ "reasoning_effort": reasoning_effort, ++ } ++ }) + system_prompt = body.get("system_prompt") + if system_prompt is not None and not isinstance(system_prompt, str): + return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400) +@@ -2308,12 +2402,13 @@ + import time as _time + conn.execute( + """INSERT INTO sessions ( +- id, source, model, system_prompt, started_at +- ) VALUES (?, ?, ?, ?, ?)""", ++ id, source, model, model_config, system_prompt, started_at ++ ) VALUES (?, ?, ?, ?, ?, ?)""", + ( + session_id, + "api_server", +- str(model) if model else None, ++ model, ++ model_config, + system_prompt, + _time.time(), + ), +@@ -2372,12 +2467,39 @@ + body, err = await self._read_json_body(request) + if err: + return err +- allowed = {"title", "end_reason"} ++ allowed = {"title", "end_reason", "model", "reasoning_effort"} + unknown = sorted(set(body) - allowed) + if unknown: + return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400) + + db = await self._ensure_session_db_async() ++ if "model" in body or "reasoning_effort" in body: ++ current_settings = self._session_runtime_settings(session) ++ model = str(body.get("model", current_settings["model_alias"]) or "").strip() ++ if not model or (model != self._model_name and self._resolve_route(model) is None): ++ return web.json_response( ++ _openai_error(f"Unsupported model: {model}", code="unsupported_model"), status=400 ++ ) ++ reasoning_effort = str( ++ body.get("reasoning_effort", current_settings["reasoning_effort"]) or "" ++ ).strip().lower() ++ from hermes_constants import parse_reasoning_effort ++ if reasoning_effort and parse_reasoning_effort(reasoning_effort) is None: ++ return web.json_response( ++ _openai_error("Unsupported reasoning_effort", code="invalid_reasoning_effort"), status=400 ++ ) ++ raw_config = session.get("model_config") ++ try: ++ model_config = json.loads(raw_config) if isinstance(raw_config, str) else dict(raw_config or {}) ++ except (TypeError, ValueError): ++ model_config = {} ++ model_config["api_session_settings"] = { ++ "model_alias": model, ++ "reasoning_effort": reasoning_effort, ++ } ++ await asyncio.to_thread( ++ db.update_session_meta, session_id, json.dumps(model_config), model ++ ) + if "title" in body: + try: + await asyncio.to_thread(db.set_session_title, session_id, "" if body["title"] is None else str(body["title"])) +@@ -2443,10 +2565,18 @@ + # SessionDB's native parent_session_id/end_reason visibility model rather + # than inventing a parallel fork store. + await asyncio.to_thread(db.end_session, source_id, "branched") ++ raw_source_config = source.get("model_config") ++ try: ++ source_model_config = ( ++ json.loads(raw_source_config) if isinstance(raw_source_config, str) else raw_source_config ++ ) ++ except (TypeError, ValueError): ++ source_model_config = None + await asyncio.to_thread(db.create_session, + fork_id, + "api_server", + model=source.get("model"), ++ model_config=source_model_config if isinstance(source_model_config, dict) else None, + system_prompt=source.get("system_prompt"), + parent_session_id=source_id, + ) diff --git a/package.json b/package.json index 6b11d41..d91c5f4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "build": "node build-hermes-workspace.mjs", - "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs && node test/hermes-workspace/workspace-actions.test.mjs", + "test": "python3 -m unittest discover -s tests/hermes_workspace -p 'test_*.py' && PYTHONPATH=/opt/hermes /opt/hermes/.venv/bin/python -m unittest tests.hermes_workspace.hermes_api_overlay_test && node test/hermes-workspace/frontend.test.mjs && node test/hermes-workspace/selection-ask.test.mjs && node test/hermes-workspace/follow-up-destination.test.mjs && node test/hermes-workspace/queued-follow-up.test.mjs && node test/hermes-workspace/workspace-actions.test.mjs && node test/hermes-workspace/model-settings.test.mjs", "check:types": "tsc --noEmit", "check:purity": "node scripts/check-ui-purity.mjs", "verify": "npm run build && npm test && npm run check:types && npm run check:purity" diff --git a/src/hermes-workspace/api.js b/src/hermes-workspace/api.js index 42823ac..59c472b 100644 --- a/src/hermes-workspace/api.js +++ b/src/hermes-workspace/api.js @@ -5,19 +5,32 @@ export async function listWorkspaces() { return Array.isArray(payload) ? payload : (payload.workspaces || []); } -export async function createWorkspace(prompt, title = "") { +export async function getModels() { + return requestJson("/api/models"); +} + +export async function createWorkspace(prompt, title = "", model = "hermes-agent", reasoningEffort = "medium") { return requestJson("/api/workspaces", { method: "POST", headers: JSON_HEADERS, - body: JSON.stringify({ prompt, ...(title ? { title } : {}) }), + body: JSON.stringify({ + prompt, + model, + reasoning_effort: reasoningEffort, + ...(title ? { title } : {}), + }), }); } -export async function createStandaloneSession(workspaceId, prompt, x, y, title = "") { +export async function createStandaloneSession( + workspaceId, prompt, x, y, title = "", model = "hermes-agent", reasoningEffort = "medium", +) { return requestJson(`/api/workspaces/${encodeURIComponent(workspaceId)}/nodes`, { method: "POST", headers: JSON_HEADERS, - body: JSON.stringify({ prompt, x, y, ...(title ? { title } : {}) }), + body: JSON.stringify({ + prompt, x, y, model, reasoning_effort: reasoningEffort, ...(title ? { title } : {}), + }), }); } @@ -43,6 +56,14 @@ export async function cancelQueuedFollowUp(queueId) { }); } +export async function updateNodeSettings(nodeId, model, reasoningEffort) { + return requestJson(`/api/nodes/${encodeURIComponent(nodeId)}/settings`, { + method: "PATCH", + headers: JSON_HEADERS, + body: JSON.stringify({ model, reasoning_effort: reasoningEffort }), + }); +} + export async function saveNodeLayout(node) { return requestJson(`/api/nodes/${encodeURIComponent(node.id)}/layout`, { method: "PATCH", diff --git a/src/hermes-workspace/app.js b/src/hermes-workspace/app.js index d9e1c8f..c0dc06a 100644 --- a/src/hermes-workspace/app.js +++ b/src/hermes-workspace/app.js @@ -6,10 +6,12 @@ import { createStandaloneSession, createWorkspace, getHealth, + getModels, getWorkspaceArchive, listWorkspaces, loadWorkspace, queueFollowUp, + updateNodeSettings, saveNodeLayout, streamBranch, streamContinue, @@ -28,11 +30,16 @@ const state = { mode: "canvas", railOpen: false, activeStreams: 0, camera: { x: 96, y: 96, scale: 1 }, layoutPatchCount: 0, selectionByParent: new Map(), queuedByNode: new Map(), unreadNodeIds: new Set(), + models: [{ id: "hermes-agent", label: "Default" }], + reasoningLevels: ["none", "minimal", "low", "medium", "high", "xhigh"], + defaults: { model: "hermes-agent", reasoning_effort: "medium" }, }; const renderer = createMarkdownRenderer({ encodeBase64: encodeUtf8Base64 }); let newDialog = null; let newSessionDialog = null; let mergeDialog = null; +let settingsDialog = null; +let settingsNodeId = null; let pendingSessionPoint = null; let mergeSourceId = null; let selectionAsk = null; @@ -51,10 +58,17 @@ boot().catch(showFatal); async function boot() { installEvents(); setHealth("checking", "Checking Hermes…"); - const [workspaces] = await Promise.all([ + const [workspaces, modelCatalog] = await Promise.all([ listWorkspaces(), + getModels().catch(() => null), getHealth().then(() => setHealth("ready", "Hermes ready")).catch(() => setHealth("offline", "Hermes unavailable")), ]); + if (modelCatalog) { + state.models = modelCatalog.models || state.models; + state.reasoningLevels = modelCatalog.reasoning_levels || state.reasoningLevels; + state.defaults = modelCatalog.defaults || state.defaults; + } + populateSettingsControls(); state.workspaces = workspaces; renderWorkspaceList(); const lastId = safeStorageGet(LAST_WORKSPACE_KEY); @@ -116,11 +130,15 @@ function renderShell() { ready
- +

Enter a Rabbithole

Hermes will turn your question into a branchable document.

+
+ + +
@@ -133,6 +151,10 @@ function renderShell() {

New session here

Start an independent Hermes session at this canvas position.

+
+ + +
@@ -154,6 +176,20 @@ function renderShell() {
+ +
+

Session settings

New turns and branches will use these runtime settings.

+
+ + +
+ +
+ + +
+
+
+ +
+

Delete this window?

+ +
+ + +
+
+
+