feat(frontend): reference conversations from the composer (#5465)

* feat(frontend): reference conversations from the composer

Adds a "Reference a conversation" button next to the attachment button,
shown only while GET /api/features reports read_conversation enabled. It
opens a picker over the recent-conversation list (current thread excluded,
capped at max_references) and shows removable chips in the composer.

On send the thread IDs ride SendMessageOptions.conversationReferences into
run context.conversation_references, which the Gateway consumes at
admission; the LangGraph SDK drops unknown top-level body fields. A
display-only copy ({thread_id, title}) on the visible human message lets
the transcript render read-only chips linking to the source.

References are per message: not persisted with the draft and cleared on
send or thread switch; regenerating or editing a turn runs without them
unless they are attached again.

Related to #5398. Depends on #5463.

* fix(frontend): pin the run-context contract and finish the picker states

Both thread.submit paths now build their run context through one exported
buildRunContext helper, tested directly: attached references travel as a
plain string[] under context.conversation_references only when the caller
passed them, a stray key in local settings is dropped instead of forwarded,
and the regenerate/edit replay path never carries references.

The picker shows a loading row while the conversation list is still in
flight instead of claiming there are no conversations, and the transcript
chip group is labelled with the previously unused referencedConversations
translation.

* fix(frontend): route conversation-reference chips to custom-agent sources

The picker offered custom-agent conversations but kept only the thread ID
and title, so transcript chips always linked to /workspace/chats/{id} and
dropped the source's custom-agent context on navigation.

Preserve the agent identity end to end: the picker now attaches
agentNameOfThread() (context first, then metadata.agent_name, mirroring
pathOfThread) to the selection, the display-only additional_kwargs metadata
round-trips it as agent_name, and the transcript chip passes it to
pathOfThread so custom-agent sources resolve to
/workspace/agents/{agent}/chats/{id}.

Tests: agent_name metadata round-trip and malformed-entry tolerance, picker
toggle carrying the metadata agent with run context winning, and a
picker-to-transcript regression pinning the /workspace/agents/writer/chats/
source-1 href.

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
This commit is contained in:
Totoro 2026-09-16 21:19:52 +08:00 committed by GitHub
parent f0cb67b223
commit 94e69d6ff7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1109 additions and 58 deletions

View File

@ -1545,7 +1545,10 @@ continuation, so the agent can read the rest; it asks for the missing part only
if that read is unavailable.
SDK clients that cannot add top-level request fields may send the same list as
`context.conversation_references`, and `GET /api/features` reports whether the
tool is enabled. There is no frontend selector or automatic history search. See
tool is enabled. When it is, the web composer shows a "Reference a conversation"
button next to the attachment button: pick up to three of your recent
conversations, and they are attached to the next message only, shown as chips
in the composer and in the transcript. There is no automatic history search. See
[configuration](backend/docs/CONFIGURATION.md#reading-referenced-conversations)
and the [request contract](backend/docs/API.md#referencing-a-previous-conversation).

View File

@ -102,6 +102,8 @@ The workspace-change card follows the same rule: it is resolved from `(threadId,
Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
Conversation references (`read_conversation`, opt-in on the backend) are attached from the composer. `ReferenceConversationsButton` (`components/workspace/conversation-references/`) renders only while `/api/features` reports `conversation_references.enabled`, opens a picker over the same `useThreads()` list the sidebar uses (the current thread excluded, capped at `max_references`), and shows removable chips in the composer header. On submit the thread IDs ride `InputBoxSubmitOptions.conversationReferences``SendMessageOptions.conversationReferences` → run `context.conversation_references`, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. `core/conversation-references` also writes display-only `additional_kwargs.conversation_references` (`{thread_id, title, agent_name?}`) on the visible human message so `message-list-item.tsx` can render read-only chips linking to the source — through `pathOfThread`, so custom-agent sources route to `/workspace/agents/{agent}/chats/{id}`; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again.
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.

View File

@ -0,0 +1,77 @@
import { MessagesSquareIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
/**
* Shared visual for an attached conversation: a removable chip in the
* composer, and a read-only chip (linking to the source) in the transcript.
*/
const CHIP_BASE_CLASS =
"border-border bg-muted text-foreground inline-flex h-6 max-w-60 shrink-0 items-center gap-1 rounded-md border px-1.5 text-xs leading-none font-medium shadow-xs";
export function ConversationReferenceChip({
title,
href,
className,
onRemove,
removeLabel,
}: {
title: string;
/** When provided (and the chip is not removable), the chip links to the source conversation. */
href?: string;
className?: string;
/** When provided, the chip renders as a removable button with a close icon. */
onRemove?: () => void;
removeLabel?: string;
}) {
const body = (
<>
<MessagesSquareIcon className="text-muted-foreground size-3 shrink-0" />
<span className="min-w-0 truncate">{title}</span>
</>
);
if (onRemove) {
return (
<button
aria-label={removeLabel ?? `Remove ${title}`}
className={cn(
CHIP_BASE_CLASS,
"hover:bg-accent cursor-pointer transition-colors",
className,
)}
data-testid="conversation-reference-chip"
onClick={onRemove}
type="button"
>
{body}
<XIcon className="text-muted-foreground size-2.5 shrink-0" />
</button>
);
}
if (href) {
return (
<Link
className={cn(
CHIP_BASE_CLASS,
"hover:bg-accent transition-colors",
className,
)}
data-testid="conversation-reference-chip"
href={href}
title={title}
>
{body}
</Link>
);
}
return (
<span
className={cn(CHIP_BASE_CLASS, className)}
data-testid="conversation-reference-chip"
title={title}
>
{body}
</span>
);
}

View File

@ -0,0 +1,128 @@
"use client";
import { CheckIcon } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import type { ConversationReference } from "@/core/conversation-references";
import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { agentNameOfThread, titleOfThread } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
type ConversationReferenceListProps = {
/** The conversation being composed in; it is never offered as a reference. */
currentThreadId: string;
selected: ConversationReference[];
maxReferences: number;
onToggle: (reference: ConversationReference) => void;
};
/**
* Recent conversations with a title filter. Selecting a row toggles it; once
* the per-message cap is reached, unselected rows are disabled while selected
* rows stay clickable so they can be removed.
*/
export function ConversationReferenceList({
currentThreadId,
selected,
maxReferences,
onToggle,
}: ConversationReferenceListProps) {
const { t } = useI18n();
const { data: threads, isPending } = useThreads();
const selectedIds = new Set(selected.map((reference) => reference.threadId));
const atCap = selected.length >= maxReferences;
const candidates = (threads ?? []).filter(
(thread) => thread.thread_id !== currentThreadId,
);
return (
<Command className="[&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2">
<CommandInput placeholder={t.inputBox.referenceConversationsSearch} />
<CommandList>
{isPending ? (
// Never claim there are no conversations before the list has loaded.
<div
className="text-muted-foreground py-6 text-center text-sm"
data-testid="conversation-reference-loading"
>
{t.common.loading}
</div>
) : (
<CommandEmpty>{t.inputBox.referenceConversationsEmpty}</CommandEmpty>
)}
<CommandGroup>
{candidates.map((thread) => {
const title = titleOfThread(thread);
const isSelected = selectedIds.has(thread.thread_id);
return (
<CommandItem
key={thread.thread_id}
className={cn("gap-2", isSelected && "text-accent-foreground")}
data-testid="conversation-reference-option"
disabled={atCap && !isSelected}
onSelect={() =>
onToggle({
threadId: thread.thread_id,
title,
// Preserve the source's agent identity so transcript chips
// link back to custom-agent conversations, not the default.
agentName: agentNameOfThread(thread),
})
}
value={`${title} ${thread.thread_id}`}
>
<span className="min-w-0 flex-1 truncate">{title}</span>
{isSelected ? (
<CheckIcon className="size-4 shrink-0" />
) : (
<span className="size-4 shrink-0" />
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
<p className="text-muted-foreground border-t px-3 py-2 text-xs">
{t.inputBox.referenceConversationsLimit(maxReferences)}
</p>
</Command>
);
}
export function ConversationReferencePicker({
open,
onOpenChange,
...listProps
}: ConversationReferenceListProps & {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0">
<DialogTitle className="sr-only">
{t.inputBox.referenceConversations}
</DialogTitle>
<DialogDescription className="sr-only">
{t.inputBox.referenceConversationsLimit(listProps.maxReferences)}
</DialogDescription>
<ConversationReferenceList {...listProps} />
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { MessagesSquareIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import type { ConversationReference } from "@/core/conversation-references";
import { useConversationReferencesCapability } from "@/core/features/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { Tooltip } from "../tooltip";
import { ConversationReferencePicker } from "./conversation-reference-picker";
/**
* Composer entry point for attaching conversations to the next message.
* Renders nothing unless `/api/features` reports `read_conversation` enabled,
* so deployments without the tool see no change.
*/
export function ReferenceConversationsButton({
className,
currentThreadId,
disabled,
references,
onChange,
}: {
className?: string;
currentThreadId: string;
disabled?: boolean;
references: ConversationReference[];
onChange: (references: ConversationReference[]) => void;
}) {
const { t } = useI18n();
const { enabled, maxReferences } = useConversationReferencesCapability();
const [open, setOpen] = useState(false);
const toggle = useCallback(
(reference: ConversationReference) => {
if (references.some((item) => item.threadId === reference.threadId)) {
onChange(
references.filter((item) => item.threadId !== reference.threadId),
);
return;
}
if (references.length >= maxReferences) {
return;
}
onChange([...references, reference]);
},
[references, maxReferences, onChange],
);
if (!enabled || maxReferences <= 0) {
return null;
}
return (
<>
<Tooltip content={t.inputBox.referenceConversations}>
<PromptInputButton
aria-label={t.inputBox.referenceConversations}
className={cn("gap-1 px-2!", className)}
data-testid="reference-conversations-button"
disabled={disabled}
onClick={() => setOpen(true)}
>
<MessagesSquareIcon className="size-3" />
{references.length > 0 && (
<span className="text-xs">{references.length}</span>
)}
</PromptInputButton>
</Tooltip>
<ConversationReferencePicker
currentThreadId={currentThreadId}
maxReferences={maxReferences}
onOpenChange={setOpen}
onToggle={toggle}
open={open}
selected={references}
/>
</>
);
}

View File

@ -71,6 +71,10 @@ import {
import { fetch } from "@/core/api/fetcher";
import { useAuth } from "@/core/auth/AuthProvider";
import { getBackendBaseURL } from "@/core/config";
import {
buildConversationReferenceMetadata,
type ConversationReference,
} from "@/core/conversation-references";
import { useI18n } from "@/core/i18n/hooks";
import { polishInputDraft } from "@/core/input-polish/api";
import {
@ -128,6 +132,8 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { ConversationReferenceChip } from "./conversation-references/conversation-reference-chip";
import { ReferenceConversationsButton } from "./conversation-references/reference-conversations-button";
import {
abortGoalRequest,
beginGoalRequest,
@ -226,6 +232,8 @@ function escapeXmlAttribute(value: string) {
export type InputBoxSubmitOptions = {
additionalKwargs?: Record<string, unknown>;
additionalInputMessages?: Message[];
/** Thread IDs attached through the conversation picker; sent as run context. */
conversationReferences?: string[];
onSent?: () => void;
};
@ -381,6 +389,14 @@ export function InputBox({
const setTextInput = textInput.setInput;
const sidecar = useMaybeSidecar();
const attachmentParts = attachments.files;
// Conversations attached for the next message only. Not persisted with the
// draft; cleared once a send proceeds or the composer moves to another thread.
const [conversationReferences, setConversationReferences] = useState<
ConversationReference[]
>([]);
useEffect(() => {
setConversationReferences([]);
}, [threadId]);
const removeAttachment = attachments.remove;
// Project documents attached from the shelf arrive already ingested
// thread-side (spec §9): the composer shows them as completed attachments
@ -1125,6 +1141,9 @@ export function InputBox({
const quoteIds = quotes.map((quote) => quote.id);
const quoteContexts = quotes.map((quote) => quote.context);
pendingDraftSubmissionKeyRef.current = draftKey;
const referenceIds = conversationReferences.map(
(reference) => reference.threadId,
);
// Project-shelf attachments are already ingested thread-side (§9):
// they join ``additional_kwargs.files`` as completed uploads without a
// re-upload, and merge with any files uploaded in this send
@ -1137,18 +1156,15 @@ export function InputBox({
status: "uploaded" as const,
}),
);
const quoteKwargs = quotes.length
? buildReferenceMessageMetadata(quoteContexts)
: {};
const submitOptions: InputBoxSubmitOptions = {
...(quotes.length || stagedFiles.length > 0
? {
additionalKwargs: {
...quoteKwargs,
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
},
}
const additionalKwargs = {
...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}),
...(referenceIds.length
? buildConversationReferenceMetadata(conversationReferences)
: {}),
...(stagedFiles.length > 0 ? { files: stagedFiles } : {}),
};
const submitOptions: InputBoxSubmitOptions = {
...(Object.keys(additionalKwargs).length ? { additionalKwargs } : {}),
...(quotes.length
? {
additionalInputMessages: [
@ -1158,6 +1174,9 @@ export function InputBox({
],
}
: {}),
...(referenceIds.length
? { conversationReferences: referenceIds }
: {}),
// Clear one-time state only once the send genuinely proceeds. If the
// send is dropped by the in-flight guard, `onSent` never fires.
onSent: () => {
@ -1168,6 +1187,7 @@ export function InputBox({
clearComposerDraft(getSessionComposerDraftStorage(), draftKey);
}
sidecar?.clearConversationQuotes(quoteIds);
setConversationReferences([]);
setProjectAttachments([]);
},
};
@ -1198,6 +1218,7 @@ export function InputBox({
},
[
context,
conversationReferences,
draftKey,
invalidateDraftSaveTimer,
onContextChange,
@ -2342,6 +2363,22 @@ export function InputBox({
</button>
</div>
))}
{conversationReferences.map((reference) => (
<ConversationReferenceChip
key={reference.threadId}
onRemove={() =>
setConversationReferences((current) =>
current.filter(
(item) => item.threadId !== reference.threadId,
),
)
}
removeLabel={t.inputBox.referenceConversationsRemove(
reference.title,
)}
title={reference.title}
/>
))}
{polishingInput && (
<div
aria-live="polite"
@ -2437,6 +2474,13 @@ export function InputBox({
disabled={composerLocked}
uploadLimits={uploadLimits}
/>
<ReferenceConversationsButton
className="px-2!"
currentThreadId={threadId}
disabled={composerLocked}
onChange={setConversationReferences}
references={conversationReferences}
/>
<VoiceInputButton
disabled={composerLocked}
listening={voiceListening}

View File

@ -41,6 +41,7 @@ import {
resolveMessageImageURL,
} from "@/core/artifacts/utils";
import { extractCitationSources } from "@/core/citations/sources";
import { readConversationReferences } from "@/core/conversation-references";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@ -57,10 +58,12 @@ import {
} from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { SafeReasoningContent } from "@/core/streamdown/components";
import { pathOfThread } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
import { WorkspaceChangeBadge } from "../changes";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { ConversationReferenceChip } from "../conversation-references/conversation-reference-chip";
import { CopyButton } from "../copy-button";
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
import { SlashSkillChip } from "../slash-skill-chip";
@ -451,6 +454,10 @@ function MessageContent_({
),
[message.additional_kwargs],
);
const conversationReferences = useMemo(
() => readConversationReferences(message.additional_kwargs),
[message.additional_kwargs],
);
const contentToDisplay = useMemo(() => {
if (isHuman) {
@ -515,6 +522,24 @@ function MessageContent_({
testId="message-reference-attachment"
/>
)}
{conversationReferences.length > 0 && (
<div
aria-label={t.inputBox.referencedConversations}
className="flex max-w-full flex-wrap justify-end gap-1"
data-testid="message-conversation-references"
role="group"
>
{conversationReferences.map((reference) => (
<ConversationReferenceChip
href={pathOfThread(reference.threadId, {
agent_name: reference.agentName,
})}
key={reference.threadId}
title={reference.title || "Untitled"}
/>
))}
</div>
)}
{filesList}
{editState ? (
<div className="bg-background border-border flex w-full min-w-0 flex-col gap-2 rounded-lg border p-2 shadow-sm">

View File

@ -56,6 +56,7 @@ export async function staticApiResponse(
worker_running: false,
max_running: 0,
},
conversation_references: { enabled: false, max_references: 0 },
} satisfies FeaturesResponse;
break;
case "channels/providers":

View File

@ -0,0 +1 @@
export * from "./metadata";

View File

@ -0,0 +1,75 @@
export const CONVERSATION_REFERENCES_KWARG = "conversation_references";
/** A conversation the user attached to the next message. Display data only. */
export type ConversationReference = {
threadId: string;
title: string;
/** Custom agent owning the source conversation; omitted for the default agent. */
agentName?: string;
};
type ConversationReferenceMetadata = {
thread_id: string;
title: string;
agent_name?: string;
};
export type ConversationReferencesMetadata = {
[CONVERSATION_REFERENCES_KWARG]: ConversationReferenceMetadata[];
};
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* Display-only metadata stored on the visible human message so the transcript
* can show which conversations were attached. It grants nothing: read access
* comes only from `conversation_references` in the run request context, which
* the Gateway consumes at admission and never persists in the chat history.
*/
export function buildConversationReferenceMetadata(
references: ConversationReference[],
): ConversationReferencesMetadata {
return {
[CONVERSATION_REFERENCES_KWARG]: references.map((reference) => ({
thread_id: reference.threadId,
title: reference.title,
...(reference.agentName ? { agent_name: reference.agentName } : {}),
})),
};
}
export function readConversationReferences(
additionalKwargs: unknown,
): ConversationReference[] {
if (!isObjectRecord(additionalKwargs)) {
return [];
}
const raw = additionalKwargs[CONVERSATION_REFERENCES_KWARG];
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const references: ConversationReference[] = [];
for (const entry of raw) {
if (
!isObjectRecord(entry) ||
typeof entry.thread_id !== "string" ||
entry.thread_id.length === 0 ||
seen.has(entry.thread_id)
) {
continue;
}
seen.add(entry.thread_id);
const reference: ConversationReference = {
threadId: entry.thread_id,
title: typeof entry.title === "string" ? entry.title : "",
};
if (typeof entry.agent_name === "string" && entry.agent_name.length > 0) {
reference.agentName = entry.agent_name;
}
references.push(reference);
}
return references;
}

View File

@ -11,6 +11,15 @@ export interface FeaturesResponse {
worker_running?: boolean;
max_running?: number;
};
conversation_references?: {
enabled?: boolean;
max_references?: number;
};
}
export interface ConversationReferencesCapability {
enabled: boolean;
maxReferences: number;
}
export interface SubagentBatchesCapability {
@ -48,3 +57,18 @@ export async function fetchSubagentBatchesCapability(): Promise<SubagentBatchesC
maxRunning: feature?.max_running ?? 0,
};
}
export async function fetchConversationReferencesCapability(): Promise<ConversationReferencesCapability> {
const features = await fetchFeatures();
const capability = features.conversation_references;
const maxReferences = capability?.max_references;
return {
enabled: capability?.enabled === true,
maxReferences:
typeof maxReferences === "number" &&
Number.isInteger(maxReferences) &&
maxReferences > 0
? maxReferences
: 0,
};
}

View File

@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import {
fetchBrowserControlEnabled,
fetchConversationReferencesCapability,
fetchMcpTasksEnabled,
fetchSubagentBatchesCapability,
} from "./api";
@ -51,3 +52,18 @@ export function useSubagentBatchesCapability() {
isLoading: isPending,
};
}
export function useConversationReferencesCapability() {
const { data, isPending } = useQuery({
queryKey: ["features", "conversation_references"],
queryFn: () => fetchConversationReferencesCapability(),
staleTime: 0,
refetchOnMount: true,
retry: false,
});
return {
enabled: data?.enabled ?? false,
maxReferences: data?.maxReferences ?? 0,
isLoading: isPending,
};
}

View File

@ -254,6 +254,14 @@ export const enUS: Translations = {
createSkillPrompt:
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
addAttachments: "Add attachments",
referenceConversations: "Reference a conversation",
referenceConversationsSearch: "Search conversations",
referenceConversationsEmpty: "No conversations found",
referenceConversationsLimit: (max: number) =>
`Up to ${max} conversations per message`,
referenceConversationsRemove: (title: string) =>
`Remove reference to ${title}`,
referencedConversations: "Referenced conversations",
removeProjectAttachment: "Remove attached document",
inputPolish: "Polish input",
inputPolishing: "Polishing input...",

View File

@ -218,6 +218,12 @@ export interface Translations {
disclaimer: string;
createSkillPrompt: string;
addAttachments: string;
referenceConversations: string;
referenceConversationsSearch: string;
referenceConversationsEmpty: string;
referenceConversationsLimit: (max: number) => string;
referenceConversationsRemove: (title: string) => string;
referencedConversations: string;
removeProjectAttachment: string;
inputPolish: string;
inputPolishing: string;

View File

@ -242,6 +242,13 @@ export const zhCN: Translations = {
createSkillPrompt:
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
addAttachments: "添加附件",
referenceConversations: "引用会话",
referenceConversationsSearch: "搜索会话",
referenceConversationsEmpty: "没有找到会话",
referenceConversationsLimit: (max: number) =>
`每条消息最多引用 ${max} 个会话`,
referenceConversationsRemove: (title: string) => `移除对「${title}」的引用`,
referencedConversations: "引用的会话",
removeProjectAttachment: "移除附加文档",
inputPolish: "优化输入",
inputPolishing: "正在优化输入...",

View File

@ -91,6 +91,14 @@ export type ThreadStreamOptions = {
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
additionalInputMessages?: Message[];
/**
* Thread IDs of conversations the user attached for this run. They ride in
* `context.conversation_references`, which the Gateway consumes at admission;
* the LangGraph SDK drops unknown top-level body fields, so the top-level
* request field is not reachable from here. Display metadata for the
* transcript travels separately in `additionalKwargs`.
*/
conversationReferences?: string[];
/**
* Invoked exactly once when the send passes the in-flight guard and is
* genuinely dispatched. It never fires on the early-return path, so callers
@ -201,6 +209,52 @@ export function buildThreadSubmitMessages({
];
}
/**
* Run context sent with `thread.submit`. Both submit paths (send, and the
* regenerate/edit replay) build it here so the client half of the Gateway
* contract stays in one place: conversation references travel only as a plain
* `string[]` under `context.conversation_references`, only when the caller
* attached them, and never from local settings. A stray key in settings is
* dropped rather than forwarded, so a stale value can never grant access.
*/
export function buildRunContext({
settings,
threadId,
extraContext,
conversationReferences,
}: {
settings: LocalSettings["context"];
threadId: string;
extraContext?: Record<string, unknown>;
conversationReferences?: string[];
}): Record<string, unknown> {
const ownedSettings = Object.fromEntries(
Object.entries(settings).filter(
([key]) => key !== "conversation_references",
),
);
return {
...extraContext,
...ownedSettings,
...(conversationReferences?.length
? { conversation_references: [...conversationReferences] }
: {}),
thinking_enabled: settings.mode !== "flash",
is_plan_mode: settings.mode === "pro" || settings.mode === "ultra",
subagent_enabled: settings.mode === "ultra",
reasoning_effort:
settings.reasoning_effort ??
(settings.mode === "ultra"
? "high"
: settings.mode === "pro"
? "medium"
: settings.mode === "thinking"
? "low"
: undefined),
thread_id: threadId,
};
}
// Stable identity for "no optimistic messages" so the merged-messages memo
// below is not invalidated by a fresh empty array on every render.
const EMPTY_MESSAGES: Message[] = [];
@ -2364,23 +2418,12 @@ export function useThreadStream({
config: {
recursion_limit: 1000,
},
context: {
...extraContext,
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
reasoning_effort:
context.reasoning_effort ??
(context.mode === "ultra"
? "high"
: context.mode === "pro"
? "medium"
: context.mode === "thinking"
? "low"
: undefined),
thread_id: threadId,
},
context: buildRunContext({
settings: context,
threadId,
extraContext,
conversationReferences: options?.conversationReferences,
}),
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@ -2514,22 +2557,10 @@ export function useThreadStream({
config: {
recursion_limit: 1000,
},
context: {
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
reasoning_effort:
context.reasoning_effort ??
(context.mode === "ultra"
? "high"
: context.mode === "pro"
? "medium"
: context.mode === "thinking"
? "low"
: undefined),
thread_id: threadId,
},
// Replaying a turn never carries conversation references: the grant
// is per send, so a regenerate or edit runs without them unless the
// user attaches them again.
context: buildRunContext({ settings: context, threadId }),
});
void queryClient.invalidateQueries({ queryKey: ["thread", threadId] });
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });

View File

@ -32,24 +32,32 @@ type ThreadRouteTarget =
metadata?: Record<string, unknown> | null;
};
/**
* The custom agent owning a thread, from its run context first and then its
* stored metadata; undefined for default-agent conversations.
*/
export function agentNameOfThread(thread: {
context?: Pick<AgentThreadContext, "agent_name"> | null;
metadata?: Record<string, unknown> | null;
}): string | undefined {
const contextAgent = thread.context?.agent_name;
if (contextAgent) {
return contextAgent;
}
const metaAgent = thread.metadata?.agent_name;
return typeof metaAgent === "string" && metaAgent ? metaAgent : undefined;
}
export function pathOfThread(
thread: ThreadRouteTarget,
context?: Pick<AgentThreadContext, "agent_name"> | null,
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const encodedThreadId = encodeURIComponent(threadId);
let agentName: string | undefined;
if (typeof thread === "string") {
agentName = context?.agent_name;
} else {
agentName = thread.context?.agent_name;
if (!agentName) {
const metaAgent = thread.metadata?.agent_name;
if (typeof metaAgent === "string") {
agentName = metaAgent;
}
}
}
const agentName =
typeof thread === "string"
? context?.agent_name
: agentNameOfThread(thread);
return agentName
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${encodedThreadId}`

View File

@ -0,0 +1,191 @@
import { afterEach, beforeAll, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ConversationReferenceList } from "@/components/workspace/conversation-references/conversation-reference-picker";
import type { AgentThread } from "@/core/threads/types";
const threads = [
{
thread_id: "t-current",
updated_at: "2026-09-15T09:00:00Z",
values: { title: "This conversation" },
metadata: {},
},
{
thread_id: "t-a",
updated_at: "2026-09-15T08:00:00Z",
values: { title: "Alpha requirements" },
metadata: {},
},
{
thread_id: "t-b",
updated_at: "2026-09-15T07:00:00Z",
values: { title: "Beta design" },
metadata: {},
},
{
thread_id: "t-c",
updated_at: "2026-09-15T06:00:00Z",
values: {},
metadata: {},
},
{
thread_id: "t-writer",
updated_at: "2026-09-15T05:00:00Z",
values: { title: "Writer drafts" },
metadata: { agent_name: "writer" },
},
{
thread_id: "t-scribe",
updated_at: "2026-09-15T04:00:00Z",
values: { title: "Scribe notes" },
metadata: { agent_name: "stale-agent" },
context: { agent_name: "scribe" },
},
] as unknown as AgentThread[];
let threadsQuery: {
data?: AgentThread[];
isPending: boolean;
isError: boolean;
} = {
data: threads,
isPending: false,
isError: false,
};
rs.mock("@/core/threads/hooks", () => ({
useThreads: () => threadsQuery,
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
locale: "en-US",
t: {
inputBox: {
referenceConversations: "Reference a conversation",
referenceConversationsSearch: "Search conversations",
referenceConversationsEmpty: "No conversations found",
referenceConversationsLimit: (max: number) =>
`Up to ${max} conversations per message`,
referenceConversationsRemove: (title: string) => `Remove ${title}`,
referencedConversations: "Referenced conversations",
},
common: { loading: "Loading...", untitled: "Untitled" },
},
}),
}));
beforeAll(() => {
// cmdk measures its list and scrolls the active item; happy-dom has neither.
class ResizeObserverStub {
observe = rs.fn();
unobserve = rs.fn();
disconnect = rs.fn();
}
globalThis.ResizeObserver ??=
ResizeObserverStub as unknown as typeof ResizeObserver;
if (!("scrollIntoView" in Element.prototype)) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: rs.fn(),
writable: true,
});
}
});
afterEach(() => {
cleanup();
threadsQuery = { data: threads, isPending: false, isError: false };
});
describe("ConversationReferenceList", () => {
it("shows a loading row, not the empty state, while the list is still loading", () => {
threadsQuery = { data: undefined, isPending: true, isError: false };
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={rs.fn()}
selected={[]}
/>,
);
expect(screen.getByTestId("conversation-reference-loading")).toBeTruthy();
expect(screen.queryByText("No conversations found")).toBeNull();
});
it("lists other conversations by title and never the current one", () => {
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={rs.fn()}
selected={[]}
/>,
);
expect(screen.getByText("Alpha requirements")).toBeTruthy();
expect(screen.getByText("Beta design")).toBeTruthy();
expect(screen.getByText("Untitled")).toBeTruthy();
expect(screen.queryByText("This conversation")).toBeNull();
});
it("toggles a reference with its title", () => {
const onToggle = rs.fn();
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={onToggle}
selected={[]}
/>,
);
fireEvent.click(screen.getByText("Alpha requirements"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-a",
title: "Alpha requirements",
});
});
it("carries the source's custom agent from metadata, with context winning", () => {
const onToggle = rs.fn();
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={onToggle}
selected={[]}
/>,
);
fireEvent.click(screen.getByText("Writer drafts"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-writer",
title: "Writer drafts",
agentName: "writer",
});
fireEvent.click(screen.getByText("Scribe notes"));
expect(onToggle).toHaveBeenCalledWith({
threadId: "t-scribe",
title: "Scribe notes",
agentName: "scribe",
});
});
it("disables unselected rows at the cap but keeps selected rows removable", () => {
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={2}
onToggle={rs.fn()}
selected={[
{ threadId: "t-a", title: "Alpha requirements" },
{ threadId: "t-b", title: "Beta design" },
]}
/>,
);
const untitled = screen.getByText("Untitled").closest("[cmdk-item]");
const alpha = screen.getByText("Alpha requirements").closest("[cmdk-item]");
expect(untitled?.getAttribute("aria-disabled")).toBe("true");
expect(alpha?.getAttribute("aria-disabled")).not.toBe("true");
expect(screen.getByText("Up to 2 conversations per message")).toBeTruthy();
});
});

