fix(sidecar): correct text-selection toolbar actions inside the side chat (#3959)

* fix(sidecar): correct text-selection toolbar actions inside the side chat

The sidecar panel reuses MessageList, but it did not distinguish the side
chat surface from the main conversation, so selecting text inside the side
chat behaved as if it were the main list:

- The "Ask in side chat" action was shown even though the user is already
  in the side chat, which is a no-op interaction.
- "Add to conversation" routed the snippet to the main composer's quotes
  (conversationQuotes) instead of the side chat's own composer, so the
  reference landed in the wrong input box.

Add a `sidecarSurface` prop to MessageList. On the sidecar surface, hide
"Ask in side chat" and route "Add to conversation" to `sidecar.openContext`
so the snippet attaches to the side chat's own composer (activeReferences).
Main-list behavior is unchanged.

* docs(sidecar): drop AGENTS.md note for the toolbar surface change

The sidecarSurface behavior is self-evident from the code; no dedicated
Interaction Ownership entry is needed.
This commit is contained in:
Ryker_Feng 2026-07-06 14:37:31 +08:00 committed by GitHub
parent eb5eb9c574
commit cb83edb0da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 56 additions and 31 deletions

View File

@ -212,6 +212,7 @@ export function MessageList({
canRegenerate = false,
canBranch = false,
enableSidecarActions = true,
sidecarSurface = false,
initialScroll = "smooth",
resizeScroll = "smooth",
}: {
@ -235,6 +236,7 @@ export function MessageList({
canRegenerate?: boolean;
canBranch?: boolean;
enableSidecarActions?: boolean;
sidecarSurface?: boolean;
initialScroll?: ConversationProps["initial"];
resizeScroll?: ConversationProps["resize"];
}) {
@ -417,10 +419,18 @@ export function MessageList({
if (!selectionToolbar) {
return;
}
sidecar?.addContextToConversation(selectionToolbar.context);
// On the sidecar surface, "add to conversation" targets the side chat's
// own composer (activeReferences) rather than the main composer's quotes,
// so the selected snippet is attached to the conversation the user is
// actually reading.
if (sidecarSurface) {
sidecar?.openContext(selectionToolbar.context);
} else {
sidecar?.addContextToConversation(selectionToolbar.context);
}
window.getSelection()?.removeAllRanges();
setSelectionToolbar(null);
}, [selectionToolbar, sidecar]);
}, [selectionToolbar, sidecar, sidecarSurface]);
const handleAskSelectionInSidecar = useCallback(() => {
if (!selectionToolbar) {
@ -873,17 +883,19 @@ export function MessageList({
<MessageCircleIcon className="size-3.5" />
{t.sidecar.addToConversation}
</Button>
<Button
className="h-8 rounded-full px-2.5 text-xs"
size="sm"
type="button"
variant="ghost"
onClick={handleAskSelectionInSidecar}
onMouseDown={(event) => event.preventDefault()}
>
<MessageSquarePlusIcon className="size-3.5" />
{t.sidecar.askInSideChat}
</Button>
{!sidecarSurface && (
<Button
className="h-8 rounded-full px-2.5 text-xs"
size="sm"
type="button"
variant="ghost"
onClick={handleAskSelectionInSidecar}
onMouseDown={(event) => event.preventDefault()}
>
<MessageSquarePlusIcon className="size-3.5" />
{t.sidecar.askInSideChat}
</Button>
)}
<Button
aria-label={t.common.close}
className="size-8 rounded-full"

View File

@ -478,6 +478,7 @@ export function SidecarPanel({ className }: { className?: string }) {
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
sidecarSurface
initialScroll="instant"
resizeScroll="instant"
/>

View File

@ -105,6 +105,32 @@ async function selectTextAndClickToolbarButton(
throw lastError;
}
async function expectSidecarSelectionToolbarActions(page: Page, text: string) {
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await selectTextOnPage(page, text, "sidecar-message-list");
const labels = await page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLButtonElement>(
"[data-sidecar-selection-toolbar] button",
),
).map((button) => button.textContent?.trim() ?? ""),
);
expect(labels.some((label) => /add to conversation/i.test(label))).toBe(
true,
);
expect(labels.some((label) => /ask in side chat/i.test(label))).toBe(
false,
);
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function expectComposerHeightsEqual(page: Page) {
const metrics = await page.evaluate(() => {
const findFormByPlaceholder = (pattern: RegExp) => {
@ -943,30 +969,16 @@ test.describe("Side chat", () => {
.first(),
).toBeVisible();
// Selecting text inside the side chat itself only offers "Add to
// conversation" (no "Ask in side chat"), and the snippet attaches to the
// side chat's own composer rather than the main composer's quotes.
await expectSidecarSelectionToolbarActions(page, "Hello from DeerFlow!");
await selectTextAndClickToolbarButton(
page,
"Hello from DeerFlow!",
"Add to conversation",
"sidecar-message-list",
);
await expect(
mainInputForm.getByTestId("conversation-quote-attachment"),
).toContainText("1 selected text fragment");
await expect(sidecarReference).toBeHidden();
await mainInputForm
.getByTestId("conversation-quote-attachment")
.getByRole("button", { name: /clear selected references/i })
.click();
await expect(
mainInputForm.getByTestId("conversation-quote-attachment"),
).toBeHidden();
await selectTextAndClickToolbarButton(
page,
"Hello from DeerFlow!",
"Ask in side chat",
"sidecar-message-list",
);
await expect(sidecarReference).toContainText("1 selected text fragment");
await expect(
mainInputForm.getByTestId("conversation-quote-attachment"),