PT-3958: Configure simple 3-column layout with dispatched editor routing#2272
PT-3958: Configure simple 3-column layout with dispatched editor routing#2272jolierabideau wants to merge 3 commits into
Conversation
- Add `simpleLayout` to the docking framework: a 3-column dock layout (home / scripture editor / new-tab) used when `platform.interfaceMode` is `'simple'`. - Wire the new layout through web-view.service-host and the platform-dock-layout component. - Fix Scripture Editor startup errors that surfaced under the simple layout (empty-projectId path). - Replace the hardcoded empty-editor id lookup with a shape lookup. - Extract editor-open dispatch into a pure `resolveOpenEditorDispatch` helper that centralizes the simple-mode "every open lands in the editor column" invariant and the power-mode empty-editor probe. - Cosmetic: use the correct Tailwind classnames in the editor web view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Key simple-mode editor dispatch on (projectId, isReadOnly) so editable Scripture Editors and read-only Resource Viewers no longer collide when both target the same project; add 4 tests covering the distinction in simple mode plus a confirmation that power mode ignores isReadOnly. - Document the simple-mode override behavior on openScriptureEditor and openResourceViewer JSDoc, and document the new timeout/rejection contract on getSelection. - Tighten resolveOpenEditorDispatch's interfaceMode parameter type to 'simple' | 'power' to match the setting's actual type. - Alphabetize the new emptyState_noProject key in localizedStrings.json (en and es) and drop the trailing period for short-label consistency. - Add a comment that loadLayout reads platform.interfaceMode once at startup; remove the dead-end TODO in the same function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-simple-layout-and-dispatch
katherinejensen00
left a comment
There was a problem hiding this comment.
These changes to support a three column layout look really helpful, Jolie! Thank you for making them! Just a few things (mostly from Claude) to look at.
@katherinejensen00 reviewed 12 files and all commit messages, and made 10 comments.
Reviewable status: all files reviewed, 8 unresolved discussions (waiting on jolierabideau).
extensions/src/platform-scripture-editor/src/main.ts line 683 at r1 (raw file):
}, async getSelection() { // If we already have a selection, return it directly.
## 1. `getSelection()` — rejection only reaches the first caller
After the 10s timeout fires and `firstSelectionAsync` rejects, `hasSettled` becomes `true`. Any subsequent call to `getSelection()` will skip the `return firstSelectionAsync.promise` line and fall through to `return currentSelection` — which resolves to `undefined`. So the rejection only surfaces to the first concurrent caller.
The JSDoc now says "rejects if none arrives in that window — callers should be prepared to handle a rejected promise as well as a resolved `undefined`." That technically covers both cases, but is this the intended contract? Callers who retry after the timeout will silently get `undefined` instead of another rejection.
---extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.ts line 453 at r1 (raw file):
if (interfaceMode === 'simple') { // Simple mode invariant: every Scripture Editor open lands in the editor column. The // caller-supplied `existingTabIdToReplace` is intentionally ignored — e.g., the column 3
NIT: "The column 3" sounds a tiny bit awkward. Could this be "the last column" or "the third column" or just "column 3"
extensions/src/platform-scripture-editor/src/platform-scripture-editor.utils.test.ts line 423 at r1 (raw file):
'caller-supplied-tab-id', ); // Simple-mode invariant: opens land in the editor column. Caller's tab is not the editor —
NIT: What does "opens land in the editor column" mean? Is it opening space or something else? It's just a test, but it is confusing.
lib/papi-dts/papi.d.ts line 3131 at r1 (raw file):
* service is refactored to split the code between processes. The only reason this is passed from * `platform-dock-layout.component.tsx` is that we cannot import `testLayout` here since this * service is currently all shared code. Refactor should happen in PT-2799
Thanks for updating this work item number!
src/renderer/components/docking/simple-layout.data.ts line 7 at r1 (raw file):
// Using `as` here simplifies type changes. /* eslint-disable no-type-assertion/no-type-assertion */ export const simpleLayout: LayoutBase = {
NIT ```markdown
3. Redundant export default alongside the named export
All callers (the component and the test) use the named import { simpleLayout }. The default export on line 733 goes unused and leaves a dangling API surface — suggest removing it.
src/renderer/components/docking/simple-layout.data.ts line 65 at r1 (raw file):
tabType: TAB_TYPE_WEBVIEW, data: { // TODO PT-3962: Replace with Bible texts tab webViewType when implemented
## 2. Column 3 has two tabs with identical `webViewType`
Both column 3 placeholder tabs render the same `'platformGetResources.newTab'` component — they're visually indistinguishable at runtime. Is it necessary for both to be in the layout now, or could the second be deferred until PT-3963 actually lands? Also minor: the test asserts `.toContain('platformGetResources.newTab')` but doesn't assert the count, so the duplication is invisible in test output.
---src/renderer/components/docking/simple-layout.data.test.ts line 11 at r1 (raw file):
vi.mock('../../../shared/services/logger.service'); describe('Dock Layout Component', () => {
NIT
## 4. Test describe label is misleading
The outer `describe('Dock Layout Component', ...)` suggests this tests a React component, but `simple-layout.data.ts` is pure data. Failed test output would print `Dock Layout Component > simpleLayout > ...` which is confusing. Suggest renaming to `'simpleLayout data'` or `'simple-layout.data'`.
---src/renderer/services/web-view.service-host.ts line 649 at r1 (raw file):
} // Startup load — pick the layout based on interface mode. This runs once at startup; changes
Question for you. Claude pointed out that "In simple mode, saveLayout still fires whenever the layout changes (e.g., user resizes a column). That writes to DOCK_LAYOUT_KEY in localStorage. But on the next startup in simple mode, the code loads dockLayoutVar.simpleLayout (the static definition), not localStorage. This means column resizes are ephemeral in simple mode — they reset on every restart."
Is that intentional behavior? It sounds like it could be, just checking
src/renderer/services/web-view.service-host.ts line 653 at r1 (raw file):
// mode switcher exists yet). const interfaceMode = await settingsService.get('platform.interfaceMode'); const layoutToLoad =
## 5. Simple-mode user dock changes are silently discarded on restart
`onLayoutChange` still fires when the user drags tabs in simple mode and writes to `DOCK_LAYOUT_KEY`. But on next startup that stored value is ignored — `simpleLayout` is always loaded fresh. If the intent is to enforce a fixed layout, consider suppressing `onLayoutChange` storage writes in simple mode so stale data doesn't accumulate in storage and the intent is explicit in the code.
Code Review Summary
Branch: pt-3958-simple-layout-and-dispatch
Base: origin/main
Date: 2026-05-14
Review model: Claude Opus 4.7
Files changed: 11
Overview
This PR delivers the 10Simple workspace foundation called out in T1 of the saroj-studies-implementation-spec: a fixed 3-column layout (
[Model Text | Scripture Editor | Resources & Tools]) loaded whenplatform.interfaceMode === 'simple'. It also adds the editor-dispatch logic that forces a single Scripture Editor tab in the designated editor column at a time. Downstream work (T3+:addedByRole/addedByUsermetadata, Bible Texts tab, Commentaries tab,platform-get-resourceschanges) is intentionally out of scope.Two related but at-first-glance unrelated changes are bundled in:
firstSelectionAsyncis now lazy-constructed. Before this PR the editor was rarely opened without aprojectId; in simple mode the layout opens the editor empty by default, which previously triggered the eager 10sAsyncVariabletimer with no listener and produced an unhandled rejection in startup logs. MakingfirstSelectionAsynclazy fixes that bug. ThegetSelection()JSDoc was updated to document the timeout/rejection contract (it now rejects after 10s if no first selection arrives, rather than resolvingundefinedfrom an already-timed-out variable).projectId. A follow-up PR will auto-open the user's last sent/received project, so this state should become rare in practice.API Changes
shared/models/docking-framework.model(internal, not re-exported via@papi/): addedsimpleLayout: LayoutInfofield toPapiDockLayouttype.lib/papi-dts/papi.d.ts: auto-regenerated to mirror the above.extensions/src/platform-scripture-editor/src/types/platform-scripture-editor.d.ts:'platformScriptureEditor.openScriptureEditor'and'platformScriptureEditor.openResourceViewer''sexistingTabIdToReplaceparameter now notes that the parameter is honored in power mode but ignored in simple mode.getSelection()now documents the 10s wait and rejection-on-timeout contract.@papi/module exports,lib/platform-bible-react/,lib/platform-bible-utils/, or other extension.d.tsdeclarations.Findings
Critical — Must address before merge
None.
Important — Should address before merge
resolveOpenEditorDispatchfiltered only bywebViewType === SCRIPTURE_EDITOR_WEBVIEW_TYPE, so opening a Resource Viewer for project X while an editable Scripture Editor for project X was open returnedfocus-existingon the wrong view. (fixed during review: addedisReadOnlyto dispatch input, derived fromstate?.isReadOnlyper editor def;focus-existingnow requires bothprojectIdANDisReadOnlyto match. Added 4 new test cases covering the editable/readonly distinction in simple mode and a confirmation that power mode ignoresisReadOnly.)existingTabIdToReplacedidn't note simple-mode behavior — Callers (e.g.,platform-get-resourcespassing its NewTab id) couldn't reliably reason about the parameter in simple mode. (fixed during review: bothopenScriptureEditorandopenResourceViewerJSDocs now state that the parameter is honored in power mode but ignored in simple mode.)emptyState_noProjectkey was inserted betweenerror_*keys (emptyStatesorts beforeerror), violating the alphabetization rule in the style guide. (fixed during review: moved the new key to the correct alphabetical position in bothenandesblocks.)Empty state is a dead-end for the user — "No project selected" message offers no path forward (no link, button, or hint). For Saroj in simple mode this is likely confusing.(A follow-up PR will auto-open the user's last sent/received project on startup, so this empty state should become rare. Author chose to address there rather than wire a guided action into this PR.)Spanish translation source — Was the new Spanish string(Author confirmed the translation was machine-translated. Flag for the localization team to review when they next pass over this file.)"Ningún proyecto seleccionado"translated with the localization team?Minor — Consider
getSelection()JSDoc didn't note the new timeout/rejection contract (fixed during review: expanded JSDoc to describe lazy first-selection wait, 10s timeout, and explicit rejection behavior so callers can prepare for bothundefinedand rejected-promise outcomes.)interfaceModeparameter typed'simple' | 'power' | undefined— Setting type is'simple' | 'power'with default'simple'; theundefinedbranch is unreachable. (fixed during review: tightened parameter type to'simple' | 'power'and updated the JSDoc accordingly.)web-view.service-host.tsleft as dead-end documentation (fixed during review: removed the TODO comment; kept theif (layout)branch since it remains semantically valid for an explicit override even if no callers use it today.)loadLayoutreadsplatform.interfaceModeonly at startup — Toggling at runtime won't switch layouts. (fixed during review: added a code comment explaining this is intentional since no runtime mode switcher exists yet.)Placeholder webViewType strings could be extracted to named constants in(Author chose to defer; placeholder constants will be touched when the follow-up tickets PT-3961/3962/3963 land.)simple-layout.data.ts(Author explained: the eager 10s timer fired with no caller listening because nofirstSelectionAsyncrefactor appears unrelated to PR scopegetSelection()caller exists during the pre-project window, producing unhandled rejections in startup logs. The bug is directly caused by this PR's new empty-editor case in simple mode, so it's on-topic. See the Overview for the explicit linkage included in this PR description.)Test hardcodes(On inspection, the mock exists only to satisfy module-load whenTAB_TYPE_WEBVIEW: 'webView'mock valuesimple-layout.data.tsimports fromweb-view.component.tsx. No assertion in the test depends on the mocked value, so staleness risk is zero. Would only be worth addressing if the constant were extracted to a tiny shared module to drop the mock entirely.)(Author will address in a follow-up issue; matches the file's current pattern of no existing tests.)loadLayoutinterface-mode branching has no tests(Author explained:simple-layout.data.tsplacement diverges fromtest-layout.data.tssimple-layout.data.tsis production code used wheninterfaceMode === 'simple', not test code, so it correctly lives next toplatform-dock-layout.component.tsxrather than undersrc/renderer/testing/.)Empty state UI is a bare string with no semantic wrapper or visual treatment(Author chose to keep consistent with the existingbookNotFound*branches in the same render path. Combined with the auto-open follow-up making the empty state rare, the styling is left as is for this PR.)enandesso the string reads as a status label rather than a sentence.)Column sizes hardcoded(Author will discuss column sizing and resize behavior with UX and address in a follow-up issue. See Suggested Review Focus for the open UX question.)1:2:1may approach the 300px-per-tab responsive floor on smaller windowsTemplate Propagation
Shared Regions Modified
None.
Extension Config Changes
None.
Positive Observations
resolveOpenEditorDispatchis a pure function with thorough TSDoc and (post-review-fix) 14 unit tests covering each branch in both simple and power modes, including the new(projectId, isReadOnly)-keyed focus-existing logic.OpenEditorDispatchtagged-union type follows the style guide's preference for string unions over interdependent booleans.simpleLayoutuses kebab-case filename, follows the existingtestLayoutpattern, and ships with a colocated.test.tsthat asserts the structural invariants (3 columns, vertical boxes, unique tab ids, expectedwebViewTypeplaceholders).papi.d.tswas regenerated correctly — diff mirrors the source change and is not hand-edited.%webView_platformScriptureEditor_emptyState_noProject%was correctly added to bothenandeslocales rather than hardcoded, and is registered inEDITOR_LOCALIZED_STRINGSso it actually loads at runtime.convertScriptureRangeToEditorRangeutility had no tests before this PR; the new test file adds a comprehensive suite covering all fourScriptureRangelocation input shapes, mixed types, error paths, and data-provider interactions.tw:flex tw:items-center tw:justify-center tw:h-full tw:px-4) as the existingbookNotFound*branches — consistent treatment, RTL-safe.firstSelectionAsyncfix resolves a real bug (unhandled rejection in startup logs) that was triggered by this PR's empty-editor-in-simple-mode case.Interview Notes
Stated purpose: Create the 3-column layout for
interfaceMode === 'simple'only and force a single editor tab in the designated editor column. Follows the T1 ticket in the saroj-studies-implementation-spec.Key design explanations (verified in interview):
simple-layout.data.tsplacement: deliberately colocated withplatform-dock-layout.component.tsxrather than undersrc/renderer/testing/because it is production code used wheninterfaceMode === 'simple', not test fixture data. The placement is correct; the divergence fromtest-layout.data.ts's location is intentional.firstSelectionAsynclazy construction: directly motivated by this PR's new empty-editor case. The eager 10s timer fired with no caller listening, producing unhandled rejection in startup logs. The author confirmed they explicitly considered whether to split this into a separate PR and chose to keep it bundled so that the bug and its triggering case ship together — the PR description (this summary's Overview) calls out the linkage explicitly.Decisions made during the interview:
isReadOnlyto the dispatch helper input sofocus-existingrequires bothprojectIdANDisReadOnlyto match).loadLayoutTODO: removed the comment (kept the branch as a valid override path).No unresolved understanding gaps. The author explained both non-obvious design decisions (
simple-layoutplacement andfirstSelectionAsynclinkage) clearly and without deferring to AI tooling.In-Review Quality Check
npm run typecheck— ReportedTS2688errors forhello-world,platform-home, andresource-viewertype definitions. Verified pre-existing on the branch (reproduces with in-review changes stashed). Not caused by this review's changes — these three extensions lack a top-levelpackage.jsonwith atypesfield, unlikeplatform-scripture-editor. Author should be aware these exist but they are out of scope for this PR.npm run lint— Passed (warnings only). 8 prettier/JSDoc reformatting warnings in the JSDoc strings I added/edited were auto-fixed vianpm run lint-fixin theplatform-scripture-editorextension. Re-ran lint: 0 warnings.npm run format— Clean after the lint-fix pass.npm test— All 1,845 tests across 8 suites passed, 1 skipped, 0 failures. The 33 tests inplatform-scripture-editor.utils.test.tsinclude the 4 newly-added editable/readonly distinction cases.Suggested Review Focus
Prioritized areas for the author-reviewer meeting:
(projectId, isReadOnly): walk through the simple-modefocus-existingvsreplace-tabdecision tree. Is there a scenario the reviewer wants covered that the new 4 tests don't (e.g., multiple existing editors of different modes)? Verify thestate?.isReadOnlyderivation inmain.tshandles the case wherestateisundefinedfor newly-opened editors.firstSelectionAsynclazy construction: confirm with the reviewer that bundling this fix into this PR (rather than splitting) is acceptable. The Overview section above describes the linkage; thegetSelection()JSDoc documents the new timeout/rejection contract.loadLayoutis one-shot on startup: there is no runtime mode switcher, so changingplatform.interfaceModeat runtime does not switch layouts. Confirm with the reviewer this is the right product behavior for the demo deadline.1:2:1column proportions. The 10Simple PRD does not specify whether columns can be dragged/resized; the implementation spec lists "prevent columns from being moved or closed" as a nice-to-have. At smaller widths, the side columns can approach the 300px-per-tab responsive floor. Question for UX: should simple-mode columns be resizable? Are the1:2:1proportions correct, and should they adapt at narrower widths (e.g., reflow, collapse, or hide a column)?"Ningún proyecto seleccionado"string was machine-translated. Localization team should pass over it.TS2688errors forhello-world,platform-home,resource-viewerare unrelated to this PR but visible innpm run typecheckoutput. Flag for separate cleanup.This change is