Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions packages/web/src/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import SessionStatusBar from './SessionStatusBar.tsx';
import { borderColors } from '../lib/design-tokens.ts';
import { cn } from '../lib/utils.ts';

interface ChatComposerProps {
export interface ChatComposerProps {
sessionId: string;
readonly: boolean;
sessionStatus?: string;
Expand Down Expand Up @@ -44,10 +44,15 @@ interface ChatComposerProps {
content: string,
images?: MessageImage[],
deliveryMode?: MessageDeliveryMode
) => Promise<void>;
) => Promise<void | boolean>;
onOpenTools: () => void;
onEnterRewindMode: () => void;
onExitRewindMode: () => void;
agentMentionCandidates?: Array<{ id: string; name: string }>;
/** Override the default placeholder text in the message input */
inputPlaceholder?: string;
/** Optional inline error message rendered above the status bar (used by task sessions) */
errorMessage?: string | null;
}

export function ChatComposer({
Expand Down Expand Up @@ -82,9 +87,19 @@ export function ChatComposer({
onOpenTools,
onEnterRewindMode,
onExitRewindMode,
agentMentionCandidates,
inputPlaceholder,
errorMessage,
}: ChatComposerProps) {
return (
<div class="chat-footer absolute bottom-0 left-0 right-0 z-10 pt-4 bg-transparent">
{errorMessage && (
<div class="px-3 mb-1">
<p class="rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-300">
{errorMessage}
</p>
</div>
)}
<SessionStatusBar
sessionId={sessionId}
isProcessing={isProcessing}
Expand Down Expand Up @@ -147,6 +162,8 @@ export function ChatComposer({
onEnterRewindMode={onEnterRewindMode}
rewindMode={rewindMode}
onExitRewindMode={onExitRewindMode}
agentMentionCandidates={agentMentionCandidates}
placeholder={inputPlaceholder}
/>
)
)}
Expand Down
23 changes: 21 additions & 2 deletions packages/web/src/components/InputTextarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { cn } from '../lib/utils.ts';
import { borderColors } from '../lib/design-tokens.ts';
import CommandAutocomplete from './CommandAutocomplete.tsx';
import ReferenceAutocomplete from './ReferenceAutocomplete.tsx';
import MentionAutocomplete from './space/MentionAutocomplete.tsx';
import type { ReferenceSearchResult } from '@neokai/shared';
import { REFERENCE_PATTERN } from '@neokai/shared';

Expand All @@ -54,6 +55,12 @@ export interface InputTextareaProps {
selectedReferenceIndex?: number;
onReferenceSelect?: (result: ReferenceSearchResult) => void;
onReferenceClose?: () => void;
// Agent mention autocomplete
showAgentMentionAutocomplete?: boolean;
agentMentionCandidates?: Array<{ id: string; name: string }>;
selectedAgentMentionIndex?: number;
onAgentMentionSelect?: (name: string) => void;
onAgentMentionClose?: () => void;
// Agent state - passed as prop to avoid direct signal reads that cause re-renders
isAgentWorking?: boolean;
onStop?: () => void;
Expand All @@ -79,6 +86,11 @@ export function InputTextarea({
disabled,
maxChars = 10000,
placeholder = 'Ask or make anything...',
showAgentMentionAutocomplete = false,
agentMentionCandidates = [],
selectedAgentMentionIndex = 0,
onAgentMentionSelect,
onAgentMentionClose,
showCommandAutocomplete = false,
filteredCommands = [],
selectedCommandIndex = 0,
Expand Down Expand Up @@ -181,8 +193,15 @@ export function InputTextarea({

return (
<div class="relative flex-1">
{/* Autocomplete menus — only one shown at a time; reference takes priority */}
{showReferenceAutocomplete && onReferenceSelect && onReferenceClose ? (
{/* Autocomplete menus — only one shown at a time; agent mention takes highest priority */}
{showAgentMentionAutocomplete && onAgentMentionSelect && onAgentMentionClose ? (
<MentionAutocomplete
agents={agentMentionCandidates}
selectedIndex={selectedAgentMentionIndex}
onSelect={onAgentMentionSelect}
onClose={onAgentMentionClose}
/>
) : showReferenceAutocomplete && onReferenceSelect && onReferenceClose ? (
<ReferenceAutocomplete
results={referenceResults ?? []}
selectedIndex={selectedReferenceIndex ?? 0}
Expand Down
148 changes: 138 additions & 10 deletions packages/web/src/components/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Refactored to use shared hooks for better separation of concerns.
*/

import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type {
MessageDeliveryMode,
MessageImage,
Expand Down Expand Up @@ -70,14 +70,17 @@ interface MessageInputProps {
content: string,
images?: MessageImage[],
deliveryMode?: MessageDeliveryMode
) => Promise<void>;
) => Promise<void | boolean>;
disabled?: boolean;
autoScroll?: boolean;
onAutoScrollChange?: (autoScroll: boolean) => void;
onOpenTools?: () => void;
onEnterRewindMode?: () => void;
rewindMode?: boolean;
onExitRewindMode?: () => void;
agentMentionCandidates?: Array<{ id: string; name: string }>;
/** Override the default placeholder derived from sessionType */
placeholder?: string;
}

interface QueuedOverlayMessage {
Expand All @@ -99,6 +102,8 @@ export default function MessageInput({
onEnterRewindMode,
rewindMode,
onExitRewindMode,
agentMentionCandidates,
placeholder: placeholderProp,
}: MessageInputProps) {
// Cache touch device detection — computed once on first render, stable thereafter.
// Using useRef (not a module constant) so tests can mock matchMedia before render.
Expand Down Expand Up @@ -167,6 +172,72 @@ export default function MessageInput({
content,
onSelect: handleReferenceSelect,
});

// Agent mention autocomplete (for workflow agent @-mentions)
const [agentMentionQuery, setAgentMentionQuery] = useState<string | null>(null);
const [agentMentionSelectedIndex, setAgentMentionSelectedIndex] = useState(0);
const lastCursorRef = useRef(0);

const filteredAgentMentionCandidates = useMemo(() => {
if (agentMentionQuery === null || !agentMentionCandidates) return [];
return agentMentionCandidates.filter((a) =>
a.name.toLowerCase().startsWith(agentMentionQuery.toLowerCase())
);
}, [agentMentionCandidates, agentMentionQuery]);

const showAgentMentionAutocomplete =
agentMentionQuery !== null && filteredAgentMentionCandidates.length > 0;

// Wrap setContent to detect @-mentions
const handleContentChange = useCallback(
(value: string) => {
// Track cursor position via the textarea ref
const cursor = textareaInputRef.current?.selectionStart ?? value.length;
lastCursorRef.current = cursor;
setContent(value);

if (agentMentionCandidates && agentMentionCandidates.length > 0) {
const textBeforeCursor = value.slice(0, cursor);
const match = textBeforeCursor.match(/@(\w*)$/);
if (match) {
setAgentMentionQuery(match[1]);
setAgentMentionSelectedIndex(0);
} else {
setAgentMentionQuery(null);
}
}
},
[setContent, agentMentionCandidates]
);

const handleAgentMentionSelect = useCallback(
(name: string) => {
const cursor = textareaInputRef.current?.selectionStart ?? lastCursorRef.current;
const textBeforeCursor = content.slice(0, cursor);
const textAfterCursor = content.slice(cursor);
const match = textBeforeCursor.match(/@(\w*)$/);
if (!match) return;
const start = cursor - match[0].length;
const newValue = content.slice(0, start) + '@' + name + ' ' + textAfterCursor;
setContent(newValue);
setAgentMentionQuery(null);
setAgentMentionSelectedIndex(0);
setTimeout(() => {
if (textareaInputRef.current) {
const newCursor = start + name.length + 2;
textareaInputRef.current.focus();
textareaInputRef.current.setSelectionRange(newCursor, newCursor);
}
}, 0);
},
[content, setContent]
);

const handleAgentMentionClose = useCallback(() => {
setAgentMentionQuery(null);
setAgentMentionSelectedIndex(0);
}, []);

const agentWorking = isAgentWorking.value;
const [queuedForCurrentTurn, setQueuedForCurrentTurn] = useState<QueuedOverlayMessage[]>([]);
const [queuedForNextTurn, setQueuedForNextTurn] = useState<QueuedOverlayMessage[]>([]);
Expand Down Expand Up @@ -265,12 +336,20 @@ export default function MessageInput({
const outgoing = extractOutgoingMessage();
if (!outgoing) return;

// Clear UI
// Save content before clearing so we can restore it if the send fails.
const savedContent = outgoing.content;

// Clear UI optimistically
clearDraft();
clearAttachments();

// Send message with images
await onSend(outgoing.content, outgoing.images, deliveryMode);
// Send message with images; a boolean false return signals failure
const result = await onSend(savedContent, outgoing.images, deliveryMode);
if (result === false) {
// Restore the draft so the user doesn't lose their message
setContent(savedContent);
return;
}
if (
agentWorking ||
deliveryMode === 'defer' ||
Expand All @@ -285,6 +364,7 @@ export default function MessageInput({
extractOutgoingMessage,
clearDraft,
clearAttachments,
setContent,
onSend,
agentWorking,
queuedForCurrentTurn.length,
Expand All @@ -302,6 +382,35 @@ export default function MessageInput({
// Keyboard handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Agent mention autocomplete takes highest precedence when visible
if (showAgentMentionAutocomplete) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setAgentMentionSelectedIndex((i) =>
Math.min(i + 1, filteredAgentMentionCandidates.length - 1)
);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setAgentMentionSelectedIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const candidate = filteredAgentMentionCandidates[agentMentionSelectedIndex];
if (candidate) {
handleAgentMentionSelect(candidate.name);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
handleAgentMentionClose();
return;
}
}

// Reference autocomplete takes precedence when visible
if (refHandleKeyDown(e)) {
return;
Expand Down Expand Up @@ -333,7 +442,17 @@ export default function MessageInput({
}
}
},
[refHandleKeyDown, cmdHandleKeyDown, handleSubmit, agentWorking]
[
refHandleKeyDown,
cmdHandleKeyDown,
handleSubmit,
agentWorking,
showAgentMentionAutocomplete,
filteredAgentMentionCandidates,
agentMentionSelectedIndex,
handleAgentMentionSelect,
handleAgentMentionClose,
]
);

// Model switch handler
Expand Down Expand Up @@ -515,21 +634,30 @@ export default function MessageInput({
{/* Input Textarea */}
<InputTextarea
content={content}
onContentChange={setContent}
onContentChange={handleContentChange}
onKeyDown={handleKeyDown}
onSubmit={() => {
void handleSubmit('immediate');
}}
disabled={disabled}
placeholder={getPlaceholderForSessionType(sessionType)}
placeholder={placeholderProp ?? getPlaceholderForSessionType(sessionType)}
showAgentMentionAutocomplete={showAgentMentionAutocomplete}
agentMentionCandidates={filteredAgentMentionCandidates}
selectedAgentMentionIndex={agentMentionSelectedIndex}
onAgentMentionSelect={handleAgentMentionSelect}
onAgentMentionClose={handleAgentMentionClose}
showCommandAutocomplete={
commandAutocomplete.showAutocomplete && !referenceAutocomplete.showAutocomplete
!showAgentMentionAutocomplete &&
commandAutocomplete.showAutocomplete &&
!referenceAutocomplete.showAutocomplete
}
filteredCommands={commandAutocomplete.filteredCommands}
selectedCommandIndex={commandAutocomplete.selectedIndex}
onCommandSelect={commandAutocomplete.handleSelect}
onCommandClose={commandAutocomplete.close}
showReferenceAutocomplete={referenceAutocomplete.showAutocomplete}
showReferenceAutocomplete={
!showAgentMentionAutocomplete && referenceAutocomplete.showAutocomplete
}
referenceResults={referenceAutocomplete.results}
selectedReferenceIndex={referenceAutocomplete.selectedIndex}
onReferenceSelect={referenceAutocomplete.handleSelect}
Expand Down
23 changes: 11 additions & 12 deletions packages/web/src/components/space/SpaceTaskPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { TaskBlockedBanner } from './TaskBlockedBanner';
import { PendingGateBanner } from './PendingGateBanner';
import { PendingCompletionActionBanner } from './PendingCompletionActionBanner';
import { PendingTaskCompletionBanner } from './PendingTaskCompletionBanner';
import { ThreadedChatComposer } from './ThreadedChatComposer';
import { TaskSessionChatComposer } from './TaskSessionChatComposer';
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: ThreadedChatComposer.tsx is no longer imported anywhere (the only consumer SpaceTaskPane.tsx now imports TaskSessionChatComposer). Delete this file and its test ThreadedChatComposer.test.tsx in this PR.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — deleted both ThreadedChatComposer.tsx and ThreadedChatComposer.test.tsx in the follow-up commit.

import { ReadOnlyWorkflowCanvas } from './ReadOnlyWorkflowCanvas';
import { Dropdown, type DropdownMenuItem } from '../ui/Dropdown';

Expand Down Expand Up @@ -471,17 +471,16 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps)
</div>

{showInlineComposer && (
<div class="absolute bottom-0 left-0 right-0 z-10">
<ThreadedChatComposer
taskSessionId={agentSessionId ?? ''}
mentionCandidates={mentionCandidates}
hasTaskAgentSession={!!agentSessionId}
canSend={canSendThreadMessage}
isSending={sendingThread}
errorMessage={threadSendError}
onSend={sendThreadMessage}
/>
</div>
<TaskSessionChatComposer
sessionId={agentSessionId ?? ''}
mentionCandidates={mentionCandidates}
hasTaskAgentSession={!!agentSessionId}
canSend={canSendThreadMessage}
isSending={sendingThread}
isProcessing={isAgentActive}
errorMessage={threadSendError}
onSend={sendThreadMessage}
/>
)}
</div>
)}
Expand Down
Loading