feat: linkify unsupported pasted files (#598)

Convert unsupported pasted files into links instead of dropping them, and auto-upload them to inbox for sandbox workspaces so links resolve predictably.
This commit is contained in:
ben
2026-02-17 16:01:46 -08:00
committed by GitHub
parent d58e68e913
commit 6b9093f325
5 changed files with 195 additions and 18 deletions

View File

@@ -49,7 +49,10 @@ type ComposerProps = {
searchFiles: (query: string) => Promise<string[]>;
isRemoteWorkspace: boolean;
isSandboxWorkspace: boolean;
onUploadInboxFiles?: (files: File[]) => void | Promise<void>;
onUploadInboxFiles?: (
files: File[],
options?: { notify?: boolean },
) => void | Promise<Array<{ name: string; path: string }> | void>;
attachmentsEnabled: boolean;
attachmentsDisabledReason: string | null;
listCommands: () => Promise<SlashCommandOption[]>;
@@ -61,8 +64,76 @@ const IMAGE_COMPRESS_QUALITY = 0.82;
const IMAGE_COMPRESS_TARGET_BYTES = 1_500_000;
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"];
const FILE_URL_RE = /^file:\/\//i;
const HTTP_URL_RE = /^https?:\/\//i;
const WINDOWS_PATH_RE = /^[a-zA-Z]:\\/;
const UNC_PATH_RE = /^\\\\/;
const isImageMime = (mime: string) => ACCEPTED_IMAGE_TYPES.includes(mime);
const isSupportedAttachmentType = (mime: string) => ACCEPTED_FILE_TYPES.includes(mime);
const escapeMarkdownLabel = (value: string) =>
value
.replace(/\\/g, "\\\\")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
const normalizeLinkTarget = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return "";
if (FILE_URL_RE.test(trimmed) || HTTP_URL_RE.test(trimmed)) {
return encodeURI(trimmed);
}
if (WINDOWS_PATH_RE.test(trimmed)) {
return `file:///${encodeURI(trimmed.replace(/\\/g, "/"))}`;
}
if (UNC_PATH_RE.test(trimmed)) {
const normalized = trimmed.replace(/\\/g, "/").replace(/^\/+/, "");
return `file://${encodeURI(normalized)}`;
}
if (trimmed.startsWith("/")) {
return `file://${encodeURI(trimmed)}`;
}
return "";
};
const parseClipboardLinks = (clipboard: DataTransfer) => {
const values = [
clipboard.getData("text/uri-list") ?? "",
clipboard.getData("text/plain") ?? "",
clipboard.getData("text") ?? "",
];
const links: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const lines = value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
for (const line of lines) {
const target = normalizeLinkTarget(line);
if (!target || seen.has(target)) continue;
seen.add(target);
links.push(target);
}
}
return links;
};
const inboxPathToLink = (path: string) => {
const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
if (!normalized) return "";
if (normalized.startsWith(".opencode/openwork/inbox/")) {
return normalized;
}
return `.opencode/openwork/inbox/${normalized}`;
};
const formatLinks = (links: Array<{ name: string; target: string }>) =>
links
.filter((entry) => entry.target)
.map((entry) => `[${escapeMarkdownLabel(entry.name || "file")}](${entry.target})`)
.join("\n");
const fileToDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
@@ -377,6 +448,12 @@ export default function Composer(props: ComposerProps) {
let suppressPromptSync = false;
let pasteCounter = 0;
const pasteTextById = new Map<string, string>();
const objectUrls = new Set<string>();
const createObjectUrl = (file: File) => {
const url = URL.createObjectURL(file);
objectUrls.add(url);
return url;
};
// Track IME composition state so we can combine it with keyCode === 229 to
// reliably suppress Enter during CJK input across Chrome, Safari, and WebKit.
let imeComposing = false;
@@ -396,6 +473,13 @@ export default function Composer(props: ComposerProps) {
const activeVariant = createMemo(() => props.modelVariant ?? "none");
const attachmentsDisabled = createMemo(() => !props.attachmentsEnabled);
onCleanup(() => {
for (const url of objectUrls) {
URL.revokeObjectURL(url);
}
objectUrls.clear();
});
const createPasteSpan = (part: Extract<ComposerPart, { type: "paste" }>) => {
pasteTextById.set(part.id, part.text);
const span = document.createElement("span");
@@ -908,7 +992,7 @@ export default function Composer(props: ComposerProps) {
}
const next: ComposerAttachment[] = [];
for (const file of files) {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) {
if (!isSupportedAttachmentType(file.type)) {
props.onToast(`${file.name} is not a supported attachment type.`);
continue;
}
@@ -1022,6 +1106,53 @@ export default function Composer(props: ComposerProps) {
emitDraftChange();
};
const insertUnsupportedFileLinks = async (files: File[], clipboardLinks: string[]) => {
const fallbackLinks = () =>
files.map((file, index) => ({
name: file.name || `file-${index + 1}`,
target: clipboardLinks[index] || createObjectUrl(file),
}));
if (props.isSandboxWorkspace && props.onUploadInboxFiles) {
const uploaded = await Promise.resolve(props.onUploadInboxFiles(files, { notify: false }));
if (Array.isArray(uploaded) && uploaded.length) {
const links = uploaded
.map((item, index) => {
const target = inboxPathToLink(item.path ?? "");
const fallbackName = files[index]?.name || `file-${index + 1}`;
const name = item.name?.trim() || fallbackName;
return { name, target };
})
.filter((entry) => entry.target);
const text = formatLinks(links);
if (text) {
insertPlainTextAtSelection(text);
updateMentionQuery();
updateSlashQuery();
emitDraftChange();
props.onToast(
links.length === 1
? `Uploaded ${links[0].name} to inbox and inserted a link.`
: `Uploaded ${links.length} files to inbox and inserted links.`,
);
return;
}
}
props.onToast("Couldn't upload to inbox. Inserted local links instead.");
}
const text = formatLinks(fallbackLinks());
if (!text) {
props.onToast("Unsupported attachment type.");
return;
}
insertPlainTextAtSelection(text);
updateMentionQuery();
updateSlashQuery();
emitDraftChange();
props.onToast("Inserted links for unsupported files.");
};
const handlePaste = (event: ClipboardEvent) => {
if (!event.clipboardData) return;
const clipboard = event.clipboardData;
@@ -1033,12 +1164,15 @@ export default function Composer(props: ComposerProps) {
const allFiles = files.length ? files : itemFiles;
if (allFiles.length) {
event.preventDefault();
const hasSupported = allFiles.some((file) => ACCEPTED_FILE_TYPES.includes(file.type));
if (!hasSupported) {
props.onToast("Unsupported attachment type.");
return;
const supported = allFiles.filter((file) => isSupportedAttachmentType(file.type));
const unsupported = allFiles.filter((file) => !isSupportedAttachmentType(file.type));
if (supported.length) {
void addAttachments(supported);
}
if (unsupported.length) {
const links = parseClipboardLinks(clipboard);
void insertUnsupportedFileLinks(unsupported, links);
}
void addAttachments(allFiles);
return;
}

View File

@@ -359,6 +359,12 @@ export type OpenworkInboxList = {
items: OpenworkInboxItem[];
};
export type OpenworkInboxUploadResult = {
ok: boolean;
path: string;
bytes: number;
};
type RawJsonResponse<T> = {
ok: boolean;
status: number;
@@ -1271,7 +1277,27 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
throw new OpenworkServerError(result.status, "request_failed", message || "Inbox upload failed");
}
return result.text;
const body = result.text.trim();
if (body) {
try {
const parsed = JSON.parse(body) as Partial<OpenworkInboxUploadResult>;
if (typeof parsed.path === "string" && parsed.path.trim()) {
return {
ok: parsed.ok ?? true,
path: parsed.path.trim(),
bytes: typeof parsed.bytes === "number" ? parsed.bytes : file.size,
} satisfies OpenworkInboxUploadResult;
}
} catch {
// ignore invalid JSON and fall back
}
}
return {
ok: true,
path: options?.path?.trim() || file.name,
bytes: file.size,
} satisfies OpenworkInboxUploadResult;
},
listInbox: (workspaceId: string) =>

View File

@@ -1625,27 +1625,44 @@ export default function SessionView(props: SessionViewProps) {
const isSandboxWorkspace = createMemo(() => Boolean((props.activeWorkspaceDisplay as any)?.sandboxContainerName?.trim()));
const uploadInboxFiles = async (files: File[]) => {
const uploadInboxFiles = async (
files: File[],
options?: { notify?: boolean },
): Promise<Array<{ name: string; path: string }>> => {
const notify = options?.notify ?? true;
const client = props.openworkServerClient;
const workspaceId = props.openworkServerWorkspaceId?.trim() ?? "";
if (!client || !workspaceId) {
setToastMessage("Connect to the OpenWork server to upload inbox files.");
return;
if (notify) {
setToastMessage("Connect to the OpenWork server to upload inbox files.");
}
return [];
}
if (!files.length) return;
if (!files.length) return [];
const label = files.length === 1 ? files[0]?.name ?? "file" : `${files.length} files`;
setToastMessage(`Uploading ${label} to inbox...`);
if (notify) {
setToastMessage(`Uploading ${label} to inbox...`);
}
try {
const uploaded: Array<{ name: string; path: string }> = [];
for (const file of files) {
await client.uploadInbox(workspaceId, file);
const result = await client.uploadInbox(workspaceId, file);
const path = result.path?.trim() || file.name;
uploaded.push({ name: file.name || path, path });
}
const summary = files.map((file) => file.name).filter(Boolean).join(", ");
setToastMessage(summary ? `Uploaded to inbox: ${summary}` : "Uploaded to inbox.");
if (notify) {
const summary = uploaded.map((file) => file.name).filter(Boolean).join(", ");
setToastMessage(summary ? `Uploaded to inbox: ${summary}` : "Uploaded to inbox.");
}
return uploaded;
} catch (error) {
const message = error instanceof Error ? error.message : "Inbox upload failed";
setToastMessage(message);
if (notify) {
const message = error instanceof Error ? error.message : "Inbox upload failed";
setToastMessage(message);
}
return [];
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB