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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions extensions/open-in-workspace/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Open in Workspace — Hermes WebUI Extension

Adds an **"Open in workspace"** button to `read_file` tool cards. When you click it, the file opens in the workspace file-tree panel — the same behavior as clicking an artifact link.

## How it works

- Watches for new tool-card-row elements with `data-tool-kind="read"`
- Extracts the file path from `row._tcData.args` (or the rendered args section)
- Injects a Lucide folder-open button inside `.tool-card-detail`
- Uses `MutationObserver` so it works on dynamically-rendered cards

## Install

From **Settings → Extensions → Gallery**, find **Open in Workspace** and click Install.

Or configure manually:

```bash
# Clone or copy the extension files, then:
export HERMES_WEBUI_EXTENSION_DIR=/path/to/open-in-workspace-ext
export HERMES_WEBUI_EXTENSION_MANIFEST=manifest.json
./start.sh
```
7 changes: 7 additions & 0 deletions extensions/open-in-workspace/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "open-in-workspace",
"name": "Open in Workspace",
"description": "Adds an 'Open in workspace' button to read_file tool cards, letting you click to reveal the file in the workspace file-tree panel.",
"scripts": ["open-in-workspace.js"],
"stylesheets": []
}
129 changes: 129 additions & 0 deletions extensions/open-in-workspace/open-in-workspace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
(function () {
'use strict';

if (document.getElementById('ext-open-in-workspace--loaded')) return;
var MARKER = document.createElement('meta');
MARKER.id = 'ext-open-in-workspace--loaded';

/* ── CSS injected via plain <style> block ── */
var style = document.createElement('style');
style.textContent =
'.ext-ws-open-wrap{padding:4px 12px 8px}' +
'.ext-ws-open-btn{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:0;opacity:.7;display:inline-flex;align-items:center;gap:3px;white-space:nowrap}' +
'.ext-ws-open-btn:hover{opacity:1}' +
'.ext-ws-open-btn svg{width:12px;height:12px;flex-shrink:0}';
document.head.appendChild(style);

/* ── SVG icon (Lucide folder-open, small) ── */
var FOLDER_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M2 6a2 2 0 0 1 2-2h3.28a2 2 0 0 1 1.82 1.07l1.8 3.87A2 2 0 0 0 12.72 11H20a2 2 0 0 1 2 2v5"/></svg>';

/* ── Inject button into a single tool-card-row ── */
function injectButton(row) {
if (row.getAttribute('data-ext-ws-open')) return;
var tcData = row._tcData;
if (!tcData || !tcData.args) return;

var path =
tcData.args.path ||
tcData.args.file_path ||
tcData.args.file ||
tcData.args.target ||
tcData.args.name;
if (!path || typeof path !== 'string') return;

/* Make sure we don't re-process */
row.setAttribute('data-ext-ws-open', '1');

/* Find the tool-card detail container or create a fallback */
var detail = row.querySelector('.tool-card-detail');
if (detail) {
var wrap = document.createElement('div');
wrap.className = 'ext-ws-open-wrap';
var btn = document.createElement('button');
btn.className = 'ext-ws-open-btn';
btn.innerHTML = FOLDER_ICON;
btn.appendChild(document.createTextNode(' Open in workspace'));
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (typeof openArtifactPath === 'function') {
openArtifactPath(path);
}
});
wrap.appendChild(btn);
detail.appendChild(wrap);
} else {
/* Cards without .tool-card-detail (no snippet/args) — add below the card */
var card = row.querySelector('.tool-card');
if (card) {
var wrap = document.createElement('div');
wrap.className = 'ext-ws-open-wrap';
wrap.style.padding = '2px 12px 8px';
var btn = document.createElement('button');
btn.className = 'ext-ws-open-btn';
btn.innerHTML = FOLDER_ICON;
btn.appendChild(document.createTextNode(' Open in workspace'));
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (typeof openArtifactPath === 'function') {
openArtifactPath(path);
}
});
wrap.appendChild(btn);
card.appendChild(wrap);
}
}
}

/* ── Observe the entire chat for new tool-card-rows ── */
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
if (!added || added.length === 0) continue;
for (var j = 0; j < added.length; j++) {
var node = added[j];
if (node.nodeType !== 1) continue;
/* Direct match */
if (
node.matches &&
node.matches('.tool-card-row[data-tool-kind="read"]')
) {
injectButton(node);
}
/* Check children */
if (node.querySelectorAll) {
var cards = node.querySelectorAll(
'.tool-card-row[data-tool-kind="read"]'
);
for (var k = 0; k < cards.length; k++) {
injectButton(cards[k]);
}
}
}
}
});

/* ── Also process existing cards on startup ── */
function processExisting() {
var existing = document.querySelectorAll(
'.tool-card-row[data-tool-kind="read"]'
);
for (var i = 0; i < existing.length; i++) {
injectButton(existing[i]);
}
}

/* ── Start observing after DOM is ready ── */
function start() {
document.head.appendChild(MARKER);
var chat = document.querySelector('main') || document.body;
observer.observe(chat, { childList: true, subtree: true });
processExisting();
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();
Loading