OTUS-303 Embed Otus in the sidebar as a tab - #1737
jeslefcourt wants to merge 4 commits into
Conversation
Otus is EarthRanger's field-intelligence chat agent, a separate web app rather than part of this client. The sidebar now carries it as a tab holding it in an iframe, shown only where the server reports an Otus URL in its system status payload. The frame lives outside the sidebar routes and mounts on the tab's first activation, so switching tabs neither remounts it nor loses the conversation. Credentials reach it over a postMessage handshake pinned to the Otus origin: Otus announces itself, the client replies with the site and the access token, and re-sends that payload when the token is renewed. The panel can be dragged wider by pointer or keyboard and remembers its width per user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
🚀 PR Environment Deployed
Access: https://otus-303.dev.pamdas.org |
JoshuaVulcan
left a comment
There was a problem hiding this comment.
Code looks good! Nice clean comm paradigm b/w the parent and child frames.
luixlive
left a comment
There was a problem hiding this comment.
Awesome 💯 Ran a Claude review with context on future plans of augmented prompts with app state awareness so Otus knows what the user is seeing and possibly providing tools to Otus to actually interact with the app. Based on that and our repository standards, Claude came with these items, none of them sound blocking for me, but a couple sound like better user experience.
1. The time slider sits under the panel after a resize
src/utils/map.js:23
calcSidebarPaddingLeft reads the Otus panel width with store.getState() inside a helper. The TimeSlider calls that helper during render but doesn't subscribe to the width. So after the user resizes the panel, its --sidebar-offset stays stale. Example: with the Otus tab open and the time slider showing, drag the panel from 736px to 1200px. The slider stays at an 806px offset and ends up under the panel until something else re-renders it.
The width math (default 736, clamp to the viewport) is also copied from OtusTab, and only the component applies the 512 minimum.
Suggestion: add one selector, e.g. selectOtusTabWidth, that applies the default and the clamp. Use it in OtusTab, the TimeSlider and the padding helper, instead of two copies and a direct store read.
2. The loading overlay covers the Otus header
src/SideBar/OtusTab/index.js:154
The LoadingOverlay is absolutely positioned at top: 0 with height: 100% inside .otusTab.active. It covers the header, including Close, New conversation and Recent, until the iframe fires onLoad. If onLoad never fires, the header stays covered. Keyboard users can still tab to the covered buttons but can't see the focus. The overlay also announces nothing to screen readers.
Suggestion: cover only the frame area below the header, and give the overlay an accessible status message.
3. EarthRanger sign-out doesn't sign Otus out
src/SideBar/OtusTab/index.js:24
OTUS_COMMANDS leaves out the sign-out command. The Otus embed contract defines it for the embedder's own logout, "so the session this frame holds does not outlive the person it was for". Today an EarthRanger sign-out only unmounts the iframe, so the Otus session, which runs on the user's ER token, stays alive until it expires. That matters on a shared machine.
Suggestion: have the sign-out flow post { type: 'otus:command', command: 'sign-out' } (or call an Otus logout) before the shell unmounts.
4. Otus ignores the active profile
src/SideBar/OtusTab/index.js:79
postConnect sends only the access token and site_url. RequestConfigManager adds a USER-PROFILE header whenever a profile is active, but the Otus connect never includes the profile. Switching profiles doesn't reconnect Otus either. So a user working as a (possibly PIN-protected) profile gets Otus answers based on the base account's permissions and data. Future tool actions would have the same mismatch.
Suggestion: add a profile field to the connect message (this needs a matching change in Otus), and reconnect when the selected profile changes.
5. The beta badge fails WCAG AA contrast
src/SideBar/OtusTab/Header/styles.module.scss:53
$ui-cta-primary (#0f865d) on $palette-green-6 (#f5f7f3) gives about 4.25:1. At 0.75rem this counts as normal-size text, which needs 4.5:1.
Suggestion: use a darker text token or a lighter badge background.
6. The panel can grow to cover the whole map
src/SideBar/OtusTab/index.js:29
clampWidth lets the panel grow to window.innerWidth minus the 70px rail, which leaves no map. calcSidebarPaddingLeft then passes a left padding as wide as the whole map to useJumpToLocation's easeTo and to calcClusterZoomPadding's fitBounds. To reproduce: press End on the resize handle (or drag it to the right edge), then jump to a subject or click a cluster. easeTo puts the target off screen, and fitBounds fails ("Map cannot fit within canvas"). The planned Otus "move the map" tools would hit the same problem.
Suggestion: cap the maximum width so some map area always stays visible.
7. The iframe messaging will be hard to extend for tools and app state
src/SideBar/OtusTab/index.js:136
All the postMessage handling lives inside the OtusTab component: the message types, the origin and source checks, the single READY branch, the connect and command sending, and the iframe window ref. There is no protocol version and no way to match a reply to its request. Adding Otus tools (move the map, change filters, show tracks), or sending app state with a prompt, would mean piling MapContext, dispatch and more hard-coded branches into this one component. Replies have no ids, and errors have no way to come back.
Suggestion: before building on this, move the messaging into a dedicated module at the shell level, similar to withSocketConnection. That module would own the frame window and origin, register one handler per message type, and carry a protocol version and request ids. It could switch to a MessageChannel port after READY.
8. The token-renewal effect does almost nothing
src/SideBar/OtusTab/index.js:141
The effect's own comment admits that Otus drops the token while signed in. Otus's onEmbedMessage returns early when it already has a sessionId or a connect in progress. So a renewed token is only used in the rare case where the 8-second timeout falls back to the login form. Readers will assume Otus picks up renewed tokens, but it keeps the old one until its session ends. Then it sends otus:ready again, and the existing listener already handles that.
Suggestion: either remove the effect and isFrameReadyRef, or agree a real token-refresh message with Otus. Long-lived tool sessions will need that eventually.
9. The header can't reflect what Otus is doing
src/SideBar/OtusTab/Header/index.js:32
The Recent button opens and closes the history overlay inside the iframe, but it has no aria-expanded or aria-pressed. Nothing reports the Otus state back, so the header can't show it. Screen reader users hear a plain "Recent, button" and never learn whether the history is open. New conversation and Recent also look enabled while Otus is still connecting. Clicking them then does nothing and gives no feedback, because Otus ignores those commands until its session exists.
Suggestion: have Otus send state events back to the app (session ready, history open), so the header can set aria-expanded and disable its buttons until the session is ready. This needs a change on the Otus side.
10. Weak test assertions
src/GlobalMenuDrawer/index.test.js:128
The new Otus tests assert with toBeDefined() and toBeNull() instead of jest-dom matchers. That breaks the testing rule in CLAUDE.md, and the pattern was copied from an older test in the same file. getByRole(...) never returns undefined, so toBeDefined() checks nothing beyond getByRole's own throw.
Suggestion: use toBeVisible(), and not.toBeInTheDocument() for the negative case.
Minor
- Only the Otus tab link closes the sidebar when clicked again (
onClickActiveOtusLink). If we want that toggle, it belongs on every tab link, not just Otus. - The Otus header uses an
h2, while the other tabs' headers useh3. - A JSX comment in
src/SideBar/index.jsgoes past the 80-column comment limit. - Handlers in
OtusTabaren't in alphabetical order. - No test checks that the loading overlay appears and then goes away.
- If the Otus URL changes,
isLoadingandisFrameReadyRefaren't reset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Derive the Otus panel width from one selector that applies the default and the clamp, and read it from the time slider, the jump-to-location hook and the cluster zoom padding, so the slider follows a resize. - Cap the panel width so some map always stays visible beside it. - Cover only the frame with the loading overlay, and announce it as a status. - Drop the token renewal effect, which Otus ignores while signed in. - Reset the loading state when the Otus URL changes. - Darken the beta badge text to meet WCAG AA contrast. - Expose the panel as a labelled region with an h3, matching the other tabs. - Use jest-dom matchers in the global menu Otus tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the thorough review. Addressed in fdaaec0: 1. Time slider / duplicated width math — new Left for follow-ups since they need a matching change on the Otus side or a design pass: 3 (sign-out), 4 (active profile), 7 (messaging module), 9 (header state). The active-tab toggle on the Otus link is still only on Otus; happy to remove it or extend it to every tab, whichever we prefer. 🤖 Generated with Claude Code |
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
pr-review · core-reviewer
Ticket OTUS-303
Not checked against acceptance criteria: the ticket has an empty description and no comments, and its summary describes a requirements-writing task rather than this implementation. A human should confirm this PR is the intended deliverable for it, or link the ticket that is.
Critical 🔴
None found. The ready/connect handshake pins the origin on send and checks origin, source window and message type on receive; the Otus URL is validated in the duck before it can reach new URL(); the message listener is removed on unmount.
Important 🟡 — addressed in 789a551
Both were found at the previous head (fdaaec00b) and fixed in the commit above:
src/SideBar/OtusTab/index.js— Token was not re-sent to Otus when it was renewed. The PR body said it was, butpostConnectwas only called from theotus:readyhandler. Now ahasConnectedRefis set onreadyand an effect re-postsotus:connectwhenever the token changes while connected; tests cover the re-send and the not-yet-ready case.src/SideBar/OtusTab/index.js— Iframe had nosandbox. It now carriesallow-downloads allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts, leaving top-window navigation blocked. The Otus team should confirm this set is enough for their app.
Suggestions 🟢
src/SideBar/index.js:246— Clicking the active Otus rail link closes the panel, which no other tab does, andonClickActiveOtusLinkre-implements React Router's modifier-key checks. If wanted,to={isOtusTabActive ? '/' : tabPath(TAB_KEYS.OTUS)}gives the same without the handler; the header's Close button already covers closing, so dropping it is also reasonable.src/SideBar/OtusTab/styles.module.scss:40— Addtouch-action: noneto.resizeHandle; otherwise a touch drag on a medium layout is claimed for panning and firespointercancel, reverting the resize.src/SideBar/OtusTab/Header/styles.module.scss:56— The beta badge's resting text colour iscolor.adjustof$ui-cta-primary; the convention reserves derived shades for hover/active/disabled. Use$ui-cta-primarydirectly or add a token. Line 54's$palette-green-6is a primitive where a semantic token would be preferred; worth asking Design.src/selectors/otus/index.js:4— Comment is 88 columns; the convention caps comments at 80.src/SideBar/OtusTab/index.test.js:12— Import blocks sort by first binding, so thefireEventimport belongs abovemockStore. Line 35 assignsElement.prototype.setPointerCaptureinbeforeEachwithout restoring it; preferjest.spyOnwith a restore, or define it once insetupTests.AGENTS.md:325— The new Otus section has no UI or Key files paragraphs like its siblings, and names an env variable that Architecture already covers undersrc/constants/. Either give it the sibling shape or fold it into App Chrome beside the tab table.src/ducks/user-preferences/index.js:38—userPreferencesis localStorage-persisted and not wrapped in the sign-out reset, so the width is per browser, not per user as the PR body says. Matches the other preferences; worth correcting the description.src/common/images/icons/otus.svg— Placeholder glyph, as the author notes. Please link the follow-up ticket for the designed mark so it is tracked before preview tenants see it.
Positive ✅
- Credentials never enter the URL, the handshake is origin-pinned both ways, and the source-window check defeats a same-origin sibling frame. Each rejection path has its own test.
- URL validation lives in the duck, so a bad server value is a warning rather than a render-time throw; relative, scheme-less,
javascript:and empty values are covered. - The resize is careful: pointer capture so the cross-origin frame cannot swallow the drag,
pointer-events: noneon the frame while resizing, persistence only on release, reverts onpointercancel, lost capture and a layout narrowing mid-drag, and a correct window-splitter ARIA pattern. - Keeping the frame outside
Routesbehind first activation is the right trade-off, and the SideBar test proves the frame survives a tab switch. - Padding consumers read the width through one selector, and
useJumpToLocationreads the store at jump time so a resize does not re-render every caller. - All six locales are translated rather than copied, keys are alphabetised, and
I18N_FILES_VERSIONis bumped above develop's.
Not checked: live drag behaviour in a browser (pointerup vs lostpointercapture ordering, touch); Otus-side handling of otus:connect / otus:command and OTUS_EMBED_ORIGINS, and the das OTUS-303 server branch, which are outside this PR; test suite execution (read-only review).
Advisory review from the er-developer plugin. Verify before acting; approval stays with human reviewers.
What does this PR do?
Adds an Otus tab to the sidebar, holding EarthRanger's field-intelligence chat agent in an iframe. The tab appears only where the server reports an Otus URL in its system status payload, which it sends per tenant behind the
otuspreview feature, so a site without Otus sees no change at all.The frame is rendered outside the sidebar routes and mounts on the tab's first activation. A tab switch hides it rather than unmounting it, so the conversation and the per-user sandbox behind it survive. Credentials never travel in the URL: Otus posts
otus:ready, the client replies with the site and the access token asotus:connectpinned to the Otus origin, and re-sends that payload when the token is renewed. The client drives the controls its own header carries (new conversation, recent) withotus:command.The panel can be dragged wider by pointer or keyboard and remembers its width per user. On small layouts, where the icon rail is hidden, the tab is reachable from the global menu.
Evidence
Exercised locally against a local Otus server: the embedded frame loads, completes the handshake and signs in.
Automated checks:
yarn check-i18n-files-versionpasses at 1.68.Relevant link(s)
deploylabel if one would help review.Notes
This is the client half, and it stays invisible until the rest is in place:
dason branchOTUS-303sendsotus_settings.urlon/statusbehind the per-tenantotuspreview feature. That PR is not open yet.OTUS_URLhas to be set on the deployeddas-server, andOTUS_EMBED_ORIGINSon the Otus service has to name this client's origin. Unset, Otus sendsframe-ancestors 'self'and the browser refuses the frame.🤖 Generated with Claude Code