This commit is contained in:
seb
2026-10-07 13:36:16 +02:00
parent 7045fe5e69
commit 8f46e59f08
19 changed files with 299 additions and 66 deletions

View File

@@ -16,7 +16,6 @@ import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
import InsertPhotoRoundedIcon from '@mui/icons-material/InsertPhotoRounded';
import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import { enqueueSnackbar } from 'notistack';
@@ -368,7 +367,6 @@ export class VfsAttachmentLink extends PureComponent {
const { name, path, type, label, children, isUser = false } = this.props;
const { isDownloading, isOpening, notFound } = this.state;
const downloadName = name || (path ? path.split('/').pop() : 'datei');
const displayName = label || children || name || downloadName;
const effectiveType = type || inferMimeType(downloadName);
const vfsUrl = resolveVfsUrl(path);
const canView = isBrowserViewable(downloadName, effectiveType);

View File

@@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import InputBase from '@mui/material/InputBase';
import TextInput from '../TextInput';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
@@ -32,7 +31,7 @@ import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel } from '../vfs';
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
import ChatFlowList from './ChatFlowList';
import AskUserPrompt from './AskUserPrompt';
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
@@ -71,7 +70,9 @@ export default class ChatDrawer extends PureComponent {
sendError: s.sendError,
activeTurns: s.activeTurns || [],
inputText: '',
isInputFocused: false,
stagedAttachments: [],
stagedReferences: [],
isProcessingFiles: false,
isDraggingFiles: false,
attachmentError: null,
@@ -150,12 +151,68 @@ export default class ChatDrawer extends PureComponent {
};
handleInsertToChat = (text) => {
// If it's a file / line tag, e.g. [file.js#L10-L20](file.js#L10-L20) or [file.js](file.js)
const match =
typeof text === 'string' &&
text.trim().match(/^(!?)\[([^#\]]+)(?:#L?(\d+)(?:-L?(\d+))?)?\]\(([^)]+)\)$/);
if (match) {
const [, isImg, fileName, startLine, endLine, path] = match;
this.handleMentionReference({
fileName,
path,
startLine: startLine ? parseInt(startLine, 10) : null,
endLine: endLine ? parseInt(endLine, 10) : (startLine ? parseInt(startLine, 10) : null),
isImage: Boolean(isImg),
});
return;
}
this.setState((prev) => ({
inputText: prev.inputText ? `${prev.inputText}\n${text}` : text,
}));
this.focusInput(50);
};
handleMentionReference = (refData) => {
if (!refData || !refData.fileName) return;
const {
fileName,
path = refData.fileName,
startLine = null,
endLine = null,
selectedText = '',
isImage = false,
} = refData;
this.setState((prev) => {
const exists = prev.stagedReferences.some(
(r) => r.fileName === fileName && r.startLine === startLine && r.endLine === endLine
);
if (exists) {
return null;
}
const newRef = {
id: `ref-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
fileName,
path,
startLine,
endLine,
selectedText,
isImage,
};
return {
stagedReferences: [...prev.stagedReferences, newRef],
};
});
this.focusInput(50);
};
handleRemoveReference = (id) => {
this.setState((prev) => ({
stagedReferences: prev.stagedReferences.filter((r) => r.id !== id),
}));
this.focusInput(50);
};
handleVfsResizerStart = (e) => {
if (e.button !== undefined && e.button !== 0) return;
e.preventDefault();
@@ -327,12 +384,51 @@ export default class ChatDrawer extends PureComponent {
handleSend = async (e) => {
e?.preventDefault();
const text = this.state.inputText.trim();
const rawText = this.state.inputText.trim();
const attachments = this.state.stagedAttachments || [];
if ((!text && attachments.length === 0) || this.state.conversationState === 'busy' || this.state.isSending) {
const references = this.state.stagedReferences || [];
if (
(!rawText && attachments.length === 0 && references.length === 0) ||
this.state.conversationState === 'busy' ||
this.state.isSending
) {
return;
}
this.setState({ inputText: '', stagedAttachments: [], attachmentError: null });
let text = rawText;
if (references.length > 0) {
const refMarkdown = references
.map((ref) => {
const lineRef = ref.startLine
? (ref.startLine === ref.endLine ? `L${ref.startLine}` : `L${ref.startLine}-L${ref.endLine}`)
: null;
const lineLabel = ref.startLine
? (ref.startLine === ref.endLine ? `:${ref.startLine}` : `:${ref.startLine}-${ref.endLine}`)
: '';
const link =
ref.isImage && !lineRef
? `![${ref.fileName}](${ref.path || ref.fileName})`
: `[${ref.fileName}${lineLabel}](${ref.path || ref.fileName}${lineRef ? `#${lineRef}` : ''})`;
if (ref.selectedText && ref.selectedText.trim()) {
const ext = ref.fileName.split('.').pop() || '';
return `${link}\n\`\`\`${ext}\n${ref.selectedText.trim()}\n\`\`\``;
}
return link;
})
.join('\n\n');
text = text ? `${refMarkdown}\n\n${text}` : refMarkdown;
}
this.setState({
inputText: '',
stagedAttachments: [],
stagedReferences: [],
attachmentError: null,
});
try {
await chatAutomat.actions.sendMessage(text, attachments);
} catch {
@@ -1009,8 +1105,10 @@ export default class ChatDrawer extends PureComponent {
selectedConversationId,
draftConversation,
inputText,
isInputFocused,
sendError,
stagedAttachments,
stagedReferences,
isProcessingFiles,
isDraggingFiles,
attachmentError,
@@ -1020,7 +1118,10 @@ export default class ChatDrawer extends PureComponent {
const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
const canSend = !isWorking && canChat && (Boolean(inputText.trim()) || stagedAttachments.length > 0);
const canSend =
!isWorking &&
canChat &&
(Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0);
return (
<Box
@@ -1164,24 +1265,158 @@ export default class ChatDrawer extends PureComponent {
style={{ display: 'none' }}
/>
<TextInput
inputRef={this.inputRef}
multiline
maxRows={4}
fullWidth
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
disabled={!canChat}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
}
/>
{/* Unified Copilot-style input container with context reference pills */}
<Box
onClick={() => this.focusInput(0)}
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: isInputFocused ? 'primary.main' : 'divider',
boxShadow: isInputFocused ? (theme) => `0 0 0 1px ${theme.palette.primary.main}` : 'none',
borderRadius: 2.5,
p: 0.75,
gap: 0.5,
cursor: 'text',
transition: 'border-color 0.15s, box-shadow 0.15s',
minHeight: 40,
boxSizing: 'border-box',
}}
>
{stagedReferences.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.6,
alignItems: 'center',
mb: 0.25,
}}
>
{stagedReferences.map((ref) => (
<Box
key={ref.id}
onClick={(e) => {
e.stopPropagation();
vfsAutomat.actions.openVfs();
vfsAutomat.actions.selectFile(ref.fileName);
}}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.6,
py: 0.2,
pl: 0.7,
pr: 0.4,
borderRadius: 1.5,
bgcolor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.05)',
border: '1px solid',
borderColor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.1)',
color: 'text.primary',
fontSize: '0.75rem',
lineHeight: 1.2,
userSelect: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.14)'
: 'rgba(0, 0, 0, 0.09)',
borderColor: 'primary.light',
},
}}
>
<VfsFileIcon fileName={ref.fileName} sx={{ fontSize: 14 }} />
<Typography
component="span"
sx={{
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
fontSize: '0.74rem',
fontWeight: 600,
letterSpacing: '-0.2px',
display: 'inline-flex',
alignItems: 'center',
}}
>
{ref.fileName}
{ref.startLine && (
<Box
component="span"
sx={{
color: 'primary.main',
fontWeight: 700,
ml: 0.3,
}}
>
:{ref.startLine === ref.endLine ? ref.startLine : `${ref.startLine}-${ref.endLine}`}
</Box>
)}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
this.handleRemoveReference(ref.id);
}}
aria-label="Referenz entfernen"
sx={{
p: 0.2,
ml: 0.1,
color: 'text.secondary',
'&:hover': {
color: 'error.main',
bgcolor: 'action.hover',
},
}}
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
))}
</Box>
)}
<InputBase
inputRef={this.inputRef}
multiline
minRows={1}
maxRows={5}
fullWidth
value={inputText}
onChange={(e) => this.setState({ inputText: e.target.value })}
onKeyDown={this.handleKeyDown}
onPaste={this.handlePaste}
onFocus={() => this.setState({ isInputFocused: true })}
onBlur={() => this.setState({ isInputFocused: false })}
disabled={!canChat}
placeholder={
pendingAsk
? 'Bitte Frage oben beantworten…'
: stagedAttachments.length > 0
? 'Nachricht hinzufügen oder direkt absenden…'
: undefined
}
sx={{
px: 0.5,
py: 0.1,
fontSize: '0.875rem',
lineHeight: 1.45,
'& .MuiInputBase-input': {
p: 0,
},
}}
/>
</Box>
<IconButton
type="submit"
@@ -1318,7 +1553,10 @@ export default class ChatDrawer extends PureComponent {
overflow: 'hidden',
}}
>
<VfsEditorPanel onInsertToChat={this.handleInsertToChat} />
<VfsEditorPanel
onInsertToChat={this.handleInsertToChat}
onMentionReference={this.handleMentionReference}
/>
</Box>
)}

View File

@@ -25,8 +25,6 @@ import { VfsAttachmentLink, parseAttachmentTag, classifyLinkTarget, resolveVfsUr
* ~~strike~~, [links](url) and ![images](url).
*/
const SAFE_URL = /^(https?:\/\/|mailto:|\/|#)/i;
/**
* Resolve an image target to something the browser may load.
*