From b12f5b6c81de907cbeb96bae1740a088ef852206 Mon Sep 17 00:00:00 2001 From: Pavel Date: Mon, 28 Sep 2026 19:02:50 +0400 Subject: [PATCH] Chunk nav fixed --- frontend/src/components/Chunks.test.tsx | 13 ++++- frontend/src/components/Chunks.tsx | 78 +++++++++++++++++-------- frontend/src/locale/de.json | 3 + frontend/src/locale/en.json | 3 + frontend/src/locale/es.json | 3 + frontend/src/locale/jp.json | 3 + frontend/src/locale/ru.json | 3 + frontend/src/locale/zh-TW.json | 3 + frontend/src/locale/zh.json | 3 + 9 files changed, 87 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/Chunks.test.tsx b/frontend/src/components/Chunks.test.tsx index c1778a9a..42a1e372 100644 --- a/frontend/src/components/Chunks.test.tsx +++ b/frontend/src/components/Chunks.test.tsx @@ -507,6 +507,8 @@ describe('Chunks', () => { await act(async () => buttonByText('modals.chunk.save')!.click()); expect(drawer()).toBeNull(); expect(container.querySelector('h2')?.textContent).toBe('Rewritten'); + expect(container.textContent).toContain('chunkPositionUnplaced'); + expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true); }); it('deleting the only chunk on the last page lands on the page before', async () => { @@ -789,7 +791,7 @@ describe('Chunks', () => { expect(container.querySelector('h1')?.textContent).toBe('Second'); }); - it('an edited chunk the store moved off the probed positions keeps what is shown', async () => { + it('an edited chunk the store moved off the probed positions keeps its text but not its place', async () => { serveStore(1); await render({ embedded: true }); const tiles = container.querySelectorAll( @@ -799,7 +801,14 @@ describe('Chunks', () => { service.getDocumentChunks.mockClear(); await editOpenChunk('# First edited'); expect(container.querySelector('h1')?.textContent).toBe('First edited'); - expect(position()).toEqual([1, 3]); + // Its position is unknown: no number is claimed and paging is off, so + // previous / next can't step from a place the chunk no longer holds. + expect(container.textContent).not.toMatch(/chunkPosition \{/); + expect(container.textContent).toContain('chunkPositionUnplaced'); + expect(buttonByLabel('settings.sources.previousChunk')!.disabled).toBe( + true, + ); + expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true); // Only the open and last positions are probed: the whole filtered list // (one page of `total`) is never fetched. const sizes = service.getDocumentChunks.mock.calls.map( diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx index bd1c4039..ae6e0ddc 100644 --- a/frontend/src/components/Chunks.tsx +++ b/frontend/src/components/Chunks.tsx @@ -111,6 +111,9 @@ const Chunks: React.FC = ({ const [openChunk, setOpenChunk] = useState(null); // The open chunk's position in the whole filtered list (1-based). const [openPosition, setOpenPosition] = useState(0); + // Set when a save leaves the open chunk off every probed position: its + // place in the list is unknown, so position labels and paging are off. + const [positionLost, setPositionLost] = useState(false); // A previous / next fetch in flight. const [stepping, setStepping] = useState(false); const stepRef = useRef(0); @@ -197,6 +200,7 @@ const Chunks: React.FC = ({ if (chunk) { setOpenChunk(chunk); setOpenPosition(position); + setPositionLost(false); } } catch (error) { console.error(error); @@ -214,7 +218,10 @@ const Chunks: React.FC = ({ * position (the chunk stayed), then the last one (a store that re-adds the * copy at the end). Each probe matches by id, so no store ordering is * assumed. When neither holds it (moved elsewhere, or a search it no longer - * matches), what is shown stays: the whole filtered list is never fetched. + * matches), the saved text stays but its position is unknown, so the + * position labels drop the number and previous / next are off until the + * reader closes; the whole filtered list is never fetched. A failed or + * superseded probe leaves what is shown. * * @param chunk The chunk just saved, with its new id. */ @@ -237,6 +244,7 @@ const Chunks: React.FC = ({ setTotalChunks(total); setOpenChunk(stored); setOpenPosition(at); + setPositionLost(false); }; try { const here = await fetchPage(openPosition, 1); @@ -248,12 +256,17 @@ const Chunks: React.FC = ({ show(stored, openPosition, total); return; } - if (total < 1) return; - if (total === openPosition) return; - const last = await fetchPage(total, 1); - if (!last) return; - const lastChunk: ChunkType | undefined = last.chunks?.[0]; - if (lastChunk?.doc_id === chunk.doc_id) show(lastChunk, total, total); + if (total >= 1 && total !== openPosition) { + const last = await fetchPage(total, 1); + if (!last) return; + const lastChunk: ChunkType | undefined = last.chunks?.[0]; + if (lastChunk?.doc_id === chunk.doc_id) { + show(lastChunk, total, total); + return; + } + } + setTotalChunks(total); + setPositionLost(true); } catch (error) { console.error(error); } @@ -296,6 +309,7 @@ const Chunks: React.FC = ({ const showChunk = (chunk: ChunkType, position: number) => { setOpenChunk(chunk); setOpenPosition(position); + setPositionLost(false); }; /** @@ -459,8 +473,10 @@ const Chunks: React.FC = ({ setPage(1); }, [path]); - const canGoPrevious = !!openChunk && !stepping && openPosition > 1; - const canGoNext = !!openChunk && !stepping && openPosition < totalChunks; + const canGoPrevious = + !!openChunk && !stepping && !positionLost && openPosition > 1; + const canGoNext = + !!openChunk && !stepping && !positionLost && openPosition < totalChunks; // ← / → walk the chunks while nothing else owns the keys. const goToChunkRef = useRef(goToChunk); @@ -490,7 +506,13 @@ const Chunks: React.FC = ({ onSelect: openChunk ? () => closeChunk() : undefined, }, ...(openChunk - ? [{ label: t('settings.sources.chunkCrumb', { n: openPosition }) }] + ? [ + { + label: positionLost + ? t('settings.sources.chunkCrumbUnplaced') + : t('settings.sources.chunkCrumb', { n: openPosition }), + }, + ] : []), ]} byline={ @@ -609,11 +631,15 @@ const Chunks: React.FC = ({ - {t('settings.sources.chunkPosition', { - n: openPosition, - total: totalChunks, - tokens: formatChunkTokens(chunk.metadata), - })} + {positionLost + ? t('settings.sources.chunkPositionUnplaced', { + tokens: formatChunkTokens(chunk.metadata), + }) + : t('settings.sources.chunkPosition', { + n: openPosition, + total: totalChunks, + tokens: formatChunkTokens(chunk.metadata), + })} } actions={ @@ -723,14 +749,20 @@ const Chunks: React.FC = ({ : t('settings.sources.addChunk') } description={ - editing - ? t('settings.sources.editChunkDescription', { - file: fileLabel, - n: openPosition, - tokens: formatChunkTokens(openChunk?.metadata), - interpolation: { escapeValue: false }, - }) - : fileLabel || undefined + !editing + ? fileLabel || undefined + : positionLost + ? t('settings.sources.editChunkDescriptionUnplaced', { + file: fileLabel, + tokens: formatChunkTokens(openChunk?.metadata), + interpolation: { escapeValue: false }, + }) + : t('settings.sources.editChunkDescription', { + file: fileLabel, + n: openPosition, + tokens: formatChunkTokens(openChunk?.metadata), + interpolation: { escapeValue: false }, + }) } value={draft} onChange={setDraft} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 777ac34f..d1c06fc5 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -411,6 +411,9 @@ }, "chunkPosition": "Chunk {{n}} von {{total}} · {{tokens}} Tokens", "chunkCrumb": "Chunk {{n}}", + "chunkCrumbUnplaced": "Bearbeiteter Chunk", + "chunkPositionUnplaced": "Bearbeiteter Chunk · {{tokens}} Tokens", + "editChunkDescriptionUnplaced": "{{file}} · bearbeiteter Chunk · {{tokens}} Tokens", "chunkTileMeta": "#{{n}} · {{tokens}} Tokens", "copyText": "Text kopieren", "chunkTitleHint": "Der Name, unter dem Antworten diesen Chunk zitieren.", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 64710d2a..85db5a4c 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -416,6 +416,9 @@ }, "chunkPosition": "Chunk {{n}} of {{total}} · {{tokens}} tokens", "chunkCrumb": "Chunk {{n}}", + "chunkCrumbUnplaced": "Edited chunk", + "chunkPositionUnplaced": "Edited chunk · {{tokens}} tokens", + "editChunkDescriptionUnplaced": "{{file}} · edited chunk · {{tokens}} tokens", "chunkTileMeta": "#{{n}} · {{tokens}} tokens", "copyText": "Copy text", "chunkTitleHint": "The name answers cite this chunk by.", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index be8701d6..fb2eeb4f 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -411,6 +411,9 @@ }, "chunkPosition": "Fragmento {{n}} de {{total}} · {{tokens}} tokens", "chunkCrumb": "Fragmento {{n}}", + "chunkCrumbUnplaced": "Fragmento editado", + "chunkPositionUnplaced": "Fragmento editado · {{tokens}} tokens", + "editChunkDescriptionUnplaced": "{{file}} · fragmento editado · {{tokens}} tokens", "chunkTileMeta": "#{{n}} · {{tokens}} tokens", "copyText": "Copiar texto", "chunkTitleHint": "El nombre con el que las respuestas citan este fragmento.", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 55cfe814..7b4ce48a 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -404,6 +404,9 @@ }, "chunkPosition": "チャンク {{n}} / {{total}} · {{tokens}} トークン", "chunkCrumb": "チャンク {{n}}", + "chunkCrumbUnplaced": "編集済みチャンク", + "chunkPositionUnplaced": "編集済みチャンク · {{tokens}} トークン", + "editChunkDescriptionUnplaced": "{{file}} · 編集済みチャンク · {{tokens}} トークン", "chunkTileMeta": "#{{n}} · {{tokens}} トークン", "copyText": "テキストをコピー", "chunkTitleHint": "回答がこのチャンクを引用するときの名前です。", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index fd694e1e..d7e48ac6 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -435,6 +435,9 @@ }, "chunkPosition": "Фрагмент {{n}} из {{total}} · токенов: {{tokens}}", "chunkCrumb": "Фрагмент {{n}}", + "chunkCrumbUnplaced": "Изменённый фрагмент", + "chunkPositionUnplaced": "Изменённый фрагмент · токенов: {{tokens}}", + "editChunkDescriptionUnplaced": "{{file}} · изменённый фрагмент · токенов: {{tokens}}", "chunkTileMeta": "#{{n}} · токенов: {{tokens}}", "copyText": "Копировать текст", "chunkTitleHint": "Название, под которым ответы ссылаются на этот фрагмент.", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 35d1aebc..3d3f5e40 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -404,6 +404,9 @@ }, "chunkPosition": "第 {{n}} 個文本塊,共 {{total}} 個 · {{tokens}} Token", "chunkCrumb": "第 {{n}} 個文本塊", + "chunkCrumbUnplaced": "已編輯的文本塊", + "chunkPositionUnplaced": "已編輯的文本塊 · {{tokens}} Token", + "editChunkDescriptionUnplaced": "{{file}} · 已編輯的文本塊 · {{tokens}} Token", "chunkTileMeta": "#{{n}} · {{tokens}} Token", "copyText": "複製文字", "chunkTitleHint": "回答引用此文本塊時使用的名稱。", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index e63ea714..34bb52b6 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -404,6 +404,9 @@ }, "chunkPosition": "第 {{n}} 个文本块,共 {{total}} 个 · {{tokens}} 个令牌", "chunkCrumb": "第 {{n}} 个文本块", + "chunkCrumbUnplaced": "已编辑的文本块", + "chunkPositionUnplaced": "已编辑的文本块 · {{tokens}} 个令牌", + "editChunkDescriptionUnplaced": "{{file}} · 已编辑的文本块 · {{tokens}} 个令牌", "chunkTileMeta": "#{{n}} · {{tokens}} 个令牌", "copyText": "复制文本", "chunkTitleHint": "回答引用此文本块时使用的名称。",