feat(agent-mode): collapse empty context row, dock status icon in a composer accessory column, label context-held queue rows (#2662)

* feat(agent-mode): collapse empty context row, dock status icon in a composer accessory column, label context-held queue rows (#205)

* fix(agent-mode): omit showIndexingCard instead of passing NOOP so the indexing chip's render guard holds

* docs(agent-mode): state the overlay rejection rationale instead of build history in the accessory DESIGN NOTE
This commit is contained in:
Emt-lin 2026-07-05 05:54:49 +08:00 committed by GitHub
parent 9fc6153ae6
commit 358abe6b8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 372 additions and 111 deletions

View file

@ -32,14 +32,23 @@ jest.mock("@/agentMode/ui/mentionedAgents", () => ({
// through `handleSendMessage` — the same entry the real Lexical editor's Enter
// key hits (send-flow regression tests).
let capturedAgentBrands: ReadonlyArray<unknown> | undefined;
let capturedTopRightAccessory: React.ReactNode | undefined;
jest.mock("@/components/chat-components/ChatInput", () => ({
__esModule: true,
default: (props: { agentBrands?: ReadonlyArray<unknown>; handleSendMessage?: () => void }) => {
default: (props: {
agentBrands?: ReadonlyArray<unknown>;
topRightAccessory?: React.ReactNode;
handleSendMessage?: () => void;
}) => {
capturedAgentBrands = props.agentBrands;
capturedTopRightAccessory = props.topRightAccessory;
return (
<button type="button" onClick={() => props.handleSendMessage?.()}>
send
</button>
<>
{props.topRightAccessory}
<button type="button" onClick={() => props.handleSendMessage?.()}>
send
</button>
</>
);
},
}));
@ -174,6 +183,96 @@ describe("AgentChatInput turn-completion loading reset", () => {
});
});
describe("AgentChatInput queue reason", () => {
const makeBackend = () =>
({
sendMessage: jest.fn(() => ({ turn: Promise.resolve() })),
cancel: jest.fn(),
}) as unknown as AgentChatBackend;
/** Apply the functional updater handed to setQueue and return the enqueued item. */
const enqueuedItem = (setQueue: jest.Mock) => {
const updater = setQueue.mock.calls[0][0] as (
q: readonly unknown[]
) => { queueReason?: string }[];
return updater([])[0];
};
beforeEach(() => {
mockUseCanUseMultiAgent.mockReturnValue(true);
});
it("snapshots 'context' when the send is held for project-context materialization", async () => {
const backend = makeBackend();
const draft = makeDraft();
renderInput(backend, draft, { activeProjectId: "proj-1", contextLoadBlocking: true });
fireEvent.click(screen.getByText("send"));
await waitFor(() => expect(draft.setQueue).toHaveBeenCalled());
expect(backend.sendMessage).not.toHaveBeenCalled();
expect(enqueuedItem(draft.setQueue as jest.Mock).queueReason).toBe("context");
});
it("snapshots 'busy' when queued behind an in-flight turn", async () => {
const backend = makeBackend();
const draft = makeDraft({ loading: true });
renderInput(backend, draft);
fireEvent.click(screen.getByText("send"));
await waitFor(() => expect(draft.setQueue).toHaveBeenCalled());
expect(backend.sendMessage).not.toHaveBeenCalled();
expect(enqueuedItem(draft.setQueue as jest.Mock).queueReason).toBe("busy");
});
it("labels only context-held rows with the amber waiting prefix", () => {
const draft = makeDraft({
queue: [
{
id: "q1",
text: "held for context",
rawInput: "held for context",
queueReason: "context",
},
{ id: "q2", text: "held while busy", rawInput: "held while busy", queueReason: "busy" },
],
});
renderInput(makeBackend(), draft);
const rows = screen.getAllByTitle(/held/);
expect(rows[0].textContent).toContain("Waiting for context · held for context");
expect(rows[1].textContent).toContain("held while busy");
expect(rows[1].textContent).not.toContain("Waiting for context");
});
});
describe("AgentChatInput status-icon boundary", () => {
// Locks the #205 layering decision: AgentChatInput owns the project-context
// status node and hands it to the shared ChatInput only through the neutral
// topRightAccessory slot — the shared component never learns what it is.
beforeEach(() => {
capturedTopRightAccessory = undefined;
mockUseCanUseMultiAgent.mockReturnValue(true);
});
it("passes the indicator through the accessory slot when mounted", () => {
const backend = { sendMessage: jest.fn(), cancel: jest.fn() } as unknown as AgentChatBackend;
renderInput(backend, makeDraft(), { contextStatusIndicator: <span>status</span> });
expect(capturedTopRightAccessory).toBeTruthy();
expect(screen.getByText("status")).toBeTruthy();
});
it("passes no accessory when there is no indicator (global scope)", () => {
const backend = { sendMessage: jest.fn(), cancel: jest.fn() } as unknown as AgentChatBackend;
renderInput(backend, makeDraft());
expect(capturedTopRightAccessory).toBeUndefined();
});
});
describe("AgentChatInput hard-disable", () => {
it("drops a send when the composer is disabled (orphaned project)", async () => {
// The mocked ChatInput's send button routes through handleSendMessage — the

View file

@ -87,12 +87,17 @@ interface AgentChatInputProps {
* {@link contextLoadBlocking}, which only defers sends.
*/
disabled?: boolean;
/** Agent project-context status icon, rendered in the composer's badge row. */
/**
* Agent project-context status icon, rendered through ChatInput's
* top-right accessory column (see the DESIGN NOTE at the mount point).
*/
contextStatusIndicator?: React.ReactNode;
}
// Stable no-op handlers for ChatInput props that don't apply to Agent Mode
// (project progress card, vault indexing card).
// Stable no-op handler for required ChatInput props that don't apply to
// Agent Mode (project progress card). Optional props with no Agent Mode
// surface (e.g. showIndexingCard) are omitted instead, so their `&& prop`
// render guards stay effective.
const NOOP = () => {};
const dedupeBy = <T,>(items: Iterable<T>, key: (item: T) => string): T[] => {
@ -407,9 +412,14 @@ export const AgentChatInput = memo(function AgentChatInput({
// Queue-and-hold: while a turn is in flight, starting, or the project's
// context is still materializing, park the message instead of sending.
// The flush effect below drains it once all three clear.
// The flush effect below drains it once all three clear. Context-held
// rows get an amber "Waiting for context" label; the reason is an
// enqueue-time snapshot, not re-derived as blockers evolve.
if (loading || isStarting || holdForContext) {
setQueuedMessages((q) => [...q, item]);
setQueuedMessages((q) => [
...q,
{ ...item, queueReason: holdForContext ? "context" : "busy" },
]);
return;
}
@ -497,6 +507,20 @@ export const AgentChatInput = memo(function AgentChatInput({
}
aria-disabled={hasPendingPlanPermission || disabled || undefined}
>
{/* DESIGN NOTE: the project-context status trigger renders through
ChatInput's structural top-right accessory column a real layout
column, not an overlay so badges, images, placeholder, and
editor text stay clear of it without avoidance padding (up to the
column's small deliberate negative-margin overlap, documented at
the ChatInput mount point). One mount point, no layout jump
when badges appear. Moving it into the bottom control row was
considered and rejected (the corner is the established home for
this status, and it should read as context state for the whole
composer, not a send control); an absolute overlay was rejected
because every content type would then need its own avoidance
padding an open-ended maintenance obligation (#205). If a
future review flags either choice again, point them at this
note. */}
{/* Key by session so ChatInput remounts on a tab/session switch. The
per-session draft store (input/images/contextNotes/include flags)
lives up in AgentHome and is threaded back as controlled props, so
@ -536,8 +560,16 @@ export const AgentChatInput = memo(function AgentChatInput({
agentBrands={agentBrands}
onMentionedAgentsChange={handleMentionedAgentsChange}
showProgressCard={NOOP}
showIndexingCard={NOOP}
contextStatusIndicator={contextStatusIndicator}
// showIndexingCard is deliberately NOT passed: the vault-indexing
// chip is not an Agent Mode surface (a NOOP here used to defeat
// ChatContextMenu's `&& showIndexingCard` guard and leak a chip
// whose click did nothing).
// No placeholder swap while context is loading, on purpose: loads
// often clear in ~hundreds of ms, so any transient placeholder (text
// or color) flickers in and out and reads as a glitch. The status
// icon covers the loading state; the queued-row "Waiting for
// context" prefix explains an actually-held send.
topRightAccessory={contextStatusIndicator}
/>
</div>
</>
@ -578,8 +610,16 @@ const QueuedMessageList: React.FC<QueuedMessageListProps> = ({ messages, onRemov
className="tw-flex tw-min-w-0 tw-items-center tw-gap-2 tw-rounded-md tw-bg-secondary-alt tw-px-2 tw-py-1 tw-text-ui-smaller"
title={m.text}
>
<Clock className="tw-size-3 tw-shrink-0 tw-text-muted" />
<Clock
className={cn(
"tw-size-3 tw-shrink-0",
m.queueReason === "context" ? "tw-text-warning" : "tw-text-muted"
)}
/>
<span className="tw-min-w-0 tw-flex-1 tw-truncate tw-whitespace-nowrap tw-text-normal">
{m.queueReason === "context" && (
<span className="tw-font-semibold tw-text-warning">Waiting for context · </span>
)}
{m.text}
</span>
<Button

View file

@ -10,6 +10,14 @@ export interface QueuedAgentMessage {
text: string;
rawInput: string;
context?: MessageContext;
/**
* Why the message entered the queue, snapshotted at enqueue time an
* enqueue reason, not a live "what's blocking now" (the blockers can evolve
* before the queue drains; the label deliberately doesn't chase them).
* Absent on combined flush items, which are sent immediately and never
* rendered as queue rows.
*/
queueReason?: "context" | "busy";
/** Image blocks for the backend prompt. */
promptContent?: PromptContent[];
/**

View file

@ -0,0 +1,86 @@
/**
* RTL tests for ChatContextMenu's empty-state collapse (#205).
*
* Agent Mode has no "@ Add context" button in this row and mounts its status
* trigger outside it, so an empty row is pure dead height and must not render.
* Legacy Chat keeps the row: its "@ Add context" entry point lives here.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { TFile } from "obsidian";
jest.mock("obsidian", () => ({
TFile: class {},
TFolder: class {},
Platform: { isDesktopApp: true },
}));
// Mock factory names must match the real `use*` exports, so the no-hook `use`
// prefix is expected on the mocked hooks below.
/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
jest.mock("@/context", () => ({
useApp: () => ({}),
}));
jest.mock("@/aiParams", () => ({
useChainType: () => ["llm_chain"],
useIndexingProgress: () => [{ isActive: false }],
}));
jest.mock("@/hooks/useProjectContextStatus", () => ({
useProjectContextStatus: () => "initial",
}));
/* eslint-enable @eslint-react/hooks-extra/no-unnecessary-use-prefix */
jest.mock("@/utils/desktopRuntime", () => ({
isDesktopRuntime: () => true,
}));
jest.mock("@/utils", () => ({
isPlusChain: () => false,
openFileInWorkspace: jest.fn(),
}));
jest.mock("./AtMentionTypeahead", () => ({
AtMentionTypeahead: () => null,
}));
import { ChatContextMenu } from "./ChatContextMenu";
const baseProps = {
includeActiveNote: false,
currentActiveFile: null,
includeActiveWebTab: false,
activeWebTab: null,
contextNotes: [] as TFile[],
contextUrls: [] as string[],
contextFolders: [] as string[],
contextWebTabs: [],
onRemoveContext: jest.fn(),
showProgressCard: jest.fn(),
onTypeaheadSelect: jest.fn(),
};
describe("ChatContextMenu empty-state collapse", () => {
it("renders nothing in Agent Mode when there are no context badges", () => {
const { container } = render(
<ChatContextMenu {...baseProps} isAgentMode hideAddContextButton />
);
expect(container.childElementCount).toBe(0);
});
it("keeps the row with its badges in Agent Mode once context exists", () => {
// The mocked TFile class keeps this instanceof-safe without a cast.
const note = Object.assign(new TFile(), { path: "Note.md", basename: "Note", extension: "md" });
const { container } = render(
<ChatContextMenu {...baseProps} contextNotes={[note]} isAgentMode hideAddContextButton />
);
expect(container.childElementCount).toBeGreaterThan(0);
expect(screen.getByText("Note")).toBeTruthy();
});
it("keeps the empty row in legacy Chat — the '@ Add context' entry lives here", () => {
render(<ChatContextMenu {...baseProps} />);
expect(screen.getByText("Add context")).toBeTruthy();
});
});

View file

@ -45,13 +45,10 @@ interface ChatContextMenuProps {
lexicalEditorRef?: React.RefObject<{ focus: () => void }>;
hideAddContextButton?: boolean;
/**
* Agent Mode passes its project-context status icon here; it renders at the
* right of the badge row. Legacy Chat leaves it undefined.
*/
statusIndicator?: React.ReactNode;
/**
* True in Agent Mode. Suppresses the legacy CAG project-status icon (Agent Mode
* owns its own via {@link statusIndicator}); keeps the two from ever co-existing.
* True in Agent Mode. Suppresses the legacy CAG project-status icon (Agent
* Mode mounts its own status trigger outside this row) and collapses the row
* entirely when there are no badges Agent Mode has no "@ Add context"
* button here, so an empty row would just push the editor down (#205).
*/
isAgentMode?: boolean;
}
@ -72,7 +69,6 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
onTypeaheadSelect,
lexicalEditorRef,
hideAddContextButton = false,
statusIndicator,
isAgentMode = false,
}) => {
const app = useApp();
@ -136,6 +132,13 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
activeNoteVisible ||
activeWebTabVisible;
// Agent Mode only: with no "@ Add context" button and the status trigger
// living outside this row, an empty row is pure dead height above the
// editor — drop it. Legacy Chat must keep rendering (the "@" button below).
if (isAgentMode && !hasContext) {
return null;
}
// Get contextStatus from the shared hook
const getContextStatusIcon = () => {
switch (contextStatus) {
@ -244,13 +247,6 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
</>
)}
{statusIndicator && (
<>
<Separator orientation="vertical" />
{statusIndicator}
</>
)}
{currentChain !== ChainType.PROJECT_CHAIN && indexingState.isActive && showIndexingCard && (
<>
<Separator orientation="vertical" />

View file

@ -53,6 +53,16 @@ const ACCENT_CIRCLE_BUTTON_CLASS =
"tw-rounded-full tw-bg-interactive-accent tw-text-on-accent hover:tw-bg-interactive-accent-hover";
export interface ChatInputProps {
/**
* Accessory rendered in a structural column to the right of the
* badge/image/editor stack (top-aligned). Being a real layout column not
* an overlay no content needs open-ended avoidance padding; the only
* deliberate exception is the column's own negative margin at the mount
* point (a few px of top-corner proximity, documented there). Kept as a
* single flat slot on purpose; if a second accessory ever appears,
* consolidate into a config object, not more props.
*/
topRightAccessory?: React.ReactNode;
inputMessage: string;
setInputMessage: (message: string) => void;
handleSendMessage: (metadata?: {
@ -129,8 +139,6 @@ export interface ChatInputProps {
onRemoveSelectedText?: (id: string) => void;
showProgressCard: () => void;
showIndexingCard?: () => void;
/** Agent Mode project-context status icon, rendered in the context badge row. */
contextStatusIndicator?: React.ReactNode;
/**
* Render slot for the toggle row that sits next to the send button.
@ -203,6 +211,7 @@ export interface ChatInputHandle {
const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{
topRightAccessory,
inputMessage,
setInputMessage,
handleSendMessage,
@ -226,7 +235,6 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
onRemoveSelectedText,
showProgressCard,
showIndexingCard,
contextStatusIndicator,
toolControls,
onToolPillsChange,
onTagSelected,
@ -781,89 +789,113 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
)}
ref={containerRef}
>
{/* Hide context controls in edit mode - editing only changes text, not context */}
{!editMode && (
<ContextControl
contextNotes={contextNotes}
includeActiveNote={includeActiveNote}
activeNote={currentActiveNote}
includeActiveWebTab={includeActiveWebTab}
activeWebTab={activeWebTab}
contextUrls={contextUrls}
contextFolders={contextFolders}
contextWebTabs={mergedContextWebTabs}
selectedTextContexts={selectedTextContexts}
showProgressCard={showProgressCard}
showIndexingCard={showIndexingCard}
onAddToContext={handleAddToContext}
onRemoveFromContext={handleRemoveFromContext}
hideAddContextButton={isAgentMode}
statusIndicator={contextStatusIndicator}
isAgentMode={isAgentMode}
/>
)}
{/* Two columns: the content stack, and (when provided) a structural
accessory column. A column, not an overlay, so badges, images,
placeholder, and editor text stay clear of the accessory without
any of them knowing to avoid it except for the column's own
deliberate negative-margin nibble, documented below. */}
<div className="tw-flex tw-gap-1">
<div className="tw-flex tw-min-w-0 tw-flex-1 tw-flex-col tw-gap-0.5">
{/* Hide context controls in edit mode - editing only changes text, not context */}
{!editMode && (
<ContextControl
contextNotes={contextNotes}
includeActiveNote={includeActiveNote}
activeNote={currentActiveNote}
includeActiveWebTab={includeActiveWebTab}
activeWebTab={activeWebTab}
contextUrls={contextUrls}
contextFolders={contextFolders}
contextWebTabs={mergedContextWebTabs}
selectedTextContexts={selectedTextContexts}
showProgressCard={showProgressCard}
showIndexingCard={showIndexingCard}
onAddToContext={handleAddToContext}
onRemoveFromContext={handleRemoveFromContext}
hideAddContextButton={isAgentMode}
isAgentMode={isAgentMode}
/>
)}
{selectedImages.length > 0 && (
<div className="selected-images">
{selectedImages.map((file, index) => (
<div key={getFileIdentityKey(file)} className="image-preview-container">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="selected-image-preview"
/>
<button
type="button"
className="remove-image-button"
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
title="Remove image"
>
<X className="tw-size-4" />
</button>
{selectedImages.length > 0 && (
<div className="selected-images">
{selectedImages.map((file, index) => (
<div key={getFileIdentityKey(file)} className="image-preview-container">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="selected-image-preview"
/>
<button
type="button"
className="remove-image-button"
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
title="Remove image"
>
<X className="tw-size-4" />
</button>
</div>
))}
</div>
))}
</div>
)}
)}
<div className="tw-relative">
{isProjectLoading && (
<div className="tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-bg-primary tw-opacity-80 tw-backdrop-blur-sm">
<div className="tw-flex tw-items-center tw-gap-2">
<Loader2 className="tw-size-4 tw-animate-spin" />
<span className="tw-text-sm">{loadingMessages[loadingMessageIndex]}</span>
</div>
<div className="tw-relative">
{isProjectLoading && (
<div className="tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-bg-primary tw-opacity-80 tw-backdrop-blur-sm">
<div className="tw-flex tw-items-center tw-gap-2">
<Loader2 className="tw-size-4 tw-animate-spin" />
<span className="tw-text-sm">{loadingMessages[loadingMessageIndex]}</span>
</div>
</div>
)}
<LexicalEditor
value={inputMessage}
onChange={(value) => setInputMessage(value)}
onSubmit={onSendMessage}
onNotesChange={setNotesFromPills}
onNotesRemoved={handleNotePillsRemoved}
onActiveNoteAdded={handleActiveNoteAdded}
onActiveNoteRemoved={handleActiveNoteRemoved}
onURLsChange={isCopilotPlus ? setUrlsFromPills : undefined}
onURLsRemoved={isCopilotPlus ? handleURLPillsRemoved : undefined}
onToolsChange={isCopilotPlus ? onToolPillsChange : undefined}
onFoldersChange={setFoldersFromPills}
onFoldersRemoved={handleFolderPillsRemoved}
onWebTabsChange={setWebTabsFromPills}
onActiveWebTabAdded={handleActiveWebTabAdded}
onActiveWebTabRemoved={handleActiveWebTabRemoved}
agentBrands={agentBrands}
onAgentsChange={handleAgentsChange}
onEditorReady={onEditorReady}
onImagePaste={onAddImage}
onTagSelected={onTagSelected}
placeholder={
"Your AI assistant for Obsidian • @ to add context • / for custom prompts"
}
disabled={isProjectLoading}
isCopilotPlus={isCopilotPlus}
showTools={showAtMentionTools}
currentActiveFile={currentActiveNote}
currentChain={currentChain}
onEscape={onEscape}
onShiftTab={onShiftTab}
/>
</div>
</div>
{topRightAccessory && (
// -ml-4 pulls the column 16px left: across the 4px flex gap, the
// editor's own 8px right-padding strip (px-2, permanently
// text-free), and 4px into the nominal text box — the trigger's
// inner padding keeps its glyph clear of that last strip (value
// eyeballed against the live vault). Accepted trade-offs, both
// confined to the top-right 24px corner: a click there hits the
// status trigger instead of the underlying composer content
// (reasonable for a click that close to the icon), and a scrolling editor's
// scrollbar top briefly passes under the accessory. If a future
// review flags this geometry again, point them at this note.
<div className="-tw-ml-4 tw-w-6 tw-shrink-0 tw-self-start">{topRightAccessory}</div>
)}
<LexicalEditor
value={inputMessage}
onChange={(value) => setInputMessage(value)}
onSubmit={onSendMessage}
onNotesChange={setNotesFromPills}
onNotesRemoved={handleNotePillsRemoved}
onActiveNoteAdded={handleActiveNoteAdded}
onActiveNoteRemoved={handleActiveNoteRemoved}
onURLsChange={isCopilotPlus ? setUrlsFromPills : undefined}
onURLsRemoved={isCopilotPlus ? handleURLPillsRemoved : undefined}
onToolsChange={isCopilotPlus ? onToolPillsChange : undefined}
onFoldersChange={setFoldersFromPills}
onFoldersRemoved={handleFolderPillsRemoved}
onWebTabsChange={setWebTabsFromPills}
onActiveWebTabAdded={handleActiveWebTabAdded}
onActiveWebTabRemoved={handleActiveWebTabRemoved}
agentBrands={agentBrands}
onAgentsChange={handleAgentsChange}
onEditorReady={onEditorReady}
onImagePaste={onAddImage}
onTagSelected={onTagSelected}
placeholder={"Your AI assistant for Obsidian • @ to add context • / for custom prompts"}
disabled={isProjectLoading}
isCopilotPlus={isCopilotPlus}
showTools={showAtMentionTools}
currentActiveFile={currentActiveNote}
currentChain={currentChain}
onEscape={onEscape}
onShiftTab={onShiftTab}
/>
</div>
<div className="tw-flex tw-h-7 tw-justify-between tw-gap-1 tw-px-1">

View file

@ -4,6 +4,9 @@ import { SelectedTextContext, WebTabContext } from "@/types/message";
import { TFile, TFolder } from "obsidian";
import { ChatContextMenu } from "./ChatContextMenu";
// Pass-through shell over ChatContextMenu (predates this file's props; kept
// as-is — inlining it into ChatInput is a standalone refactor, not something
// to piggyback on feature work).
interface ChatControlsProps {
contextNotes: TFile[];
includeActiveNote: boolean;
@ -23,7 +26,6 @@ interface ChatControlsProps {
onRemoveFromContext: (category: string, data: string) => void;
hideAddContextButton?: boolean;
statusIndicator?: React.ReactNode;
isAgentMode?: boolean;
}
@ -43,7 +45,6 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
onAddToContext,
onRemoveFromContext,
hideAddContextButton,
statusIndicator,
isAgentMode,
}) => {
const handleRemoveContext = (category: string, data: string) => {
@ -78,7 +79,6 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
onTypeaheadSelect={handleTypeaheadSelect}
lexicalEditorRef={lexicalEditorRef}
hideAddContextButton={hideAddContextButton}
statusIndicator={statusIndicator}
isAgentMode={isAgentMode}
/>
);