mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
9fc6153ae6
commit
358abe6b8d
7 changed files with 372 additions and 111 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
/**
|
||||
|
|
|
|||
86
src/components/chat-components/ChatContextMenu.test.tsx
Normal file
86
src/components/chat-components/ChatContextMenu.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue