Skip to content

refactor: update shell layout - #89

Open
MarinaLitueva wants to merge 5 commits into
constructorfabric:mainfrom
MarinaLitueva:frontend-update-shell-table-layout
Open

refactor: update shell layout#89
MarinaLitueva wants to merge 5 commits into
constructorfabric:mainfrom
MarinaLitueva:frontend-update-shell-table-layout

Conversation

@MarinaLitueva

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: cd1e518a-8aba-48d0-aa3b-76cc9b6e28c6


Comment @coderabbitai help to get the list of available commands.

@vasylcf vasylcf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the shell-levels refactor against the shipped SDLC docs (shell-levels.md, workspaces-screen.md, organization-overview.md). The overall design is solid and well documented, but there's one real feature regression that contradicts this PR's own acceptance criteria, plus several smaller correctness/UX issues worth fixing before merge.

case 'settings':
return <SettingsSection project={project} config={config} />;
default:
return <PlaceholderSection title={t(`section_${section}`)} note={t('no_source_yet')} />;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. The switch (section) here lost its 'team' and 'settings' cases, and SettingsSection.tsx was deleted outright (0 bytes in this diff). Both sections now fall through to defaultPlaceholderSection ("no source yet").

mfe.json still registers "Team" (section: 'team') and "Project settings" (section: 'settings', order 100) as live rail items for the project level, and this PR's own acceptance criteria in shell-levels.md requires "Opening a project shows the project's rail — Overview, Artifacts, Findings, Activity, Timeline, Team, and settings last". Right now clicking either one silently shows a placeholder instead of real content — a functional regression, not a refactor.

Either restore the two sections or explicitly drop those rail items (and update the AC) if removing them is intentional.

@MarinaLitueva MarinaLitueva Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Artifacts is the only section with a content. The rest - settings and team among them - are declared in the manifest so the rail is whole, and show the placeholder until there is something to read there.

const screens = registry.getExtensionsForDomain(screenDomain.id) as ScreenExtension[];
const target = entryPointOf(screens, level);
if (!target) return;
mountScreen(registry, target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix. This app/context/level/requested handler calls mountScreen() with no re-entrancy guard. Rail.tsx's choose() guards the identical mountScreen() call with a mounting state flag specifically to prevent a double-mount race, but this handler — the one ContextChain's breadcrumb slots call via enter() — has no equivalent guard. Two quick clicks on different breadcrumb slots (or on the same one before the first mount resolves) can fire this handler twice concurrently and race two mounts against each other.

)}
<WorkspacesTable
rows={rows}
total={rows.length}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix. WorkspacesTable is given total={rows.length} — the already search-filtered array — instead of all.length (the sibling WorkspacesToolbar on line 80 correctly uses all.length for the same screen). WorkspacesTable's footer renders {from}-{to} of {total}, so as soon as a search narrows the list, the caption becomes tautological (e.g. "1-2 of 2" instead of reflecting the organization's real workspace count).

@@ -41,30 +30,20 @@ export function MfeScreenContainer() {
eventBus.emit('app/mfe/bootstrap', { status: 'failed' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix. On a bootstrap failure, bootstrapped is never set to true, so this container renders nothing (as before). What changed is the surrounding navigation: the deleted Menu.tsx used to show "Screens could not be loaded. Check the console for the manifest error." in the nav area on this exact status. Rail.tsx (its replacement) just returns null when items.length <= 1, which is also true when bootstrap fails with no extensions registered. Net effect: a failed/unreachable manifest now leaves the whole shell blank with no user-facing indication anything went wrong, where before there was an explicit message.

const t = useWorkspacesText();
const [query, setQuery] = useState("");
const { data, isLoading, isError, refetch } = useApiQuery(
accounts.getWorkspaces({ organizationId: organization.id }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix. This fetches the org's workspace list via accounts.getWorkspaces(...) with staleTime: 0, which duplicates the same tenant-children-of-type read the shell already performs and caches in appContextSlice (resolveWorkspaces in appContextEffects.ts). The doc for this feature accepts "two readers of one AM endpoint" as a tradeoff for keeping this screen decoupled from the shell's channel, which is a reasonable call — but staleTime: 0 means every time this screen mounts (or the scoped workspace changes, per the effect below) it re-fetches from the network rather than reusing react-query's cache, so it's paying that cost more often than necessary.

const [open, setOpen] = useState(false);
const [mounting, setMounting] = useState(false);

const closeOnBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix. Rail only closes via onPointerLeave/onBlur (focus or pointer leaving the subtree) — there's no onKeyDown handling anywhere in app/layout for this component. A keyboard user who tabs into the Rail and opens it has no way to collapse it again short of tabbing all the way out of the subtree. Worth adding an Escape handler here, consistent with the removed Menu.tsx's behavior.

announceSection(bridge, 'artifacts');
start();
}, [isFirstImport, dispatch, start]);
}, [isFirstImport, dispatch, start, bridge]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup. bridge (from useMfeBridge()) is now in this effect's dependency array alongside start. If bridge isn't referentially stable across renders, a re-render before isFirstImport flips to false (the dispatch is async relative to this render) could re-run the effect and call start()/announceSection() a second time for the same first-import. Worth double-checking useMfeBridge()'s memoization, or guarding this effect with a ref the way MfeScreenContainer's bootstrap does.

return;
}
void refetch();
}, [scopedWorkspace, refetch]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup. This effect only special-cases the very first invocation via seenScoped.current; every subsequent run — triggered either by scopedWorkspace changing or by refetch's identity changing — calls refetch() unconditionally. If useApiQuery's refetch isn't stable per query instance, a refetch-triggered re-render could produce a new refetch reference and re-fire this effect, looping redundant network calls. Worth confirming refetch's stability or dropping it from the dependency array.

