Skip to content

Commit 8dcf8c7

Browse files
committed
feat: add DevTools "Rossum" panel (raw object editor)
A Chrome DevTools panel that detects the API resource behind the inspected Rossum page, shows it as an editable CodeMirror JSON editor, navigates between related resources via link tabs, and PATCHes edits through a diff-confirm flow. - Detection (detect.js) + URL->resource mapping (resourceFromApiUrl.js): detail routes, schema-via-fields, emails->inbox, documents dashboard-> queue/org, read-only list pages, numeric sub-resource links. - Tabbed shell: a permanent default (page) tab (always visible, never closeable), link tabs (Cmd/Ctrl+click + right-click open, drag reorder), capture-phase Cmd+F -> CodeMirror search. - Whole-top-level-key PATCH with diff confirm; reload only on page-tab save; 403/405 read-only fallback; theme-aware DevTools-approx highlighting. - Content preview: non-JSON responses (e.g. documents/{id}/content) render as image/PDF/file-info + Download/Open instead of JSON. - Inline resource-name hints on visible references (dimmed, at line end); the objects fetched for names double as a prefetch cache for instant link-open. Manifest gains devtools_page (no new permissions); build.js adds the two entry points; adds @codemirror/lang-json. Read-only is client-side defense-in-depth; a server-side write-lock remains the ship-blocker before non-dogfood use.
1 parent 1f8fdd7 commit 8dcf8c7

45 files changed

Lines changed: 2984 additions & 5 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,22 @@ Uses **esbuild** to bundle ES modules from `src/` into `dist/`. No other build t
1313
- `npm run build` — clean build into `dist/`
1414
- `npm run dev` — watch mode (JS only; re-run build for CSS/HTML changes)
1515
- `dist/` is the loadable Chrome extension (gitignored)
16-
- `build.js` orchestrates bundling + static asset copying (manifest.json, icons/, popup HTML/CSS, console HTML/CSS)
16+
- `build.js` orchestrates bundling + static asset copying (manifest.json, icons/, popup HTML/CSS, console HTML/CSS, devtools HTML/CSS)
1717

1818
esbuild config: `format: 'iife'`, `minify: true`, `jsxFactory: 'h'`, `jsxFragment: 'Fragment'` (Preact JSX).
1919

2020
## Architecture
2121

22-
Six esbuild entry points:
22+
Eight esbuild entry points:
2323

2424
1. **`src/rossum/index.js`** → content script for Rossum pages
2525
2. **`src/netsuite/index.js`** → content script for NetSuite pages
2626
3. **`src/coupa/index.js`** → content script for Coupa pages
2727
4. **`src/popup/popup.jsx`** → extension popup UI (Preact)
2828
5. **`src/console/index.jsx`** → unified Console page (`console/console.html`, opened via `chrome.tabs.create`) — a left app-switcher rail over three apps: Dataset Management (`src/mdh/`), Audit Log Viewer (`src/audit/`), and Galaxy (`src/galaxy/`, a 3D org birdview)
2929
6. **`src/background/index.js`** → MV3 service worker (`background.js`)
30+
7. **`src/devtools/devtools.js`** → Chrome DevTools registrar (`devtools.html`, creates the "Rossum" panel + forwards `panel.onSearch` to CodeMirror)
31+
8. **`src/devtools/panel.jsx`** → DevTools panel page (`panel.html`)
3032

3133
The background service worker exists for a single job: a content script can't
3234
`chrome.tabs.create` an extension page, so the `dataset-mgmt-suggest` feature
@@ -147,6 +149,19 @@ spec: `docs/superpowers/specs/2026-07-03-inspector-overhaul-design.md`).
147149
- Read-only stance unchanged: the agent's read-only framing is defense-in-depth, and the
148150
server-side write-lock remains the ship-blocker before non-dogfood use.
149151

