From 1b9d082266f1560cdda4aa2a426022696ef36eaa Mon Sep 17 00:00:00 2001 From: suguanYang Date: Mon, 10 Aug 2026 00:35:43 +0800 Subject: [PATCH 1/2] feat(chat): add agentic retrieval toggle to chat composer --- src/components/chat-composer.test.ts | 43 +++++++++- src/components/chat-composer.tsx | 79 ++++++++++++++----- src/components/chat-panel.test.ts | 4 +- src/components/chat-panel.tsx | 14 +++- .../workspace-chat-workflow.test.ts | 13 ++- src/components/workspace-chat-workflow.ts | 12 ++- src/components/workspace-shell-layout.tsx | 6 +- src/domains/chat/contracts.ts | 1 + src/domains/chat/index.test.ts | 17 ++++ src/domains/chat/index.ts | 5 +- src/domains/chat/request.ts | 3 + src/domains/chat/route-answer.ts | 1 + src/domains/chat/route-service.test.ts | 2 + src/domains/chat/service.ts | 2 + src/domains/workspace/client.ts | 1 + 15 files changed, 171 insertions(+), 32 deletions(-) diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 1d7051b..0853e00 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -28,10 +28,51 @@ describe("ChatComposer", () => { await user.type(input, " Summarize this document "); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize this document"); + expect(onSend).toHaveBeenCalledWith("Summarize this document", { + useAgentic: true, + }); expect(input.value).toBe(""); }); + it("defaults to agentic retrieval enabled and explains the toggle", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + const toggle = screen.getByRole("button", { + name: "Toggle agentic retrieval", + }); + expect(toggle.getAttribute("aria-pressed")).toBe("true"); + + await user.hover(toggle); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toContain( + "Agentic retrieval plans document selection and navigation", + ); + }); + + it("sends useAgentic false after toggling agentic retrieval off", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + render(React.createElement(ChatComposer, { onSend })); + + const toggle = screen.getByRole("button", { + name: "Toggle agentic retrieval", + }); + await user.click(toggle); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + + const input = getComposerTextArea(); + await user.type(input, "Quick summary"); + await user.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("Quick summary", { + useAgentic: false, + }); + }); + it("caps long prompts and resets the composer after sending", async () => { const user = userEvent.setup(); const onSend = vi.fn(); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 4cfa62c..e9ee085 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -10,7 +10,7 @@ import { type MouseEvent, type ReactElement, } from "react"; -import { BarChart3, FileText, Plus, Send } from "lucide-react"; +import { BarChart3, FileText, Plus, Send, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -22,6 +22,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; const chatComposerName = "chat-composer"; const chatComposerTextAreaMinHeight = 128; @@ -33,6 +39,10 @@ type TextRange = { readonly end: number; }; +export type ChatSendOptions = { + readonly useAgentic: boolean; +}; + export type ChatComposerProps = { readonly canCreateDiagram?: boolean; readonly isDisabled?: boolean; @@ -40,7 +50,7 @@ export type ChatComposerProps = { readonly isSending?: boolean; readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; - readonly onSend?: (text: string) => void; + readonly onSend?: (text: string, options: ChatSendOptions) => void; }; export function ChatComposer({ @@ -53,6 +63,7 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [useAgentic, setUseAgentic] = useState(true); const composerInputId = useId(); const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); @@ -79,7 +90,7 @@ export function ChatComposer({ function handleSend(): void { if (!canSend) return; - onSend?.(trimmedInput); + onSend?.(trimmedInput, { useAgentic }); setInput(""); } @@ -177,22 +188,52 @@ export function ChatComposer({ onCreateDiagram={onCreateDiagram} onTemplateSelect={handleTemplateSelect} /> - +
+ + + + + + + Agentic retrieval plans document selection and navigation + for more thorough answers. Turn off for faster classic + search. + + + + +
)} diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 54ccce5..f1bd6d1 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -254,7 +254,9 @@ describe("ChatPanel", () => { ); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize revenue"); + expect(onSend).toHaveBeenCalledWith("Summarize revenue", { + useAgentic: true, + }); expect( analyticsMocks.trackNotebookAssistantQuestionSubmitted, ).toHaveBeenCalledWith({ diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 92d3cbf..1f0d1e8 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -6,7 +6,10 @@ import { type ReactElement, } from "react"; import { History, Plus } from "lucide-react"; -import { ChatComposer } from "@/components/chat-composer"; +import { + ChatComposer, + type ChatSendOptions, +} from "@/components/chat-composer"; import { ChatHistorySheet } from "@/components/chat-history-sheet"; import { ChatMessageList, @@ -47,7 +50,7 @@ export type ChatPanelProps = { messages: ChatMessageView[]; threads: ChatThreadView[]; activeThreadId?: string | null; - onSend?: (text: string) => void; + onSend?: (text: string, options: ChatSendOptions) => void; onNewChat?: () => void; onThreadSelect?: (threadId: string) => void; onThreadArchive?: (threadId: string) => void; @@ -152,7 +155,10 @@ export function ChatPanel({ } } - function handleComposerSend(text: string): void { + function handleComposerSend( + text: string, + options: ChatSendOptions, + ): void { if (isCreateDiagramCommand(text)) { void handleCreateDiagramCommand(); return; @@ -165,7 +171,7 @@ export function ChatPanel({ sourceCountSnapshot: sourceCount, messageLength: text.length, }); - onSend?.(text); + onSend?.(text, options); } return ( diff --git a/src/components/workspace-chat-workflow.test.ts b/src/components/workspace-chat-workflow.test.ts index f6b36a3..babf01a 100644 --- a/src/components/workspace-chat-workflow.test.ts +++ b/src/components/workspace-chat-workflow.test.ts @@ -84,12 +84,15 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("Summarize it") + await result.current.handleChatSend("Summarize it", { + useAgentic: true, + }) }) expect(mocks.sendChatMessage).toHaveBeenCalledWith({ message: "Summarize it", threadId: undefined, + useAgentic: true, excludedSourceIds: ["source_excluded"], }) await waitFor(() => { @@ -127,7 +130,9 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("What changed in Q4?") + await result.current.handleChatSend("What changed in Q4?", { + useAgentic: true, + }) }) expect(mocks.materializeDemoSources).toHaveBeenCalledWith({ @@ -172,7 +177,9 @@ describe("useWorkspaceChatWorkflow", () => { }) await act(async () => { - await result.current.handleChatSend("Summarize it") + await result.current.handleChatSend("Summarize it", { + useAgentic: true, + }) }) expect(mocks.materializeDemoSources).not.toHaveBeenCalled() diff --git a/src/components/workspace-chat-workflow.ts b/src/components/workspace-chat-workflow.ts index 2943a23..dd85576 100644 --- a/src/components/workspace-chat-workflow.ts +++ b/src/components/workspace-chat-workflow.ts @@ -12,6 +12,7 @@ import { type AnalyticsContext, } from "@/lib/posthog" import { workspaceClient } from "@/domains/workspace/client" +import type { ChatSendOptions } from "@/components/chat-composer" import { workspaceClientCache, type ChatThreadDetailResponse, @@ -43,7 +44,10 @@ type WorkspaceChatWorkflow = { readonly chat: ReturnType readonly chatThreads: ChatThreadView[] readonly handleArchiveChatThread: (threadId: string) => Promise - readonly handleChatSend: (text: string) => Promise + readonly handleChatSend: ( + text: string, + options: ChatSendOptions, + ) => Promise readonly handleCreateChatThread: () => Promise readonly handleRefreshActiveChatThread: () => Promise readonly handleSelectChatThread: (threadId: string) => void @@ -247,7 +251,10 @@ export function useWorkspaceChatWorkflow({ } } - async function handleChatSend(text: string): Promise { + async function handleChatSend( + text: string, + options: ChatSendOptions, + ): Promise { const sendStart = Date.now() const selectedSourcesCount = sources.filter( (source) => @@ -297,6 +304,7 @@ export function useWorkspaceChatWorkflow({ const body = await sendChatMessage({ message: text, threadId: chat.threadId ?? undefined, + useAgentic: options.useAgentic, excludedSourceIds: sources .filter((source) => source.excludedFromQuery) .map((source) => source.id), diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx index 235eb07..760aa07 100644 --- a/src/components/workspace-shell-layout.tsx +++ b/src/components/workspace-shell-layout.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react" import { ChatPanel } from "@/components/chat-panel" +import type { ChatSendOptions } from "@/components/chat-composer" import { ChunksPanel } from "@/components/chunks-panel" import { MobileTabBar } from "@/components/mobile-tab-bar" import { OfficialLibraryPanel } from "@/components/official-library-panel" @@ -91,7 +92,10 @@ export type WorkspaceShellLayoutProps = { readonly onArchiveChatThread: (threadId: string) => void | Promise readonly onArchiveSource: (sourceId: string) => void | Promise readonly onRetrySource?: (sourceId: string) => void | Promise - readonly onChatSend: (text: string) => void | Promise + readonly onChatSend: ( + text: string, + options: ChatSendOptions, + ) => void | Promise readonly onCitationClick: ( citation: ChatCitationView, citationId: string, diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts index 4c8d224..c7820b3 100644 --- a/src/domains/chat/contracts.ts +++ b/src/domains/chat/contracts.ts @@ -65,6 +65,7 @@ export type AnswerQuestionInput = { namespaces?: readonly string[] sources: readonly Source[] excludedSourceIds: readonly string[] + useAgentic?: boolean retrieval: RetrievalClient generateAnswer: GenerateAnswer loadSourceAssetUrls?: LoadSourceAssetUrls diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts index 0c8cfd6..9ddfb64 100644 --- a/src/domains/chat/index.test.ts +++ b/src/domains/chat/index.test.ts @@ -1734,11 +1734,28 @@ describe("parseChatRequestBody", () => { value: { question: "What changed?", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_1", "source_2"], }, }); }); + it("keeps an explicit useAgentic choice from the request body", () => { + expect( + parseChatRequestBody({ + message: "Quick summary", + useAgentic: false, + }), + ).toEqual({ + ok: true, + value: { + question: "Quick summary", + useAgentic: false, + excludedSourceIds: [], + }, + }); + }); + it("rejects empty questions before retrieval or model calls", () => { expect(parseChatRequestBody({ message: " " })).toEqual({ ok: false, diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts index 2d7245d..2fd3b17 100644 --- a/src/domains/chat/index.ts +++ b/src/domains/chat/index.ts @@ -132,6 +132,7 @@ export const answerQuestionWithRetrieval = ( input: queryInput, fallbackQuestion: question, namespace, + useAgentic: input.useAgentic ?? true, sources: input.sources, excludedSourceIds: input.excludedSourceIds, }) @@ -139,6 +140,7 @@ export const answerQuestionWithRetrieval = ( namespace, query: retrievalQueryParams.query, topK: retrievalQueryParams.topK, + useAgentic: retrievalQueryParams.useAgentic, dataType: retrievalQueryParams.dataType ?? null, signalPathCount: retrievalQueryParams.signalPaths?.length ?? 0, filterMode: retrievalQueryParams.filterMode ?? null, @@ -686,6 +688,7 @@ function buildRetrievalQueryParams(input: { readonly input: AgenticRetrievalQuery readonly fallbackQuestion: string readonly namespace: string + readonly useAgentic: boolean readonly sources: AnswerQuestionInput["sources"] readonly excludedSourceIds: readonly string[] }): RetrievalQueryParams { @@ -698,7 +701,7 @@ function buildRetrievalQueryParams(input: { namespace: input.namespace, query, topK: normalizeTopK(input.input.topK), - useAgentic: true, + useAgentic: input.useAgentic, dataType, ...(input.input.signalPaths && input.input.signalPaths.length > 0 ? { signalPaths: input.input.signalPaths } diff --git a/src/domains/chat/request.ts b/src/domains/chat/request.ts index c15a76e..3e56f06 100644 --- a/src/domains/chat/request.ts +++ b/src/domains/chat/request.ts @@ -3,6 +3,7 @@ import { Either, Schema } from "effect" export type ParsedChatRequest = { question: string threadId?: string + useAgentic: boolean excludedSourceIds: string[] } @@ -13,6 +14,7 @@ export type ParseChatRequestResult = const ChatRequestBody = Schema.Struct({ message: Schema.String, threadId: Schema.optional(Schema.String), + useAgentic: Schema.optional(Schema.Boolean), excludedSourceIds: Schema.optional(Schema.Array(Schema.Unknown)), }) @@ -43,6 +45,7 @@ export function parseChatRequestBody(body: unknown): ParseChatRequestResult { parsed.threadId !== undefined && parsed.threadId.length > 0 ? parsed.threadId : undefined, + useAgentic: parsed.useAgentic ?? true, excludedSourceIds, }, } diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts index ea75943..1d792c5 100644 --- a/src/domains/chat/route-answer.ts +++ b/src/domains/chat/route-answer.ts @@ -78,6 +78,7 @@ const answerChatEffect = (input: AnswerChatInput) => sources, question: body.value.question, threadId: body.value.threadId, + useAgentic: body.value.useAgentic, excludedSourceIds: body.value.excludedSourceIds, retrieval: client.retrieval, generateAnswer: generateAgenticOutputManifest, diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts index 4358e8a..3b1d789 100644 --- a/src/domains/chat/route-service.test.ts +++ b/src/domains/chat/route-service.test.ts @@ -104,6 +104,7 @@ describe("chat route services", () => { body: { message: " Summarize it ", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_skipped", null], }, }) @@ -126,6 +127,7 @@ describe("chat route services", () => { sources: [readySource], question: "Summarize it", threadId: "thread_1", + useAgentic: true, excludedSourceIds: ["source_skipped"], retrieval: client.retrieval, generateAnswer: mocks.generateAgenticOutputManifest, diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts index 5361d5b..9691ddf 100644 --- a/src/domains/chat/service.ts +++ b/src/domains/chat/service.ts @@ -64,6 +64,7 @@ type ChatTurnInput = { sources: readonly Source[] question: string threadId?: string + useAgentic?: boolean excludedSourceIds: readonly string[] retrieval: RetrievalClient generateAnswer: GenerateAnswer @@ -123,6 +124,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) => namespace: input.workspace.namespace, namespaces: getCompatibleNamespaces(input.workspace), sources: readySources, + useAgentic: input.useAgentic ?? true, excludedSourceIds: input.excludedSourceIds, retrieval: input.retrieval, generateAnswer: input.generateAnswer, diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts index 9a7b3cd..d57eebc 100644 --- a/src/domains/workspace/client.ts +++ b/src/domains/workspace/client.ts @@ -45,6 +45,7 @@ type ChatThreadDetailResponse = ChatThreadResponse & { type ChatMessageRequest = { message: string threadId?: string + useAgentic: boolean excludedSourceIds: string[] } From 69edcb4f9e7580285e3a93a7a2c7d0f240a49aaf Mon Sep 17 00:00:00 2001 From: suguanYang Date: Mon, 10 Aug 2026 09:37:35 +0800 Subject: [PATCH 2/2] feat(chat): refine deep search toggle UI --- src/components/chat-composer.test.ts | 18 ++++++------- src/components/chat-composer.tsx | 40 +++++++++++++++------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 0853e00..a12264a 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -34,35 +34,35 @@ describe("ChatComposer", () => { expect(input.value).toBe(""); }); - it("defaults to agentic retrieval enabled and explains the toggle", async () => { + it("defaults to deep search enabled and explains the toggle", async () => { const user = userEvent.setup(); render(React.createElement(ChatComposer)); - const toggle = screen.getByRole("button", { - name: "Toggle agentic retrieval", + const toggle = screen.getByRole("checkbox", { + name: "Deep search", }); - expect(toggle.getAttribute("aria-pressed")).toBe("true"); + expect(toggle.getAttribute("aria-checked")).toBe("true"); await user.hover(toggle); const tooltip = await screen.findByRole("tooltip"); expect(tooltip.textContent).toContain( - "Agentic retrieval plans document selection and navigation", + "Deep search plans document selection and navigation", ); }); - it("sends useAgentic false after toggling agentic retrieval off", async () => { + it("sends useAgentic false after toggling deep search off", async () => { const user = userEvent.setup(); const onSend = vi.fn(); render(React.createElement(ChatComposer, { onSend })); - const toggle = screen.getByRole("button", { - name: "Toggle agentic retrieval", + const toggle = screen.getByRole("checkbox", { + name: "Deep search", }); await user.click(toggle); - expect(toggle.getAttribute("aria-pressed")).toBe("false"); + expect(toggle.getAttribute("aria-checked")).toBe("false"); const input = getComposerTextArea(); await user.type(input, "Quick summary"); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index e9ee085..4aab263 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -10,9 +10,10 @@ import { type MouseEvent, type ReactElement, } from "react"; -import { BarChart3, FileText, Plus, Send, Sparkles } from "lucide-react"; +import { BarChart3, FileText, Plus, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -192,28 +193,29 @@ export function ChatComposer({ - + + setUseAgentic(checked === true) + } + /> + Deep search + - Agentic retrieval plans document selection and navigation - for more thorough answers. Turn off for faster classic - search. + Deep search plans document selection and navigation for + more thorough answers. Turn off for faster classic search.