From efa3ea4879e1e67c11d9e383916e4a00ec327891 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 21 Oct 2026 21:52:17 +0200 Subject: [PATCH] fix(assistant): the found places of every search in one strip An answer that searched several times showed only its last search, about ten places. The strip now holds the places of all its searches, each once (the same Google or OSM id, or the same name at the same spot), and shows up to 60. --- .../Assistant/AssistantPlaceStrip.tsx | 2 +- .../Assistant/assistantParts.test.ts | 12 +++--- .../components/Assistant/assistantParts.ts | 41 +++++++++++++++++-- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/client/src/components/Assistant/AssistantPlaceStrip.tsx b/client/src/components/Assistant/AssistantPlaceStrip.tsx index f50de2b48..da0acd3ae 100644 --- a/client/src/components/Assistant/AssistantPlaceStrip.tsx +++ b/client/src/components/Assistant/AssistantPlaceStrip.tsx @@ -9,7 +9,7 @@ import { useAssistantView } from './assistantViewContext'; import { useAssistantPlaceStrip } from './useAssistantPlaceStrip'; /** Cards the strip shows, every one the server sends; what it left out is counted on the tile at its end. */ -const SHOWN = 20; +const SHOWN = 60; // In the strip's head, never over a card: a press that ends a pixel off still lands on the arrow. const ARROW = diff --git a/client/src/components/Assistant/assistantParts.test.ts b/client/src/components/Assistant/assistantParts.test.ts index ef285c47b..ace1d1624 100644 --- a/client/src/components/Assistant/assistantParts.test.ts +++ b/client/src/components/Assistant/assistantParts.test.ts @@ -183,21 +183,23 @@ function adds(id: string, spot: { lat: number; lng: number } | null, placeId: nu } describe('messageBlocks: found places and weather', () => { - it('FE-AI-PARTS-008: shows only the newest search, and steps after it join the row before it', () => { + it('FE-AI-PARTS-008: joins the places of all searches into one strip, each once, and steps after it join the row before it', () => { const blocks = messageBlocks( message([ read('s1', []), - places('s1', [found('s1:0')]), + places('s1', [found('s1:0'), found('s1:1', { googlePlaceId: 'ChIJa' })], 2), read('s2', []), - places('s2', [found('s2:0'), found('s2:1', { lat: 40 })], 3), - { type: 'text', text: 'Two good ones.' }, + places('s2', [found('s2:0', { name: 'Place s1:0' }), found('s2:1', { googlePlaceId: 'ChIJa', lat: 40 })], 3), + places('s3', [found('s3:0', { lat: 40 })]), + { type: 'text', text: 'Three good ones.' }, ]), false ); expect(blocks.map((b) => b.kind)).toEqual(['steps', 'places', 'text']); expect(blocks[0]).toMatchObject({ steps: [{ toolCallId: 's1' }, { toolCallId: 's2' }] }); + // The same name at the same spot, or the same Google id, shows once. expect(blocks[1]).toMatchObject({ - found: { toolCallId: 's2', more: 3, places: [{ ref: 's2:0' }, { ref: 's2:1' }] }, + found: { toolCallId: 's3', more: 5, places: [{ ref: 's1:0' }, { ref: 's1:1' }, { ref: 's3:0' }] }, }); }); diff --git a/client/src/components/Assistant/assistantParts.ts b/client/src/components/Assistant/assistantParts.ts index 43acab133..ad9504526 100644 --- a/client/src/components/Assistant/assistantParts.ts +++ b/client/src/components/Assistant/assistantParts.ts @@ -82,6 +82,37 @@ function weatherDays(parts: readonly AssistantMessagePart[]): AssistantWeatherDa return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); } +/** The handles one place goes by: its ids, and its name at its spot (to about 100 m). */ +function placeKeys(place: AssistantFoundPlace): string[] { + const ids = [place.googlePlaceId && `g:${place.googlePlaceId}`, place.osmId && `o:${place.osmId}`]; + const spot = `n:${place.name.trim().toLowerCase()}@${place.lat.toFixed(3)},${place.lng.toFixed(3)}`; + return [...ids.filter((key): key is string => !!key), spot]; +} + +/** + * The places all searches of an answer found, in the order they came, each + * once (also when two searches name it by different ids, as long as name and + * spot agree), with what the searches left out counted together. + */ +function allFound(parts: AssistantMessage['parts']): AssistantPlaces { + const seen = new Set(); + const places: AssistantFoundPlace[] = []; + let more = 0; + let toolCallId = ''; + for (const part of parts) { + if (part.type !== 'data-trek-places') continue; + toolCallId = part.data.toolCallId; + more += part.data.more; + for (const place of part.data.places) { + const keys = placeKeys(place); + if (keys.some((key) => seen.has(key))) continue; + keys.forEach((key) => seen.add(key)); + places.push(place); + } + } + return { toolCallId, places, more }; +} + /** * Adds a change to the change block before it, or starts one. A suggestion * from the strip is a block of its own, decided apart from the changes before @@ -121,8 +152,9 @@ function changeState(part: ToolCallPart, applied: AssistantApplied | null): Chan * message is still being streamed, so a step without a result is running and * not merely unanswered. * - * Only the newest search shows its places, without the ones a change the - * model proposed in the answer already holds; the weather of all steps joins + * The places of all its searches join into one strip where the newest search + * sat, each place once, without the ones a change the model proposed in the + * answer already holds; the weather of all steps joins * into one row where the first look sat. A strip does not split the steps * around it: a step after it joins the row of steps before it. */ @@ -152,8 +184,9 @@ export function messageBlocks(message: AssistantMessage, writing: boolean): Mess return; } if (part.type === 'data-trek-places' && index === lastPlaces) { - const places = part.data.places.filter((place) => !proposed(place, effects)); - if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...part.data, places } }); + const found = allFound(message.parts); + const places = found.places.filter((place) => !proposed(place, effects)); + if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } }); return; } if (part.type === 'data-trek-weather' && index === firstWeather) {