152+
### DevTools panel (Raw Object Editor) (`src/devtools/`)
153+
154+
A Chrome DevTools panel named **"Rossum"** that displays and edits the API resource backing the current Rossum page. The editor fills the panel with compact font (11px) and no header — the tab itself shows the resource identity. Detected resources (`detect.js` `detectResource`): detail routes — **queue** (`/queues/{id}` + async `/queues/{id}/settings/emails` → `queue.inbox`), **hook** (`/extensions/my-extensions/{id}`), **user** (`/settings/users/{id}`), **schema** (`/settings/field-manager/detail/{id}` and queue **Fields** tab via async `queue.schema` fetch), **engine** (`/automation/engines/{id}`), **rule** (`/queues/{q}/settings/rules/{ruleId}/detail` — matched *before* the queue row, first-match-wins), **annotation** (`/document/{id}` and `/annotation/{id}` → `/api/v1/annotations/{id}`); and read-only collection pages — **Hooks** (`/extensions/my-extensions`), **Users** (`/settings/users`), **Labels** (`/settings/labels`), **Organization Groups** (part of `READONLY_COLLECTIONS`, always non-editable). Additional: a **queue** from `/documents?filtering=…&level=queue`, and `/documents?level=all` resolves to **organization** (via `GET /api/v1/organizations` → `results[0].url`). Links open via **Cmd/Ctrl-click** or right-click **"Open in new tab"**, reaching any Rossum API URL including workspace/org and sub-resources (e.g. annotation `content`, read-only). For annotations the panel edits the **annotation object** (metadata/status/labels) via PATCH — datapoint **content** is NOT edited here (that needs the content-operations API). Resource identity uses `keyOf(apiPath)` so sub-resources (different API paths) open as distinct tabs; `readOnly` descriptor flag marks non-editable resources. 404 shows a clearer message (out-of-org, support-access user, or deleted).
155+
156+
- **Registrar & auth flow**`devtools.js` creates the panel; `panel.jsx` is the panel page. Auth: the panel uses `chrome.devtools.inspectedWindow.eval` to read `{token, domain, pathname, search}` from the inspected Rossum tab's main-world context (no storage staging needed) and re-polls for SPA navigation (`inspected.js` `startBridge`, dedup keyed on domain|pathname|search|token — `search` is included so `/documents?level=all` vs `?…&level=queue` on the same path re-detect). Panel calls `${domain}/api/v1/…` with `Token` auth (reuses extension's existing `host_permissions`). Self-gated: always available on Rossum pages, no popup toggle or experimental unlock.
157+
- **In-panel tabs** — one permanent **default (page) tab** (`.rawjson-tab--page`, visually distinct) follows the inspected page, is pinned first, and is **always visible and never closeable** (seeded at store load via `ensurePageTab()`; `syncPageTab` never drops it; `closeTab`/`closeOtherTabs` preserve it). When no resource is detected it becomes resource-less (labelled "Page") and its **body shows the "Open a Rossum queue, hook, user, …" hint** (there is no separate no-tabs empty state). **Cmd/Ctrl-click** or right-click a Rossum API URL opens a **link tab** (closeable, reorderable via drag-and-drop, pinned after the root via `store.moveTab`). Tab state lives in Preact `store.tabs` / `store.activeId`. Right-click a tab → context menu (`store.tabMenu`): **"Close"** (link tabs only — never offered for the default tab) + **"Close Other Tabs"** (`closeOtherTabs`, keeps the clicked tab *and* the default tab); right-clicking the sole default tab opens no menu.
158+
- **Core UI** — CodeMirror `JsonCodeEditor` (basicSetup + `@codemirror/lang-json`). Theme-aware — `theme.js` `isDark()` (DevTools `chrome.devtools.panels.themeName`, `prefers-color-scheme` fallback) drives both the CodeMirror syntax colors (custom `HighlightStyle` approximating DevTools, light+dark) and the panel chrome (`data-theme` on the root; `panel.css`). **Cmd/Ctrl-F** is captured at the window capture phase (`keydown` listener) → focus + `openSearchPanel` on `store.views.active` (the DevTools native search bar does not appear; `search.js` was removed).
159+
- **Content preview** — when a resource's body is NOT JSON (by response `Content-Type`; e.g. `documents/{id}/content` returns the original file), `api.getResource` returns a blob descriptor and the tab shows `PreviewPane` instead of the editor: `image/*``<img>`, `application/pdf``<iframe>` (NOT `<embed>`/`<object>` — the extension-page CSP `object-src 'self'` blocks a `blob:` object), else a file-info card; every preview has **Download** + **Open in browser tab** (both use the `blob:` object URL — a direct `${domain}${apiPath}` nav would 401). Object URL is created/revoked with the component lifecycle. Preview tabs are read-only (no Save). `contentMeta.js` = pure `extFor`/`formatBytes`/`filenameFrom`. `getJson` is kept for JSON-guaranteed calls (`via` resolution + save re-fetch).
160+
- **Inline resource-name hints + prefetch cache** — the editor annotates every visible `/api/v1/<collection>/<id>` reference (scalar fields AND array elements) with the target object's **name**, dimmed at line end (`cmNames.js` ViewPlugin `.rawjson-name`; debounced `refreshNames` effect as names arrive). `nameResolve.js` `makeNameResolver(getJson)` resolves visible links (in-flight dedupe, ~6 concurrency cap, negative-cached errors) via a session `resourceCache.js` (`apiPath → {name, obj, at, status}`, ~200-entry cap). `pickName`: user → `username (first last)` (else `username`, else `email`); documents → `original_file_name`; else `.name`. Because resolving a name fetches the whole object, `resourceCache` doubles as a **prefetch cache**: `loadResource` reuses `deps.getCached` (≤60s `getFresh`) to open a link tab instantly with no network call, and warms `deps.putCached` on any JSON load. Cache is in-memory only (never persisted). Sub-resources (`…/content`, read-only) are never name-resolved.
161+
- **Editing & Save** — edits generate a diff (pure `diff.js` logic) shown in a `DiffConfirm` overlay; user accepts → `PATCH` sent to the API → on success, **reloads the inspected page** (`inspectedWindow.reload`). No Undo. Reuses pure modules `detect.js` (URL→resource descriptor `{type,id,apiPath,label,readOnly}`), `diff.js` (`buildPatchBody`/`diffObjects` — diff-shown == diff-sent), Preact signals `store.js`, and `actions.js` (`loadResource`/`requestDiff`/`saveResource`, dependency-injected with `{getJson, patch}`, with a resource-change guard so a mid-save SPA nav never writes the wrong resource); components `DiffConfirm` and the lean CodeMirror `JsonCodeEditor` are in `src/devtools/`.
162+
- **Fallback** — 403/405 (read-only org or insufficient perms) → the editor is non-editable and Save is hidden (view-only). 404 displays context (out-of-org, support-access user, resource deleted).
163+
- **Nothing leaves the browser** — resource JSON is fetched + displayed + patched inline; no contents persisted, no sync to background/storage.
164+
150165
### Coupa content script
151166

