mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
BIN
pr/paste-unsupported-file-links/session-smoke-response.png
Normal file
BIN
pr/paste-unsupported-file-links/session-smoke-response.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
BIN
pr/paste-unsupported-file-links/session-ui-smoke.png
Normal file
BIN
pr/paste-unsupported-file-links/session-ui-smoke.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
Reference in New Issue
Block a user