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
@@ -14,6 +14,7 @@
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const field = "input w-full";
const label = "block text-xs text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
);
}
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
const clean = text.replace(/^\uFEFF/, "");
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
return {
name: frontmatterField(clean, "name"),
description: frontmatterField(clean, "description"),
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
};
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
export function SkillNameField({
value,
onChange,
disabled,
@@ -175,7 +187,7 @@ function SkillNameField({
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={value}
disabled={disabled}
placeholder="release-notes"
@@ -296,7 +308,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
disabled={many}
@@ -310,7 +322,7 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
@@ -452,7 +464,10 @@ export function SkillAddPanel({
/>
)}
{meta}
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -489,7 +504,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
@@ -499,7 +514,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
className={`${field} mt-1 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
@@ -509,12 +524,15 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -534,7 +552,7 @@ export function SkillAddPanel({
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
className="select w-full"
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<div className="mt-1 flex gap-2">
<input
className={field}
value={link}
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
</li>
))}
</ul>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn-ghost text-sm"
onClick={onClose}
disabled={busy}
>
{t("form.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="submit"
className="btn-primary text-sm"
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>