feat(chat): align command, shell, and subtask UX (#444)
* feat: reload interface after skills operations - Adds configurable delay before interface reload after skills changes - Introduces polling to wait for application health after reload - Updates UI to show reload message when installing or modifying skills * feat: distinguish skills from commands in UI - Displays skill badge for commands that are registered skills - Prevents editing skills through command management interface - Triggers interface reload after skill operations to reflect changes * feat(chat): align command and subtask UX with opencode parity Route commands/shell via parity paths, render delegated subtasks cleanly, and surface child-session permission/question prompts in parent chat. * fix(ProjectEditDialog): improve layout consistency * fix: update task icon and session handling in ToolPart * feat(chat): add shell-mode input and collapse shell bridge output Switch leading ! to shell mode UX and fold synthetic shell bridge assistant messages into the user shell bubble with inline output actions. * fix: remove AI agent icon from file mention autocomplete * fix: remove unused icon import from file mention component
This commit is contained in:
committed by
GitHub
parent
e4a2486312
commit
85f21cb945
@@ -24,6 +24,49 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
|||||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||||
|
|
||||||
|
const collectVisibleSessionIdsForBlockingRequests = (
|
||||||
|
sessions: Array<{ id: string; parentID?: string }> | undefined,
|
||||||
|
currentSessionId: string | null
|
||||||
|
): string[] => {
|
||||||
|
if (!currentSessionId) return [];
|
||||||
|
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
|
||||||
|
|
||||||
|
const current = sessions.find((session) => session.id === currentSessionId);
|
||||||
|
if (!current) return [currentSessionId];
|
||||||
|
|
||||||
|
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
|
||||||
|
if (current.parentID) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const childIds = sessions
|
||||||
|
.filter((session) => session.parentID === currentSessionId)
|
||||||
|
.map((session) => session.id);
|
||||||
|
|
||||||
|
return [currentSessionId, ...childIds];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenBlockingRequests = <T extends { id: string }>(
|
||||||
|
source: Map<string, T[]>,
|
||||||
|
sessionIds: string[]
|
||||||
|
): T[] => {
|
||||||
|
if (sessionIds.length === 0) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: T[] = [];
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const entries = source.get(sessionId);
|
||||||
|
if (!entries || entries.length === 0) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (seen.has(entry.id)) continue;
|
||||||
|
seen.add(entry.id);
|
||||||
|
result.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export const ChatContainer: React.FC = () => {
|
export const ChatContainer: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -67,20 +110,32 @@ export const ChatContainer: React.FC = () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionPermissions = useSessionStore(
|
const blockingRequestState = useSessionStore(
|
||||||
React.useCallback(
|
useShallow((state) => ({
|
||||||
(state) => (currentSessionId ? state.permissions.get(currentSessionId) ?? EMPTY_PERMISSIONS : EMPTY_PERMISSIONS),
|
sessions: state.sessions,
|
||||||
[currentSessionId]
|
permissions: state.permissions,
|
||||||
)
|
questions: state.questions,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionQuestions = useSessionStore(
|
const scopedSessionIds = React.useMemo(
|
||||||
React.useCallback(
|
() => collectVisibleSessionIdsForBlockingRequests(
|
||||||
(state) => (currentSessionId ? state.questions.get(currentSessionId) ?? EMPTY_QUESTIONS : EMPTY_QUESTIONS),
|
blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
|
||||||
[currentSessionId]
|
currentSessionId,
|
||||||
)
|
),
|
||||||
|
[blockingRequestState.sessions, currentSessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sessionPermissions = React.useMemo(() => {
|
||||||
|
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
|
||||||
|
return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
|
||||||
|
}, [blockingRequestState.permissions, scopedSessionIds]);
|
||||||
|
|
||||||
|
const sessionQuestions = React.useMemo(() => {
|
||||||
|
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
||||||
|
return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
|
||||||
|
}, [blockingRequestState.questions, scopedSessionIds]);
|
||||||
|
|
||||||
const memoryState = useSessionStore(
|
const memoryState = useSessionStore(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
|
(state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { AttachedFilesList } from './FileAttachment';
|
|||||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
|
||||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ServerFilePicker } from './ServerFilePicker';
|
import { ServerFilePicker } from './ServerFilePicker';
|
||||||
@@ -77,13 +76,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
return draft;
|
return draft;
|
||||||
});
|
});
|
||||||
|
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [showFileMention, setShowFileMention] = React.useState(false);
|
const [showFileMention, setShowFileMention] = React.useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = React.useState('');
|
const [mentionQuery, setMentionQuery] = React.useState('');
|
||||||
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
|
||||||
const [commandQuery, setCommandQuery] = React.useState('');
|
const [commandQuery, setCommandQuery] = React.useState('');
|
||||||
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
|
|
||||||
const [agentQuery, setAgentQuery] = React.useState('');
|
|
||||||
const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
|
const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
|
||||||
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
||||||
const [skillQuery, setSkillQuery] = React.useState('');
|
const [skillQuery, setSkillQuery] = React.useState('');
|
||||||
@@ -98,7 +96,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const canAcceptDropRef = React.useRef(false);
|
const canAcceptDropRef = React.useRef(false);
|
||||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||||
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
|
||||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||||
|
|
||||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||||
@@ -210,6 +207,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (prevSessionIdRef.current !== currentSessionId) {
|
if (prevSessionIdRef.current !== currentSessionId) {
|
||||||
prevSessionIdRef.current = currentSessionId;
|
prevSessionIdRef.current = currentSessionId;
|
||||||
|
setInputMode('normal');
|
||||||
|
|
||||||
if (!persistChatDraft) {
|
if (!persistChatDraft) {
|
||||||
// Clear draft when switching sessions if persist is disabled
|
// Clear draft when switching sessions if persist is disabled
|
||||||
@@ -542,9 +540,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
textareaRef.current?.blur();
|
textareaRef.current?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle slash commands locally before sending
|
// Handle local slash commands only in normal mode
|
||||||
const normalizedCommand = primaryText.trimStart();
|
const normalizedCommand = primaryText.trimStart();
|
||||||
if (normalizedCommand.startsWith('/')) {
|
if (inputMode === 'normal' && normalizedCommand.startsWith('/')) {
|
||||||
const commandName = normalizedCommand
|
const commandName = normalizedCommand
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.trim()
|
.trim()
|
||||||
@@ -571,28 +569,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
return; // Don't send to assistant
|
return; // Don't send to assistant
|
||||||
}
|
}
|
||||||
// /compact - call SDK summarize endpoint
|
|
||||||
else if (commandName === 'compact' && currentSessionId) {
|
|
||||||
try {
|
|
||||||
const { opencodeClient } = await import('@/lib/opencode/client');
|
|
||||||
const directory = opencodeClient.getDirectory();
|
|
||||||
const response = await opencodeClient.getApiClient().session.summarize({
|
|
||||||
sessionID: currentSessionId,
|
|
||||||
directory: directory || undefined,
|
|
||||||
providerID: currentProviderId,
|
|
||||||
modelID: currentModelId,
|
|
||||||
});
|
|
||||||
if (response.error) {
|
|
||||||
throw new Error('Failed to compact session');
|
|
||||||
}
|
|
||||||
scrollToBottom?.({ instant: true, force: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to compact session:', error);
|
|
||||||
toast.error('Failed to compact session');
|
|
||||||
}
|
|
||||||
setMessage('');
|
|
||||||
return; // Don't send to assistant
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all attachments for error recovery
|
// Collect all attachments for error recovery
|
||||||
@@ -609,7 +585,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
primaryAttachments,
|
primaryAttachments,
|
||||||
agentMentionName,
|
agentMentionName,
|
||||||
additionalParts.length > 0 ? additionalParts : undefined,
|
additionalParts.length > 0 ? additionalParts : undefined,
|
||||||
currentVariant
|
currentVariant,
|
||||||
|
inputMode
|
||||||
).catch((error: unknown) => {
|
).catch((error: unknown) => {
|
||||||
const rawMessage =
|
const rawMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@@ -663,13 +640,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
// Primary action for send button - respects queue mode setting
|
// Primary action for send button - respects queue mode setting
|
||||||
const handlePrimaryAction = React.useCallback(() => {
|
const handlePrimaryAction = React.useCallback(() => {
|
||||||
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
|
||||||
if (queueModeEnabled && canQueue) {
|
if (queueModeEnabled && canQueue) {
|
||||||
handleQueueMessage();
|
handleQueueMessage();
|
||||||
} else {
|
} else {
|
||||||
void handleSubmitRef.current();
|
void handleSubmitRef.current();
|
||||||
}
|
}
|
||||||
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
|
}, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
|
||||||
|
|
||||||
// Auto-send queued messages when session becomes idle (but not after abort)
|
// Auto-send queued messages when session becomes idle (but not after abort)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -706,6 +683,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
|
||||||
if (isIMECompositionEvent(e)) return;
|
if (isIMECompositionEvent(e)) return;
|
||||||
|
|
||||||
|
if (inputMode === 'shell' && e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputMode('normal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInputMode('normal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (showCommandAutocomplete && commandRef.current) {
|
if (showCommandAutocomplete && commandRef.current) {
|
||||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -714,14 +703,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAgentAutocomplete && agentRef.current) {
|
|
||||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
agentRef.current.handleKeyDown(e.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSkillAutocomplete && skillRef.current) {
|
if (showSkillAutocomplete && skillRef.current) {
|
||||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -738,7 +719,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) {
|
if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleCycleAgent();
|
handleCycleAgent();
|
||||||
return;
|
return;
|
||||||
@@ -747,7 +728,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
// Handle ArrowUp/ArrowDown for message history navigation
|
// Handle ArrowUp/ArrowDown for message history navigation
|
||||||
// ArrowUp: only when cursor at start (position 0) or input is empty
|
// ArrowUp: only when cursor at start (position 0) or input is empty
|
||||||
// ArrowDown: also works when cursor at end (to cycle forward through history)
|
// ArrowDown: also works when cursor at end (to cycle forward through history)
|
||||||
const isAnyAutocompleteOpen = showCommandAutocomplete || showAgentAutocomplete || showSkillAutocomplete || showFileMention;
|
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention;
|
||||||
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
|
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
|
||||||
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
|
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
|
||||||
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
|
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
|
||||||
@@ -800,7 +781,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
// Normal mode: Enter sends, Ctrl+Enter queues
|
// Normal mode: Enter sends, Ctrl+Enter queues
|
||||||
// Note: Queueing only works when there's an existing session (currentSessionId)
|
// Note: Queueing only works when there's an existing session (currentSessionId)
|
||||||
// For new sessions (draft), always send immediately
|
// For new sessions (draft), always send immediately
|
||||||
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
|
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
|
||||||
|
|
||||||
if (queueModeEnabled) {
|
if (queueModeEnabled) {
|
||||||
if (isCtrlEnter || !canQueue) {
|
if (isCtrlEnter || !canQueue) {
|
||||||
@@ -896,6 +877,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [adjustTextareaHeight, message, isMobile]);
|
}, [adjustTextareaHeight, message, isMobile]);
|
||||||
|
|
||||||
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
||||||
|
if (inputMode === 'shell') {
|
||||||
|
setShowCommandAutocomplete(false);
|
||||||
|
setShowFileMention(false);
|
||||||
|
setShowSkillAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (value.startsWith('/')) {
|
if (value.startsWith('/')) {
|
||||||
const firstSpace = value.indexOf(' ');
|
const firstSpace = value.indexOf(' ');
|
||||||
const firstNewline = value.indexOf('\n');
|
const firstNewline = value.indexOf('\n');
|
||||||
@@ -910,7 +898,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
setAutocompleteTab('commands');
|
setAutocompleteTab('commands');
|
||||||
setShowCommandAutocomplete(true);
|
setShowCommandAutocomplete(true);
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
setShowAgentAutocomplete(false);
|
|
||||||
setShowSkillAutocomplete(false);
|
setShowSkillAutocomplete(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -920,25 +907,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const textBeforeCursor = value.substring(0, cursorPosition);
|
const textBeforeCursor = value.substring(0, cursorPosition);
|
||||||
|
|
||||||
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
|
||||||
if (lastHashSymbol !== -1) {
|
|
||||||
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
|
|
||||||
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
|
|
||||||
const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n');
|
|
||||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
|
||||||
|
|
||||||
if (isWordBoundary && !hasSeparator) {
|
|
||||||
setAgentQuery(textAfterHash);
|
|
||||||
setAutocompleteTab('agents');
|
|
||||||
setShowAgentAutocomplete(true);
|
|
||||||
setShowFileMention(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowAgentAutocomplete(false);
|
|
||||||
setAgentQuery('');
|
|
||||||
|
|
||||||
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
||||||
if (lastSlashSymbol !== -1) {
|
if (lastSlashSymbol !== -1) {
|
||||||
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
|
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
|
||||||
@@ -950,7 +918,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
setSkillQuery(textAfterSlash);
|
setSkillQuery(textAfterSlash);
|
||||||
setShowSkillAutocomplete(true);
|
setShowSkillAutocomplete(true);
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
setShowAgentAutocomplete(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,10 +927,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
if (lastAtSymbol !== -1) {
|
if (lastAtSymbol !== -1) {
|
||||||
|
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
|
||||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||||
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||||
|
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||||
setMentionQuery(textAfterAt);
|
setMentionQuery(textAfterAt);
|
||||||
setAutocompleteTab('files');
|
setAutocompleteTab('agents');
|
||||||
setShowFileMention(true);
|
setShowFileMention(true);
|
||||||
} else {
|
} else {
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
@@ -971,12 +940,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
} else {
|
} else {
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
}
|
}
|
||||||
}, [setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
}, [inputMode, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
||||||
|
|
||||||
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '#' | '@') => {
|
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '@') => {
|
||||||
const nextMessage = message.length === 0
|
const nextMessage = message.length === 0
|
||||||
? prefix
|
? prefix
|
||||||
: (message[0] === '/' || message[0] === '#' || message[0] === '@')
|
: (message[0] === '/' || message[0] === '@')
|
||||||
? `${prefix}${message.slice(1)}`
|
? `${prefix}${message.slice(1)}`
|
||||||
: `${prefix}${message}`;
|
: `${prefix}${message}`;
|
||||||
setMessage(nextMessage);
|
setMessage(nextMessage);
|
||||||
@@ -1008,22 +977,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
setAutocompleteTab(tab);
|
setAutocompleteTab(tab);
|
||||||
setCommandQuery('');
|
setCommandQuery('');
|
||||||
setAgentQuery('');
|
|
||||||
setMentionQuery('');
|
setMentionQuery('');
|
||||||
if (tab === 'commands') {
|
if (tab === 'commands') {
|
||||||
applyAutocompletePrefix('/');
|
applyAutocompletePrefix('/');
|
||||||
}
|
}
|
||||||
if (tab === 'agents') {
|
if (tab === 'agents') {
|
||||||
applyAutocompletePrefix('#');
|
applyAutocompletePrefix('@');
|
||||||
}
|
}
|
||||||
if (tab === 'files') {
|
if (tab === 'files') {
|
||||||
applyAutocompletePrefix('@');
|
applyAutocompletePrefix('@');
|
||||||
}
|
}
|
||||||
setShowSkillAutocomplete(false);
|
setShowSkillAutocomplete(false);
|
||||||
setShowCommandAutocomplete(tab === 'commands');
|
setShowCommandAutocomplete(tab === 'commands');
|
||||||
setShowAgentAutocomplete(tab === 'agents');
|
setShowFileMention(tab === 'agents' || tab === 'files');
|
||||||
setShowFileMention(tab === 'files');
|
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
||||||
}, [applyAutocompletePrefix, isMobile, setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
|
||||||
|
|
||||||
const handleOpenCommandMenu = React.useCallback(() => {
|
const handleOpenCommandMenu = React.useCallback(() => {
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
@@ -1047,10 +1014,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
setCommandQuery('');
|
setCommandQuery('');
|
||||||
setAutocompleteTab('commands');
|
setAutocompleteTab('commands');
|
||||||
setShowCommandAutocomplete(true);
|
setShowCommandAutocomplete(true);
|
||||||
setShowAgentAutocomplete(false);
|
|
||||||
setShowFileMention(false);
|
setShowFileMention(false);
|
||||||
setShowSkillAutocomplete(false);
|
setShowSkillAutocomplete(false);
|
||||||
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
|
||||||
|
|
||||||
const insertTextAtSelection = React.useCallback((text: string) => {
|
const insertTextAtSelection = React.useCallback((text: string) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -1087,6 +1053,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursorPosition = e.target.selectionStart ?? value.length;
|
const cursorPosition = e.target.selectionStart ?? value.length;
|
||||||
|
|
||||||
|
if (inputMode === 'normal' && value.startsWith('!')) {
|
||||||
|
const shellCommand = value.slice(1);
|
||||||
|
const nextCursor = Math.max(0, cursorPosition - 1);
|
||||||
|
setInputMode('shell');
|
||||||
|
setMessage(shellCommand);
|
||||||
|
adjustTextareaHeight();
|
||||||
|
setShowCommandAutocomplete(false);
|
||||||
|
setShowSkillAutocomplete(false);
|
||||||
|
setShowFileMention(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.selectionStart = nextCursor;
|
||||||
|
textareaRef.current.selectionEnd = nextCursor;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setMessage(value);
|
setMessage(value);
|
||||||
adjustTextareaHeight();
|
adjustTextareaHeight();
|
||||||
updateAutocompleteState(value, cursorPosition);
|
updateAutocompleteState(value, cursorPosition);
|
||||||
@@ -1186,16 +1171,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
const cursorPosition = textarea?.selectionStart ?? message.length;
|
const cursorPosition = textarea?.selectionStart ?? message.length;
|
||||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||||
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
if (lastHashSymbol !== -1) {
|
if (lastAtSymbol !== -1) {
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, lastHashSymbol) +
|
message.substring(0, lastAtSymbol) +
|
||||||
`#${agentName} ` +
|
`@${agentName} ` +
|
||||||
message.substring(cursorPosition);
|
message.substring(cursorPosition);
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
|
|
||||||
const nextCursor = lastHashSymbol + agentName.length + 2;
|
const nextCursor = lastAtSymbol + agentName.length + 2;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.selectionStart = nextCursor;
|
textareaRef.current.selectionStart = nextCursor;
|
||||||
@@ -1207,7 +1192,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
} else if (textareaRef.current) {
|
} else if (textareaRef.current) {
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, cursorPosition) +
|
message.substring(0, cursorPosition) +
|
||||||
`#${agentName} ` +
|
`@${agentName} ` +
|
||||||
message.substring(cursorPosition);
|
message.substring(cursorPosition);
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
|
|
||||||
@@ -1222,8 +1207,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowAgentAutocomplete(false);
|
setShowFileMention(false);
|
||||||
setAgentQuery('');
|
setMentionQuery('');
|
||||||
|
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
@@ -1237,11 +1222,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (lastSlashSymbol !== -1) {
|
if (lastSlashSymbol !== -1) {
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, lastSlashSymbol) +
|
message.substring(0, lastSlashSymbol) +
|
||||||
`${skillName} ` +
|
`/${skillName} ` +
|
||||||
message.substring(cursorPosition);
|
message.substring(cursorPosition);
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
|
|
||||||
const nextCursor = lastSlashSymbol + skillName.length + 1;
|
const nextCursor = lastSlashSymbol + skillName.length + 2;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.selectionStart = nextCursor;
|
textareaRef.current.selectionStart = nextCursor;
|
||||||
@@ -1914,7 +1899,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible",
|
"flex flex-col relative overflow-visible",
|
||||||
"border border-border/80",
|
"border border-border/80",
|
||||||
"focus-within:ring-1 focus-within:ring-primary/50",
|
"focus-within:ring-1",
|
||||||
|
inputMode === 'shell'
|
||||||
|
? 'focus-within:ring-[var(--status-info)]'
|
||||||
|
: 'focus-within:ring-primary/50',
|
||||||
isDragging && "ring-2 ring-primary ring-offset-2"
|
isDragging && "ring-2 ring-primary ring-offset-2"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1958,18 +1946,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ }
|
{ }
|
||||||
{showAgentAutocomplete && (
|
|
||||||
<AgentMentionAutocomplete
|
|
||||||
ref={agentRef}
|
|
||||||
searchQuery={agentQuery}
|
|
||||||
onAgentSelect={handleAgentSelect}
|
|
||||||
showTabs={isMobile}
|
|
||||||
activeTab={autocompleteTab}
|
|
||||||
onTabSelect={handleAutocompleteTabSelect}
|
|
||||||
onClose={() => setShowAgentAutocomplete(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSkillAutocomplete && (
|
{showSkillAutocomplete && (
|
||||||
<SkillAutocomplete
|
<SkillAutocomplete
|
||||||
ref={skillRef}
|
ref={skillRef}
|
||||||
@@ -1985,6 +1961,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
ref={mentionRef}
|
ref={mentionRef}
|
||||||
searchQuery={mentionQuery}
|
searchQuery={mentionQuery}
|
||||||
onFileSelect={handleFileSelect}
|
onFileSelect={handleFileSelect}
|
||||||
|
onAgentSelect={handleAgentSelect}
|
||||||
showTabs={isMobile}
|
showTabs={isMobile}
|
||||||
activeTab={autocompleteTab}
|
activeTab={autocompleteTab}
|
||||||
onTabSelect={handleAutocompleteTabSelect}
|
onTabSelect={handleAutocompleteTabSelect}
|
||||||
@@ -2003,7 +1980,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||||
placeholder={currentSessionId || newSessionDraftOpen
|
placeholder={currentSessionId || newSessionDraftOpen
|
||||||
? "# for agents; @ for files; / for commands"
|
? inputMode === 'shell'
|
||||||
|
? "Enter shell command..."
|
||||||
|
: "@ for files/agents; / for commands; ! for shell"
|
||||||
: "Select or create a session to start chatting"}
|
: "Select or create a session to start chatting"}
|
||||||
disabled={!currentSessionId && !newSessionDraftOpen}
|
disabled={!currentSessionId && !newSessionDraftOpen}
|
||||||
autoCorrect={isMobile ? "on" : "off"}
|
autoCorrect={isMobile ? "on" : "off"}
|
||||||
@@ -2012,6 +1991,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
outerClassName="focus-within:ring-0"
|
outerClassName="focus-within:ring-0"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
||||||
|
inputMode === 'shell' && 'font-mono',
|
||||||
isMobile ? "py-2.5" : "pt-4 pb-2"
|
isMobile ? "py-2.5" : "pt-4 pb-2"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
|
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
|
||||||
if (trimmed.startsWith('The plan at ')) return true;
|
if (trimmed.startsWith('The plan at ')) return true;
|
||||||
|
if (trimmed.startsWith('The following tool was executed by the user')) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +177,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
if (rawPart.type === 'compaction') {
|
if (rawPart.type === 'compaction') {
|
||||||
return { type: 'text', text: '/compact' } as Part;
|
return { type: 'text', text: '/compact' } as Part;
|
||||||
}
|
}
|
||||||
|
if (rawPart.type === 'text') {
|
||||||
|
const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
|
||||||
|
if (text.startsWith('The following tool was executed by the user')) {
|
||||||
|
return { type: 'text', text: '/shell' } as Part;
|
||||||
|
}
|
||||||
|
}
|
||||||
return part;
|
return part;
|
||||||
});
|
});
|
||||||
}, [isUser, message.parts]);
|
}, [isUser, message.parts]);
|
||||||
@@ -460,10 +467,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}
|
}
|
||||||
const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
|
const rawValue = partWithName.source && typeof partWithName.source.value === 'string' && partWithName.source.value.trim().length > 0
|
||||||
? partWithName.source.value
|
? partWithName.source.value
|
||||||
: `#${name}`;
|
: `@${name}`;
|
||||||
return { name, token: rawValue } satisfies AgentMentionInfo;
|
return { name, token: rawValue } satisfies AgentMentionInfo;
|
||||||
}, [isUser, message.parts]);
|
}, [isUser, message.parts]);
|
||||||
|
|
||||||
|
const shouldHideUserMessage = isUser && displayParts.length === 0;
|
||||||
|
|
||||||
// Message is considered to have an "open step" if info.finish is not yet present
|
// Message is considered to have an "open step" if info.finish is not yet present
|
||||||
const hasOpenStep = typeof messageFinish !== 'string';
|
const hasOpenStep = typeof messageFinish !== 'string';
|
||||||
|
|
||||||
@@ -642,6 +651,30 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
|
|
||||||
const messageTextContent = React.useMemo(() => {
|
const messageTextContent = React.useMemo(() => {
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
|
const shellOutputs = displayParts
|
||||||
|
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||||
|
.map((part) => {
|
||||||
|
const output = part.shellAction?.output;
|
||||||
|
return typeof output === 'string' ? output.trim() : '';
|
||||||
|
})
|
||||||
|
.filter((output) => output.length > 0);
|
||||||
|
|
||||||
|
if (shellOutputs.length > 0) {
|
||||||
|
return shellOutputs.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellCommands = displayParts
|
||||||
|
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||||
|
.map((part) => {
|
||||||
|
const command = part.shellAction?.command;
|
||||||
|
return typeof command === 'string' ? command.trim() : '';
|
||||||
|
})
|
||||||
|
.filter((command) => command.length > 0);
|
||||||
|
|
||||||
|
if (shellCommands.length > 0) {
|
||||||
|
return shellCommands.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
const textParts = displayParts
|
const textParts = displayParts
|
||||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
@@ -885,6 +918,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
};
|
};
|
||||||
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
|
||||||
|
|
||||||
|
if (shouldHideUserMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsL
|
|||||||
import { cn, fuzzyMatch } from '@/lib/utils';
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface CommandInfo {
|
|||||||
agent?: string;
|
agent?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
isBuiltIn?: boolean;
|
isBuiltIn?: boolean;
|
||||||
|
isSkill?: boolean;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
|
const { commands: commandsWithMetadata, loadCommands: refreshCommands } = useCommandsStore();
|
||||||
|
const { skills, loadSkills: refreshSkills } = useSkillsStore();
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
@@ -82,18 +85,21 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Force refresh to get latest project context when mounting
|
// Force refresh to get latest project context when mounting
|
||||||
void refreshCommands();
|
void refreshCommands();
|
||||||
}, [refreshCommands]);
|
void refreshSkills();
|
||||||
|
}, [refreshCommands, refreshSkills]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadCommands = async () => {
|
const loadCommands = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const skillNames = new Set(skills.map((skill) => skill.name));
|
||||||
const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
|
const customCommands: CommandInfo[] = commandsWithMetadata.map(cmd => ({
|
||||||
name: cmd.name,
|
name: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
agent: cmd.agent ?? undefined,
|
agent: cmd.agent ?? undefined,
|
||||||
model: cmd.model ?? undefined,
|
model: cmd.model ?? undefined,
|
||||||
isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
|
isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
|
||||||
|
isSkill: skillNames.has(cmd.name),
|
||||||
scope: cmd.scope,
|
scope: cmd.scope,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -171,7 +177,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadCommands();
|
loadCommands();
|
||||||
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata]);
|
}, [searchQuery, hasMessagesInCurrentSession, hasSession, commandsWithMetadata, skills]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
@@ -356,6 +362,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="typography-ui-label font-medium">/{command.name}</span>
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
||||||
|
{command.isSkill ? (
|
||||||
|
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
||||||
|
skill
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{isSystem ? (
|
{isSystem ? (
|
||||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
||||||
system
|
system
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cn, truncatePathMiddle } from '@/lib/utils';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -11,6 +12,11 @@ import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
|||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
|
|
||||||
type FileInfo = ProjectFileSearchHit;
|
type FileInfo = ProjectFileSearchHit;
|
||||||
|
type AgentInfo = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
mode?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FileMentionHandle {
|
export interface FileMentionHandle {
|
||||||
handleKeyDown: (key: string) => void;
|
handleKeyDown: (key: string) => void;
|
||||||
@@ -21,6 +27,7 @@ type AutocompleteTab = 'commands' | 'agents' | 'files';
|
|||||||
interface FileMentionAutocompleteProps {
|
interface FileMentionAutocompleteProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onFileSelect: (file: FileInfo) => void;
|
onFileSelect: (file: FileInfo) => void;
|
||||||
|
onAgentSelect?: (agentName: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
showTabs?: boolean;
|
showTabs?: boolean;
|
||||||
activeTab?: AutocompleteTab;
|
activeTab?: AutocompleteTab;
|
||||||
@@ -30,6 +37,7 @@ interface FileMentionAutocompleteProps {
|
|||||||
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
|
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
|
onAgentSelect,
|
||||||
onClose,
|
onClose,
|
||||||
showTabs,
|
showTabs,
|
||||||
activeTab = 'files',
|
activeTab = 'files',
|
||||||
@@ -37,11 +45,13 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { currentDirectory } = useDirectoryStore();
|
const { currentDirectory } = useDirectoryStore();
|
||||||
const { addServerFile } = useSessionStore();
|
const { addServerFile } = useSessionStore();
|
||||||
|
const { getVisibleAgents } = useConfigStore();
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||||
const showHidden = useDirectoryShowHidden();
|
const showHidden = useDirectoryShowHidden();
|
||||||
const showGitignored = useFilesViewShowGitignored();
|
const showGitignored = useFilesViewShowGitignored();
|
||||||
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
const [files, setFiles] = React.useState<FileInfo[]>([]);
|
||||||
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
||||||
@@ -52,6 +62,8 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const ignoreTabClickRef = React.useRef(false);
|
const ignoreTabClickRef = React.useRef(false);
|
||||||
|
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||||
|
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
|
||||||
|
|
||||||
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -179,11 +191,30 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const visibleAgents = getVisibleAgents();
|
||||||
|
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
||||||
|
const filtered = visibleAgents
|
||||||
|
.filter((agent) => agent.mode && agent.mode !== 'primary')
|
||||||
|
.filter((agent) => {
|
||||||
|
if (!normalizedQuery) return true;
|
||||||
|
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
|
||||||
|
return haystack.includes(normalizedQuery);
|
||||||
|
})
|
||||||
|
.map((agent) => ({
|
||||||
|
name: agent.name,
|
||||||
|
description: agent.description,
|
||||||
|
mode: agent.mode,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setAgents(filtered);
|
||||||
|
}, [getVisibleAgents, searchQuery]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setOverflowMap({});
|
setOverflowMap({});
|
||||||
setMarqueeDurations({});
|
setMarqueeDurations({});
|
||||||
}, [files]);
|
}, [files, visibleAgents.length]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
@@ -265,6 +296,10 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
onFileSelect(file);
|
onFileSelect(file);
|
||||||
}, [addServerFile, onFileSelect]);
|
}, [addServerFile, onFileSelect]);
|
||||||
|
|
||||||
|
const handleAgentPick = React.useCallback((agentName: string) => {
|
||||||
|
onAgentSelect?.(agentName);
|
||||||
|
}, [onAgentSelect]);
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
handleKeyDown: (key: string) => {
|
handleKeyDown: (key: string) => {
|
||||||
if (key === 'Escape') {
|
if (key === 'Escape') {
|
||||||
@@ -272,7 +307,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = files.length;
|
const total = visibleAgents.length + files.length;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,13 +324,20 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
|
|
||||||
if (key === 'Enter' || key === 'Tab') {
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||||
const selectedFile = files[safeIndex];
|
if (safeIndex < visibleAgents.length) {
|
||||||
|
const agent = visibleAgents[safeIndex];
|
||||||
|
if (agent) {
|
||||||
|
handleAgentPick(agent.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedFile = files[safeIndex - visibleAgents.length];
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
handleFileSelect(selectedFile);
|
handleFileSelect(selectedFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}), [files, selectedIndex, onClose, handleFileSelect]);
|
}), [files, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||||
|
|
||||||
const getFileIcon = (file: FileInfo) => {
|
const getFileIcon = (file: FileInfo) => {
|
||||||
const ext = file.extension?.toLowerCase();
|
const ext = file.extension?.toLowerCase();
|
||||||
@@ -373,26 +415,52 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
|
{visibleAgents.map((agent, index) => {
|
||||||
|
const isSelected = selectedIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`agent-${agent.name}`}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg',
|
||||||
|
isSelected && 'bg-interactive-selection',
|
||||||
|
)}
|
||||||
|
onClick={() => handleAgentPick(agent.name)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-semibold truncate">@{agent.name}</div>
|
||||||
|
{agent.description ? (
|
||||||
|
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleAgents.length > 0 && files.length > 0 && (
|
||||||
|
<div className="my-1 border-t border-border/60" />
|
||||||
|
)}
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
|
const rowIndex = visibleAgents.length + index;
|
||||||
const relativePath = file.relativePath || file.name;
|
const relativePath = file.relativePath || file.name;
|
||||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 });
|
const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 });
|
||||||
const isSelected = selectedIndex === index;
|
const isSelected = selectedIndex === rowIndex;
|
||||||
const isOverflowing = overflowMap[index] ?? false;
|
const isOverflowing = overflowMap[rowIndex] ?? false;
|
||||||
const marqueeDuration = marqueeDurations[index] ?? 2.6;
|
const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
|
||||||
|
|
||||||
const item = (
|
const item = (
|
||||||
<div
|
<div
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
ref={(el) => { itemRefs.current[rowIndex] = el; }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||||
isSelected && "bg-interactive-selection"
|
isSelected && "bg-interactive-selection"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleFileSelect(file)}
|
onClick={() => handleFileSelect(file)}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseEnter={() => setSelectedIndex(rowIndex)}
|
||||||
>
|
>
|
||||||
{getFileIcon(file)}
|
{getFileIcon(file)}
|
||||||
<span
|
<span
|
||||||
ref={(el) => { labelRefs.current[index] = el; }}
|
ref={(el) => { labelRefs.current[rowIndex] = el; }}
|
||||||
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
|
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
|
||||||
style={isSelected ? {
|
style={isSelected ? {
|
||||||
['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
|
['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
|
||||||
@@ -401,7 +469,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
aria-label={relativePath}
|
aria-label={relativePath}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
ref={(el) => { measureRefs.current[index] = el; }}
|
ref={(el) => { measureRefs.current[rowIndex] = el; }}
|
||||||
className="absolute invisible whitespace-nowrap pointer-events-none"
|
className="absolute invisible whitespace-nowrap pointer-events-none"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@@ -426,11 +494,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{}
|
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
||||||
{files.length > 0 && <div className="h-2" />}
|
<div className="px-3 py-1 typography-meta text-muted-foreground">
|
||||||
{files.length === 0 && (
|
Type to search more agents
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{files.length === 0 && visibleAgents.length === 0 && (
|
||||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
No files found
|
No matches found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,221 @@ interface ChatMessageEntry {
|
|||||||
parts: Part[];
|
parts: Part[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USER_SHELL_MARKER = 'The following tool was executed by the user';
|
||||||
|
|
||||||
|
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
|
||||||
|
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
|
||||||
|
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
||||||
|
?? (typeof info.role === 'string' ? info.role : null)
|
||||||
|
?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||||
|
if (!message) return false;
|
||||||
|
if (resolveMessageRole(message) !== 'user') return false;
|
||||||
|
return message.parts.some((part) => part?.type === 'subtask');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageId = (message: ChatMessageEntry | undefined): string | null => {
|
||||||
|
if (!message) return null;
|
||||||
|
const id = (message.info as unknown as { id?: unknown }).id;
|
||||||
|
return typeof id === 'string' && id.trim().length > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageParentId = (message: ChatMessageEntry): string | null => {
|
||||||
|
const parentID = (message.info as unknown as { parentID?: unknown }).parentID;
|
||||||
|
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||||
|
if (!message) return false;
|
||||||
|
if (resolveMessageRole(message) !== 'user') return false;
|
||||||
|
|
||||||
|
return message.parts.some((part) => {
|
||||||
|
if (part?.type !== 'text') return false;
|
||||||
|
const text = (part as unknown as { text?: unknown }).text;
|
||||||
|
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
|
||||||
|
return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShellBridgeDetails = {
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => {
|
||||||
|
if (resolveMessageRole(message) !== 'assistant') {
|
||||||
|
return { hide: false, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
|
||||||
|
return { hide: false, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.parts.length !== 1) {
|
||||||
|
return { hide: false, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const part = message.parts[0] as unknown as {
|
||||||
|
type?: unknown;
|
||||||
|
tool?: unknown;
|
||||||
|
state?: {
|
||||||
|
status?: unknown;
|
||||||
|
input?: { command?: unknown };
|
||||||
|
output?: unknown;
|
||||||
|
metadata?: { output?: unknown };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (part.type !== 'tool') {
|
||||||
|
return { hide: false, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
|
||||||
|
if (toolName !== 'bash') {
|
||||||
|
return { hide: false, details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
|
||||||
|
const output =
|
||||||
|
(typeof part.state?.output === 'string' ? part.state.output : undefined)
|
||||||
|
?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
|
||||||
|
const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hide: true,
|
||||||
|
details: {
|
||||||
|
command,
|
||||||
|
output,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readTaskSessionId = (toolPart: Part): string | null => {
|
||||||
|
const partRecord = toolPart as unknown as {
|
||||||
|
state?: {
|
||||||
|
metadata?: { sessionId?: unknown; sessionID?: unknown };
|
||||||
|
output?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const metadata = partRecord.state?.metadata;
|
||||||
|
const fromMetadata =
|
||||||
|
(typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0
|
||||||
|
? metadata.sessionId.trim()
|
||||||
|
: null)
|
||||||
|
?? (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0
|
||||||
|
? metadata.sessionID.trim()
|
||||||
|
: null);
|
||||||
|
if (fromMetadata) return fromMetadata;
|
||||||
|
|
||||||
|
const output = partRecord.state?.output;
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => {
|
||||||
|
if (resolveMessageRole(message) !== 'assistant') {
|
||||||
|
return { hide: false, taskSessionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.parts.length !== 1) {
|
||||||
|
return { hide: false, taskSessionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlyPart = message.parts[0] as unknown as {
|
||||||
|
type?: unknown;
|
||||||
|
tool?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onlyPart.type !== 'tool') {
|
||||||
|
return { hide: false, taskSessionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : '';
|
||||||
|
if (toolName !== 'task') {
|
||||||
|
return { hide: false, taskSessionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hide: true,
|
||||||
|
taskSessionId: readTaskSessionId(message.parts[0]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => {
|
||||||
|
if (!taskSessionId) return message;
|
||||||
|
const nextParts = message.parts.map((part) => {
|
||||||
|
if (part?.type !== 'subtask') return part;
|
||||||
|
const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID;
|
||||||
|
if (typeof existing === 'string' && existing.trim().length > 0) return part;
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
taskSessionID: taskSessionId,
|
||||||
|
} as Part;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: nextParts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => {
|
||||||
|
const command = typeof details?.command === 'string' ? details.command.trim() : '';
|
||||||
|
const output = typeof details?.output === 'string' ? details.output : '';
|
||||||
|
const status = typeof details?.status === 'string' ? details.status.trim() : '';
|
||||||
|
|
||||||
|
const nextParts: Part[] = [];
|
||||||
|
let injected = false;
|
||||||
|
|
||||||
|
for (const part of message.parts) {
|
||||||
|
if (!injected && part?.type === 'text') {
|
||||||
|
const text = (part as unknown as { text?: unknown }).text;
|
||||||
|
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
|
||||||
|
if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) {
|
||||||
|
nextParts.push({
|
||||||
|
type: 'text',
|
||||||
|
text: '/shell',
|
||||||
|
shellAction: {
|
||||||
|
...(command ? { command } : {}),
|
||||||
|
...(output ? { output } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
},
|
||||||
|
} as unknown as Part);
|
||||||
|
injected = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextParts.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!injected) {
|
||||||
|
nextParts.push({
|
||||||
|
type: 'text',
|
||||||
|
text: '/shell',
|
||||||
|
shellAction: {
|
||||||
|
...(command ? { command } : {}),
|
||||||
|
...(output ? { output } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
},
|
||||||
|
} as unknown as Part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
parts: nextParts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: ChatMessageEntry[];
|
messages: ChatMessageEntry[];
|
||||||
permissions: PermissionRequest[];
|
permissions: PermissionRequest[];
|
||||||
@@ -215,7 +430,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
|
|
||||||
const baseDisplayMessages = React.useMemo(() => {
|
const baseDisplayMessages = React.useMemo(() => {
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
return messages
|
const normalizedMessages = messages
|
||||||
.filter((message) => {
|
.filter((message) => {
|
||||||
const messageId = message.info?.id;
|
const messageId = message.info?.id;
|
||||||
if (typeof messageId === 'string') {
|
if (typeof messageId === 'string') {
|
||||||
@@ -238,6 +453,33 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
parts: filteredParts,
|
parts: filteredParts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const output: ChatMessageEntry[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
||||||
|
const current = normalizedMessages[index];
|
||||||
|
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
||||||
|
|
||||||
|
if (isUserSubtaskMessage(previous)) {
|
||||||
|
const bridge = isSyntheticSubtaskBridgeAssistant(current);
|
||||||
|
if (bridge.hide) {
|
||||||
|
output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserShellMarkerMessage(previous)) {
|
||||||
|
const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous));
|
||||||
|
if (bridge.hide) {
|
||||||
|
output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const activeRetryStatus = useSessionStore(
|
const activeRetryStatus = useSessionStore(
|
||||||
@@ -266,16 +508,9 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
data: { message: activeRetryStatus.message },
|
data: { message: activeRetryStatus.message },
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRole = (message: ChatMessageEntry): string | null => {
|
|
||||||
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
|
|
||||||
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
|
||||||
?? (typeof info.role === 'string' ? info.role : null)
|
|
||||||
?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
let lastUserIndex = -1;
|
let lastUserIndex = -1;
|
||||||
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
|
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
|
||||||
if (resolveRole(baseDisplayMessages[index]) === 'user') {
|
if (resolveMessageRole(baseDisplayMessages[index]) === 'user') {
|
||||||
lastUserIndex = index;
|
lastUserIndex = index;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -289,7 +524,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
// to avoid rendering a separate header-only placeholder + error block.
|
// to avoid rendering a separate header-only placeholder + error block.
|
||||||
let targetAssistantIndex = -1;
|
let targetAssistantIndex = -1;
|
||||||
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
|
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
|
||||||
if (resolveRole(baseDisplayMessages[index]) === 'assistant') {
|
if (resolveMessageRole(baseDisplayMessages[index]) === 'assistant') {
|
||||||
targetAssistantIndex = index;
|
targetAssistantIndex = index;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
const { respondToPermission } = useSessionStore();
|
const { respondToPermission } = useSessionStore();
|
||||||
|
const isFromSubagent = useSessionStore(
|
||||||
|
React.useCallback((state) => {
|
||||||
|
const currentSessionId = state.currentSessionId;
|
||||||
|
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
|
||||||
|
const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
|
||||||
|
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||||
|
}, [permission.sessionID])
|
||||||
|
);
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
|
||||||
@@ -317,6 +325,11 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
|
|||||||
<span className="typography-meta font-medium text-muted-foreground">
|
<span className="typography-meta font-medium text-muted-foreground">
|
||||||
Permission Required
|
Permission Required
|
||||||
</span>
|
</span>
|
||||||
|
{isFromSubagent ? (
|
||||||
|
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
|
||||||
|
From subagent
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{getToolIcon(toolName)}
|
{getToolIcon(toolName)}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ const SUMMARY_TAB = 'summary';
|
|||||||
|
|
||||||
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
||||||
const { respondToQuestion, rejectQuestion } = useSessionStore();
|
const { respondToQuestion, rejectQuestion } = useSessionStore();
|
||||||
|
const isFromSubagent = useSessionStore(
|
||||||
|
React.useCallback((state) => {
|
||||||
|
const currentSessionId = state.currentSessionId;
|
||||||
|
if (!currentSessionId || question.sessionID === currentSessionId) return false;
|
||||||
|
const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
|
||||||
|
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
|
||||||
|
}, [question.sessionID])
|
||||||
|
);
|
||||||
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
|
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
|
||||||
const [isResponding, setIsResponding] = React.useState(false);
|
const [isResponding, setIsResponding] = React.useState(false);
|
||||||
const [hasResponded, setHasResponded] = React.useState(false);
|
const [hasResponded, setHasResponded] = React.useState(false);
|
||||||
@@ -169,6 +177,11 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
|
<RiQuestionLine className="h-3.5 w-3.5 text-primary" />
|
||||||
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
|
<span className="typography-meta font-medium text-muted-foreground">Input needed</span>
|
||||||
|
{isFromSubagent ? (
|
||||||
|
<span className="typography-micro text-muted-foreground px-1.5 py-0.5 rounded bg-foreground/5">
|
||||||
|
From subagent
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{activeHeader ? (
|
{activeHeader ? (
|
||||||
<span className="ml-auto typography-micro font-medium text-foreground/70 px-1.5 py-0.5 rounded bg-muted/30 border border-border/20">
|
<span className="ml-auto typography-micro font-medium text-foreground/70 px-1.5 py-0.5 rounded bg-muted/30 border border-border/20">
|
||||||
{activeHeader}
|
{activeHeader}
|
||||||
|
|||||||
@@ -28,6 +28,235 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||||
|
|
||||||
|
type SubtaskPartLike = Part & {
|
||||||
|
type: 'subtask';
|
||||||
|
description?: unknown;
|
||||||
|
command?: unknown;
|
||||||
|
agent?: unknown;
|
||||||
|
prompt?: unknown;
|
||||||
|
taskSessionID?: unknown;
|
||||||
|
model?: {
|
||||||
|
providerID?: unknown;
|
||||||
|
modelID?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShellActionPartLike = Part & {
|
||||||
|
type: 'text';
|
||||||
|
shellAction?: {
|
||||||
|
command?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubtaskPart = (part: Part): part is SubtaskPartLike => {
|
||||||
|
return part.type === 'subtask';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isShellActionPart = (part: Part): part is ShellActionPartLike => {
|
||||||
|
const textPart = part as unknown as { type?: unknown; shellAction?: unknown };
|
||||||
|
return textPart.type === 'text' && typeof textPart.shellAction === 'object' && textPart.shellAction !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null => {
|
||||||
|
if (!model || typeof model !== 'object') return null;
|
||||||
|
const providerID = typeof model.providerID === 'string' ? model.providerID.trim() : '';
|
||||||
|
const modelID = typeof model.modelID === 'string' ? model.modelID.trim() : '';
|
||||||
|
if (!providerID || !modelID) return null;
|
||||||
|
return `${providerID}/${modelID}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
|
|
||||||
|
const description = typeof part.description === 'string' ? part.description.trim() : '';
|
||||||
|
const command = typeof part.command === 'string' ? part.command.trim() : '';
|
||||||
|
const agent = typeof part.agent === 'string' ? part.agent.trim() : '';
|
||||||
|
const prompt = typeof part.prompt === 'string' ? part.prompt.trim() : '';
|
||||||
|
const taskSessionID = typeof part.taskSessionID === 'string' ? part.taskSessionID.trim() : '';
|
||||||
|
const model = normalizeSubtaskModel(part.model);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="typography-meta font-semibold text-foreground">Delegated task</span>
|
||||||
|
{command ? (
|
||||||
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||||
|
/{command}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{agent ? (
|
||||||
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||||
|
@{agent}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{model ? (
|
||||||
|
<span className="inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none bg-foreground/5 text-muted-foreground">
|
||||||
|
{model}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<div className="typography-ui-label text-foreground/90 mt-1.5">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{prompt ? (
|
||||||
|
<div className="mt-2 border-t border-border/60 pt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide prompt' : 'Show prompt'}
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/85">
|
||||||
|
{prompt}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{taskSessionID ? (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||||
|
onClick={() => {
|
||||||
|
void setCurrentSession(taskSessionID);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open subtask session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
||||||
|
const copiedResetTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
|
||||||
|
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
|
||||||
|
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
|
||||||
|
const hasOutput = output.trim().length > 0;
|
||||||
|
|
||||||
|
const clearCopiedResetTimeout = React.useCallback(() => {
|
||||||
|
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.clearTimeout(copiedResetTimeoutRef.current);
|
||||||
|
copiedResetTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearCopiedResetTimeout();
|
||||||
|
};
|
||||||
|
}, [clearCopiedResetTimeout]);
|
||||||
|
|
||||||
|
const copyOutputToClipboard = React.useCallback(async () => {
|
||||||
|
if (!hasOutput) return;
|
||||||
|
|
||||||
|
let succeeded = false;
|
||||||
|
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof window !== 'undefined' && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(output);
|
||||||
|
succeeded = true;
|
||||||
|
} catch {
|
||||||
|
succeeded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!succeeded && typeof document !== 'undefined') {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = output;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.top = '-1000px';
|
||||||
|
textarea.style.left = '-1000px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
|
succeeded = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!succeeded) return;
|
||||||
|
|
||||||
|
clearCopiedResetTimeout();
|
||||||
|
setCopiedOutput(true);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
copiedResetTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopiedOutput(false);
|
||||||
|
copiedResetTimeoutRef.current = null;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}, [clearCopiedResetTimeout, hasOutput, output]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="typography-meta font-semibold text-foreground">Shell command</span>
|
||||||
|
{status ? (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex h-5 items-center rounded px-1.5 text-[11px] leading-none',
|
||||||
|
status === 'error'
|
||||||
|
? 'bg-[var(--status-error-background)] text-[var(--status-error)]'
|
||||||
|
: 'bg-foreground/5 text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{command ? (
|
||||||
|
<pre className="typography-meta mt-1.5 overflow-x-auto whitespace-pre-wrap break-words text-foreground/90 font-mono">
|
||||||
|
{command}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasOutput ? (
|
||||||
|
<div className="mt-2 border-t border-border/60 pt-1.5">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide output' : 'Show output'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
void copyOutputToClipboard();
|
||||||
|
}}
|
||||||
|
aria-label={copiedOutput ? 'Copied' : 'Copy output'}
|
||||||
|
title={copiedOutput ? 'Copied' : 'Copy output'}
|
||||||
|
>
|
||||||
|
{copiedOutput ? <RiCheckLine className="h-3.5 w-3.5" /> : <RiFileCopyLine className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<pre className="typography-meta mt-1.5 max-h-56 overflow-auto whitespace-pre-wrap break-words text-foreground/85 font-mono">
|
||||||
|
{output}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTurnDuration = (durationMs: number): string => {
|
const formatTurnDuration = (durationMs: number): string => {
|
||||||
const totalSeconds = durationMs / 1000;
|
const totalSeconds = durationMs / 1000;
|
||||||
if (totalSeconds < 60) {
|
if (totalSeconds < 60) {
|
||||||
@@ -94,10 +323,18 @@ const UserMessageBody: React.FC<{
|
|||||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
const textParts = React.useMemo(() => {
|
const userContentParts = React.useMemo(() => {
|
||||||
return parts.filter((part) => {
|
return parts.filter((part) => {
|
||||||
if (part.type !== 'text') return false;
|
if (part.type === 'text') {
|
||||||
return !isEmptyTextPart(part);
|
return !isEmptyTextPart(part);
|
||||||
|
}
|
||||||
|
if (isSubtaskPart(part)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isShellActionPart(part)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}, [parts]);
|
}, [parts]);
|
||||||
|
|
||||||
@@ -160,7 +397,23 @@ const UserMessageBody: React.FC<{
|
|||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||||
>
|
>
|
||||||
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
|
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
|
||||||
{textParts.map((part, index) => {
|
{userContentParts.map((part, index) => {
|
||||||
|
if (isSubtaskPart(part)) {
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={part.id ?? `user-subtask-${index}`}>
|
||||||
|
<UserSubtaskPart part={part} />
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShellActionPart(part)) {
|
||||||
|
return (
|
||||||
|
<FadeInOnReveal key={part.id ?? `user-shell-${index}`}>
|
||||||
|
<UserShellActionPart part={part} />
|
||||||
|
</FadeInOnReveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mentionForPart: AgentMentionInfo | undefined;
|
let mentionForPart: AgentMentionInfo | undefined;
|
||||||
if (agentMention && mentionToken && !mentionInjected) {
|
if (agentMention && mentionToken && !mentionInjected) {
|
||||||
const candidateText = extractTextContent(part);
|
const candidateText = extractTextContent(part);
|
||||||
@@ -170,7 +423,7 @@ const UserMessageBody: React.FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<FadeInOnReveal key={`user-text-${index}`}>
|
<FadeInOnReveal key={part.id ?? `user-text-${index}`}>
|
||||||
<UserTextPart
|
<UserTextPart
|
||||||
part={part}
|
part={part}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||||
@@ -11,6 +11,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ export const getToolIcon = (toolName: string) => {
|
|||||||
if (tool === 'skill') {
|
if (tool === 'skill') {
|
||||||
return <RiBookLine className={iconClass} />;
|
return <RiBookLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
|
if (tool === 'task') {
|
||||||
|
return <RiAiAgentLine className={iconClass} />;
|
||||||
|
}
|
||||||
if (tool === 'question') {
|
if (tool === 'question') {
|
||||||
return <RiSurveyLine className={iconClass} />;
|
return <RiSurveyLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
@@ -277,6 +281,66 @@ type TaskToolSummaryEntry = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionMessageWithParts = {
|
||||||
|
info?: {
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
parts?: Array<{
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
tool?: string;
|
||||||
|
state?: {
|
||||||
|
status?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
|
||||||
|
|
||||||
|
const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
|
||||||
|
if (typeof output !== 'string' || output.trim().length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsedMetadata = parseTaskMetadataBlock(output);
|
||||||
|
if (parsedMetadata.sessionId) {
|
||||||
|
return parsedMetadata.sessionId;
|
||||||
|
}
|
||||||
|
const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
|
||||||
|
const candidate = match?.[1];
|
||||||
|
return typeof candidate === 'string' && candidate.trim().length > 0 ? candidate : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
|
||||||
|
const entries: TaskToolSummaryEntry[] = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message?.info?.role !== 'assistant') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part?.type !== 'tool') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
|
||||||
|
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
id: part.id,
|
||||||
|
tool: part.tool,
|
||||||
|
state: {
|
||||||
|
status: part.state?.status,
|
||||||
|
title: part.state?.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||||
const title = entry.state?.title;
|
const title = entry.state?.title;
|
||||||
if (typeof title === 'string' && title.trim().length > 0) {
|
if (typeof title === 'string' && title.trim().length > 0) {
|
||||||
@@ -293,6 +357,97 @@ const stripTaskMetadataFromOutput = (output: string): string => {
|
|||||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: TaskToolSummaryEntry[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
normalized.push({
|
||||||
|
tool: 'tool',
|
||||||
|
state: { status: 'completed', title: entry },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = entry as {
|
||||||
|
id?: unknown;
|
||||||
|
tool?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
state?: { status?: unknown; title?: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined;
|
||||||
|
const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined;
|
||||||
|
const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined);
|
||||||
|
const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined);
|
||||||
|
|
||||||
|
normalized.push({
|
||||||
|
id: typeof record.id === 'string' ? record.id : undefined,
|
||||||
|
tool: typeof record.tool === 'string' ? record.tool : 'tool',
|
||||||
|
state: {
|
||||||
|
status,
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTaskMetadataBlock = (output: string | undefined): {
|
||||||
|
sessionId?: string;
|
||||||
|
summaryEntries: TaskToolSummaryEntry[];
|
||||||
|
} => {
|
||||||
|
if (typeof output !== 'string' || output.trim().length === 0) {
|
||||||
|
return { summaryEntries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
|
||||||
|
if (!blockMatch?.[1]) {
|
||||||
|
return { summaryEntries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = blockMatch[1].trim();
|
||||||
|
if (!raw) {
|
||||||
|
return { summaryEntries: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
sessionId?: unknown;
|
||||||
|
sessionID?: unknown;
|
||||||
|
summary?: unknown;
|
||||||
|
entries?: unknown;
|
||||||
|
tools?: unknown;
|
||||||
|
calls?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryEntries = normalizeTaskSummaryEntries(
|
||||||
|
parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionId =
|
||||||
|
(typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0
|
||||||
|
? parsed.sessionId.trim()
|
||||||
|
: undefined) ??
|
||||||
|
(typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0
|
||||||
|
? parsed.sessionID.trim()
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
return { sessionId, summaryEntries };
|
||||||
|
} catch {
|
||||||
|
return { summaryEntries: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const TaskToolSummary: React.FC<{
|
const TaskToolSummary: React.FC<{
|
||||||
entries: TaskToolSummaryEntry[];
|
entries: TaskToolSummaryEntry[];
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -302,8 +457,9 @@ const TaskToolSummary: React.FC<{
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => {
|
}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => {
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
const completedEntries = React.useMemo(() => {
|
const displayEntries = React.useMemo(() => {
|
||||||
return entries.filter((entry) => entry.state?.status === 'completed');
|
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
||||||
|
return nonPending.length > 0 ? nonPending : entries;
|
||||||
}, [entries]);
|
}, [entries]);
|
||||||
|
|
||||||
const trimmedOutput = typeof output === 'string'
|
const trimmedOutput = typeof output === 'string'
|
||||||
@@ -319,12 +475,12 @@ const TaskToolSummary: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (completedEntries.length === 0 && !hasOutput && !sessionId) {
|
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleEntries = isExpanded ? completedEntries : completedEntries.slice(-6);
|
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
|
||||||
const hiddenCount = Math.max(0, completedEntries.length - visibleEntries.length);
|
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -335,7 +491,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
|
hasNextTool ? 'before:bottom-[-0.6rem]' : 'before:bottom-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{completedEntries.length > 0 ? (
|
{displayEntries.length > 0 ? (
|
||||||
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
|
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
|
||||||
<div className="w-full min-w-0 space-y-1">
|
<div className="w-full min-w-0 space-y-1">
|
||||||
{hiddenCount > 0 ? (
|
{hiddenCount > 0 ? (
|
||||||
@@ -345,6 +501,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
{visibleEntries.map((entry, idx) => {
|
{visibleEntries.map((entry, idx) => {
|
||||||
const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool';
|
const toolName = typeof entry.tool === 'string' && entry.tool.trim().length > 0 ? entry.tool : 'tool';
|
||||||
const label = getTaskSummaryLabel(entry);
|
const label = getTaskSummaryLabel(entry);
|
||||||
|
const status = entry.state?.status;
|
||||||
|
|
||||||
const displayName = getToolMetadata(toolName).displayName;
|
const displayName = getToolMetadata(toolName).displayName;
|
||||||
|
|
||||||
@@ -352,7 +509,10 @@ const TaskToolSummary: React.FC<{
|
|||||||
<div key={entry.id ?? `${toolName}-${idx}`} className="flex items-center gap-2 min-w-0">
|
<div key={entry.id ?? `${toolName}-${idx}`} className="flex items-center gap-2 min-w-0">
|
||||||
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
||||||
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
|
<span className="typography-meta text-foreground/80 flex-shrink-0">{displayName}</span>
|
||||||
<span className="typography-meta text-muted-foreground/70 truncate">{label}</span>
|
<span className={cn(
|
||||||
|
'typography-meta truncate',
|
||||||
|
status === 'error' ? 'text-[var(--status-error)]' : 'text-muted-foreground/70'
|
||||||
|
)}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -373,7 +533,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasOutput ? (
|
{hasOutput ? (
|
||||||
<div className={cn('space-y-1', (completedEntries.length > 0 || sessionId) && 'pt-1')}
|
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1043,24 +1203,105 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start;
|
const effectiveTimeStart = isTaskTool ? (pinnedTaskTimeRef.current.start ?? time?.start) : time?.start;
|
||||||
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end;
|
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTimeRef.current.end ?? time?.end) : time?.end;
|
||||||
|
|
||||||
|
const taskOutputString = React.useMemo(() => {
|
||||||
|
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
|
||||||
|
}, [stateWithData.output]);
|
||||||
|
|
||||||
|
const parsedTaskMetadata = React.useMemo(() => {
|
||||||
|
return parseTaskMetadataBlock(taskOutputString);
|
||||||
|
}, [taskOutputString]);
|
||||||
|
|
||||||
const taskSessionId = React.useMemo<string | undefined>(() => {
|
const taskSessionId = React.useMemo<string | undefined>(() => {
|
||||||
if (!isTaskTool) {
|
if (!isTaskTool) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const candidate = metadata as { sessionId?: string } | undefined;
|
const candidate = metadata as { sessionId?: string } | undefined;
|
||||||
return typeof candidate?.sessionId === 'string' ? candidate.sessionId : undefined;
|
if (typeof candidate?.sessionId === 'string' && candidate.sessionId.trim().length > 0) {
|
||||||
}, [isTaskTool, metadata]);
|
return candidate.sessionId;
|
||||||
|
}
|
||||||
|
if (parsedTaskMetadata.sessionId) {
|
||||||
|
return parsedTaskMetadata.sessionId;
|
||||||
|
}
|
||||||
|
return readTaskSessionIdFromOutput(taskOutputString);
|
||||||
|
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, taskOutputString]);
|
||||||
|
|
||||||
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
const childSessionMessages = useSessionStore(
|
||||||
|
React.useCallback((store) => {
|
||||||
|
if (!taskSessionId) {
|
||||||
|
return EMPTY_SESSION_MESSAGES;
|
||||||
|
}
|
||||||
|
return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
|
||||||
|
}, [taskSessionId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||||
if (!isTaskTool) {
|
if (!isTaskTool) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const candidate = (metadata as { summary?: unknown } | undefined)?.summary;
|
const candidateSummary = (metadata as { summary?: unknown; entries?: unknown; tools?: unknown; calls?: unknown } | undefined);
|
||||||
if (!Array.isArray(candidate)) {
|
const normalized = normalizeTaskSummaryEntries(
|
||||||
|
candidateSummary?.summary ?? candidateSummary?.entries ?? candidateSummary?.tools ?? candidateSummary?.calls
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedTaskMetadata.summaryEntries;
|
||||||
|
}, [isTaskTool, metadata, parsedTaskMetadata.summaryEntries]);
|
||||||
|
|
||||||
|
const childSessionTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||||
|
if (!isTaskTool || !taskSessionId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return candidate.filter((entry): entry is TaskToolSummaryEntry => typeof entry === 'object' && entry !== null) as TaskToolSummaryEntry[];
|
if (!Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
|
||||||
}, [isTaskTool, metadata]);
|
return [];
|
||||||
|
}
|
||||||
|
return buildTaskSummaryEntriesFromSession(childSessionMessages);
|
||||||
|
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||||
|
|
||||||
|
const taskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||||
|
if (childSessionTaskSummaryEntries.length > 0) {
|
||||||
|
return childSessionTaskSummaryEntries;
|
||||||
|
}
|
||||||
|
return metadataTaskSummaryEntries;
|
||||||
|
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
|
||||||
|
|
||||||
|
const fetchedTaskSessionsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isTaskTool || !taskSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (childSessionTaskSummaryEntries.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fetchedTaskSessionsRef.current.has(taskSessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedTaskSessionsRef.current.add(taskSessionId);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void opencodeClient
|
||||||
|
.getSessionMessages(taskSessionId, 500)
|
||||||
|
.then((messages) => {
|
||||||
|
if (cancelled || !Array.isArray(messages)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messages.length === 0) {
|
||||||
|
fetchedTaskSessionsRef.current.delete(taskSessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
fetchedTaskSessionsRef.current.delete(taskSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [childSessionTaskSummaryEntries.length, isTaskTool, taskSessionId]);
|
||||||
|
|
||||||
|
|
||||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||||
@@ -1211,7 +1452,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
hasPrevTool={hasPrevTool}
|
hasPrevTool={hasPrevTool}
|
||||||
hasNextTool={hasNextTool}
|
hasNextTool={hasNextTool}
|
||||||
output={typeof stateWithData.output === 'string' ? stateWithData.output : undefined}
|
output={taskOutputString}
|
||||||
sessionId={taskSessionId}
|
sessionId={taskSessionId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader className="min-w-0">
|
||||||
<DialogTitle>Edit project</DialogTitle>
|
<DialogTitle>Edit project</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-5 py-1">
|
<div className="min-w-0 space-y-5 py-1">
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="space-y-1.5">
|
<div className="min-w-0 space-y-1.5">
|
||||||
<label className="typography-ui-label font-medium text-foreground">
|
<label className="typography-ui-label font-medium text-foreground">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
@@ -76,13 +76,13 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="typography-meta text-muted-foreground truncate">
|
<p className="typography-meta text-muted-foreground truncate" title={projectPath}>
|
||||||
{projectPath}
|
{projectPath}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color */}
|
{/* Color */}
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<label className="typography-ui-label font-medium text-foreground">
|
<label className="typography-ui-label font-medium text-foreground">
|
||||||
Color
|
Color
|
||||||
</label>
|
</label>
|
||||||
@@ -120,7 +120,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<label className="typography-ui-label font-medium text-foreground">
|
<label className="typography-ui-label font-medium text-foreground">
|
||||||
Icon
|
Icon
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
import { RiAddLine, RiTerminalBoxLine, RiMore2Line, RiDeleteBinLine, RiFileCopyLine, RiRestartLine, RiEditLine } from '@remixicon/react';
|
||||||
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
|
||||||
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
@@ -45,6 +46,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
deleteCommand,
|
deleteCommand,
|
||||||
loadCommands,
|
loadCommands,
|
||||||
} = useCommandsStore();
|
} = useCommandsStore();
|
||||||
|
const { skills, loadSkills } = useSkillsStore();
|
||||||
|
|
||||||
const { setSidebarOpen } = useUIStore();
|
const { setSidebarOpen } = useUIStore();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
@@ -53,7 +55,24 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadCommands();
|
loadCommands();
|
||||||
}, [loadCommands]);
|
loadSkills();
|
||||||
|
}, [loadCommands, loadSkills]);
|
||||||
|
|
||||||
|
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
|
||||||
|
const commandOnlyItems = React.useMemo(
|
||||||
|
() => commands.filter((command) => !skillNames.has(command.name)),
|
||||||
|
[commands, skillNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedCommandName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillNames.has(selectedCommandName)) {
|
||||||
|
setSelectedCommand(null);
|
||||||
|
}
|
||||||
|
}, [selectedCommandName, setSelectedCommand, skillNames]);
|
||||||
|
|
||||||
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
|
||||||
|
|
||||||
@@ -203,14 +222,14 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
setRenameDialogCommand(null);
|
setRenameDialogCommand(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const builtInCommands = commands.filter(isCommandBuiltIn);
|
const builtInCommands = commandOnlyItems.filter(isCommandBuiltIn);
|
||||||
const customCommands = commands.filter((cmd) => !isCommandBuiltIn(cmd));
|
const customCommands = commandOnlyItems.filter((cmd) => !isCommandBuiltIn(cmd));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||||
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="typography-meta text-muted-foreground">Total {commands.length}</span>
|
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -224,7 +243,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2">
|
||||||
{commands.length === 0 ? (
|
{commandOnlyItems.length === 0 ? (
|
||||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||||
<RiTerminalBoxLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
<RiTerminalBoxLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||||
<p className="typography-ui-label font-medium">No commands configured</p>
|
<p className="typography-ui-label font-medium">No commands configured</p>
|
||||||
|
|||||||
@@ -793,6 +793,28 @@ export const useEventStream = () => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldKeepSyntheticUserText = (value: unknown): boolean => {
|
||||||
|
const text = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!text) return false;
|
||||||
|
return (
|
||||||
|
text.startsWith('User has requested to enter plan mode') ||
|
||||||
|
text.startsWith('The plan at ') ||
|
||||||
|
text.startsWith('The following tool was executed by the user')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferUserRoleFromPart = (): boolean => {
|
||||||
|
const partType = typeof partExt.type === 'string' ? partExt.type : '';
|
||||||
|
if (partType === 'subtask' || partType === 'agent' || partType === 'file') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (partType === 'text' && partExt.synthetic === true) {
|
||||||
|
const text = (partExt as { text?: unknown }).text;
|
||||||
|
return shouldKeepSyntheticUserText(text);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
let roleInfo = 'assistant';
|
let roleInfo = 'assistant';
|
||||||
if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') {
|
if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') {
|
||||||
roleInfo = (messageInfo as { role?: string }).role as string;
|
roleInfo = (messageInfo as { role?: string }).role as string;
|
||||||
@@ -806,11 +828,18 @@ export const useEventStream = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (roleInfo !== 'user' && inferUserRoleFromPart()) {
|
||||||
|
roleInfo = 'user';
|
||||||
|
}
|
||||||
|
|
||||||
trackMessage(messageId, 'part_received', { role: roleInfo });
|
trackMessage(messageId, 'part_received', { role: roleInfo });
|
||||||
|
|
||||||
if (roleInfo === 'user' && partExt.synthetic === true) {
|
if (roleInfo === 'user' && partExt.synthetic === true) {
|
||||||
trackMessage(messageId, 'skipped_synthetic_user_part');
|
const text = (partExt as { text?: unknown }).text;
|
||||||
break;
|
if (!shouldKeepSyntheticUserText(text)) {
|
||||||
|
trackMessage(messageId, 'skipped_synthetic_user_part');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagePart: Part = {
|
const messagePart: Part = {
|
||||||
@@ -1162,7 +1191,8 @@ export const useEventStream = () => {
|
|||||||
const textStr = typeof text === 'string' ? text.trim() : '';
|
const textStr = typeof text === 'string' ? text.trim() : '';
|
||||||
const shouldKeep =
|
const shouldKeep =
|
||||||
textStr.startsWith('User has requested to enter plan mode') ||
|
textStr.startsWith('User has requested to enter plan mode') ||
|
||||||
textStr.startsWith('The plan at ');
|
textStr.startsWith('The plan at ') ||
|
||||||
|
textStr.startsWith('The following tool was executed by the user');
|
||||||
if (!shouldKeep) continue;
|
if (!shouldKeep) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,6 +1487,11 @@ export const useEventStream = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID);
|
||||||
|
if (requestSession?.parentID && requestSession.parentID === current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pending = useSessionStore
|
const pending = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.permissions
|
.permissions
|
||||||
@@ -1514,6 +1549,11 @@ export const useEventStream = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestSession = useSessionStore.getState().sessions.find((session) => session.id === request.sessionID);
|
||||||
|
if (requestSession?.parentID && requestSession.parentID === current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pending = useSessionStore
|
const pending = useSessionStore
|
||||||
.getState()
|
.getState()
|
||||||
.questions
|
.questions
|
||||||
|
|||||||
@@ -1036,4 +1036,7 @@ export interface SkillsInstallResponse {
|
|||||||
installed?: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
installed?: Array<{ skillName: string; scope: 'user' | 'project'; source?: 'opencode' | 'agents' }>;
|
||||||
skipped?: Array<{ skillName: string; reason: string }>;
|
skipped?: Array<{ skillName: string; reason: string }>;
|
||||||
error?: SkillsInstallError;
|
error?: SkillsInstallError;
|
||||||
|
requiresReload?: boolean;
|
||||||
|
message?: string;
|
||||||
|
reloadDelayMs?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,15 @@ export const parseAgentMentions = (rawText: string, agents: Agent[]): ParsedAgen
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nonPrimaryAgents = agents.filter((agent) => agent.mode && agent.mode !== "primary");
|
const nonPrimaryAgents = agents.filter((agent) => agent.mode && agent.mode !== "primary");
|
||||||
if (nonPrimaryAgents.length === 0 || !rawText.includes("#")) {
|
if (nonPrimaryAgents.length === 0 || !rawText.includes("@")) {
|
||||||
return { sanitizedText: rawText, mention: null };
|
return { sanitizedText: rawText, mention: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
let firstMention: ParsedAgentMention | null = null;
|
let firstMention: ParsedAgentMention | null = null;
|
||||||
|
|
||||||
for (const agent of nonPrimaryAgents) {
|
for (const agent of nonPrimaryAgents) {
|
||||||
const pattern = new RegExp(`#${agent.name}\\b`, "gi");
|
const escapedAgentName = agent.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const pattern = new RegExp(`@${escapedAgentName}\\b`, "gi");
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = pattern.exec(rawText)) !== null) {
|
while ((match = pattern.exec(rawText)) !== null) {
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ type AgentPartInputLite = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FileInputLite = {
|
||||||
|
id?: string;
|
||||||
|
type: 'file';
|
||||||
|
mime: string;
|
||||||
|
filename?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DirectorySwitchResult = {
|
export type DirectorySwitchResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
restarted: boolean;
|
restarted: boolean;
|
||||||
@@ -139,6 +147,7 @@ class OpencodeService {
|
|||||||
private scopedClients: Map<string, OpencodeClient> = new Map();
|
private scopedClients: Map<string, OpencodeClient> = new Map();
|
||||||
private sseAbortControllers: Map<string, AbortController> = new Map();
|
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||||
private currentDirectory: string | undefined = undefined;
|
private currentDirectory: string | undefined = undefined;
|
||||||
|
private directoryContextQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
private globalSseAbortController: AbortController | null = null;
|
private globalSseAbortController: AbortController | null = null;
|
||||||
private globalSseTask: Promise<void> | null = null;
|
private globalSseTask: Promise<void> | null = null;
|
||||||
@@ -250,17 +259,27 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async withDirectory<T>(directory: string | undefined | null, fn: () => Promise<T>): Promise<T> {
|
async withDirectory<T>(directory: string | undefined | null, fn: () => Promise<T>): Promise<T> {
|
||||||
if (directory === undefined || directory === null) {
|
const runWithContext = async (): Promise<T> => {
|
||||||
return fn();
|
if (directory === undefined || directory === null) {
|
||||||
}
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
const previousDirectory = this.currentDirectory;
|
const previousDirectory = this.currentDirectory;
|
||||||
this.currentDirectory = directory;
|
this.currentDirectory = directory;
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
this.currentDirectory = previousDirectory;
|
this.currentDirectory = previousDirectory;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queuedRun = this.directoryContextQueue.then(runWithContext, runWithContext);
|
||||||
|
this.directoryContextQueue = queuedRun.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return queuedRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the raw API client for direct access
|
// Get the raw API client for direct access
|
||||||
@@ -571,6 +590,17 @@ class OpencodeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async toNormalizedFilePartInput(file: FileInputLite): Promise<FilePartInput> {
|
||||||
|
const normalized = await this.normalizeFilePart(file);
|
||||||
|
return {
|
||||||
|
...(file.id ? { id: file.id } : {}),
|
||||||
|
type: 'file',
|
||||||
|
mime: normalized.mime,
|
||||||
|
filename: normalized.filename,
|
||||||
|
url: normalized.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async sendMessage(params: {
|
async sendMessage(params: {
|
||||||
id: string;
|
id: string;
|
||||||
providerID: string;
|
providerID: string;
|
||||||
@@ -580,24 +610,12 @@ class OpencodeService {
|
|||||||
prefaceTextSynthetic?: boolean;
|
prefaceTextSynthetic?: boolean;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
files?: Array<{
|
files?: Array<FileInputLite>;
|
||||||
id?: string;
|
|
||||||
type: 'file';
|
|
||||||
mime: string;
|
|
||||||
filename?: string;
|
|
||||||
url: string;
|
|
||||||
}>;
|
|
||||||
/** Additional text/file parts to include (for batch sending queued messages) */
|
/** Additional text/file parts to include (for batch sending queued messages) */
|
||||||
additionalParts?: Array<{
|
additionalParts?: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
synthetic?: boolean;
|
synthetic?: boolean;
|
||||||
files?: Array<{
|
files?: Array<FileInputLite>;
|
||||||
id?: string;
|
|
||||||
type: 'file';
|
|
||||||
mime: string;
|
|
||||||
filename?: string;
|
|
||||||
url: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
}>;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>;
|
||||||
@@ -630,14 +648,7 @@ class OpencodeService {
|
|||||||
// Add file parts if provided (normalizing MIME types for compatibility)
|
// Add file parts if provided (normalizing MIME types for compatibility)
|
||||||
if (params.files && params.files.length > 0) {
|
if (params.files && params.files.length > 0) {
|
||||||
for (const file of params.files) {
|
for (const file of params.files) {
|
||||||
const normalized = await this.normalizeFilePart(file);
|
const filePart = await this.toNormalizedFilePartInput(file);
|
||||||
const filePart: FilePartInput = {
|
|
||||||
...(file.id ? { id: file.id } : {}),
|
|
||||||
type: 'file',
|
|
||||||
mime: normalized.mime,
|
|
||||||
filename: normalized.filename,
|
|
||||||
url: normalized.url
|
|
||||||
};
|
|
||||||
parts.push(filePart);
|
parts.push(filePart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,14 +665,7 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
if (additional.files && additional.files.length > 0) {
|
if (additional.files && additional.files.length > 0) {
|
||||||
for (const file of additional.files) {
|
for (const file of additional.files) {
|
||||||
const normalized = await this.normalizeFilePart(file);
|
const filePart = await this.toNormalizedFilePartInput(file);
|
||||||
const filePart: FilePartInput = {
|
|
||||||
...(file.id ? { id: file.id } : {}),
|
|
||||||
type: 'file',
|
|
||||||
mime: normalized.mime,
|
|
||||||
filename: normalized.filename,
|
|
||||||
url: normalized.url
|
|
||||||
};
|
|
||||||
parts.push(filePart);
|
parts.push(filePart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,12 +673,12 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (params.agentMentions && params.agentMentions.length > 0) {
|
if (params.agentMentions && params.agentMentions.length > 0) {
|
||||||
const [first] = params.agentMentions;
|
for (const mention of params.agentMentions) {
|
||||||
if (first?.name) {
|
if (!mention?.name) continue;
|
||||||
parts.push({
|
parts.push({
|
||||||
type: 'agent',
|
type: 'agent',
|
||||||
name: first.name,
|
name: mention.name,
|
||||||
...(first.source ? { source: first.source } : {}),
|
...(mention.source ? { source: mention.source } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,6 +730,66 @@ class OpencodeService {
|
|||||||
return tempMessageId;
|
return tempMessageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendCommand(params: {
|
||||||
|
id: string;
|
||||||
|
providerID: string;
|
||||||
|
modelID: string;
|
||||||
|
command: string;
|
||||||
|
arguments?: string;
|
||||||
|
agent?: string;
|
||||||
|
variant?: string;
|
||||||
|
files?: Array<FileInputLite>;
|
||||||
|
messageId?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const baseTimestamp = Date.now();
|
||||||
|
const tempMessageId = params.messageId ?? `temp_${baseTimestamp}_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
|
const parts: FilePartInput[] = [];
|
||||||
|
if (params.files && params.files.length > 0) {
|
||||||
|
for (const file of params.files) {
|
||||||
|
parts.push(await this.toNormalizedFilePartInput(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = this.baseUrl.replace(/\/+$/, '');
|
||||||
|
const url = new URL(`${base}/session/${encodeURIComponent(params.id)}/command`);
|
||||||
|
if (this.currentDirectory) {
|
||||||
|
url.searchParams.set('directory', this.currentDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
command: params.command,
|
||||||
|
arguments: params.arguments ?? '',
|
||||||
|
model: `${params.providerID}/${params.modelID}`,
|
||||||
|
...(params.agent ? { agent: params.agent } : {}),
|
||||||
|
...(params.variant ? { variant: params.variant } : {}),
|
||||||
|
...(parts.length > 0 ? { parts } : {}),
|
||||||
|
...(params.messageId ? { messageID: params.messageId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
detail = await response.text();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : '';
|
||||||
|
throw new Error(`Failed to run command (${response.status})${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempMessageId;
|
||||||
|
}
|
||||||
|
|
||||||
async abortSession(id: string): Promise<boolean> {
|
async abortSession(id: string): Promise<boolean> {
|
||||||
const response = await this.client.session.abort(
|
const response = await this.client.session.abort(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ interface MessageState {
|
|||||||
|
|
||||||
interface MessageActions {
|
interface MessageActions {
|
||||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>;
|
||||||
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
|
||||||
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
|
||||||
@@ -579,7 +579,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => {
|
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
throw new Error("No session selected");
|
throw new Error("No session selected");
|
||||||
}
|
}
|
||||||
@@ -596,54 +596,47 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
|
|
||||||
await executeWithSessionDirectory(sessionId, async () => {
|
await executeWithSessionDirectory(sessionId, async () => {
|
||||||
try {
|
try {
|
||||||
let effectiveContent = content;
|
const trimmedContent = content.trimStart();
|
||||||
const isCommand = content.startsWith("/");
|
const commandPayload = (() => {
|
||||||
|
if (inputMode === 'shell') return null;
|
||||||
if (isCommand) {
|
if (!trimmedContent.startsWith("/")) return null;
|
||||||
const spaceIndex = content.indexOf(" ");
|
const firstLineEnd = trimmedContent.indexOf("\n");
|
||||||
const command = spaceIndex === -1 ? content.substring(1) : content.substring(1, spaceIndex);
|
const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd);
|
||||||
const commandArgs = spaceIndex === -1 ? "" : content.substring(spaceIndex + 1).trim();
|
const [commandToken, ...firstLineArgs] = firstLine.split(" ");
|
||||||
|
const command = commandToken.slice(1).trim();
|
||||||
const apiClient = opencodeClient.getApiClient();
|
if (command.toLowerCase() === "shell") return null;
|
||||||
const directory = opencodeClient.getDirectory();
|
if (!command) return null;
|
||||||
|
const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1);
|
||||||
if (command === "init") {
|
const argsFromFirstLine = firstLineArgs.join(" ").trim();
|
||||||
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
const args = restOfInput
|
||||||
|
? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput)
|
||||||
await apiClient.session.init({
|
: argsFromFirstLine;
|
||||||
sessionID: sessionId,
|
return {
|
||||||
...(directory ? { directory } : {}),
|
command,
|
||||||
messageID: messageId,
|
arguments: args,
|
||||||
providerID,
|
};
|
||||||
modelID,
|
})();
|
||||||
});
|
const shellPayload = (() => {
|
||||||
|
if (inputMode !== 'shell') return null;
|
||||||
return;
|
const command = content.trim();
|
||||||
}
|
if (!command.trim()) return null;
|
||||||
|
return { command };
|
||||||
if (command === "summarize") {
|
})();
|
||||||
await apiClient.session.summarize({
|
const slashShellPayload = (() => {
|
||||||
sessionID: sessionId,
|
if (!trimmedContent.startsWith("/")) return null;
|
||||||
...(directory ? { directory } : {}),
|
const firstLineEnd = trimmedContent.indexOf("\n");
|
||||||
providerID,
|
const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd);
|
||||||
modelID,
|
const [commandToken, ...firstLineArgs] = firstLine.split(" ");
|
||||||
});
|
const commandName = commandToken.slice(1).trim().toLowerCase();
|
||||||
|
if (commandName !== "shell") return null;
|
||||||
return;
|
const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1);
|
||||||
}
|
const argsFromFirstLine = firstLineArgs.join(" ").trim();
|
||||||
|
const command = restOfInput
|
||||||
try {
|
? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput)
|
||||||
const commandDetails = await opencodeClient.getCommandDetails(command);
|
: argsFromFirstLine;
|
||||||
if (commandDetails?.template) {
|
if (!command.trim()) return null;
|
||||||
effectiveContent = commandDetails.template.replace(/\$ARGUMENTS/g, commandArgs);
|
return { command };
|
||||||
} else {
|
})();
|
||||||
effectiveContent = content;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Command template resolution failed:", error);
|
|
||||||
effectiveContent = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
lastUsedProvider: { providerID, modelID },
|
lastUsedProvider: { providerID, modelID },
|
||||||
@@ -723,17 +716,51 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await opencodeClient.sendMessage({
|
const apiClient = opencodeClient.getApiClient();
|
||||||
id: sessionId,
|
const directory = opencodeClient.getDirectory();
|
||||||
providerID,
|
|
||||||
modelID,
|
if (shellPayload || slashShellPayload) {
|
||||||
text: effectiveContent,
|
await apiClient.session.shell({
|
||||||
agent,
|
sessionID: sessionId,
|
||||||
variant,
|
...(directory ? { directory } : {}),
|
||||||
files: filePayloads.length > 0 ? filePayloads : undefined,
|
...(agent ? { agent } : {}),
|
||||||
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
|
model: {
|
||||||
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
providerID,
|
||||||
});
|
modelID,
|
||||||
|
},
|
||||||
|
command: (shellPayload ?? slashShellPayload)!.command,
|
||||||
|
});
|
||||||
|
} else if (commandPayload && commandPayload.command.toLowerCase() === 'compact') {
|
||||||
|
await apiClient.session.summarize({
|
||||||
|
sessionID: sessionId,
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
});
|
||||||
|
} else if (commandPayload) {
|
||||||
|
await opencodeClient.sendCommand({
|
||||||
|
id: sessionId,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
command: commandPayload.command,
|
||||||
|
arguments: commandPayload.arguments,
|
||||||
|
agent,
|
||||||
|
variant,
|
||||||
|
files: filePayloads.length > 0 ? filePayloads : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await opencodeClient.sendMessage({
|
||||||
|
id: sessionId,
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
text: content,
|
||||||
|
agent,
|
||||||
|
variant,
|
||||||
|
files: filePayloads.length > 0 ? filePayloads : undefined,
|
||||||
|
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
|
||||||
|
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (filePayloads.length > 0) {
|
if (filePayloads.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -1145,7 +1172,8 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
const incomingText = extractTextFromPart(part).trim();
|
const incomingText = extractTextFromPart(part).trim();
|
||||||
const shouldKeep =
|
const shouldKeep =
|
||||||
incomingText.startsWith('User has requested to enter plan mode') ||
|
incomingText.startsWith('User has requested to enter plan mode') ||
|
||||||
incomingText.startsWith('The plan at ');
|
incomingText.startsWith('The plan at ') ||
|
||||||
|
incomingText.startsWith('The following tool was executed by the user');
|
||||||
if (!shouldKeep) {
|
if (!shouldKeep) {
|
||||||
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part');
|
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part');
|
||||||
return state;
|
return state;
|
||||||
@@ -1226,7 +1254,8 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
const incomingText = extractTextFromPart(part).trim();
|
const incomingText = extractTextFromPart(part).trim();
|
||||||
const shouldKeep =
|
const shouldKeep =
|
||||||
incomingText.startsWith('User has requested to enter plan mode') ||
|
incomingText.startsWith('User has requested to enter plan mode') ||
|
||||||
incomingText.startsWith('The plan at ');
|
incomingText.startsWith('The plan at ') ||
|
||||||
|
incomingText.startsWith('The following tool was executed by the user');
|
||||||
if (!shouldKeep) {
|
if (!shouldKeep) {
|
||||||
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part');
|
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part');
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export interface SessionStore {
|
|||||||
unshareSession: (id: string) => Promise<Session | null>;
|
unshareSession: (id: string) => Promise<Session | null>;
|
||||||
setCurrentSession: (id: string | null) => void;
|
setCurrentSession: (id: string | null) => void;
|
||||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||||
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
|
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>;
|
||||||
abortCurrentOperation: () => Promise<void>;
|
abortCurrentOperation: () => Promise<void>;
|
||||||
acknowledgeSessionAbort: (sessionId: string) => void;
|
acknowledgeSessionAbort: (sessionId: string) => void;
|
||||||
armAbortPrompt: (durationMs?: number) => number | null;
|
armAbortPrompt: (durationMs?: number) => number | null;
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
get().evictLeastRecentlyUsed();
|
get().evictLeastRecentlyUsed();
|
||||||
},
|
},
|
||||||
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
|
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
|
||||||
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => {
|
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => {
|
||||||
const draft = get().newSessionDraft;
|
const draft = get().newSessionDraft;
|
||||||
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
try {
|
try {
|
||||||
return await useMessageStore
|
return await useMessageStore
|
||||||
.getState()
|
.getState()
|
||||||
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant);
|
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(created.id, 'idle');
|
setStatus(created.id, 'idle');
|
||||||
throw error;
|
throw error;
|
||||||
@@ -477,7 +477,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant);
|
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
setStatus(currentSessionId, 'idle');
|
setStatus(currentSessionId, 'idle');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
SkillsCatalogSourceResponse,
|
SkillsCatalogSourceResponse,
|
||||||
} from '@/lib/api/types';
|
} from '@/lib/api/types';
|
||||||
|
|
||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
|
||||||
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||||
@@ -373,8 +373,14 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
|||||||
return { ok: false, error };
|
return { ok: false, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh installed skills list.
|
if (payload.requiresReload) {
|
||||||
void useSkillsStore.getState().loadSkills();
|
await refreshSkillsAfterOpenCodeRestart({
|
||||||
|
message: payload.message,
|
||||||
|
delayMs: payload.reloadDelayMs,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void useSkillsStore.getState().loadSkills();
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/
|
|||||||
import {
|
import {
|
||||||
startConfigUpdate,
|
startConfigUpdate,
|
||||||
finishConfigUpdate,
|
finishConfigUpdate,
|
||||||
|
updateConfigUpdateMessage,
|
||||||
} from "@/lib/configUpdate";
|
} from "@/lib/configUpdate";
|
||||||
import { getSafeStorage } from "./utils/safeStorage";
|
import { getSafeStorage } from "./utils/safeStorage";
|
||||||
|
|
||||||
@@ -136,6 +137,13 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_EVENT_SOURCE = "useSkillsStore";
|
const CONFIG_EVENT_SOURCE = "useSkillsStore";
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const MAX_HEALTH_WAIT_MS = 20000;
|
||||||
|
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
||||||
|
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
||||||
|
const SLOW_HEALTH_POLL_BASE_MS = 800;
|
||||||
|
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
|
||||||
|
const SLOW_HEALTH_POLL_MAX_MS = 2000;
|
||||||
|
|
||||||
export const useSkillsStore = create<SkillsStore>()(
|
export const useSkillsStore = create<SkillsStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
@@ -211,6 +219,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
|
|
||||||
createSkill: async (config: SkillConfig) => {
|
createSkill: async (config: SkillConfig) => {
|
||||||
startConfigUpdate("Creating skill...");
|
startConfigUpdate("Creating skill...");
|
||||||
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
const skillConfig: Record<string, unknown> = {
|
const skillConfig: Record<string, unknown> = {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
@@ -237,8 +246,16 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills are just files - no need to reload OpenCode
|
const needsReload = payload?.requiresReload ?? false;
|
||||||
// Just refresh our local list
|
if (needsReload) {
|
||||||
|
requiresReload = true;
|
||||||
|
await refreshSkillsAfterOpenCodeRestart({
|
||||||
|
message: payload?.message,
|
||||||
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const loaded = await get().loadSkills();
|
const loaded = await get().loadSkills();
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||||
@@ -247,12 +264,15 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
finishConfigUpdate();
|
if (!requiresReload) {
|
||||||
|
finishConfigUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
|
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
|
||||||
startConfigUpdate("Updating skill...");
|
startConfigUpdate("Updating skill...");
|
||||||
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
const skillConfig: Record<string, unknown> = {};
|
const skillConfig: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -275,8 +295,16 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills are just files - no need to reload OpenCode
|
const needsReload = payload?.requiresReload ?? false;
|
||||||
// Just refresh our local list
|
if (needsReload) {
|
||||||
|
requiresReload = true;
|
||||||
|
await refreshSkillsAfterOpenCodeRestart({
|
||||||
|
message: payload?.message,
|
||||||
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const loaded = await get().loadSkills();
|
const loaded = await get().loadSkills();
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||||
@@ -285,12 +313,15 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
finishConfigUpdate();
|
if (!requiresReload) {
|
||||||
|
finishConfigUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSkill: async (name: string) => {
|
deleteSkill: async (name: string) => {
|
||||||
startConfigUpdate("Deleting skill...");
|
startConfigUpdate("Deleting skill...");
|
||||||
|
let requiresReload = false;
|
||||||
try {
|
try {
|
||||||
const currentDirectory = getCurrentDirectory();
|
const currentDirectory = getCurrentDirectory();
|
||||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||||
@@ -305,8 +336,16 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills are just files - no need to reload OpenCode
|
const needsReload = payload?.requiresReload ?? false;
|
||||||
// Just refresh our local list
|
if (needsReload) {
|
||||||
|
requiresReload = true;
|
||||||
|
await refreshSkillsAfterOpenCodeRestart({
|
||||||
|
message: payload?.message,
|
||||||
|
delayMs: payload?.reloadDelayMs,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const loaded = await get().loadSkills();
|
const loaded = await get().loadSkills();
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||||
@@ -320,7 +359,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
finishConfigUpdate();
|
if (!requiresReload) {
|
||||||
|
finishConfigUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -402,6 +443,73 @@ if (typeof window !== "undefined") {
|
|||||||
window.__zustand_skills_store__ = useSkillsStore;
|
window.__zustand_skills_store__ = useSkillsStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForOpenCodeConnection(delayMs?: number) {
|
||||||
|
const initialPause = typeof delayMs === "number" && delayMs > 0
|
||||||
|
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (initialPause > 0) {
|
||||||
|
await sleep(initialPause);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
let attempt = 0;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
|
||||||
|
attempt += 1;
|
||||||
|
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isHealthy = await opencodeClient.checkHealth();
|
||||||
|
if (isHealthy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = new Error("OpenCode health check reported not ready");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
const waitMs =
|
||||||
|
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
|
||||||
|
? FAST_HEALTH_POLL_INTERVAL_MS
|
||||||
|
: Math.min(
|
||||||
|
SLOW_HEALTH_POLL_BASE_MS +
|
||||||
|
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
|
||||||
|
SLOW_HEALTH_POLL_MAX_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
await sleep(waitMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("OpenCode did not become ready in time");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
|
||||||
|
try {
|
||||||
|
updateConfigUpdateMessage(options?.message || "Refreshing skills…");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForOpenCodeConnection(options?.delayMs);
|
||||||
|
updateConfigUpdateMessage("Refreshing skills…");
|
||||||
|
const skillsStore = useSkillsStore.getState();
|
||||||
|
const loaded = await skillsStore.loadSkills();
|
||||||
|
if (loaded) {
|
||||||
|
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
|
||||||
|
await sleep(1500);
|
||||||
|
} finally {
|
||||||
|
finishConfigUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to config changes from other stores
|
// Subscribe to config changes from other stores
|
||||||
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
|
let unsubscribeSkillsConfigChanges: (() => void) | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2004,45 +2004,48 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await ctx?.manager?.restart();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} created successfully`,
|
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'PATCH') {
|
if (normalizedMethod === 'PATCH') {
|
||||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await ctx?.manager?.restart();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} updated successfully`,
|
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'DELETE') {
|
if (normalizedMethod === 'DELETE') {
|
||||||
deleteSkill(skillName, workingDirectory);
|
deleteSkill(skillName, workingDirectory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await ctx?.manager?.restart();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} deleted successfully`,
|
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2117,6 +2120,30 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
conflictDecisions: body.conflictDecisions,
|
conflictDecisions: body.conflictDecisions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
const installed = data.installed || [];
|
||||||
|
const skipped = data.skipped || [];
|
||||||
|
const requiresReload = installed.length > 0;
|
||||||
|
|
||||||
|
if (requiresReload) {
|
||||||
|
await ctx?.manager?.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
ok: true,
|
||||||
|
installed,
|
||||||
|
skipped,
|
||||||
|
requiresReload,
|
||||||
|
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||||
|
reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { id, type, success: true, data };
|
return { id, type, success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7297,7 +7297,22 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ ok: false, error: result.error });
|
return res.status(400).json({ ok: false, error: result.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
|
const installed = result.installed || [];
|
||||||
|
const skipped = result.skipped || [];
|
||||||
|
const requiresReload = installed.length > 0;
|
||||||
|
|
||||||
|
if (requiresReload) {
|
||||||
|
await refreshOpenCodeAfterConfigChange('skills install');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
installed,
|
||||||
|
skipped,
|
||||||
|
requiresReload,
|
||||||
|
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||||
|
reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle GitHub sources (git clone based)
|
// Handle GitHub sources (git clone based)
|
||||||
@@ -7334,7 +7349,22 @@ async function main(options = {}) {
|
|||||||
return res.status(400).json({ ok: false, error: result.error });
|
return res.status(400).json({ ok: false, error: result.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
|
const installed = result.installed || [];
|
||||||
|
const skipped = result.skipped || [];
|
||||||
|
const requiresReload = installed.length > 0;
|
||||||
|
|
||||||
|
if (requiresReload) {
|
||||||
|
await refreshOpenCodeAfterConfigChange('skills install');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
installed,
|
||||||
|
skipped,
|
||||||
|
requiresReload,
|
||||||
|
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||||
|
reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to install skills:', error);
|
console.error('Failed to install skills:', error);
|
||||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
||||||
@@ -7409,12 +7439,13 @@ async function main(options = {}) {
|
|||||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||||
|
|
||||||
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await refreshOpenCodeAfterConfigChange('skill creation');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} created successfully`,
|
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create skill:', error);
|
console.error('Failed to create skill:', error);
|
||||||
@@ -7436,12 +7467,13 @@ async function main(options = {}) {
|
|||||||
console.log('[Server] Working directory:', directory);
|
console.log('[Server] Working directory:', directory);
|
||||||
|
|
||||||
updateSkill(skillName, updates, directory);
|
updateSkill(skillName, updates, directory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await refreshOpenCodeAfterConfigChange('skill update');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} updated successfully`,
|
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Server] Failed to update skill:', error);
|
console.error('[Server] Failed to update skill:', error);
|
||||||
@@ -7518,12 +7550,13 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteSkill(skillName, directory);
|
deleteSkill(skillName, directory);
|
||||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
await refreshOpenCodeAfterConfigChange('skill deletion');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
requiresReload: false,
|
requiresReload: true,
|
||||||
message: `Skill ${skillName} deleted successfully`,
|
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||||
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete skill:', error);
|
console.error('Failed to delete skill:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user