chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
@@ -11,7 +11,9 @@
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
@@ -71,6 +73,10 @@ import {
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -229,7 +235,10 @@ export function promptForPane(
|
||||
}
|
||||
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||
return {
|
||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||
text: promptWithImages(
|
||||
text,
|
||||
images.filter((i) => !isImage(i))
|
||||
),
|
||||
files: images.filter(isImage).map((i) => i.path),
|
||||
};
|
||||
}
|
||||
@@ -399,7 +408,11 @@ export function PromptEditor({
|
||||
// A pasted or dropped file is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
|
||||
null
|
||||
);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const uploading = upload === "uploading";
|
||||
@@ -410,20 +423,64 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
// Each file on its own: one refused file does not cost the others.
|
||||
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
|
||||
const added: PromptImage[] = [];
|
||||
const errors: unknown[] = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === "rejected") return void errors.push(r.reason);
|
||||
const f = picked[i]!;
|
||||
added.push({
|
||||
path: r.value.path,
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (added.length) {
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
keepError.current = errors.length > 0; // these came with the error
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
}
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
setUpload(
|
||||
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
// upload's error; not the files that uploaded beside the one refused.
|
||||
const keepError = useRef(false);
|
||||
const imageCount = useRef(images.length);
|
||||
useEffect(() => {
|
||||
const grew = images.length > imageCount.current;
|
||||
imageCount.current = images.length;
|
||||
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
|
||||
keepError.current = false;
|
||||
}, [images.length]);
|
||||
const removeImage = (img: PromptImage) => {
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
@@ -452,7 +509,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -617,7 +674,7 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
@@ -661,10 +718,10 @@ export function PromptEditor({
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
role={upload === "uploading" ? "status" : "alert"}
|
||||
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
|
||||
>
|
||||
{t(`drop.${upload}`)}
|
||||
{t(`drop.${upload}`, { mb: limitMb })}
|
||||
</p>
|
||||
)}
|
||||
{kind && (
|
||||
|
||||
Reference in New Issue
Block a user