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:
+105
-35
@@ -796,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
// ETOOBIG from a prompt drop: the server's own limit.
|
||||
maxBytes: body?.error?.max_bytes as number | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -861,6 +863,44 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${url}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as T);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(payload);
|
||||
});
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
@@ -2764,40 +2804,45 @@ export const api = {
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress
|
||||
),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||
* The Blob is only composed, never read into memory here.
|
||||
*/
|
||||
uploadBatch: (
|
||||
t: FsPlace,
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
files: files.map((f) => ({
|
||||
path: f.rel,
|
||||
size: f.file.size,
|
||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, head.length);
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
onProgress
|
||||
);
|
||||
},
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dir, paths }),
|
||||
}),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
@@ -2945,18 +2990,24 @@ export const api = {
|
||||
source?: string;
|
||||
/** plugin only: the marketplace source. */
|
||||
market?: string;
|
||||
/** skill only: new contents, written as a new snapshot. */
|
||||
skill?: { name: string; description: string; content: string };
|
||||
}
|
||||
) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||
remove: (id: number) =>
|
||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
install: (id: number, replace = false) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
|
||||
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
rawUrl: (id: number, path?: string, download = false) => {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -3134,6 +3185,14 @@ export const api = {
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
},
|
||||
/** POST /api/run/local-paths — each absolute path's size when it is a
|
||||
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
|
||||
* the browser is on that machine (and never in server mode). */
|
||||
localPaths: (paths: string[]) =>
|
||||
request<{ sizes: (number | null)[] }>("/run/local-paths", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -4203,10 +4262,21 @@ export interface Artifact {
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
installs: number;
|
||||
/** False once its owner removed it from their library while shared: Hub only. */
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||
export interface ArtifactInstalls {
|
||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
||||
/** Installs counted before the list was kept. */
|
||||
unrecorded: number;
|
||||
}
|
||||
|
||||
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
|
||||
export interface MarketplaceInfo {
|
||||
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in. An attachment is a path sent with
|
||||
* the prompt, so nothing here touches the server.
|
||||
* `@` form a file is handed to `claude` in, and what a browser on the
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates`, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
@@ -65,6 +69,76 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this browser reaches the dashboard by a loopback address — a
|
||||
* hint only: an SSH tunnel's browser does too, from another machine, so a
|
||||
* dropped path is used only once `sameFilesHere` has checked it. */
|
||||
export function isLocalBrowser(): boolean {
|
||||
return /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(window.location.hostname);
|
||||
}
|
||||
|
||||
/** The `file://` paths a drop or paste carries beside its `files`, when this
|
||||
* may be a browser on the dashboard's machine — read now: a DataTransfer is
|
||||
* empty once its event is over. Null when there is nothing to check. */
|
||||
export function localCandidates(
|
||||
dt: DataTransfer | null | undefined,
|
||||
files: File[]
|
||||
): string[] | null {
|
||||
if (!isLocalBrowser()) return null;
|
||||
const paths = droppedFilePaths(dt);
|
||||
return paths && paths.length === files.length ? paths : null;
|
||||
}
|
||||
|
||||
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
|
||||
* them: every one a regular file there, and the sizes the same. Anything
|
||||
* else (an SSH tunnel's laptop paths, server mode, an error) is false, and
|
||||
* the caller uploads the bytes as before. */
|
||||
export async function sameFilesHere(paths: string[], files: File[]): Promise<boolean> {
|
||||
try {
|
||||
const { sizes } = await api.run.localPaths(paths);
|
||||
const want = files.map((f) => f.size).sort((a, b) => a - b);
|
||||
const have = sizes.slice().sort((a, b) => (a ?? -1) - (b ?? -1));
|
||||
return have.length === want.length && have.every((s, i) => s === want[i]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The local files a drop or a paste names as `file://` URIs (a file
|
||||
* manager's, an editor's), as absolute paths — or null when it names none,
|
||||
* or anything that is not one. `file:///C:/x` is `C:/x`. */
|
||||
export function droppedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) return null;
|
||||
return decodeURIComponent(u.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
return paths.length && paths.every(Boolean) ? (paths as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A file on this machine, by its absolute path, as an attachment. */
|
||||
export function localAttachment(path: string): Attachment {
|
||||
return {
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
||||
name:
|
||||
path
|
||||
.replace(/[\\/]$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() || path,
|
||||
};
|
||||
}
|
||||
|
||||
/** A drop that carries no file and none of ours, only text — a file manager
|
||||
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
|
||||
* link: the local `file://` URIs as `@path` attachments, else the text to
|
||||
@@ -73,28 +147,8 @@ export function droppedText(
|
||||
dt: DataTransfer | null | undefined
|
||||
): { attachments: Attachment[] } | { text: string } | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
return u.protocol === "file:" && (!u.host || u.host === "localhost")
|
||||
? decodeURIComponent(u.pathname)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (paths.length && paths.every(Boolean)) {
|
||||
return {
|
||||
attachments: (paths as string[]).map((p) => ({
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name: p.replace(/\/$/, "").split("/").pop() || p,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const paths = droppedFilePaths(dt);
|
||||
if (paths) return { attachments: paths.map(localAttachment) };
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
return text.trim() ? { text } : null;
|
||||
} catch {
|
||||
|
||||
@@ -3228,6 +3228,17 @@ export interface TranscriptContent {
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
/** Present on an Agent/Task "tool_result": the subagent it ran — its id
|
||||
* (the transcript's `agent_id`), model and, for a foreground run, its time,
|
||||
* tokens and tool calls (null when the result does not say). */
|
||||
agent?: {
|
||||
id: string;
|
||||
model: string | null;
|
||||
status: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens: number | null;
|
||||
tool_uses: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
@@ -3313,6 +3324,9 @@ export interface TranscriptInfo {
|
||||
/** The subagent type for subagent entries; null/absent otherwise (e.g. the
|
||||
* main or compaction entries). */
|
||||
subagent_type?: string | null;
|
||||
/** The Agent call that launched this subagent (its `tool_use` id), when its
|
||||
* meta file names it. */
|
||||
tool_use_id?: string | null;
|
||||
/** Whether a JSONL transcript file was actually found on disk for this entry -
|
||||
* false means the entry exists in the DB but its transcript isn't available
|
||||
* (e.g. the raw file was pruned), so the UI disables that picker option. */
|
||||
|
||||
Reference in New Issue
Block a user