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:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
+73 -16
View File
@@ -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 && (