152167
Two strategies: JSON metadata extraction from `#initial_full_react_data` script tag (React pages like invoices) and DOM attribute extraction with `IGNORE_S_CLASSES` filtering (Rails pages like POs).
@@ -157,7 +172,7 @@ Preact JSX. Detects current site (Rossum/NetSuite/Coupa) and dims irrelevant sec
157172

158173
## Chrome Storage Keys
159174

160-
- Feature toggles: `schemaAnnotationsEnabled`, `expandFormulasEnabled`, `expandReasoningFieldsEnabled`, `scrollLockEnabled`, `resourceIdsEnabled`, `annotateForMeEnabled`, `netsuiteFieldNamesEnabled`, `coupaFieldNamesEnabled` (the short-lived `inspectAnnotationEnabled` toggle was removed 2026-07-04 along with the floating button; any stored value is orphaned)
175+
- Feature toggles: `schemaAnnotationsEnabled`, `expandFormulasEnabled`, `expandReasoningFieldsEnabled`, `scrollLockEnabled`, `resourceIdsEnabled`, `annotateForMeEnabled`, `netsuiteFieldNamesEnabled`, `coupaFieldNamesEnabled` (the short-lived `inspectAnnotationEnabled` toggle was removed 2026-07-04 along with the floating button, and the in-page `rawObjectEditorEnabled` toggle was removed 2026-07 with the in-page Raw Object Editor surface; any stored values are orphaned)
161176
- Experimental unlock: `experimentalUnlocked` — flipped by 5 quick clicks on the popup's version hash; reveals the popup's Experimental section AND is the second half of the `annotateForMeEnabled` double-gate (`isAnnotateEnabled` in `src/rossum/features/annotate-for-me.js`): the Annotate-for-me feature injects only when BOTH are true
162177
- Console staging auth: `consoleAuth_<uuid>` (single-use, 24h TTL, removed on first read; carries `app` + optional DS pipeline prefill)
163178
- Console state: `consoleActiveApp` — per-tab (see MDH state below: session-first read with a `chrome.storage.local` seed)

build.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const chromeVersion = `${chromeMajor}.${chromeMinor}`;
1313

1414
rmSync('dist', { recursive: true, force: true });
1515