View File

@ -0,0 +1,135 @@
import type { Message } from "@langchain/langgraph-sdk";
import { afterEach, beforeAll, describe, expect, it, rs } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ConversationReferenceList } from "@/components/workspace/conversation-references/conversation-reference-picker";
import { MessageListItem } from "@/components/workspace/messages/message-list-item";
import {
buildConversationReferenceMetadata,
type ConversationReference,
} from "@/core/conversation-references";
import { enUS } from "@/core/i18n/locales/en-US";
import type { AgentThread } from "@/core/threads/types";
// The source conversation belongs to a custom agent; metadata.agent_name is
// what the sidebar's thread list carries for it.
const threads = [
{
thread_id: "t-current",
updated_at: "2026-09-16T09:00:00Z",
values: { title: "This conversation" },
metadata: {},
},
{
thread_id: "source-1",
updated_at: "2026-09-16T08:00:00Z",
values: { title: "Writer brief" },
metadata: { agent_name: "writer" },
},
] as unknown as AgentThread[];
rs.mock("@/core/threads/hooks", () => ({
useThreads: () => ({ data: threads, isPending: false, isError: false }),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({
locale: "en-US",
setLocale: () => undefined,
t: enUS,
}),
}));
// The transcript body is not under test; stubbing the markdown pipeline also
// avoids its first-render suspension.
rs.mock("@/components/workspace/messages/markdown-content", () => ({
MarkdownContent: () => null,
}));
beforeAll(() => {
// cmdk measures its list and scrolls the active item; happy-dom has neither.
class ResizeObserverStub {
observe = rs.fn();
unobserve = rs.fn();
disconnect = rs.fn();
}
globalThis.ResizeObserver ??=
ResizeObserverStub as unknown as typeof ResizeObserver;
if (!("scrollIntoView" in Element.prototype)) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
value: rs.fn(),
writable: true,
});
}
});
afterEach(cleanup);
describe("conversation reference picker-to-transcript flow", () => {
it("links the transcript chip to a custom-agent source conversation", () => {
// 1. Select the custom-agent conversation in the picker.
let selected: ConversationReference | undefined;
render(
<ConversationReferenceList
currentThreadId="t-current"
maxReferences={3}
onToggle={(reference) => {
selected = reference;
}}
selected={[]}
/>,
);
fireEvent.click(screen.getByText("Writer brief"));
expect(selected).toEqual({
threadId: "source-1",
title: "Writer brief",
agentName: "writer",
});
// 2. The send path stores the display-only metadata on the human message.
const message = {
id: "human-1",
type: "human",
content: "Summarize the referenced brief",
additional_kwargs: buildConversationReferenceMetadata([selected!]),
} as unknown as Message;
// 3. The transcript chip routes back to the custom-agent conversation.
cleanup();
render(
<MessageListItem
message={message}
threadId="t-current"
showCopyButton={false}
isLoading={false}
/>,
);
const chip = screen.getByTestId("conversation-reference-chip");
expect(chip.getAttribute("href")).toBe(
"/workspace/agents/writer/chats/source-1",
);
});
it("links a default-agent source to the plain chats path", () => {
const message = {
id: "human-2",
type: "human",
content: "Summarize the referenced chat",
additional_kwargs: buildConversationReferenceMetadata([
{ threadId: "source-2", title: "Plain chat" },
]),
} as unknown as Message;
render(
<MessageListItem
message={message}
threadId="t-current"
showCopyButton={false}
isLoading={false}
/>,
);
const chip = screen.getByTestId("conversation-reference-chip");
expect(chip.getAttribute("href")).toBe("/workspace/chats/source-2");
});
});