*/
export function publishStudioContext(app: FrontXApp): void {
publishSelectedOrganization(app);
publishSelectedWorkspace(app);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup. publishStudioContext (called once at bootstrap) publishes org/workspace/project/session but not publishSelectedSection, even though project_section.selected is a required property declared by organization-mfe and projects-mfe. Every other context property gets seeded to null at bootstrap; this one stays genuinely undefined until the first mountScreen() call emits it asynchronously. Today's section === 'settings' string checks happen to fall through safely on undefined, but it's an inconsistent guarantee versus the other properties — worth adding the missing call for consistency.

import type { ChildMfeBridge } from '@gears-frontx/react';

/** Screen domain: `kind: opened | closed | section`. */
export const STUDIO_ACTION_CONTEXT_PUBLISH =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup. STUDIO_ACTION_CONTEXT_PUBLISH and STUDIO_ACTION_WORKSPACES_PUBLISH are hand-typed as identical string literals here and independently in app/mfe/contextActions.ts, rather than one importing from the other. A future rename or copy/paste typo in either file would silently desync host and child MFEs, surfacing only at runtime as a rejected or no-op action chain with no build-time error.

Comment thread studio-frontend/src-app/app/effects/appContextEffects.ts
Comment thread studio-frontend/src-app/app/effects/appContextEffects.ts Outdated
Comment thread studio-frontend/src-app/app/mfe/contextActions.ts
@ainetx

ainetx commented Sep 9, 2026

Copy link
Copy Markdown

Duplicated AccountsApiService implementation left in shell after extraction to shared package

Severity: Minor

Problem
app/api/AccountsApiService.ts and mfe_packages/shared/src/accounts/AccountsApiService.ts are two separate class definitions with the same name and base URL, but overlapping-but-not-identical endpoint methods (e.g. no cursor param on the shell's tenantChildrenOfType, different query-string construction). The shell still imports and registers its own copy in appContextEffects.ts instead of consuming the shared package that MFEs now use.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. Read app/api/AccountsApiService.ts and mfe_packages/shared/src/accounts/AccountsApiService.ts side by side. 2. Note both implement overlapping account-management endpoints independently. 3. Note appContextEffects.ts still imports from '@/app/api' rather than '@constructor-studio/mfe-shared'.

Expected behavior
A single AccountsApiService implementation (in the shared package) consumed by both the shell and the MFEs, or an explicit documented reason the shell keeps its own client.

Actual behavior
Two parallel implementations exist post-refactor, already showing subtle behavioral differences (missing cursor support, different URL building), risking silent drift as one is updated without the other.

shell (app/api/AccountsApiService) --diverges from--> shared (mfe_packages/shared/src/accounts/AccountsApiService) --both hit--> /cf/account-management/v1

Impact
Future endpoint changes (pagination params, mocking, error handling) applied to one copy but not the other silently break shell-vs-MFE consistency for the same backend contract.

Suggested correction
Have the shell import AccountsApiService from the shared package (or re-export it) and delete app/api/AccountsApiService.ts, migrating appContextEffects.ts's usage accordingly.

How to verify
Confirm only one AccountsApiService class exists in the repo after the change, and appContextEffects.ts compiles against the shared package's API.


Original location: studio-frontend/src-app/app/api/AccountsApiService.ts:1 -- inline anchoring could not be resolved after 1 attempt(s).

Comment thread studio-frontend/src-app/app/effects/appContextEffects.ts
Comment thread studio-frontend/src-app/app/effects/appContextEffects.ts
Comment thread studio-frontend/src-app/app/mfe/mountScreen.ts Outdated
Maryna Lituyeva added 4 commits September 11, 2026 14:44
Signed-off-by: Maryna Lituyeva <maryna.lituyeva@constructor.tech>
Signed-off-by: Maryna Lituyeva <maryna.lituyeva@constructor.tech>
Signed-off-by: Maryna Lituyeva <maryna.lituyeva@constructor.tech>
Signed-off-by: Maryna Lituyeva <maryna.lituyeva@constructor.tech>
@MarinaLitueva
MarinaLitueva force-pushed the frontend-update-shell-table-layout branch from 4f5477e to 50f2380 Compare September 11, 2026 12:22
Signed-off-by: Maryna Lituyeva <maryna.lituyeva@constructor.tech>
@MarinaLitueva
MarinaLitueva force-pushed the frontend-update-shell-table-layout branch from f34d9fb to 02987df Compare September 11, 2026 15:06
@MarinaLitueva

Copy link
Copy Markdown
Contributor Author

Duplicated AccountsApiService implementation left in shell after extraction to shared package

Severity: Minor

Problem app/api/AccountsApiService.ts and mfe_packages/shared/src/accounts/AccountsApiService.ts are two separate class definitions with the same name and base URL, but overlapping-but-not-identical endpoint methods (e.g. no cursor param on the shell's tenantChildrenOfType, different query-string construction). The shell still imports and registers its own copy in appContextEffects.ts instead of consuming the shared package that MFEs now use.

Reproduction, impact, suggested fix, verification
How to reproduce

  1. Read app/api/AccountsApiService.ts and mfe_packages/shared/src/accounts/AccountsApiService.ts side by side. 2. Note both implement overlapping account-management endpoints independently. 3. Note appContextEffects.ts still imports from '@/app/api' rather than '@constructor-studio/mfe-shared'.

Expected behavior A single AccountsApiService implementation (in the shared package) consumed by both the shell and the MFEs, or an explicit documented reason the shell keeps its own client.

Actual behavior Two parallel implementations exist post-refactor, already showing subtle behavioral differences (missing cursor support, different URL building), risking silent drift as one is updated without the other.

shell (app/api/AccountsApiService) --diverges from--> shared (mfe_packages/shared/src/accounts/AccountsApiService) --both hit--> /cf/account-management/v1

Impact Future endpoint changes (pagination params, mocking, error handling) applied to one copy but not the other silently break shell-vs-MFE consistency for the same backend contract.

Suggested correction Have the shell import AccountsApiService from the shared package (or re-export it) and delete app/api/AccountsApiService.ts, migrating appContextEffects.ts's usage accordingly.

How to verify Confirm only one AccountsApiService class exists in the repo after the change, and appContextEffects.ts compiles against the shared package's API.

Original location: studio-frontend/src-app/app/api/AccountsApiService.ts:1 -- inline anchoring could not be resolved after 1 attempt(s).

Fixed

workspaceId: payload.workspace.id,
name: payload.workspace.name,
...scopeOf(payload, 'organizationId'),
});

@ainetx ainetx Sep 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale cross-organization workspace selection still forces a level change even when the workspace data itself is correctly rejected

Severity: Major

Problem
createWorkspacePublishHandler's 'selected' branch in contextActions.ts emits two independent events for one user action: 'app/context/workspace/changed' (carrying organizationId, now correctly guarded by staleScope in appContextEffects.ts) and an unconditional 'app/context/level/requested' ({level:'workspace'}) with no organization/scope information at all. The level/requested listener in appContextEffects.ts resolves the workspace level's entry screen and calls enterScreen/mountScreen purely based on 'level', independent of whether the paired workspace/changed announcement was accepted or dropped as stale.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. User is in Organization A and clicks a workspace row in organization-mfe's WorkspacesScreen, triggering requestWorkspace with organizationId=A (async, in flight).
  2. Before the action resolves, the user switches the shell's context to Organization B.
  3. The delayed 'selected' action from step 1 arrives, contextActions.ts emits workspace/changed with organizationId=A (dropped by staleScope since current org is now B) and level/requested {level:'workspace'} (no scope check).
  4. appContextEffects.ts's level/requested handler mounts the workspace-level entry screen regardless.

Expected behavior
A stale/late workspace selection should be fully ignored -- neither the stored workspace context nor the mounted screen level should change when the announcement's organization no longer matches the current one.

Actual behavior
The workspace data is dropped, but the shell still navigates into the workspace-level screen (e.g. mounts Projects/workspace entry), producing a screen level/breadcrumb inconsistent with the actual (unchanged) organization/workspace context.

selected action (orgId=A) --> emit workspace/changed(orgId=A) --[staleScope: dropped, org now B]--> (no state change)
\--> emit level/requested(level=workspace) --[no scope check]--> enterScreen/mountScreen(workspace entry) --> UI now shows workspace-level screen with no valid workspace context

Impact
Users can be navigated into a workspace-level screen/UI with no valid workspace selected for the current organization, producing inconsistent breadcrumbs, an orphaned rail/level state, and a confusing screen mount that doesn't match stored context.

Suggested correction
Either pass organizationId through the level/requested event and have its handler apply the same staleScope check before mounting, or only emit level/requested from the workspace/changed handler itself (after it has already validated/applied the scope) rather than unconditionally from the action handler.

How to verify
Add a test simulating a delayed 'selected' workspace action whose organizationId differs from the currently active organization at delivery time, and assert that no screen mount/level change occurs and the previously mounted screen/level is retained.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified against the current code -- this write-up has been updated.

Why

Problem (was): createWorkspacePublishHandler's 'selected' branch emits both 'app/context/workspace/changed' (guarded by staleScope in appContextEffects.ts) and 'app/context/level/requested' unconditionally and independently. Only the first event's handler checks staleScope(currentOrgId(app), organizationId); the second event's handler (eventBus.on('app/context/level/requested',...)) has no scope check and unconditionally resolves and mounts the workspace-level entry screen via enterScreen/mountScreen.

Problem (now): createWorkspacePublishHandler's 'selected' branch in contextActions.ts emits two independent events for one user action: 'app/context/workspace/changed' (carrying organizationId, now correctly guarded by staleScope in appContextEffects.ts) and an unconditional 'app/context/level/requested' ({level:'workspace'}) with no organization/scope information at all. The level/requested listener in appContextEffects.ts resolves the workspace level's entry screen and calls enterScreen/mountScreen purely based on 'level', independent of whether the paired workspace/changed announcement was accepted or dropped as stale.

eventBus.emit('mfe/projects/open-requested', project);
publish(bridge, { kind: 'opened', project, siblings });
publish(bridge, { kind: 'opened', project, siblings, ...(workspaceId ? { workspaceId } : {}) });
}