16-
for (const dir of ['dist/popup', 'dist/icons', 'dist/console']) {
16+
for (const dir of ['dist/popup', 'dist/icons', 'dist/console', 'dist/devtools']) {
1717
mkdirSync(dir, { recursive: true });
1818
}
1919

@@ -28,6 +28,9 @@ cpSync('src/popup/popup.html', 'dist/popup/popup.html');
2828
cpSync('src/popup/popup.css', 'dist/popup/popup.css');
2929
cpSync('src/console/console.html', 'dist/console/console.html');
3030
cpSync('src/console/console.css', 'dist/console/console.css');
31+
cpSync('src/devtools/devtools.html', 'dist/devtools/devtools.html');
32+
cpSync('src/devtools/panel.html', 'dist/devtools/panel.html');
33+
cpSync('src/devtools/panel.css', 'dist/devtools/panel.css');
3134

3235
const options = {
3336
entryPoints: {
@@ -37,6 +40,8 @@ const options = {
3740
'popup/popup': 'src/popup/popup.jsx',
3841
'console/console': 'src/console/index.jsx',
3942
'background': 'src/background/index.js',
43+
'devtools/devtools': 'src/devtools/devtools.js',
44+
'devtools/panel': 'src/devtools/panel.jsx',
4045
},
4146
bundle: true,
4247
minify: true,

manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@
4444
"48": "icons/48-blue-crunch.png",
4545
"128": "icons/128-blue-crunch.png"
4646
}
47-
}
47+
},
48+
"devtools_page": "devtools/devtools.html"
4849
}

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"dependencies": {
2323
"@codemirror/lang-javascript": "^6.2.5",
24+
"@codemirror/lang-json": "^6.0.2",
2425
"@codemirror/theme-one-dark": "^6.1.3",
2526
"@preact/signals": "^2.9.2",
2627
"codemirror": "^6.0.2",

src/devtools/DiffConfirm.jsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// src/devtools/DiffConfirm.jsx
2+
import { h } from 'preact';
3+
import { diffObjects } from './diff.js';
4+
5+
export default function DiffConfirm({ original, edited, saving, onConfirm, onCancel }) {
6+
const d = diffObjects(original, edited);
7+
const empty = d.leaves.length === 0 && d.removed.length === 0;
8+
return (
9+
<div class="rawjson-diff-overlay">
10+
<div class="rawjson-diff-card">
11+
<h4 class="rawjson-diff-title">Review changes</h4>
12+
{empty ? (
13+
<div class="rawjson-diff-empty">No changes to save.</div>
14+
) : (
15+
<div class="rawjson-diff-list">
16+
{d.leaves.map((l) => (
17+
<div class={`rawjson-diff-leaf rawjson-diff-${l.kind}`}>
18+
<code class="rawjson-diff-path">{l.path}</code>
19+
<span class="rawjson-diff-before">{l.kind === 'added' ? '—' : JSON.stringify(l.before)}</span>
20+
<span class="rawjson-diff-arrow"></span>
21+
<span class="rawjson-diff-after">{l.kind === 'removed' ? '—' : JSON.stringify(l.after)}</span>
22+
</div>
23+
))}
24+
</div>
25+
)}
26+
{d.removed.length ? (
27+
<div class="rawjson-diff-removed-warn">
28+
Removed top-level keys are NOT applied (PATCH can't delete keys): {d.removed.join(', ')}
29+
</div>
30+
) : null}
31+
<div class="rawjson-diff-actions">
32+
<button class="rawjson-cancel" onClick={onCancel} disabled={saving}>Cancel</button>
33+
<button class="rawjson-confirm" onClick={onConfirm} disabled={saving || empty}>
34+
{saving ? 'Saving…' : 'Confirm & save'}
35+
</button>
36+
</div>
37+
</div>
38+
</div>
39+
);
40+
}

src/devtools/JsonCodeEditor.jsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { h } from 'preact';
2+
import { useEffect, useRef } from 'preact/hooks';
3+
import { EditorView, basicSetup } from 'codemirror';
4+
import { EditorState } from '@codemirror/state';
5+
import { json } from '@codemirror/lang-json';
6+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
7+
import { tags } from '@lezer/highlight';
8+
import * as store from './store.js';
9+
import { isDark } from './theme.js';
10+
import { rossumLinks } from './cmLinks.js';
11+
import { rossumNames } from './cmNames.js';
12+
import { resolver } from './nameResolve.js';
13+
14+
// Approximate DevTools' JSON/source palette (exact tokens aren't exposed to
15+
// extension panels — only the theme name). Tune in dogfood.
16+
const lightHL = syntaxHighlighting(HighlightStyle.define([
17+
{ tag: tags.propertyName, color: '#881391' },
18+
{ tag: tags.string, color: '#c41a16' },
19+
{ tag: tags.number, color: '#1c00cf' },
20+
{ tag: tags.bool, color: '#0842a0' },
21+
{ tag: tags.null, color: '#808080' },
22+
{ tag: tags.keyword, color: '#881391' },
23+
]));
24+
const darkHL = syntaxHighlighting(HighlightStyle.define([
25+
{ tag: tags.propertyName, color: '#5db0d7' },
26+
{ tag: tags.string, color: '#f29766' },
27+
{ tag: tags.number, color: '#9980ff' },
28+
{ tag: tags.bool, color: '#569cd6' },
29+
{ tag: tags.null, color: '#808080' },
30+
{ tag: tags.keyword, color: '#c586c0' },
31+
]));
32+
// Editor surface inherits the panel's theme-aware background (no oneDark dark surface).
33+
const surfaceTheme = EditorView.theme({
34+
'&': { backgroundColor: 'transparent', color: 'var(--fg)' },
35+
'.cm-gutters': { backgroundColor: 'transparent', color: '#888', border: 'none' },
36+
'.cm-activeLine': { backgroundColor: 'rgba(128,128,128,0.08)' },
37+
'.cm-activeLineGutter': { backgroundColor: 'transparent' },
38+
});
39+
40+
export default function JsonCodeEditor({ tabId, onFollowLink, onContextLink }) {
41+
const parentRef = useRef(null);
42+
const viewRef = useRef(null);
43+
// True while WE programmatically push an external buffer change into the view,
44+
// so the updateListener can distinguish that from a real user edit and NOT
45+
// re-mark the store dirty (the sync fires synchronously inside view.dispatch).
46+
const syncingRef = useRef(false);
47+
const tab = store.tabs.value.find((t) => t.id === tabId) || null;
48+
const buffer = tab ? tab.buffer : '';
49+
const readOnly = tab ? tab.readOnly : false;
50+
51+
useEffect(() => {
52+
const listener = EditorView.updateListener.of((u) => {
53+
if (u.docChanged && !syncingRef.current) {
54+
store.patchTab(tabId, { buffer: u.state.doc.toString(), dirty: true });
55+
}
56+
});
57+
const extensions = [
58+
basicSetup,
59+
json(),
60+
...(onFollowLink ? [rossumLinks(onFollowLink, onContextLink)] : []),
61+
rossumNames(resolver.nameFor, resolver.ensure),
62+
listener,
63+
EditorView.editable.of(!readOnly),
64+
];
65+
extensions.push(isDark() ? darkHL : lightHL, surfaceTheme);
66+
const view = new EditorView({
67+
state: EditorState.create({ doc: buffer, extensions }),
68+
parent: parentRef.current,
69+
});
70+
viewRef.current = view;
71+
store.views.active = view;
72+
return () => {
73+
view.destroy();
74+
store.views.active = null;
75+
};
76+
// eslint-disable-next-line react-hooks/exhaustive-deps
77+
}, []);
78+
79+
// Reflect EXTERNAL buffer writes (load/save) back into the editor, without
80+
// tripping the dirty flag (guarded by syncingRef).
81+
useEffect(() => {
82+
const view = viewRef.current;
83+
if (!view) return;
84+
const cur = view.state.doc.toString();
85+
if (buffer !== cur) {
86+
syncingRef.current = true;
87+
try {
88+
view.dispatch({ changes: { from: 0, to: cur.length, insert: buffer } });
89+
} finally {
90+
syncingRef.current = false;
91+
}
92+
}
93+
}, [buffer]);
94+
95+
let parseError = null;
96+
try { JSON.parse(buffer); } catch (e) { parseError = e.message; }
97+
98+
return (
99+
<div class="rawjson-raw">
100+
<div class="rawjson-cm" ref={parentRef}></div>
101+
{parseError ? <div class="rawjson-parse-error">Invalid JSON: {parseError}</div> : null}
102+
</div>
103+
);
104+
}

0 commit comments

Comments
 (0)