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) {