Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
632bd75
feat(views): add useViewStateByReadKey for the embedded viewer
allison-truhlar Aug 10, 2026
98c85ed
feat(views): embedded NeuroglancerView page (iframe + exports)
allison-truhlar Aug 10, 2026
c5c6499
feat(views): chrome-less /view/:readKey route + dev proxy for /ngview
allison-truhlar Aug 10, 2026
d714a71
feat(views): Saved Views "Open" navigates to the embedded viewer
allison-truhlar Aug 10, 2026
8fd54c5
feat(views): link "Appears in N Views" names to the embedded viewer
allison-truhlar Aug 10, 2026
9d21a4d
refactor(views): extract useCreateViewFlow from CreateViewButton
allison-truhlar Aug 10, 2026
fc669eb
feat(browse): "View in Neuroglancer" row action opens an embedded View
allison-truhlar Aug 10, 2026
a210707
fix(views): mount ViewsProvider app-wide so /browse can create Views
allison-truhlar Aug 10, 2026
7a4f15d
feat(views): embedded viewer forces the Internal Neuroglancer (ignore…
allison-truhlar Aug 10, 2026
1cb812b
fix(views): plain Zarr arrays each become one layer in a combined View
allison-truhlar Aug 11, 2026
0520648
docs(views): plan for plain-array checkout fix
allison-truhlar Aug 11, 2026
84f917a
fix(views): plain-array layers get an explicit type, not Neuroglancer…
allison-truhlar Aug 11, 2026
5a58365
fix(views): sync consumers/mocks with useCreateViewFlow's dialog fiel…
allison-truhlar Aug 12, 2026
e832814
feat(views): embedded viewer gets navbar chrome and a breadcrumb
allison-truhlar Aug 12, 2026
c3e986e
fix(views): Create View navigates to the embedded viewer and clears t…
allison-truhlar Aug 12, 2026
f8b0a2e
fix(views): isolate clearCart failure from checkout's error path
allison-truhlar Aug 12, 2026
69892a5
feat(views): address bar reflects full NG state, copy grabs page URL
allison-truhlar Aug 12, 2026
31eb00c
docs(views): mark hash-reflection effect with ponytail scope note
allison-truhlar Aug 12, 2026
67d8a47
fix(views): stop clobbering unrelated carts; copy short link not full…
allison-truhlar Aug 12, 2026
a4d5f22
refactor(views): drop dead pending/loading props on CreateViewButton'…
allison-truhlar Aug 12, 2026
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
153 changes: 153 additions & 0 deletions docs/superpowers/plans/2026-08-11-ngviews-plain-array-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Plan: Views checkout must handle plain Zarr arrays (one layer per cart dataset)

Date: 2026-08-11
Branch: `ngviews-06-embedded-readonly`
Status: for review

## Problem

Adding several plain Zarr array directories to the Layer Cart (e.g. `.../seed6/data.zarr/affs`,
`/lsds`, `/img_zyx`, `/seg`) and clicking **Create View** should produce one Neuroglancer
layer per added directory. It does not. The saved View reports **0 layers**, expanding a cart
row throws a `not found: v3 array or group` pop-up, and opening the View shows a black
Neuroglancer with a single `1 new layer` tab.

This is **not** the "auto-detect all children of a `.zgroup`" feature requested on Slack. This is
the existing manual-add path failing for anything that is not an OME-Zarr multiscale group.

## Root cause

The checkout pipeline is OME-Zarr-first and never handles a bare array.

- `frontend/src/utils/viewCheckout.ts:37` — `generateStateForDataset` calls
`getOmeZarrMetadata(ds.url)` for **every** dataset.
- `frontend/src/omezarr-helper.ts:598` — `getOmeZarrMetadata` calls
`omezarr.getMultiscaleWithArray(store, 0)`, which demands a multiscale group. A plain array
**throws** (`not found: v3 array or group`).
- The throw is caught at `viewCheckout.ts:53` and returns `null`, so the dataset is **dropped**
(→ 0 layers).
- The fallback `generateNeuroglancerStateForDataURL` at `viewCheckout.ts:51` is **dead code**
for plain arrays: it lives inside the ternary that only runs *after* `getOmeZarrMetadata`
succeeds. When line 37 throws, the fallback never executes.
- The same throw surfaces raw in the channel-expand UI (`getOmeZarrChannels` →
`getOmeZarrMetadata`) → error pop-up.

## Fix

Make `generateStateForDataset` fall back to a single plain-array layer when OME multiscale
detection fails, instead of returning `null`.

### 1. Add a plain-array state helper (`omezarr-helper.ts`)

```ts
// Open a plain Zarr array with auto-detected storage version and emit a
// single-layer NG state. Used when a cart dataset is a bare array, not an
// OME-Zarr multiscale group.
export async function generateStateForPlainZarr(dataUrl: string): Promise<string> {
const store = new zarr.FetchStore(dataUrl, { overrides: { credentials: 'include' } });
const arr = await omezarr.getArray(store, '/', undefined); // undefined = probe v2/v3
const zarrVersion = arr.metadata.zarr_format as 2 | 3;
return generateNeuroglancerStateForDataURL(dataUrl, zarrVersion);
}
```

Reuses existing `generateNeuroglancerStateForDataURL` (emits `type:'new'`, correct `|zarrN:`
source, layout `4panel-alt`). No new render logic.

### 2. Fall back in `generateStateForDataset` (`viewCheckout.ts`)

```ts
async function generateStateForDataset(ds): Promise<NgState | null> {
try {
const metadata = await getOmeZarrMetadata(ds.url);
const multiscale = metadata.multiscales?.[0];
const encoded = multiscale
? generateNeuroglancerStateForOmeZarr(ds.url, metadata.zarrVersion, 'image',
multiscale, metadata.arr, metadata.labels, metadata.omero)
: generateNeuroglancerStateForDataURL(ds.url, metadata.zarrVersion);
return decodeState(encoded);
} catch (omeError) {
// Not an OME-Zarr multiscale group. Try a plain array before giving up.
try {
return decodeState(await generateStateForPlainZarr(ds.url));
} catch (plainError) {
log.error(`Failed to generate NG state for ${ds.url}`, omeError, plainError);
return null; // genuinely broken (moved/deleted/not zarr) → skip, keep the rest
}
}
}
```

Result: N added array dirs → N layers. `null` now means "not a Zarr array at all", not
"not OME-Zarr".

### 3. Stop the channel-expand pop-up (`getOmeZarrChannels`, `omezarr-helper.ts:642`)

Plain arrays have no channels. `getOmeZarrMetadata` throws inside `getOmeZarrChannels`. Wrap so
a plain array returns `[]` instead of throwing; the cart row already shows the
"Channels load after the View is created." / single-array hint.

```ts
async function getOmeZarrChannels(dataUrl: string): Promise<string[]> {
let metadata;
try {
metadata = await getOmeZarrMetadata(dataUrl);
} catch {
return []; // plain array / no multiscale → no channels to pick
}
// ...unchanged...
}
```

## Tests

- `frontend/src/__tests__/` unit test for `buildViewState`: mock `getOmeZarrMetadata` to throw
and `generateStateForPlainZarr` to return a one-layer state; assert a 3-dataset cart yields
`layers.length === 3` and `ng_state.layers.length === 3` (self-check for the branch/loop logic).
- Manual on dev: add `affs`, `lsds`, `img_zyx`, `seg` under `seed6/data.zarr`, Create View,
confirm 4 layers in the Saved Views table and 4 tabs in the embedded viewer.

## Follow-up (deferred to later PRs in the stack)

QA on dev after the initial fix surfaced these; user chose to keep this branch to
the type-default fix and split the rest:

- **Per-layer type override in the Layer Cart**: image / segmentation /
multi-channel selector per cart dataset, plumbed through `ViewLayerInput.opts`
into the generated NG state. Covers segmentation-by-choice and the multichannel
case below.
- **Multi-channel arrays (affs, lsds)**: a bare multi-channel float array renders
as one grey channel because there is no channel dimension/shader. Needs the NG
state to emit a local `c` dimension + shader; Neuroglancer cannot infer it
without OME axis metadata. Overlaps the override work.
- **Show data paths on the view page**: `/view/:readKey` address bar is the short
app route by design; **Copy link already yields the full Neuroglancer-style URL
with every layer's data source embedded**. Optional: a panel on `/view` that
lists each layer's full data URL, mirroring how a data link shows its path.

## Type default (done in this branch)

Plain-array fallback no longer emits `type:'new'` (which forces Neuroglancer's
layer-type picker and renders raw grey). `generateStateForPlainZarr` now opens the
array, guesses a type from name + dtype (`guessPlainLayerType`: integer dtype +
name matching `seg|label|mask` -> `segmentation`, else `image`), and uses the
explicit-type generator `generateNeuroglancerStateForZarrArray`. The existing
thumbnail-edge heuristic (`determineLayerType`) is not usable here — checkout has
no rendered thumbnail (see `viewCheckout.ts:39`).

## Out of scope (separate items, noted not fixed)

- **Misalignment**: cross-array coordinate spaces are not reconciled — first dataset's
dimensions win (`viewCheckout.ts:75` ceiling). Arrays with differing scale/offset render
misaligned. Expected; document, don't fix here.
- **Segmentation typing**: plain-array fallback emits `type:'new'`; `seg` shows as image, not a
labels layer. Neuroglancer lets the user switch. Follow-up if auto-typing wanted.
- **UX (#1/#2/#3)**: "Create View" button semantics, channels-load-after-create ordering, and
duplicate-View-on-repeat-click are real but independent of this data bug. Track separately.
- **`.zgroup` auto fan-out** (the Slack feature): not this. Explicitly not doing it.

## Risk

Low. Additive fallback; OME-Zarr path unchanged. Worst case a genuinely broken dir is still
skipped (same as today). `omezarr.getArray(store, '/', undefined)` version-probe is the one
external assumption — verify it resolves v2 arrays on dev before merge.
14 changes: 10 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import Notifications from '@/components/Notifications';
import SSHKeys from '@/components/SSHKeys';
import ErrorFallback from '@/components/ErrorFallback';
import NGViews from '@/components/NGViews';
import { ViewsProvider } from '@/contexts/ViewsContext';
import NeuroglancerView from '@/components/NeuroglancerView';

function RequireAuth({ children }: { readonly children: ReactNode }) {
const { loading, authStatus } = useAuthContext();
Expand Down Expand Up @@ -122,9 +122,7 @@ const AppComponent = () => {
<Route
element={
<RequireAuth>
<ViewsProvider>
<NGViews />
</ViewsProvider>
<NGViews />
</RequireAuth>
}
path="ngviews"
Expand Down Expand Up @@ -214,6 +212,14 @@ const AppComponent = () => {
/>
<Route element={<AppLaunch />} path="relaunch/:owner/:repo" />
</Route>
<Route
element={
<RequireAuth>
<NeuroglancerView />
</RequireAuth>
}
path="view/:readKey"
/>
</Route>
</Routes>
</BrowserRouter>
Expand Down
21 changes: 16 additions & 5 deletions frontend/src/__tests__/componentTests/AppearsInViews.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

const { useViewsForDataLinkQuery } = vi.hoisted(() => ({
useViewsForDataLinkQuery: vi.fn()
Expand All @@ -12,16 +13,26 @@ describe('AppearsInViews', () => {
it('lists the dependent Views with a count', () => {
useViewsForDataLinkQuery.mockReturnValue({
data: [
{ short_key: 'v1', name: 'Alpha' },
{ short_key: 'v2', name: 'Beta' }
{ short_key: 'v1', name: 'Alpha', read_key: 'rk1' },
{ short_key: 'v2', name: 'Beta', read_key: 'rk2' }
],
isPending: false,
isError: false
});
render(<AppearsInViews sharingKey="k1" />);
render(
<MemoryRouter>
<AppearsInViews sharingKey="k1" />
</MemoryRouter>
);
expect(screen.getByText(/appears in 2 views/i)).toBeInTheDocument();
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute(
'href',
'/view/rk1'
);
expect(screen.getByRole('link', { name: 'Beta' })).toHaveAttribute(
'href',
'/view/rk2'
);
});

it('renders nothing when there are no dependent Views', () => {
Expand Down
53 changes: 48 additions & 5 deletions frontend/src/__tests__/componentTests/CartList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CartItem } from '@/contexts/CartContext';
import type { View } from '@/queries/viewQueries';

const cartA: CartItem = { fsp_name: 'f', path: '/a', label: 'Dataset A' };
const cartB: CartItem = { fsp_name: 'f', path: '/b', label: 'Dataset B' };
const createdView: View = vi.hoisted(() => ({
short_key: 'v1',
read_key: 'rk1',
name: 'New View',
ng_state: {},
sharing_mode: 'read',
owner: 'me',
created_at: '2026-08-01T00:00:00Z',
updated_at: '2026-08-01T00:00:00Z',
layers: []
}));

let cart: CartItem[] = [];
const navigate = vi.hoisted(() => vi.fn());
const clearCart = vi.hoisted(() => vi.fn());
vi.mock('react-router', () => ({ useNavigate: () => navigate }));
vi.mock('@/contexts/CartContext', () => ({
useCartContext: () => ({
cart,
clearCart: vi.fn().mockResolvedValue(undefined)
clearCart
})
}));
vi.mock('@/queries/proxiedPathQueries', () => ({
Expand All @@ -21,13 +37,26 @@ vi.mock('@/components/ui/Views/CartDatasetRow', () => ({
)
}));
vi.mock('@/components/ui/Views/CreateViewButton', () => ({
default: ({ label }: { label?: string }) => (
<button type="button">{label ?? 'Create View'}</button>
default: ({
label,
onCreated
}: {
label?: string;
onCreated?: (view: View) => void;
}) => (
<button onClick={() => onCreated?.(createdView)} type="button">
{label ?? 'Create View'}
</button>
)
}));

import CartList from '@/components/ui/Views/CartList';

beforeEach(() => {
navigate.mockClear();
clearCart.mockReset().mockResolvedValue(undefined);
});

describe('CartList', () => {
it('shows the empty state when the cart is empty', () => {
cart = [];
Expand All @@ -46,4 +75,18 @@ describe('CartList', () => {
screen.getByRole('button', { name: /clear cart/i })
).toBeInTheDocument();
});

it('navigates to the embedded viewer and clears the cart when a View is created', async () => {
cart = [cartA, cartB];
const user = userEvent.setup();
render(<CartList />);

await user.click(screen.getByRole('button', { name: /create view/i }));

expect(navigate).toHaveBeenCalledWith('/view/rk1');
// CartList's datasets come from the persisted cart, so it - unlike
// SelectionBar/FileBrowser - is the one caller that should clear it
// after a successful checkout.
await waitFor(() => expect(clearCart).toHaveBeenCalled());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ vi.mock('@/queries/proxiedPathQueries', () => ({
useAllProxiedPathsQuery: () => ({ data: [] }) // nothing exists → 1 new link
}));
vi.mock('react-router', () => ({ useNavigate: () => vi.fn() }));
vi.mock('@/contexts/CartContext', () => ({
useCartContext: () => ({ clearCart: vi.fn().mockResolvedValue(undefined) })
}));

import CreateViewButton from '@/components/ui/Views/CreateViewButton';

Expand Down
18 changes: 18 additions & 0 deletions frontend/src/__tests__/componentTests/FileBrowserCartItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ vi.mock('@/contexts/CartContext', async importOriginal => {
};
});

// FileBrowser also renders a "View in Neuroglancer" item that depends on
// useCreateViewFlow, which in turn needs a ViewsProvider this test's render
// tree doesn't set up. This suite only cares about the cart item, so stub
// the hook rather than wiring up ViewsProvider.
vi.mock('@/hooks/useCreateViewFlow', async importOriginal => {
const actual =
await importOriginal<typeof import('@/hooks/useCreateViewFlow')>();
return {
...actual,
useCreateViewFlow: () => ({
startCreateView: vi.fn(),
dialog: null,
open: false,
pending: false
})
};
});

// FileTable virtualizes rows in a way that doesn't render meaningfully in
// jsdom. Stub it with plain buttons that invoke the same
// handleContextMenuClick callback FileBrowser wires up, so the test can
Expand Down
Loading
Loading