feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+130
-6
@@ -885,6 +885,16 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/users/:id — delete the user for good: logins, their
|
||||
* workspaces' records and (one Linux user per account) their Linux
|
||||
* account and whole home. Never yourself or the last admin.
|
||||
*/
|
||||
remove: (id: number, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
||||
setPassword: (id: number, password: string, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
||||
@@ -2545,8 +2555,7 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) =>
|
||||
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
@@ -2613,11 +2622,14 @@ export const api = {
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
|
||||
generateKey: (replace = false) =>
|
||||
/**
|
||||
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
body: JSON.stringify({ replace, type }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
@@ -2646,6 +2658,58 @@ export const api = {
|
||||
),
|
||||
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
if (params.q) qs.set("q", params.q);
|
||||
const s = qs.toString();
|
||||
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||
},
|
||||
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ installsEnabled }),
|
||||
}),
|
||||
/** Documents a workspace's sessions wrote, to pin. */
|
||||
candidates: (laneId: number) =>
|
||||
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
|
||||
/** Skill folders the caller may share (their own, and the workspace's). */
|
||||
skills: (laneId?: number) =>
|
||||
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
|
||||
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
|
||||
),
|
||||
addPage: (body: { url: string; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
|
||||
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
|
||||
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
|
||||
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/plugin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
install: (id: number, replace = false) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
rawUrl: (id: number, path?: string, download = false) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (path) qs.set("path", path);
|
||||
if (download) qs.set("download", "1");
|
||||
const s = qs.toString();
|
||||
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
|
||||
},
|
||||
},
|
||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||
pets: {
|
||||
list: () => request<PetList>("/pets"),
|
||||
@@ -3640,6 +3704,21 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
|
||||
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
|
||||
* ESSHAUTH like {@link lanes.clone}). */
|
||||
cloneFolder: (
|
||||
id: number,
|
||||
body: {
|
||||
url: string;
|
||||
name?: string;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||
addFolder: (id: number, path: string) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
||||
@@ -3820,6 +3899,51 @@ function requestCodexBackupsHelper(params?: {
|
||||
export type CcArtifactType =
|
||||
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
|
||||
|
||||
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
|
||||
|
||||
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
description: string;
|
||||
ownerId: number | null;
|
||||
owner: string | null;
|
||||
mine: boolean;
|
||||
canChange: boolean;
|
||||
laneId: number | null;
|
||||
sessionId: string | null;
|
||||
/** page: the file it was published from; file: its workspace path;
|
||||
* skill: its folder name; plugin: `name@marketplace`. */
|
||||
source: string | null;
|
||||
/** plugin: the marketplace source (owner/repo or a git URL). */
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
installs: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactCandidate {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
at: string;
|
||||
exists: boolean;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
installsEnabled: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
|
||||
export interface CcWriteArgs {
|
||||
// "auto-memory" targets a per-project memory file and requires `project`.
|
||||
@@ -3950,7 +4074,7 @@ export interface GitIdentity {
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { publicKey: string; fingerprint: string } | null;
|
||||
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
https: {
|
||||
|
||||
@@ -64,6 +64,43 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
* insert, as a terminal pastes it. */
|
||||
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 text = dt?.getData("text/plain") ?? "";
|
||||
return text.trim() ? { text } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file resolvePath.ts — resolves a path a chat reply names against the pane's
|
||||
* folder: an absolute one (POSIX, `~`-less, or `C:\…`) is kept, a relative one
|
||||
* (`server/lib/x.js`, `./a`, `../b`) is joined to `cwd` with `.`/`..` folded,
|
||||
* so the preview and the Changes panel see the same absolute path the agent meant.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** `path` as an absolute path; relative ones resolve against `cwd`. */
|
||||
export function resolveIn(cwd: string, path: string): string {
|
||||
if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path) || !cwd) return path;
|
||||
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
for (const seg of path.split(/[\\/]/)) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
if (parts.length > 1) parts.pop();
|
||||
} else parts.push(seg);
|
||||
}
|
||||
return parts.join("/") || "/";
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export const SETTINGS_INDEX: {
|
||||
to?: string;
|
||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||
adminOnly?: boolean;
|
||||
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
|
||||
serverOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "account",
|
||||
@@ -330,10 +332,17 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitCreds.title",
|
||||
adminOnly: true,
|
||||
serverOnly: true,
|
||||
keywords: ["login", "token"],
|
||||
},
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitIdentity.title",
|
||||
serverOnly: true,
|
||||
keywords: ["ssh", "key", "clone", "https"],
|
||||
to: "#git-identity",
|
||||
},
|
||||
|
||||
@@ -1925,7 +1925,9 @@ export interface WSMessage {
|
||||
/** A lane's profile hook exited, with its status code. */
|
||||
| "lane_hook_result"
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted";
|
||||
| "runs_interrupted"
|
||||
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||
| "artifact_changed";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
|
||||
Reference in New Issue
Block a user