From 208d34abc7567c52d11b714e38a96b20a3714b2e Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 29 Sep 2026 01:48:22 +0400 Subject: [PATCH] chunk bug --- frontend/src/components/Chunks.test.tsx | 5 ++- frontend/src/components/Chunks.tsx | 17 +++++++-- .../components/graph/GraphSourceView.test.tsx | 27 +++++++++++-- .../src/components/graph/GraphSourceView.tsx | 15 +++++--- .../src/components/tree/TreeBrowser.test.tsx | 38 +++++++++++++++++++ frontend/src/components/tree/TreeBrowser.tsx | 15 +++++--- 6 files changed, 99 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Chunks.test.tsx b/frontend/src/components/Chunks.test.tsx index 42a1e372..93460cda 100644 --- a/frontend/src/components/Chunks.test.tsx +++ b/frontend/src/components/Chunks.test.tsx @@ -495,7 +495,8 @@ describe('Chunks', () => { ok: true, json: async () => ({ chunk_id: 'c1-new' }), })); - await render({ embedded: true }); + const onOpenChunkChange = vi.fn(); + await render({ embedded: true, onOpenChunkChange }); await act(async () => tile()!.click()); await act(async () => buttonByText('modals.chunk.edit')!.click()); // After the save, the open position holds nothing (or another chunk). @@ -509,6 +510,8 @@ describe('Chunks', () => { expect(container.querySelector('h2')?.textContent).toBe('Rewritten'); expect(container.textContent).toContain('chunkPositionUnplaced'); expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true); + // An embedding host draws the crumb: still open, but with no number. + expect(onOpenChunkChange).toHaveBeenLastCalledWith('unplaced'); }); it('deleting the only chunk on the last page lands on the page before', async () => { diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx index ae6e0ddc..0ab28f53 100644 --- a/frontend/src/components/Chunks.tsx +++ b/frontend/src/components/Chunks.tsx @@ -35,6 +35,13 @@ import { Pagination } from './ui/pagination'; /** Chunks per page: divisible by 2, 3 and 4 columns, so a page fills the grid. */ const PAGE_SIZE_OPTIONS = [12, 24, 48]; +/** + * Where the open chunk is, as reported to an embedding host: its 1-based + * position, 'unplaced' while it is open but its place in the list is unknown + * (a save moved it off every probed position), or null while the grid shows. + */ +export type OpenChunkPosition = number | 'unplaced' | null; + /** Lets the host's crumbs close the open chunk (see TreeBrowser). */ export interface ChunksController { closeChunk: () => void; @@ -79,8 +86,8 @@ interface ChunksProps { * (`onOpenChunkChange`) and close it (`controllerRef`). */ embedded?: boolean; - /** The open chunk's position (1-based), or null while the grid shows. */ - onOpenChunkChange?: (position: number | null) => void; + /** Where the open chunk is; see {@link OpenChunkPosition}. */ + onOpenChunkChange?: (position: OpenChunkPosition) => void; controllerRef?: React.MutableRefObject; } @@ -296,7 +303,11 @@ const Chunks: React.FC = ({ closeChunk: () => closeChunk(), })); - const openChunkPosition = openChunk ? openPosition : null; + const openChunkPosition: OpenChunkPosition = !openChunk + ? null + : positionLost + ? 'unplaced' + : openPosition; const onOpenChunkChangeRef = useRef(onOpenChunkChange); useEffect(() => { onOpenChunkChangeRef.current = onOpenChunkChange; diff --git a/frontend/src/components/graph/GraphSourceView.test.tsx b/frontend/src/components/graph/GraphSourceView.test.tsx index 87da9a5f..dceedf42 100644 --- a/frontend/src/components/graph/GraphSourceView.test.tsx +++ b/frontend/src/components/graph/GraphSourceView.test.tsx @@ -73,13 +73,13 @@ vi.mock('../Chunks', async () => { default: (props: { embedded?: boolean; documentId: string; - onOpenChunkChange?: (position: number | null) => void; + onOpenChunkChange?: (position: number | 'unplaced' | null) => void; controllerRef?: { current: { closeChunk: () => boolean } | null }; }) => { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); const { onOpenChunkChange, controllerRef } = props; useEffect(() => { - onOpenChunkChange?.(open ? 2 : null); + onOpenChunkChange?.(open || null); }, [open, onOpenChunkChange]); if (controllerRef) { controllerRef.current = { @@ -92,9 +92,12 @@ vi.mock('../Chunks', async () => { return (
{props.embedded ? 'embedded' : 'page'} - +
); }, @@ -463,6 +466,22 @@ describe('GraphSourceView', () => { ).find((b) => b.textContent === 'Key Accounts')!; await act(async () => source.click()); expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']); + + // A saved chunk whose place is unknown keeps an unnumbered crumb, and the + // source crumb still closes it. + await act(async () => buttonByText('LOSE PLACE')!.click()); + expect(crumbs()).toEqual([ + 'settings.sources.label', + 'Key Accounts', + 'settings.sources.chunkCrumbUnplaced', + ]); + const again = Array.from( + container.querySelectorAll( + '[data-slot="breadcrumb-link"]', + ), + ).find((b) => b.textContent === 'Key Accounts')!; + await act(async () => again.click()); + expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']); }); it('uses the connector tree for a connector source', async () => { diff --git a/frontend/src/components/graph/GraphSourceView.tsx b/frontend/src/components/graph/GraphSourceView.tsx index 53816b1d..62faa677 100644 --- a/frontend/src/components/graph/GraphSourceView.tsx +++ b/frontend/src/components/graph/GraphSourceView.tsx @@ -13,7 +13,10 @@ import { useSelector } from 'react-redux'; import userService from '../../api/services/userService'; import { selectToken } from '../../preferences/preferenceSlice'; import { formatCount } from '../../utils/dateTimeUtils'; -import Chunks, { type ChunksController } from '../Chunks'; +import Chunks, { + type ChunksController, + type OpenChunkPosition, +} from '../Chunks'; import ConnectorTree from '../ConnectorTree'; import FileTree from '../FileTree'; import GraphView, { type GraphLoadStatus } from '../GraphView'; @@ -80,9 +83,8 @@ export default function GraphSourceView({ // header shows it while that tab is open. const [filesCrumbs, setFilesCrumbs] = useState([]); // A flat source's open chunk (its crumb), reported by the chunk list. - const [openChunkPosition, setOpenChunkPosition] = useState( - null, - ); + const [openChunkPosition, setOpenChunkPosition] = + useState(null); const chunksControllerRef = useRef(null); useEffect(() => { @@ -157,7 +159,10 @@ export default function GraphSourceView({ ...(openChunkPosition !== null ? [ { - label: t('settings.sources.chunkCrumb', { n: openChunkPosition }), + label: + openChunkPosition === 'unplaced' + ? t('settings.sources.chunkCrumbUnplaced') + : t('settings.sources.chunkCrumb', { n: openChunkPosition }), }, ] : []), diff --git a/frontend/src/components/tree/TreeBrowser.test.tsx b/frontend/src/components/tree/TreeBrowser.test.tsx index 5be1e555..9d36bd54 100644 --- a/frontend/src/components/tree/TreeBrowser.test.tsx +++ b/frontend/src/components/tree/TreeBrowser.test.tsx @@ -42,6 +42,10 @@ vi.mock('../../api/services/userService', () => ({ chunks: state.chunks, }), })), + updateChunk: vi.fn(async () => ({ + ok: true, + json: async () => ({ chunk_id: 'c1' }), + })), }, })); @@ -252,6 +256,40 @@ describe('TreeBrowser', () => { expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']); }); + it('a saved chunk whose place is unknown keeps an unnumbered crumb', async () => { + state.chunks = [{ doc_id: 'c1', text: '# Rates', metadata: {} }]; + await render(NESTED); + await openRow('readme.md'); + await act(async () => tile()!.click()); + const button = (text: string) => + Array.from(document.body.querySelectorAll('button')).find( + (el) => el.textContent?.trim() === text, + )!; + await act(async () => button('modals.chunk.edit').click()); + // After the save, the probed positions no longer hold the chunk. + state.chunks = []; + const field = document.body.querySelector( + '[role="dialog"] textarea', + )!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )!.set!.call(field, '# Rates edited'); + field.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => button('modals.chunk.save').click()); + expect(crumbs()).toEqual([ + SOURCES, + 'Contracts', + 'readme.md', + 'settings.sources.chunkCrumbUnplaced', + ]); + + await clickCrumb('readme.md'); + expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']); + }); + it('a one-file source opens straight on its chunk list', async () => { await render({ 'report.pdf': { type: 'pdf', display_name: 'Report' } }); expect( diff --git a/frontend/src/components/tree/TreeBrowser.tsx b/frontend/src/components/tree/TreeBrowser.tsx index e8d83388..d76d3b11 100644 --- a/frontend/src/components/tree/TreeBrowser.tsx +++ b/frontend/src/components/tree/TreeBrowser.tsx @@ -17,7 +17,10 @@ import { Eye, File, Folder } from 'lucide-react'; import { Button } from '../ui/button'; import { EmptyState } from '../ui/empty-state'; import { useLoaderState } from '../../hooks'; -import Chunks, { type ChunksController } from '../Chunks'; +import Chunks, { + type ChunksController, + type OpenChunkPosition, +} from '../Chunks'; import PathHeader, { type Crumb } from './PathHeader'; import SourceNavigator from './SourceNavigator'; import SkeletonLoader from '../SkeletonLoader'; @@ -207,9 +210,8 @@ const TreeBrowser: React.FC = ({ } | null>(null); const mountedRef = useRef(true); // The open file's open chunk (its crumb), reported by Chunks. - const [openChunkPosition, setOpenChunkPosition] = useState( - null, - ); + const [openChunkPosition, setOpenChunkPosition] = + useState(null); const chunksControllerRef = useRef(null); useEffect( @@ -587,7 +589,10 @@ const TreeBrowser: React.FC = ({ ...(chunkOpen ? [ { - label: t('settings.sources.chunkCrumb', { n: openChunkPosition }), + label: + openChunkPosition === 'unplaced' + ? t('settings.sources.chunkCrumbUnplaced') + : t('settings.sources.chunkCrumb', { n: openChunkPosition }), }, ] : []),