mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
96b14f2311
commit
663e55bd29
22 files changed
+695
-168
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 时还支持分支。
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user