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:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+37
View File
@@ -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.-]+)?$/;