View File

@ -0,0 +1,72 @@
import { expect, test } from "@rstest/core";
import {
CONVERSATION_REFERENCES_KWARG,
buildConversationReferenceMetadata,
readConversationReferences,
type ConversationReference,
} from "@/core/conversation-references";
const references: ConversationReference[] = [
{ threadId: "thread-a", title: "Requirements review" },
{ threadId: "thread-b", title: "Design notes" },
];
test("metadata carries thread id and title only, under one display-only key", () => {
expect(buildConversationReferenceMetadata(references)).toEqual({
[CONVERSATION_REFERENCES_KWARG]: [
{ thread_id: "thread-a", title: "Requirements review" },
{ thread_id: "thread-b", title: "Design notes" },
],
});
});
test("reading round-trips what build wrote", () => {
expect(
readConversationReferences(buildConversationReferenceMetadata(references)),
).toEqual(references);
});
test("metadata round-trips the source's custom agent when present", () => {
const withAgent: ConversationReference[] = [
{ threadId: "thread-w", title: "Writer notes", agentName: "writer" },
];
expect(buildConversationReferenceMetadata(withAgent)).toEqual({
[CONVERSATION_REFERENCES_KWARG]: [
{ thread_id: "thread-w", title: "Writer notes", agent_name: "writer" },
],
});
expect(
readConversationReferences(buildConversationReferenceMetadata(withAgent)),
).toEqual(withAgent);
});
test("reading tolerates missing, malformed and duplicate entries", () => {
expect(readConversationReferences(undefined)).toEqual([]);
expect(readConversationReferences({})).toEqual([]);
expect(readConversationReferences({ conversation_references: "x" })).toEqual(
[],
);
expect(
readConversationReferences({
conversation_references: [
{ thread_id: "thread-a", title: "Kept" },
{ thread_id: "thread-a", title: "Duplicate of the first" },
{ thread_id: 7, title: "Bad id" },
{ thread_id: "", title: "Empty id" },
{ thread_id: "thread-c" },
{ thread_id: "thread-d", title: "" },
{ thread_id: "thread-f", agent_name: 42 },
{ thread_id: "thread-g", agent_name: "" },
null,
"thread-e",
],
}),
).toEqual([
{ threadId: "thread-a", title: "Kept" },
{ threadId: "thread-c", title: "" },
{ threadId: "thread-d", title: "" },
{ threadId: "thread-f", title: "" },
{ threadId: "thread-g", title: "" },
]);
});

