Ryker_Feng 6a4e5a3bb2
feat(frontend): add side conversations for quoted follow-ups (#3934)
* feat(frontend): add side conversations for quoted follow-ups

* style(frontend): apply prettier formatting to sidecar-chat files

* fix(frontend): surface sidecar cascade cleanup failures via console.warn

Previously deleteSidecarThreadsForParent silently swallowed both
lookup errors and per-thread deletion failures, so parent thread
deletions could succeed while orphaning sidecar threads with no
signal to the caller. Log a warning that includes the parent id
and the failed thread ids/reasons so the leak is discoverable in
telemetry, matching the existing console.warn/error pattern in
this file.

* fix(frontend): address all sidecar review feedback

Resolve every reviewer comment on PR #3934:

- input-box/hooks/sidecar-panel: clear quoted references only via an
  `onSent` callback that fires after the in-flight guard, so a dropped
  send no longer silently discards quotes (willem-bd #3550).
- message-list: flip the selection toolbar below the selection when it
  would clip above the viewport (willem-bd #3551).
- reference-metadata/thread/input-box: keep referenced ids, roles, and
  count arrays 1:1 parallel instead of deduping ids (willem-bd #3552).
- message-list: widen selection containment to the shared assistant-turn
  container and hint when a selection crosses messages (willem-bd #3553).
- sidecar/api: coalesce concurrent sidecar creates for one parent behind
  a single in-flight promise to prevent duplicates (willem-bd #3554).
- sidecar-trigger/context: force-restore on trigger click so a sidecar
  deleted elsewhere self-heals instead of opening a dead thread
  (willem-bd #3555).
- threads/hooks: surface sidecar cascade cleanup failures via
  console.warn for both lookup and per-thread deletes (Copilot).

Add unit + e2e coverage for parallel metadata, atomic create, and
trigger self-healing.
2026-07-05 00:12:16 +08:00

101 lines
2.7 KiB
TypeScript

import { getAPIClient } from "@/core/api";
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { AgentThread } from "@/core/threads";
import type { SidecarContext } from "./context";
import {
SIDECAR_METADATA_KEY,
buildSidecarThreadMetadata,
isSidecarThread,
} from "./thread";
type SidecarThreadSearchClient = {
threads: {
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
};
};
// The find-then-create flow is two independent round-trips with no backend
// upsert, so a double-click on "Ask in side chat" or two callers racing on the
// same parent thread can each create a duplicate sidecar thread. Coalesce
// concurrent creates for the same parent behind a single in-flight promise so
// only one thread is created; the entry is cleared once it settles.
const inFlightCreates = new Map<string, Promise<AgentThread>>();
export async function createSidecarThread({
parentThreadId,
context,
}: {
parentThreadId: string;
context: SidecarContext | SidecarContext[];
}): Promise<AgentThread> {
const inFlight = inFlightCreates.get(parentThreadId);
if (inFlight) {
return inFlight;
}
const request = createSidecarThreadRequest({ parentThreadId, context });
inFlightCreates.set(parentThreadId, request);
try {
return await request;
} finally {
if (inFlightCreates.get(parentThreadId) === request) {
inFlightCreates.delete(parentThreadId);
}
}
}
async function createSidecarThreadRequest({
parentThreadId,
context,
}: {
parentThreadId: string;
context: SidecarContext | SidecarContext[];
}): Promise<AgentThread> {
const response = await fetchWithAuth(`${getBackendBaseURL()}/api/threads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: buildSidecarThreadMetadata(parentThreadId, context),
}),
});
if (!response.ok) {
throw new Error("Failed to create side conversation.");
}
return (await response.json()) as AgentThread;
}
export async function findLatestSidecarThread({
parentThreadId,
isMock,
apiClient = getAPIClient(isMock) as SidecarThreadSearchClient,
}: {
parentThreadId: string;
isMock?: boolean;
apiClient?: SidecarThreadSearchClient;
}): Promise<AgentThread | null> {
const response = await apiClient.threads.search({
metadata: {
[SIDECAR_METADATA_KEY]: true,
parent_thread_id: parentThreadId,
},
limit: 1,
offset: 0,
sortBy: "updated_at",
sortOrder: "desc",
});
return (
response.find(
(thread) =>
isSidecarThread(thread) &&
thread.metadata?.parent_thread_id === parentThreadId,
) ?? null
);
}