feat(web-shell): refine file cards and configure drop destination (#11298)

* feat(web-shell): customize artifact icons

* fix(web-shell): preserve artifact icon fallbacks

* fix(web-shell): improve artifact fallbacks

* feat(web-shell): refine file cards and configure drop destination

* fix(web-shell): address file card and drop review feedback

---------

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-08 02:56:51 +00:00
1 parent 96b14f2311
commit 663e55bd29
22 files changed
+695 -168

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Web Shell attachment and file reference cards
Attachments currently render as single-line chips. Render pending and sent file
attachments as rounded cards with an inset file-type icon and a filename/type
stack, matching the supplied reference. Workspace upload progress retains its
status and cancel action. Completed uploads still insert ordinary file references.
File references remain compact icon-and-title chips in the composer and sent
messages, sharing the existing FileTypeIcon mapping used by artifacts. Preserve
custom renderers/icons, directory semantics, full-path serialization, preview,
removal, keyboard access, image thumbnails, and theme isolation.
Changes are confined to Web Shell presentation, shared attachment content, and
focused regression tests. No daemon, transport, or artifact behavior changes.
FileTypeIcon shares the existing artifact
component's format resolver and URL map; formats without specific artwork keep
the existing Lucide fallback. The outer SVG preserves existing sizing, CSS and
ARIA props while referencing the same asset as artifact cards.
The shared artwork intentionally keeps its format colors across file icon consumers,
including artifact file tabs, queued prompts and the environment panel. Lucide
stroke styling continues to apply to formats without dedicated artwork.
+4 -2
View File
@@ -32,8 +32,10 @@ dialog closes if the composer target changes or upload becomes unavailable.
When workspace upload is unavailable, drops keep the existing attachment
behavior instead of showing an upload action that cannot succeed. Host-level
`fileUploadEnabled={false}` retains its existing contract and disables all
file drag-in. Clipboard paste and the `@` panel upload item are unchanged.
`fileUploadEnabled={false}` disables only the upload lane; drops fall back to
attachments when `attachmentsEnabled` permits them. Clipboard paste is unchanged,
and the `@` panel upload item remains hidden when uploads are disabled. See
[Default file drop action](web-shell-file-drop-action.md).
File attachment chips are interactive before and after optimistic submission.
Opening one shows the referenced file in the right-side preview panel.
+19
View File
@@ -0,0 +1,19 @@
# Default file drop action
Expose `fileDropAction?: 'upload' | 'attach'` on WebShell and its shared
customization context so main and split composers behave identically. Omission
preserves the choice dialog when both destinations are available. A configured
available destination runs immediately; if only one destination is available,
use it regardless of the preference. Neither available means no ingestion.
Workspace upload still requires the existing capability, selected workspace and
trust checks. Attachment availability remains governed by `attachmentsEnabled`.
`fileUploadEnabled=false` disables workspace upload without disabling attachments.
Keep the add menu's attachment availability consistent with drag-and-drop.
Cancel a pending choice if availability, default action, session or workspace
changes; do not apply an old drop to a newly selected destination.
Use existing upload and attachment ingestion callbacks. Preserve batch filtering,
drop cancellation, disabled behavior, image paste, and upload directory handling.
Validate the routing matrix with component tests and public prop/context tests;
use mocked browser drag events for direct-action and dialog flows.
+1 -1
View File
@@ -185,7 +185,7 @@ The Web Shell is multi-workspace, so uploads must use the same target as the com
- Only the existing legacy composer path with no `atWorkspaceCwd` uses `workspace.client.uploadWorkspaceFile(...)`; a modern multi-workspace composer with a missing cwd is unsupported rather than silently targeting the primary workspace.
- Drag-and-drop and the @ panel entry share the selected client. The @ panel additionally supplies a directory within that workspace.
- A legacy target requires `workspace_file_upload`; a cwd-qualified target requires both `workspace_file_upload` and `workspace_qualified_rest_core`. The selected workspace must also be present exactly once and trusted in the capabilities snapshot. Otherwise hide both upload entry points.
- Host control: the web-shell accepts an optional `fileUploadEnabled` prop (threaded through the customization context). It is an additional gate, not a replacement for the capability: `fileUploadEnabled === false` force-hides both entry points AND disables file drag-and-drop entirely — no drag highlight, no upload, and no inline image/text ingestion from dropped files — even when the daemon advertises `workspace_file_upload`, while `true`/omitted still requires the capability (and the trust / qualified-route checks above). It never bypasses the capability. Clipboard paste of images/text is unaffected.
- Host control: the web-shell accepts an optional `fileUploadEnabled` prop (threaded through the customization context). It is an additional gate, not a replacement for the capability: `fileUploadEnabled === false` force-hides workspace-upload entry points and disables the upload lane even when the daemon advertises `workspace_file_upload`, while `true`/omitted still requires the capability (and the trust / qualified-route checks above). It never bypasses the capability. Attachment drag/add remains governed by `attachmentsEnabled`; see [Default file drop action](web-shell-file-drop-action.md). Clipboard paste of images/text is unaffected.
- Upload directory: an optional `fileUploadDirectory` prop (threaded through the customization context) sets the directory that drag-and-dropped files upload into. It is a **relative path without a leading `/`** (`'uploads'`, `'uploads/images'`); a leading-slash absolute path is rejected by the daemon as outside the workspace. Omitted (or `'.'`) uploads into the workspace root. The daemon creates the directory (including intermediate components) on upload when it does not exist, so a configured drop folder needs no manual setup.
### Upload versus `@` consumption
+19
View File
@@ -205,6 +205,25 @@ const projection = projectChatRecordsToDaemonTranscript(records);
宿主应显示 `projection.diagnostics`,并在 `complete=false` 或 `truncated=true` 时提示
历史可能不完整。组件需要一个可用高度;自定义 renderer 的副作用仍由宿主负责。
## 拖入文件的默认行为
通过 `fileDropAction` 指定拖入文件时的默认去向,适用于 `WebShell` 和
`WebShellWithProviders`:
```tsx
<WebShellWithProviders fileDropAction="upload" fileUploadDirectory="uploads" />
<WebShellWithProviders fileDropAction="attach" />
```
- `upload`:直接上传到工作区,并插入 `@文件` 引用。
- `attach`:直接添加为当前消息的附件。
- 不传:仅当上传和附件都可用时显示选择弹窗。
只有一种方式可用时直接使用它,即使配置的默认去向是另一种;两种都不可用时
不接收拖入文件。`fileUploadEnabled={false}` 只关闭工作区上传,不再关闭附件
拖入或添加附件入口。上传仍受 daemon 能力、工作区信任及目标路径检查约束。
修改默认去向或可用方式时,会关闭已经打开的选择弹窗;需要重新拖入文件。
## 消息操作
- 已完成的 assistant 消息支持复制;具备持久化 checkpoint 时还支持分支。
+13
View File
@@ -995,6 +995,7 @@ vi.mock('./components/ChatEditor', async () => {
'data-artifact-image-renderer': String(
Boolean(customization.artifact?.renderImage),
),
'data-file-drop-action': customization.fileDropAction,
},
React.createElement(
'button',
@@ -34957,6 +34958,18 @@ describe('App manual-run orchestration (scheduled tasks)', () => {
});
describe('fileUploadEnabled customization plumbing', () => {
it.each(['upload', 'attach'] as const)(
'passes the %s drop preference to composers',
(fileDropAction) => {
const { container } = renderApp({ fileDropAction });
expect(
container
.querySelector('[data-web-shell-composer]')
?.getAttribute('data-file-drop-action'),
).toBe(fileDropAction);
},
);
it('reaches the composer customization when the host disables upload', () => {
const { container } = renderApp({ fileUploadEnabled: false });
const composer = container.querySelector('[data-web-shell-composer]');
+9 -1
View File
@@ -1188,12 +1188,17 @@ export interface WebShellProps {
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Works alongside the daemon's
* and the @ panel upload item) are enabled. Does not disable attachments.
* Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: `false` force-
* disables upload even when the daemon advertises the capability, while
* `true`/omitted still requires the capability to be satisfied.
*/
fileUploadEnabled?: boolean;
/** Preferred file-drop destination. Omitted: ask only when both are available.
* If the preference is unavailable, use the sole available destination.
*/
fileDropAction?: 'upload' | 'attach';
/**
* Directory that drag-and-dropped files upload into, **relative to the
* workspace root**. Use a relative path WITHOUT a leading `/` — e.g.
@@ -2892,6 +2897,7 @@ export function App({
fileUploadEnabled,
fileUploadDirectory,
artifact,
fileDropAction,
renderToolHeaderExtra,
renderWelcomeHeader,
renderWelcomeFooter,
@@ -3174,6 +3180,7 @@ export function App({
loadingPhrases,
fileUploadEnabled,
fileUploadDirectory,
fileDropAction,
}),
[
artifact,
@@ -3203,6 +3210,7 @@ export function App({
loadingPhrases,
fileUploadEnabled,
fileUploadDirectory,
fileDropAction,
],
);
const mainChatCustomization = useMemo(
@@ -2154,13 +2154,12 @@
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 4px 8px;
box-sizing: border-box;
width: 272px;
max-width: 100%;
padding: 8px 32px 8px 8px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 6px;
font-size: 12px;
line-height: 16px;
border-radius: 16px;
color: var(--chat-editor-text-primary);
}
@@ -2176,8 +2175,9 @@
.fileChipPreview {
display: inline-flex;
min-width: 0;
width: 100%;
align-items: center;
gap: 6px;
gap: 12px;
padding: 0;
border: 0;
background: transparent;
@@ -2190,39 +2190,17 @@
cursor: default;
}
.fileChipIcon {
flex: 0 0 auto;
color: var(--chat-editor-text-secondary, currentColor);
}
.fileChipName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileChipRemove {
position: absolute;
top: 50%;
right: 4px;
top: 8px;
right: 8px;
z-index: 1;
transform: translateY(-50%);
pointer-events: none;
}
.fileChip:hover .fileChipRemove:not(:disabled),
.fileChipRemove:focus-visible {
width: 20px;
height: 20px;
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.fileChipRemove:not(:disabled) {
opacity: 1;
pointer-events: auto;
}
}
.searchPanel {
display: flex;
flex-direction: column;
@@ -2341,24 +2319,18 @@
align-items: center;
gap: 10px;
min-width: 0;
padding: 7px 10px;
border: 1px solid color-mix(in srgb, var(--agent-blue-500) 24%, transparent);
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 8px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 10px;
background: color-mix(
in srgb,
var(--agent-blue-500) 8%,
var(--chat-editor-bg-primary)
);
background: var(--chat-editor-bg-primary);
color: var(--chat-editor-text-primary);
font-size: 12px;
line-height: 1.4;
}
.uploadRow[data-status='done'] {
border-color: color-mix(in srgb, var(--success-color) 24%, transparent);
background: var(--success-bg);
}
.uploadRow[data-status='error'] {
border-color: var(--error-border);
background: var(--error-bg);
@@ -2387,11 +2359,25 @@
font: inherit;
}
.uploadRowIcon {
flex: 0 0 auto;
}
.uploadRowName {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.uploadRowPreview:disabled {
cursor: default;
}
.uploadRow svg {
.uploadRowAction svg,
.uploadRowSpinner {
flex: 0 0 auto;
width: 14px;
height: 14px;
@@ -2416,41 +2402,28 @@
}
}
.uploadRowName {
.uploadRowStatus {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 0 1 auto;
max-width: 40%;
flex: 0 0 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--chat-editor-text-dimmed);
}
.uploadRowStatus {
.uploadRowStatusText {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--chat-editor-text-dimmed);
text-align: right;
}
.uploadRow[data-status='error'] .uploadRowStatus {
color: var(--error-color);
}
.uploadRow[data-status='done'] .uploadRowStatus {
color: var(--success-color);
}
.uploadRow[data-status='done'] .uploadRowPreview > svg {
color: var(--success-color);
}
.uploadRow[data-status='error'] .uploadRowPreview > svg {
color: var(--error-color);
}
.uploadRowAction {
display: inline-flex;
align-items: center;
@@ -2490,3 +2463,16 @@
font-size: 13px;
pointer-events: none;
}
.fileTag {
background: var(--chat-editor-bg-primary);
min-height: 28px;
border-radius: 8px;
font-family: var(--font-sans, system-ui, sans-serif);
}
.fileTagIcon {
flex: 0 0 auto;
margin-left: 8px;
color: var(--muted-foreground);
}
@@ -1018,6 +1018,33 @@ describe('ChatEditor context usage ring', () => {
});
describe('ChatEditor attachment reporting', () => {
it('renders attachments with a file icon, title, and type', () => {
const container = renderChatEditor({
pastedFiles: [
{
name: 'report.html',
media_type: 'text/html',
text: '<h1>Report</h1>',
},
],
});
const attachments = container.querySelector(
'[data-web-shell-composer-attachments]',
);
expect(
attachments?.querySelector('[data-file-type-icon="html"]'),
).not.toBeNull();
expect(
attachments?.querySelector('[title="report.html"]')?.textContent,
).toBe('report.html');
expect(attachments?.querySelector('[title="HTML"]')?.textContent).toBe(
'HTML',
);
expect(
attachments?.querySelector('button[aria-label="Remove report.html"]'),
).not.toBeNull();
});
it('reports whether the composer has tags or pasted images', () => {
const onEmptyAttachmentsChange = vi.fn();
renderChatEditor({
@@ -1125,7 +1152,10 @@ describe('ChatEditor composer tag icons', () => {
expect(
container.querySelectorAll('[style*="--composer-tag-icon-url"]'),
).toHaveLength(kinds.length);
).toHaveLength(kinds.length - 1);
expect(
container.querySelector('[data-file-type-icon="file"]'),
).not.toBeNull();
});
it('rejects unsafe custom icon URLs for top composer tags', () => {
@@ -2231,19 +2261,37 @@ describe('ChatEditor file upload gating', () => {
expect(composerCoreState.onFileUploadRequest).toBeUndefined();
});
it('fileUploadEnabled={false} disables file drag-and-drop in the composer core', () => {
it('fileUploadEnabled={false} keeps attachment drag-and-drop enabled', () => {
uploadWorkspaceState.current = makeWorkspace(['workspace_file_upload']);
renderChatEditor({ customization: { fileUploadEnabled: false } });
expect(latestComposerCoreOptions.current?.fileDragEnabled).toBe(true);
});
it('disables attachment drag feedback when attachments are disabled', () => {
renderChatEditor({ attachmentsEnabled: false });
expect(latestComposerCoreOptions.current?.fileDragEnabled).toBe(false);
});
it('does not advertise upload for an attach-only drop preference', () => {
uploadWorkspaceState.current = makeWorkspace(['workspace_file_upload']);
const container = renderChatEditor({
customization: { fileDropAction: 'attach' },
});
dispatchDrag(
container.querySelector('[data-web-shell-composer-editor]')!,
'dragenter',
['Files'],
);
expect(container.querySelector('[data-upload-drag-active]')).toBeNull();
});
it('enables file drag-and-drop in the composer core by default', () => {
uploadWorkspaceState.current = makeWorkspace(['workspace_file_upload']);
renderChatEditor({});
expect(latestComposerCoreOptions.current?.fileDragEnabled).toBe(true);
});
it('fileUploadEnabled={false} ingests nothing on file drop', () => {
it('fileUploadEnabled={false} routes drops to attachments without asking', () => {
const workspace = makeWorkspace(['workspace_file_upload']);
uploadWorkspaceState.current = workspace;
composerCoreState.imageDropCapture.mockImplementation((event: Event) => {
@@ -2260,15 +2308,133 @@ describe('ChatEditor file upload gating', () => {
['Files'],
[new File(['abc'], 'notes.txt')],
);
// Cancelled so the browser cannot navigate to the dropped file, but no
// lane — upload or inline image/text — reacts.
expect(drop.defaultPrevented).toBe(true);
expect(composerCoreState.imageDropCapture).not.toHaveBeenCalled();
expect(composerCoreState.imageDropCapture).toHaveBeenCalledTimes(1);
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).toBeNull();
expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled();
expect(composerCoreState.addTags).not.toHaveBeenCalled();
expect(container.querySelector('[data-web-shell-upload-strip]')).toBeNull();
});
it.each([
['upload', true],
['attach', true],
[undefined, false],
['attach', false],
] as const)(
'routes default %s with attachments %s without asking',
async (fileDropAction, attachmentsEnabled) => {
const workspace = makeWorkspace(['workspace_file_upload']);
workspace.client.uploadWorkspaceFile.mockResolvedValue({
kind: 'file_upload',
path: 'uploads/notes.txt',
sizeBytes: 3,
hash: `sha256:${'a'.repeat(64)}`,
});
uploadWorkspaceState.current = workspace;
const container = renderChatEditor({
attachmentsEnabled,
customization: { fileDropAction, fileUploadDirectory: 'uploads' },
});
const files = [new File(['abc'], 'notes.txt')];
const drop = dispatchDrag(
container.querySelector('[data-web-shell-composer-editor]')!,
'drop',
['Files'],
files,
);
await act(async () => {
await Promise.resolve();
});
expect(drop.defaultPrevented).toBe(true);
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).toBeNull();
expect(composerCoreState.focus).toHaveBeenCalled();
if (fileDropAction === 'attach' && attachmentsEnabled) {
expect(composerCoreState.ingestFiles).toHaveBeenCalledWith(files);
expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled();
} else {
expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledTimes(1);
expect(workspace.client.uploadWorkspaceFile).toHaveBeenCalledWith(
expect.objectContaining({
path: 'uploads/notes.txt',
data: files[0],
}),
);
expect(composerCoreState.ingestFiles).not.toHaveBeenCalled();
}
},
);
it('falls back to attachments when the preferred upload is unavailable', () => {
uploadWorkspaceState.current = makeWorkspace([]);
const container = renderChatEditor({
customization: { fileDropAction: 'upload' },
});
dispatchDrag(
container.querySelector('[data-web-shell-composer-editor]')!,
'drop',
['Files'],
[new File(['abc'], 'notes.txt')],
);
expect(composerCoreState.imageDropCapture).toHaveBeenCalledTimes(1);
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).toBeNull();
});
it('cancels drops when neither destination is available', () => {
const workspace = makeWorkspace([]);
uploadWorkspaceState.current = workspace;
const container = renderChatEditor({
attachmentsEnabled: false,
customization: { fileDropAction: 'upload' },
});
const onDrop = vi.fn();
container.addEventListener('drop', onDrop);
const drop = dispatchDrag(
container.querySelector('[data-web-shell-composer-editor]')!,
'drop',
['Files'],
[new File(['abc'], 'notes.txt')],
);
expect(onDrop).toHaveBeenCalledTimes(1);
expect(drop.defaultPrevented).toBe(true);
expect(composerCoreState.imageDropCapture).not.toHaveBeenCalled();
expect(composerCoreState.ingestFiles).not.toHaveBeenCalled();
expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled();
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).toBeNull();
});
it.each([
{ attachmentsEnabled: false },
{ customization: { fileDropAction: 'attach' as const } },
])('closes a pending choice when routing changes: %j', (props) => {
const workspace = makeWorkspace(['workspace_file_upload']);
uploadWorkspaceState.current = workspace;
const container = renderChatEditor({});
dispatchDrag(
container.querySelector('[data-web-shell-composer-editor]')!,
'drop',
['Files'],
[new File(['abc'], 'notes.txt')],
);
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).not.toBeNull();
rerenderChatEditor(container, props);
expect(
document.querySelector('[data-web-shell-drop-choice-dialog]'),
).toBeNull();
expect(composerCoreState.ingestFiles).not.toHaveBeenCalled();
expect(workspace.client.uploadWorkspaceFile).not.toHaveBeenCalled();
});
it('asks whether dropped files should be referenced or uploaded', () => {
const workspace = makeWorkspace(['workspace_file_upload']);
uploadWorkspaceState.current = workspace;
@@ -2911,6 +3077,9 @@ describe('ChatEditor file upload gating', () => {
'[data-web-shell-upload-strip] [data-status="done"]',
);
expect(row?.textContent).toContain('Saved as report (1).txt');
expect(
row?.querySelector('[title="Saved as report (1).txt"]'),
).not.toBeNull();
});
it('shows the plain Uploaded copy when the file kept its name', async () => {
@@ -91,6 +91,7 @@ import {
XIcon,
} from 'lucide-react';
import { FileTypeIcon } from './FileTypeIcon';
import { FileAttachmentContent } from './FileAttachmentContent';
import { WorkspaceSelector } from './WorkspaceSelector';
import {
Popover,
@@ -401,7 +402,11 @@ function TopComposerTag({
</span>
);
const tagElement = (
<span ref={anchorRef} className={styles.tag} data-web-shell-composer-tag>
<span
ref={anchorRef}
className={`${styles.tag}${isPreviewableFileComposerTag(tag) ? ` ${styles.fileTag}` : ''}`}
data-web-shell-composer-tag
>
{hasTooltip ? (
<TooltipPrimitive.Trigger asChild>
{tagContent}
@@ -1570,6 +1575,7 @@ export const ChatEditor = memo(
atProviders: contextAtProviders,
fileUploadEnabled,
fileUploadDirectory,
fileDropAction,
} = useWebShellCustomization();
// At-mention provider props win when set (main composer). Split-view
// ChatPane omits them and falls back to the App-level customization
@@ -1637,7 +1643,7 @@ export const ChatEditor = memo(
);
useLayoutEffect(() => {
setPendingDropFiles(null);
}, [disabled, uploadTargetKey]);
}, [disabled, uploadTargetKey, attachmentsEnabled, fileDropAction]);
// -- File upload ----------------------------------------------------------
// The hook's cancel/reset granularity includes the session: ChatEditor is
@@ -1692,7 +1698,7 @@ export const ChatEditor = memo(
cycleModeOnTab,
onToggleShortcuts,
disabled,
fileDragEnabled: attachmentsEnabled && fileUploadEnabled !== false,
fileDragEnabled: attachmentsEnabled,
placeholderText,
commands,
skills,
@@ -1786,10 +1792,12 @@ export const ChatEditor = memo(
};
const [uploadDragActive, setUploadDragActive] = useState(false);
const uploadDragDepthRef = useRef(0);
const uploadDropEnabled =
uploadEnabled && (!attachmentsEnabled || fileDropAction !== 'attach');
const handleUploadDragEnter = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (
!uploadEnabled ||
!uploadDropEnabled ||
disabled ||
!event.dataTransfer.types.includes('Files')
)
@@ -1798,19 +1806,19 @@ export const ChatEditor = memo(
uploadDragDepthRef.current += 1;
setUploadDragActive(true);
},
[uploadEnabled, disabled],
[uploadDropEnabled, disabled],
);
const handleUploadDragOver = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (
!uploadEnabled ||
!uploadDropEnabled ||
disabled ||
!event.dataTransfer.types.includes('Files')
)
return;
event.preventDefault();
},
[uploadEnabled, disabled],
[uploadDropEnabled, disabled],
);
const handleUploadDragLeave = useCallback(() => {
if (uploadDragDepthRef.current === 0) return;
@@ -1854,27 +1862,38 @@ export const ChatEditor = memo(
event.preventDefault();
return;
}
if (fileUploadEnabled === false) {
// Host force-disables file drag-in entirely: cancel the drop so
// the browser cannot navigate to the file, but ingest nothing on
// any lane (the image lane is gated off too).
event.preventDefault();
return;
}
if (!uploadEnabled || files.length === 0) {
core.imageTransferHandlers.onDropCapture(event);
if (attachmentsEnabled) {
core.imageTransferHandlers.onDropCapture(event);
} else {
event.preventDefault();
}
return;
}
clearImageDragState();
event.preventDefault();
event.stopPropagation();
setPendingDropFiles(files);
if (!attachmentsEnabled || fileDropAction === 'upload') {
uploadFiles(files, fileUploadDirectory ?? '.', insertUploadReference);
focusComposer();
} else if (fileDropAction === 'attach') {
ingestFiles(files);
focusComposer();
} else {
setPendingDropFiles(files);
}
},
[
core.imageTransferHandlers,
clearImageDragState,
disabled,
fileUploadEnabled,
attachmentsEnabled,
fileDropAction,
focusComposer,
fileUploadDirectory,
ingestFiles,
insertUploadReference,
uploadFiles,
pendingDropFiles,
uploadEnabled,
],
@@ -2011,8 +2030,8 @@ export const ChatEditor = memo(
};
}, [clearUploadDragState, uploadDragActive]);
useEffect(() => {
if (disabled) clearUploadDragState();
}, [clearUploadDragState, disabled]);
if (disabled || !uploadDropEnabled) clearUploadDragState();
}, [clearUploadDragState, disabled, uploadDropEnabled]);
useEffect(() => {
onAttachmentsChange?.(core.hasAttachments);
@@ -2397,13 +2416,22 @@ export const ChatEditor = memo(
}
return (
<>
{safeIconUrl && (
{isPreviewableFileComposerTag(tag) &&
!tag.icon &&
safeIconUrl === getComposerTagIconUrl('file') ? (
<FileTypeIcon
name={tagValue}
size={16}
className={styles.fileTagIcon}
aria-hidden="true"
/>
) : safeIconUrl ? (
<span
className={styles.tagIcon}
style={cssUrlVar('--composer-tag-icon-url', safeIconUrl)}
aria-hidden="true"
/>
)}
) : null}
{tagLabel && <span className={styles.tagLabel}>{tagLabel}</span>}
{tagValue && <span className={styles.tagValue}>{tagValue}</span>}
</>
@@ -2705,19 +2733,32 @@ export const ChatEditor = memo(
})
}
>
{busy ? (
<LoaderCircleIcon
className={styles.uploadRowSpinner}
aria-hidden="true"
/>
) : (
<UploadIcon aria-hidden="true" />
)}
<span className={styles.uploadRowName}>
<FileTypeIcon
name={upload.file.name}
mimeType={upload.file.type}
size={20}
className={styles.uploadRowIcon}
aria-hidden="true"
/>
<span
className={styles.uploadRowName}
title={upload.file.name}
>
{upload.file.name}
</span>
<span className={styles.uploadRowStatus}>
{uploadStatusText(upload)}
{busy && (
<LoaderCircleIcon
className={styles.uploadRowSpinner}
aria-hidden="true"
/>
)}
<span
className={styles.uploadRowStatusText}
title={uploadStatusText(upload)}
>
{uploadStatusText(upload)}
</span>
</span>
</button>
<button
@@ -2967,16 +3008,10 @@ export const ChatEditor = memo(
})
}
>
<FileTypeIcon
<FileAttachmentContent
name={file.name}
mimeType={file.media_type}
size={14}
className={styles.fileChipIcon}
aria-hidden="true"
/>
<span className={styles.fileChipName}>
{file.name}
</span>
</button>
<button
type="button"
@@ -3112,6 +3147,7 @@ export const ChatEditor = memo(
disabled={disabled}
availabilityKey={JSON.stringify([
fileUploadEnabled === false,
attachmentsEnabled,
uploadEnabled,
Boolean(
core.workspaceActionsRef.current?.globWorkspace ??
@@ -3126,9 +3162,7 @@ export const ChatEditor = memo(
),
Boolean(skills?.length),
])}
addFileAvailable={
attachmentsEnabled && fileUploadEnabled !== false
}
addFileAvailable={attachmentsEnabled}
uploadAvailable={uploadEnabled}
onAddFiles={handleAddMenuFiles}
onFilePickerCancel={focusComposer}
@@ -0,0 +1,41 @@
.icon {
display: flex;
flex: 0 0 48px;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--muted);
color: var(--muted-foreground);
}
.details {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 3px;
text-align: left;
font-family: var(--font-sans, system-ui, sans-serif);
}
.name,
.type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
color: var(--foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.type {
color: var(--muted-foreground);
font-size: 12px;
line-height: 18px;
}
@@ -0,0 +1,33 @@
import { FileTypeIcon } from './FileTypeIcon';
import styles from './FileAttachmentContent.module.css';
export function FileAttachmentContent({
name,
mimeType,
}: {
name: string;
mimeType?: string;
}) {
const extension = /\.([^.\\/]+)$/.exec(name)?.[1];
const type = extension?.toUpperCase() || mimeType || 'FILE';
return (
<>
<span className={styles.icon}>
<FileTypeIcon
name={name}
mimeType={mimeType}
size={24}
aria-hidden="true"
/>
</span>
<span className={styles.details}>
<span className={styles.name} title={name}>
{name}
</span>
<span className={styles.type} title={type}>
{type}
</span>
</span>
</>
);
}
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { FileTypeIcon } from './FileTypeIcon';
import { ARTIFACT_ICON_URLS } from './artifacts/ArtifactIcon';
describe('FileTypeIcon', () => {
it.each([
['report.HTML', 'html'],
['C#guide.pdf', 'pdf'],
['data?.csv', 'csv'],
['folder.with.dot/README', 'file'],
['report.pdf', 'pdf'],
['notes.md', 'md'],
['report.docx', 'word'],
['data.csv', 'csv'],
['data.xlsx', 'spreadsheet'],
['photo.svg', 'image'],
['movie.mp4', 'video'],
['unknown', 'file'],
] as const)('uses the artifact SVG for %s', (name, kind) => {
const container = document.createElement('div');
container.innerHTML = renderToStaticMarkup(
<FileTypeIcon name={name} size={16} />,
);
expect(container.querySelector('image')?.getAttribute('href')).toBe(
ARTIFACT_ICON_URLS[kind],
);
expect(container.querySelector('svg')?.getAttribute('width')).toBe('16');
});
it('uses the artifact MIME fallback for files without extensions', () => {
const html = renderToStaticMarkup(
<FileTypeIcon name="report" mimeType="TEXT/HTML; charset=utf-8" />,
);
expect(html).toContain('data-file-type-icon="html"');
});
it.each([
['config.json', 'braces'],
['script.ts', 'file-code-corner'],
['archive.zip', 'file-archive'],
])('keeps the existing fallback for %s', (name, icon) => {
expect(renderToStaticMarkup(<FileTypeIcon name={name} />)).toContain(
`lucide-${icon}`,
);
});
});
@@ -12,6 +12,10 @@ import {
type LucideIcon,
} from 'lucide-react';
import type { ComponentProps } from 'react';
import {
ARTIFACT_ICON_URLS,
getArtifactIconKind,
} from './artifacts/ArtifactIcon';
const EXTENSION_ICONS: ReadonlyArray<[ReadonlySet<string>, LucideIcon]> = [
[new Set(['json', 'jsonl', 'geojson']), BracesIcon],
@@ -79,8 +83,24 @@ function iconForFile(name: string, mimeType?: string): LucideIcon {
export function FileTypeIcon({
name,
mimeType,
size = 24,
absoluteStrokeWidth,
...props
}: ComponentProps<LucideIcon> & { name: string; mimeType?: string }) {
const Icon = iconForFile(name, mimeType);
return <Icon {...props} />;
const kind = getArtifactIconKind({
kind: 'file',
title: name.replace(/[?#]/g, '_'),
mimeType,
});
if (kind !== 'file' || Icon === FileIcon) {
return (
<svg width={size} height={size} {...props} data-file-type-icon={kind}>
<image href={ARTIFACT_ICON_URLS[kind]} width="100%" height="100%" />
</svg>
);
}
return (
<Icon size={size} absoluteStrokeWidth={absoluteStrokeWidth} {...props} />
);
}
@@ -35,7 +35,7 @@ export type ArtifactIconKind =
| 'video'
| 'word';
const ICONS: Readonly<Record<ArtifactIconKind, string>> = {
export const ARTIFACT_ICON_URLS: Readonly<Record<ArtifactIconKind, string>> = {
csv: csvIcon,
file: fileIcon,
html: htmlIcon,
@@ -100,7 +100,10 @@ const EXTENSION_ICONS: Readonly<Record<string, ArtifactIconKind>> = {
};
export function getArtifactIconKind(
artifact?: DaemonSessionArtifact,
artifact?: Pick<
DaemonSessionArtifact,
'kind' | 'title' | 'workspacePath' | 'url' | 'mimeType'
>,
): ArtifactIconKind {
if (!artifact) return 'file';
@@ -167,7 +170,7 @@ export function ArtifactIcon({
return (
<img
className={className}
src={ICONS[kind]}
src={ARTIFACT_ICON_URLS[kind]}
data-artifact-icon={kind}
alt=""
/>
@@ -803,7 +803,7 @@ describe('ArtifactPanel code review artifacts', () => {
await flush();
expect(
container.querySelector('[role="tab"] .lucide-file-text'),
container.querySelector('[role="tab"] [data-file-type-icon="md"]'),
).not.toBeNull();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -322,7 +322,11 @@
cursor: pointer;
}
.messageTagClickable:hover,
.messageTagClickable:where(.fileTag):hover {
border-color: color-mix(in srgb, var(--border), var(--foreground) 20%);
}
.messageTagClickable:not(.fileTag):hover,
.messageTagClickable:focus-visible {
border-color: var(--chat-editor-accent-color, var(--foreground));
outline: none;
@@ -436,14 +440,14 @@
.chatFileChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 9999px;
background: var(--background);
font-size: 14px;
line-height: 20px;
gap: 12px;
box-sizing: border-box;
width: 272px;
max-width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--background);
}
.chatFileChipPreviewable {
@@ -455,11 +459,17 @@
outline-offset: 2px;
}
.chatFileIcon {
flex: 0 0 auto;
color: var(--muted-foreground);
.fileTag {
vertical-align: middle;
background: var(--background);
min-height: 28px;
padding-right: 8px;
border-radius: 8px;
font-family: var(--font-sans, system-ui, sans-serif);
}
.chatFileName {
overflow-wrap: anywhere;
.fileTagIcon {
flex: 0 0 auto;
margin-left: 8px;
color: var(--muted-foreground);
}
@@ -484,6 +484,26 @@ describe('UserMessage', () => {
).toContain('flash');
});
it.each([
['report.HTML', 'text/html', 'HTML', 'html'],
['README', 'text/plain', 'text/plain', 'file-text'],
['LICENSE', '', 'FILE', 'file'],
])('renders attachment metadata for %s', (name, mimeType, type, icon) => {
const container = render(
<UserMessage content="" files={[{ name, mimeType }]} />,
);
const card = container.querySelector('[data-web-shell-user-files]');
expect(card?.querySelector(`[title="${name}"]`)?.textContent).toBe(name);
expect(card?.querySelector(`[title="${type}"]`)?.textContent).toBe(type);
expect(
card?.querySelector(
icon === 'file-text'
? 'svg.lucide-file-text'
: `[data-file-type-icon="${icon}"]`,
),
).not.toBeNull();
});
it('previews a sent text attachment when its chip is clicked', () => {
const onAttachmentPreview = vi.fn();
const container = render(
@@ -632,6 +652,8 @@ describe('UserMessage', () => {
name: 'notes.txt',
workspacePath: 'docs/notes.txt',
});
expect(container.querySelector('svg.lucide-file-text')).not.toBeNull();
expect(container.textContent).toContain('docs/notes.txt');
});
it('does not make a sent directory tag previewable', () => {
@@ -11,6 +11,7 @@ import {
} from 'react';
import { CalendarClockIcon, PencilIcon, RefreshCwIcon } from 'lucide-react';
import { FileTypeIcon } from '../FileTypeIcon';
import { FileAttachmentContent } from '../FileAttachmentContent';
import { describeCron } from '../dialogs/scheduledTasksSchedule';
import {
getComposerTagDisplay,
@@ -416,14 +417,10 @@ export const UserMessage = memo(function UserMessage({
}
}}
>
<FileTypeIcon
<FileAttachmentContent
name={file.name}
mimeType={file.mimeType}
size={16}
className={styles.chatFileIcon}
aria-hidden="true"
/>
<span className={styles.chatFileName}>{file.name}</span>
</span>
);
})}
@@ -559,7 +556,7 @@ export function ReadonlyComposerTag({
: undefined;
return (
<span
className={`${styles.messageTag}${
className={`${styles.messageTag}${isPreviewableFileComposerTag(tag) ? ` ${styles.fileTag}` : ''}${
clickable ? ` ${styles.messageTagClickable}` : ''
}`}
role={clickable ? 'button' : undefined}
@@ -585,13 +582,22 @@ export function ReadonlyComposerTag({
>
{custom ?? (
<>
{safeIconUrl && (
{isPreviewableFileComposerTag(tag) &&
!tag.icon &&
safeIconUrl === getComposerTagIconUrl('file') ? (
<FileTypeIcon
name={tagValue}
size={16}
className={styles.fileTagIcon}
aria-hidden="true"
/>
) : safeIconUrl ? (
<span
className={styles.messageTagIcon}
style={cssUrlVar('--user-message-tag-icon-url', safeIconUrl)}
aria-hidden="true"
/>
)}
) : null}
{tagLabel && (
<span className={styles.messageTagLabel}>{tagLabel}</span>
)}
+6 -1
View File
@@ -590,13 +590,18 @@ export interface WebShellCustomization {
loadingPhrases?: LoadingPhrasesResolver;
/**
* Controls whether the composer's file-upload entry points (drag-and-drop
* and the @ panel upload item) are enabled. Works alongside the daemon's
* and the @ panel upload item) are enabled. Does not disable attachments.
* Works alongside the daemon's
* `workspace_file_upload` capability, not instead of it: setting `false`
* force-disables upload even when the daemon advertises the capability,
* while `true`/omitted still requires the capability (and the workspace
* trust / qualified-route safety checks) to be satisfied.
*/
fileUploadEnabled?: boolean;
/** Preferred file-drop destination. Omitted: ask only when both are available.
* If the preference is unavailable, use the sole available destination.
*/
fileDropAction?: 'upload' | 'attach';
/**
* Directory that drag-and-dropped files upload into, **relative to the
* workspace root**. Use a relative path WITHOUT a leading `/` — e.g.
@@ -1330,6 +1330,39 @@ describe('useComposerCore tags', () => {
);
});
it('uses file format icons while preserving explicit icons and directories', async () => {
await mount({
composerInput: {
tags: [
{ id: 'html', kind: 'file', value: 'docs/page.html' },
{
id: 'custom',
kind: 'file',
value: 'other.html',
icon: '/custom.svg',
},
{
id: 'folder',
kind: 'file',
value: 'docs',
metadata: { fileKind: 'directory' },
},
],
tagPlacement: 'inline',
},
});
expect(
document.body.querySelectorAll('[data-file-type-icon="html"]'),
).toHaveLength(1);
expect(
document.body.querySelectorAll('[style*="--composer-tag-icon-url"]'),
).toHaveLength(2);
expect(latest!.viewRef.current!.state.doc.toString()).toContain(
'docs/page.html',
);
});
it('resubmits restored input annotations with the draft', async () => {
const { onSubmit } = await mount();
const inputAnnotations = [
@@ -1534,23 +1567,26 @@ describe('useComposerCore tags', () => {
warn.mockRestore();
});
it('uses a custom inline tooltip without a native title', async () => {
await mount({
composerInput: {
tags: [{ id: 'orders', label: 'Table', value: 'orders' }],
tagPlacement: 'inline',
},
renderComposerTagTooltip: () => 'Details',
});
it.each([undefined, 'file'] as const)(
'uses a custom inline tooltip without a native title for kind %s',
async (kind) => {
await mount({
composerInput: {
tags: [{ id: 'orders', kind, label: 'Table', value: 'orders' }],
tagPlacement: 'inline',
},
renderComposerTagTooltip: () => 'Details',
});
const tooltip = document.body.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toBe('Details');
expect(tooltip?.parentElement?.getAttribute('title')).toBeNull();
expect(tooltip?.id).toBeTruthy();
expect(tooltip?.parentElement?.getAttribute('aria-describedby')).toBe(
tooltip?.id,
);
});
const tooltip = document.body.querySelector('[role="tooltip"]');
expect(tooltip?.textContent).toBe('Details');
expect(tooltip?.parentElement?.getAttribute('title')).toBeNull();
expect(tooltip?.id).toBeTruthy();
expect(tooltip?.parentElement?.getAttribute('aria-describedby')).toBe(
tooltip?.id,
);
},
);
it('falls back to a native title when attaching an inline tooltip fails', async () => {
const error = new Error('append failed');
@@ -1637,7 +1673,10 @@ describe('useComposerCore tags', () => {
expect(
document.body.querySelectorAll('[style*="--composer-tag-icon-url"]'),
).toHaveLength(kinds.length);
).toHaveLength(kinds.length - 1);
expect(
document.body.querySelector('[data-file-type-icon="file"]'),
).not.toBeNull();
});
it('reports inline composer tags as attachments', async () => {
@@ -1,4 +1,5 @@
import {
createElement,
useEffect,
useLayoutEffect,
useRef,
@@ -10,6 +11,7 @@ import {
type DragEventHandler,
} from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { FileTypeIcon } from '../components/FileTypeIcon';
import {
Decoration,
EditorView,
@@ -660,6 +662,18 @@ class ComposerTagWidget extends WidgetType {
});
});
}
const hasCustomTooltip =
this.tag.tooltip !== undefined && this.tag.tooltip !== null;
if (isPreviewableFileComposerTag(this.tag)) {
chip.style.verticalAlign = 'middle';
chip.style.background = 'var(--chat-editor-bg-primary)';
chip.style.borderRadius = '8px';
chip.style.minHeight = '28px';
chip.style.fontFamily = 'var(--font-sans,system-ui,sans-serif)';
if (!hasCustomTooltip) {
chip.title = getComposerTagValue(this.tag);
}
}
const rawTagLabel = getComposerTagLabel(this.tag);
const tagValue = getComposerTagValue(this.tag);
const tagLabel = this.tag.kind ? '' : rawTagLabel;
@@ -697,7 +711,22 @@ class ComposerTagWidget extends WidgetType {
}
}
if (!renderedCustomContent && safeIconUrl) {
if (
!renderedCustomContent &&
isPreviewableFileComposerTag(this.tag) &&
!this.tag.icon &&
safeIconUrl === getComposerTagIconUrl('file')
) {
const icon = document.createElement('span');
icon.style.cssText =
'display:inline-flex;width:16px;height:16px;flex:0 0 auto;margin-left:8px;color:var(--muted-foreground);';
icon.setAttribute('aria-hidden', 'true');
this.contentRoot = createRoot(icon);
this.contentRoot.render(
createElement(FileTypeIcon, { name: tagValue, size: 16 }),
);
chip.appendChild(icon);
} else if (!renderedCustomContent && safeIconUrl) {
const icon = document.createElement('span');
icon.style.cssText =
'display:block;width:12px;height:12px;flex:0 0 auto;margin-left:7px;background:currentColor;mask:var(--composer-tag-icon-url) center / contain no-repeat;-webkit-mask:var(--composer-tag-icon-url) center / contain no-repeat;';
@@ -1100,9 +1129,7 @@ export interface UseComposerCoreOptions {
/**
* Whether the composer may react to FILE drags at all (drag highlight and
* drop ingestion on the inline image/text lane). `false` leaves paste
* working but makes file drag-and-drop inert, matching a host that
* force-disables file upload via `fileUploadEnabled={false}`. Defaults to
* `true`.
* working but makes attachment drag-and-drop inert. Defaults to `true`.
*/
fileDragEnabled?: boolean;
placeholderText?: string;
@@ -1981,7 +2008,7 @@ export function useComposerCore(
if (disabled) clearImageDragState();
}, [clearImageDragState, disabled]);
useEffect(() => {
// A host flipping `fileUploadEnabled` to false mid-drag gates the
// Disabling attachment drags mid-drag gates the
// leave handler, so a depth already counted would never drain; clear
// the highlight explicitly instead of waiting for dragend/blur.
if (fileDragEnabled === false) clearImageDragState();