fix(ui): compact Office attachment context

This commit is contained in:
Bohdan Triapitsyn
2026-08-18 19:54:16 +03:00
parent 1efc7fb570
commit 215749a65f
7 changed files with 257 additions and 85 deletions
+85 -41
View File
@@ -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,