@ainetx ainetx Sep 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null workspaceId is dropped from project announcement payload, bypassing staleScope rejection

Severity: Major

Problem
requestOpenProject and announceCreatedProject use ...(workspaceId ? { workspaceId }: {}), so a null or empty-string workspaceId (explicitly typed and passed by callers like ProjectsTable's workspace?.id ?? null) is omitted from the wire payload rather than sent as an explicit claim. scopeOf() on the shell then produces {} for that key, and staleScope(current, undefined) is defined to return false (never stale) for an omitted claim.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. ProjectsTable renders before workspace is resolved (workspace undefined -> workspace?.id ?? null). 2. User clicks a project row; requestOpenProject is called with workspaceId=null. 3. Payload sent to shell omits workspaceId entirely. 4. Meanwhile the shell's active workspace is some other workspace W (e.g. user already switched). 5. appContextEffects' 'app/context/project/opened' and 'app/context/projects' listeners compute staleScope(W, undefined) = false, so the announcement's project/siblings are applied to W anyway.

Expected behavior
An unresolved/null workspaceId claim should still be distinguishable from a genuinely unscoped announcement, or the shell should independently validate that the announced project actually belongs to the currently selected workspace before applying it.

Actual behavior
The null workspaceId is silently coalesced away by the spread, collapsing into the same 'unclaimed' bucket that staleScope treats as always-current.

workspace?.id ?? null -> requestOpenProject(workspaceId=null) -> spread omits key -> scopeOf() = {} -> staleScope(current, undefined) = false -> project/siblings applied to wrong workspace

Impact
A delayed or unresolved project selection/creation can populate the active workspace's breadcrumb and project switcher with another workspace's project data.

Suggested correction
Send workspaceId explicitly (including null) rather than conditionally spreading it, and change staleScope/scopeOf to treat an explicit null claim as always mismatching the current (non-null) workspace, rather than as unclaimed.

How to verify
Unit test: call requestOpenProject with workspaceId=null while the shell has a different non-null active workspace, and assert the announcement is rejected/ignored rather than applied.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified against the current code -- this write-up has been updated.

Why

Problem (was): requestOpenProject/announceCreatedProject in projects-mfe's projectsActions.ts accept workspaceId: string | null but build the outgoing payload with ...(workspaceId ? { workspaceId }: {}), so a null (or empty-string) workspaceId is omitted entirely rather than sent as an explicit scope claim. On the shell side, contextActions.ts's scopeOf() only sets {workspaceId} when the payload key is a string, and appContextEffects.ts's staleScope() explicitly treats an undefined claimed scope as never stale ('claimed !== undefined && claimed !== current'). An omitted key is therefore indistinguishable from a sender that never named a scope, and the sibling project list / opened-project announcement is applied to whatever workspace the shell currently has selected.

Problem (now): requestOpenProject and announceCreatedProject use ...(workspaceId ? { workspaceId }: {}), so a null or empty-string workspaceId (explicitly typed and passed by callers like ProjectsTable's workspace?.id ?? null) is omitted from the wire payload rather than sent as an explicit claim. scopeOf() on the shell then produces {} for that key, and staleScope(current, undefined) is defined to return false (never stale) for an omitted claim.


const announceToShell = useCallback(
async (workspace: { id: string; name: string }): Promise<void> => {
async (workspace: CreatedWorkspace): Promise<void> => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New orgId provenance chain (workspaceEffects → workspaceSlice → NewWorkspaceForm) has no MFE-side test coverage

Severity: Minor

Problem
workspaceEffects.ts captures orgId from the create-requested closure and emits it on 'mfe/workspaces/created'; workspaceSlice.ts's new CreatedWorkspace type stores orgId in both created and workspaceAnnounceFailed; NewWorkspaceForm.tsx's announceToShell/retry path now reads workspace.orgId instead of a live useOrganization() value. None of workspaceEffects.ts, workspaceSlice.ts, or NewWorkspaceForm.tsx has an associated test file touched in this diff.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. Inspect the diff for workspaceEffects.ts, workspaceSlice.ts, NewWorkspaceForm.tsx — all changed. 2. Search mfe_packages/projects-mfe/**/.test. for references to initWorkspaceEffects, CreatedWorkspace, workspaceAnnounceFailed, or NewWorkspaceForm — none found in the diff's changed-files list; only appContextEffects.test.ts (shell-side) was updated.

Expected behavior
New cross-boundary provenance logic (orgId capture and reuse on retry) should have MFE-side regression tests verifying the value threaded through effect → Redux state → retry publish is the organization the workspace was actually created under, especially across an org switch.

Actual behavior
Correctness of this chain currently rests entirely on manual code reading; a future edit (e.g., reverting to a live org read on retry, or dropping orgId in an action) would not be caught by any existing test.

create-requested(orgId) -> workspaceEffects (capture orgId, emit) -> workspaceSlice.created/workspaceAnnounceFailed(orgId) -> NewWorkspaceForm.announceToShell(workspace.orgId) -> publishCreatedWorkspace
^
no test exercises any of these links

Impact
A future regression re-introducing a live-organization read (instead of the captured orgId) or dropping the field during serialization would silently cause workspace-created announcements to be attributed to the wrong organization, with no CI signal.

Suggested correction
Add unit tests for initWorkspaceEffects verifying orgId is captured and emitted correctly, for workspaceSlice reducers verifying CreatedWorkspace.orgId round-trips through created/workspaceAnnounceFailed, and for NewWorkspaceForm verifying the retry path publishes the stored orgId (not a live organization value) after simulating an org switch.

How to verify
Run the new tests; they should fail if orgId capture/threading is reverted to a live-context read or if the field is dropped anywhere in the chain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants