fix(ui): compact Office attachment context
This commit is contained in:
@@ -7,11 +7,12 @@ import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, typ
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
|
||||
import {
|
||||
ACCEPTED_ATTACHMENT_EXTENSIONS,
|
||||
ATTACHMENT_ACCEPT,
|
||||
getUnsupportedAttachmentInputs,
|
||||
isDocumentAttachmentFilename,
|
||||
type AttachmentInputModality,
|
||||
} from '@/sync/attachment-files';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
@@ -24,6 +25,7 @@ import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import {
|
||||
createChatDraftIdentity,
|
||||
readChatDraft,
|
||||
@@ -596,59 +598,62 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
[],
|
||||
);
|
||||
|
||||
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
|
||||
const resolveInlineFileMention = React.useCallback((mentionPath: string): { serverPath: string; filename: string } | null => {
|
||||
const kind = classifyMention(mentionPath, {
|
||||
knownAgentNames: knownAgentNamesRef.current,
|
||||
confirmedMentions: confirmedMentionsRef.current,
|
||||
});
|
||||
if (kind !== 'file') return null;
|
||||
|
||||
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!normalizedMentionPath) return null;
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
let serverPath: string | null = null;
|
||||
if (mentionPath.startsWith('/')) {
|
||||
serverPath = mentionPath.replace(/\\/g, '/');
|
||||
} else if (root) {
|
||||
serverPath = `${root}/${normalizedMentionPath}`;
|
||||
}
|
||||
if (!serverPath) return null;
|
||||
|
||||
return {
|
||||
serverPath: serverPath.replace(/\/+/g, '/'),
|
||||
filename: normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath,
|
||||
};
|
||||
}, [chatSearchDirectory]);
|
||||
|
||||
const extractInlineFileMentions = React.useCallback((
|
||||
rawText: string,
|
||||
preparedDocumentMentions?: ReadonlyMap<string, AttachedFile[]>,
|
||||
) => {
|
||||
if (!rawText || !rawText.includes('@')) {
|
||||
return { sanitizedText: rawText, attachments: [] };
|
||||
}
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const seenPaths = new Set<string>();
|
||||
const attachments: AttachedFile[] = [];
|
||||
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mentionPath = token.name;
|
||||
const kind = classifyMention(mentionPath, {
|
||||
knownAgentNames: knownAgentNamesRef.current,
|
||||
confirmedMentions: confirmedMentionsRef.current,
|
||||
});
|
||||
// Agents are routed separately by parseAgentMentions; only file
|
||||
// references become attachments here.
|
||||
if (kind !== 'file') {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (!mention || seenPaths.has(mention.serverPath)) continue;
|
||||
seenPaths.add(mention.serverPath);
|
||||
|
||||
const prepared = preparedDocumentMentions?.get(mention.serverPath);
|
||||
if (prepared) {
|
||||
attachments.push(...prepared);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!normalizedMentionPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverPath = mentionPath.startsWith('/')
|
||||
? mentionPath.replace(/\\/g, '/')
|
||||
: root
|
||||
? `${root}/${normalizedMentionPath}`
|
||||
: null;
|
||||
|
||||
if (!serverPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
|
||||
if (seenPaths.has(normalizedServerPath)) {
|
||||
continue;
|
||||
}
|
||||
seenPaths.add(normalizedServerPath);
|
||||
|
||||
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
|
||||
attachments.push({
|
||||
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
file: new File([], filename, { type: 'text/plain' }),
|
||||
filename,
|
||||
file: new File([], mention.filename, { type: 'text/plain' }),
|
||||
filename: mention.filename,
|
||||
mimeType: 'text/plain',
|
||||
size: 0,
|
||||
dataUrl: toServerFileUrl(normalizedServerPath),
|
||||
dataUrl: toServerFileUrl(mention.serverPath),
|
||||
source: 'server',
|
||||
serverPath: normalizedServerPath,
|
||||
serverPath: mention.serverPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -656,7 +661,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
sanitizedText: rawText,
|
||||
attachments,
|
||||
};
|
||||
}, [chatSearchDirectory]);
|
||||
}, [resolveInlineFileMention]);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
@@ -960,6 +965,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
};
|
||||
|
||||
const handleSubmit = async (options?: SubmitOptions) => {
|
||||
const submitRuntimeKey = getRuntimeKey();
|
||||
const queuedOnly = options?.queuedOnly ?? false;
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
@@ -1051,6 +1057,44 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedMessagesToSend.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| preparedDocumentMentions.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const prepared = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
preparedDocumentMentions.set(mention.serverPath, prepared);
|
||||
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
@@ -1079,7 +1123,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return { text: sanitizedText, agentName: mention?.name };
|
||||
},
|
||||
extractFileMentions: (text) => {
|
||||
const { sanitizedText, attachments } = extractInlineFileMentions(text);
|
||||
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
|
||||
return { text: sanitizedText, attachments };
|
||||
},
|
||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||
|
||||
Reference in New Issue
Block a user