Skip to content

Commit cc8de5f

Browse files
feat(chat): show nothing for a silent comparison shortcut (#7275)
* feat(chat): show nothing for a silent comparison shortcut A comparison shortcut configured with an empty displayText persists a textless user row and echoes an empty display text. ChatMessages now renders no bubble for a user message without visible text, including the pending sentinel (tagged by openChat as metadata.promptShortcut), so the click goes straight to the agent's answer. The indices are kept so the loader and suggestions still line up. The compare bar is added to the showcase's agentic view and to the default-theme js and react examples, and the compare render state is exposed on IndexRenderState. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(compare): name the product in the Compare checkbox and clarify who sets the transcript text Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e0129e4 commit cc8de5f

19 files changed

Lines changed: 469 additions & 55 deletions

File tree

‎examples/js/default-theme/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
<div id="chat"></div>
3636
<div id="chat-trigger"></div>
37+
<div id="compare-bar"></div>
3738

3839
<script type="module" src="./src/app.js"></script>
3940
</body>

‎examples/js/default-theme/src/app.css‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ img {
5656
color: lightslategray;
5757
}
5858

59+
.Hit-compare {
60+
display: flex;
61+
align-items: center;
62+
gap: 0.25rem;
63+
margin-left: 1rem;
64+
color: lightslategray;
65+
font-size: 0.875rem;
66+
}
67+
5968
.Pagination {
6069
display: flex;
6170
justify-content: center;

‎examples/js/default-theme/src/app.js‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
queryRuleCustomData,
2424
chat,
2525
chatTrigger,
26+
compareBar,
2627
} from 'instantsearch.js/es/widgets';
2728

2829
import { createTabsWidget, createRefreshButton } from './helpers';
@@ -46,6 +47,33 @@ const search = instantsearch({
4647

4748
const customQueryRuleContext = connectQueryRules(() => {});
4849

50+
// The compare selection lives in the shared render state, so the per-hit
51+
// toggle reads and writes the same selection the `compareBar` widget shows.
52+
const hitTemplate = (hit, { html, components }) => {
53+
const compare = search.renderState.instant_search?.compare;
54+
const selected = compare?.isSelected(hit.objectID) ?? false;
55+
56+
return html`
57+
<span class="Hit-label">
58+
${components.Highlight({ hit, attribute: 'name' })}
59+
</span>
60+
<span class="Hit-price">$${hit.price}</span>
61+
${compare &&
62+
html`
63+
<label class="Hit-compare">
64+
<input
65+
type="checkbox"
66+
aria-label="Compare ${hit.name}"
67+
checked=${selected}
68+
disabled=${!selected && !compare.canAddItems}
69+
onChange=${() => compare.toggleItem(hit)}
70+
/>
71+
Compare
72+
</label>
73+
`}
74+
`;
75+
};
76+
4977
const itemTemplate = (item, { html }) => html`
5078
<article class="ais-Carousel-hit">
5179
<div class="ais-Carousel-hit-image">
@@ -198,12 +226,7 @@ search.addWidgets([
198226
hits({
199227
container,
200228
templates: {
201-
item: (hit, { html, components }) => html`
202-
<span class="Hit-label">
203-
${components.Highlight({ hit, attribute: 'name' })}
204-
</span>
205-
<span class="Hit-price">$${hit.price}</span>
206-
`,
229+
item: hitTemplate,
207230
},
208231
}),
209232
secondaryWidgetFactory: (container) =>
@@ -221,12 +244,7 @@ search.addWidgets([
221244
container,
222245
showPrevious: true,
223246
templates: {
224-
item: (hit, { html, components }) => html`
225-
<span class="Hit-label">
226-
${components.Highlight({ hit, attribute: 'name' })}
227-
</span>
228-
<span class="Hit-price">$${hit.price}</span>
229-
`,
247+
item: hitTemplate,
230248
},
231249
}),
232250
},
@@ -243,6 +261,9 @@ search.addWidgets([
243261
chatTrigger({
244262
container: '#chat-trigger',
245263
}),
264+
compareBar({
265+
container: '#compare-bar',
266+
}),
246267
]);
247268

248269
search.start();

‎examples/js/showcase/src/components/widgets/ProductCard.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import type { Hit, TemplateParams } from 'instantsearch.js';
2+
import type { CompareRenderState } from 'instantsearch.js/es/connectors/compare/connectCompare';
23

34
export function renderProductCard(
45
hit: Hit,
5-
{ html, components }: TemplateParams
6+
{ html, components }: TemplateParams,
7+
compare?: CompareRenderState
68
) {
9+
const selected = compare?.isSelected(hit.objectID) ?? false;
10+
711
return html`
812
<div class="flex h-full flex-col">
913
<div class="flex aspect-square items-center justify-center bg-white p-4">
@@ -32,6 +36,21 @@ export function renderProductCard(
3236
>
3337
$${hit.price?.toFixed(2)}
3438
</p>
39+
${compare &&
40+
html`
41+
<label
42+
class="flex items-center gap-2 pt-2 text-xs text-neutral-500 dark:text-neutral-400"
43+
>
44+
<input
45+
type="checkbox"
46+
aria-label="Compare ${hit.name}"
47+
checked=${selected}
48+
disabled=${!selected && !compare.canAddItems}
49+
onChange=${() => compare.toggleItem(hit)}
50+
/>
51+
Compare
52+
</label>
53+
`}
3554
</div>
3655
</div>
3756
`;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { compareBar } from 'instantsearch.js/es/widgets';
2+
3+
import { useWidget } from '../../hooks/useWidget';
4+
5+
export function WidgetCompareBar() {
6+
const ref = useWidget((el) => compareBar({ container: el }));
7+
8+
return (
9+
<div class="flex flex-col gap-3">
10+
<p class="text-xs text-neutral-500 dark:text-neutral-400">
11+
Tick "Compare" on two or three hits below. The bar docks to the bottom
12+
of the viewport and its Compare button opens the chat with the selected
13+
records attached.
14+
</p>
15+
<div ref={ref} />
16+
</div>
17+
);
18+
}

‎examples/js/showcase/src/components/widgets/WidgetHits.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { hits } from 'instantsearch.js/es/widgets';
22

3+
import { useSearch } from '../../context/search';
34
import { useWidget } from '../../hooks/useWidget';
45

56
import { renderProductCard } from './ProductCard';
@@ -15,3 +16,26 @@ export function WidgetHits() {
1516
);
1617
return <div ref={ref} />;
1718
}
19+
20+
/**
21+
* Hits with a "Compare" toggle on each card, wired to the `compareBar` widget
22+
* mounted on the same instance: the selection lives in the shared render
23+
* state, so the toggles and the bar stay in sync.
24+
*/
25+
export function WidgetHitsWithCompare() {
26+
const search = useSearch();
27+
const ref = useWidget((el) =>
28+
hits({
29+
container: el,
30+
templates: {
31+
item: (hit, helpers) =>
32+
renderProductCard(
33+
hit,
34+
helpers,
35+
search.renderState[search.indexName]?.compare
36+
),
37+
},
38+
})
39+
);
40+
return <div ref={ref} />;
41+
}

‎examples/js/showcase/src/views/AgenticView.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { ChatLayoutSwitcher } from '../components/ChatLayoutSwitcher';
66
import { WidgetAiAutocomplete } from '../components/widgets/WidgetAiAutocomplete';
77
import { WidgetChat, type ChatLayout } from '../components/widgets/WidgetChat';
88
import { WidgetChatTrigger } from '../components/widgets/WidgetChatTrigger';
9-
import { WidgetHits } from '../components/widgets/WidgetHits';
9+
import { WidgetCompareBar } from '../components/widgets/WidgetCompareBar';
10+
import { WidgetHitsWithCompare } from '../components/widgets/WidgetHits';
1011
import {
1112
WidgetPromptSuggestionsCustom,
1213
WidgetPromptSuggestionsPdp,
@@ -121,8 +122,22 @@ export function AgenticView() {
121122
widgets={[{ title: 'resultCard', body: WidgetResultCard }]}
122123
/>
123124

124-
{/* Row 5: Hits */}
125-
<WidgetSwitcher widgets={[{ title: 'hits', body: WidgetHits }]} />
125+
{/* Row 5: CompareBar (the chat above is its hand-off target; the
126+
hits below carry the per-hit toggles that fill its selection) */}
127+
<WidgetSwitcher
128+
widgets={[{ title: 'compareBar', body: WidgetCompareBar }]}
129+
/>
130+
131+
{/* Row 6: Hits, with Compare toggles */}
132+
<WidgetSwitcher
133+
widgets={[
134+
{
135+
title: 'hits (+ compare)',
136+
body: WidgetHitsWithCompare,
137+
docs: ['hits', 'compareBar'],
138+
},
139+
]}
140+
/>
126141
</div>
127142
</ChatLayoutContext.Provider>
128143
</SearchContext.Provider>

‎examples/react/default-theme/src/App.css‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ img {
5656
color: lightslategray;
5757
}
5858

59+
.Hit-compare {
60+
display: flex;
61+
align-items: center;
62+
gap: 0.25rem;
63+
margin-left: 1rem;
64+
color: lightslategray;
65+
font-size: 0.875rem;
66+
}
67+
5968
.Pagination {
6069
display: flex;
6170
justify-content: center;

‎examples/react/default-theme/src/App.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
ToggleRefinement,
2424
Chat,
2525
ChatTrigger,
26+
CompareBar,
27+
useCompare,
2628
} from 'react-instantsearch';
2729

2830
import {
@@ -51,10 +53,25 @@ type HitProps = {
5153
};
5254

5355
function Hit({ hit }: HitProps) {
56+
// Every `useCompare` call shares one selection with the `<CompareBar>`, so
57+
// this toggle and the bar stay in sync.
58+
const { isSelected, toggleItem, canAddItems } = useCompare();
59+
const selected = isSelected(hit.objectID);
60+
5461
return (
5562
<>
5663
<Highlight hit={hit} attribute="name" className="Hit-label" />
5764
<span className="Hit-price">${hit.price}</span>
65+
<label className="Hit-compare">
66+
<input
67+
type="checkbox"
68+
aria-label={`Compare ${hit.name}`}
69+
checked={selected}
70+
disabled={!selected && !canAddItems}
71+
onChange={() => toggleItem(hit)}
72+
/>
73+
Compare
74+
</label>
5875
</>
5976
);
6077
}
@@ -205,6 +222,7 @@ export function App() {
205222
itemComponent={ItemComponent}
206223
/>
207224
<ChatTrigger />
225+
<CompareBar />
208226
</InstantSearch>
209227
);
210228
}

‎packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx‎

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isReasoningPartActive,
1010
isPartText,
1111
isPartTool,
12+
isSilentUserMessage,
1213
} from '../../lib/utils/chat';
1314
import { createButtonComponent } from '../Button';
1415

@@ -965,38 +966,43 @@ export function createChatMessagesComponent({
965966
/>
966967
)}
967968

968-
{messages.map((message, index) => (
969-
<DefaultMessage
970-
key={message.id}
971-
message={message}
972-
isCurrentMessage={index === messages.length - 1}
973-
status={status}
974-
userMessageProps={userMessageProps}
975-
assistantMessageProps={assistantMessageProps}
976-
indexUiState={indexUiState}
977-
setIndexUiState={setIndexUiState}
978-
onReload={onReload}
979-
onFeedback={onFeedback}
980-
feedbackState={feedbackState}
981-
actionsComponent={ActionsComponent}
982-
translations={translations}
983-
classNames={messageClassNames}
984-
messageTranslations={messageTranslations}
985-
context={context}
986-
suggestionsElement={
987-
status === 'ready' &&
988-
message.role === 'assistant' &&
989-
index === messages.length - 1
990-
? suggestionsElement
991-
: undefined
992-
}
993-
loaderElement={
994-
isLoaderInline && index === messages.length - 1
995-
? loaderElement
996-
: undefined
997-
}
998-
/>
999-
))}
969+
{/* A silent user message (a prompt shortcut with no display
970+
text, or one still awaiting it) stays in the transcript and
971+
on the wire but gets no bubble. */}
972+
{messages.map((message, index) =>
973+
isSilentUserMessage(message) ? null : (
974+
<DefaultMessage
975+
key={message.id}
976+
message={message}
977+
isCurrentMessage={index === messages.length - 1}
978+
status={status}
979+
userMessageProps={userMessageProps}
980+
assistantMessageProps={assistantMessageProps}
981+
indexUiState={indexUiState}
982+
setIndexUiState={setIndexUiState}
983+
onReload={onReload}
984+
onFeedback={onFeedback}
985+
feedbackState={feedbackState}
986+
actionsComponent={ActionsComponent}
987+
translations={translations}
988+
classNames={messageClassNames}
989+
messageTranslations={messageTranslations}
990+
context={context}
991+
suggestionsElement={
992+
status === 'ready' &&
993+
message.role === 'assistant' &&
994+
index === messages.length - 1
995+
? suggestionsElement
996+
: undefined
997+
}
998+
loaderElement={
999+
isLoaderInline && index === messages.length - 1
1000+
? loaderElement
1001+
: undefined
1002+
}
1003+
/>
1004+
)
1005+
)}
10001006

10011007
{!isLoaderInline && loaderElement}
10021008

0 commit comments

Comments
 (0)