View File

@ -4,7 +4,10 @@ rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
import { fetch } from "@/core/api/fetcher";
import { fetchSubagentBatchesCapability } from "@/core/features/api";
import {
fetchConversationReferencesCapability,
fetchSubagentBatchesCapability,
} from "@/core/features/api";
const mockedFetch = rs.mocked(fetch);
@ -55,3 +58,44 @@ describe("subagent batch feature capability", () => {
});
});
});
describe("conversation references feature capability", () => {
it("reports the flag and the per-run cap", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse({
agents_api: { enabled: true },
conversation_references: { enabled: true, max_references: 3 },
}),
);
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
enabled: true,
maxReferences: 3,
});
});
it("treats a backend without the field as disabled", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse({ agents_api: { enabled: true } }),
);
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
enabled: false,
maxReferences: 0,
});
});
it("never reports a cap below zero or a non-numeric one", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse({
agents_api: { enabled: true },
conversation_references: { enabled: true, max_references: "3" },
}),
);
await expect(fetchConversationReferencesCapability()).resolves.toEqual({
enabled: true,
maxReferences: 0,
});
});
});

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from "@rstest/core";
import type { LocalSettings } from "@/core/settings";
import { buildRunContext } from "@/core/threads/hooks";
const settings = {
mode: "pro",
model_name: "gemma4",
reasoning_effort: undefined,
} as unknown as LocalSettings["context"];
describe("buildRunContext", () => {
it("sends attached references as a plain string[] under context.conversation_references", () => {
const context = buildRunContext({
settings,
threadId: "t-1",
extraContext: { agent_name: "writer" },
conversationReferences: ["source-a", "source-b"],
});
expect(context.conversation_references).toEqual(["source-a", "source-b"]);
expect(context.agent_name).toBe("writer");
expect(context.thread_id).toBe("t-1");
expect(context.is_plan_mode).toBe(true);
});
it("omits the key when nothing is attached, including on the replay path", () => {
expect(
"conversation_references" in
buildRunContext({ settings, threadId: "t-1" }),
).toBe(false);
expect(
"conversation_references" in
buildRunContext({
settings,
threadId: "t-1",
conversationReferences: [],
}),
).toBe(false);
});
it("never forwards a stray conversation_references key from local settings", () => {
const stale = {
...settings,
conversation_references: ["stale-source"],
} as unknown as LocalSettings["context"];
expect(
"conversation_references" in
buildRunContext({ settings: stale, threadId: "t-1" }),
).toBe(false);
expect(
buildRunContext({
settings: stale,
threadId: "t-1",
conversationReferences: ["source-a"],
}).conversation_references,
).toEqual(["source-a"]);
});
it("copies the list so later mutation of the caller's array cannot change the request", () => {
const references = ["source-a"];
const context = buildRunContext({
settings,
threadId: "t-1",
conversationReferences: references,
});
references.push("source-b");
expect(context.conversation_references).toEqual(["source-a"]);
});
});