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
@@ -102,9 +102,9 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
FolderOpen,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -810,8 +810,12 @@ export function LaneChangesPanel({
wide = full,
onDirtyChange,
openRequest = null,
filesChanged = 0,
}: {
laneId: number;
/** Bumped when files changed outside this panel (the header's File
* Explorer): the tree is read again, its open folders kept open. */
filesChanged?: number;
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
openRequest?: { path: string; nonce: number } | null;
/** The whole-file editor holds unsaved text (true) or not (false). */
@@ -855,6 +859,9 @@ export function LaneChangesPanel({
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
const { local } = usePermissions();
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
@@ -870,6 +877,16 @@ export function LaneChangesPanel({
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
// from the disk. Open there instead of on a page that only says "no git".
// Both reads must say so: the facts alone also read "unavailable" on a
// network error or an older server.
const notRepo = git?.available === false && data?.available === false;
useEffect(() => {
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
@@ -1078,19 +1095,32 @@ export function LaneChangesPanel({
}, [mode, laneId, query, reread, full, treeRootLoaded]);
// A folder's children are fetched lazily, the first time it is expanded.
const readDir = (path: string) =>
api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
const toggleDir = (path: string) => {
const opening = !expandedDirs.has(path);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// Every OPEN reads the folder again (the cached level shows meanwhile):
// files land in it from outside the panel — an agent, the terminal, the
// File Explorer — and a level cached while it was empty stayed empty.
if (opening) void readDir(path);
};
// Files changed outside the panel: read the root and every open folder again.
useEffect(() => {
if (!filesChanged) return;
void readDir("");
for (const p of expandedDirs) void readDir(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesChanged]);
const [treeBusy, setTreeBusy] = useState(false);
// "Expand all" is ONE read of the whole tree: a request per folder meant a
@@ -1624,15 +1654,17 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
{!local && (
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
)}
</>
)}
{treeCtx.menu.item.dir && (
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
@@ -1640,7 +1672,7 @@ export function LaneChangesPanel({
{t("changes.uploadHere")}
</MenuItem>
)}
{treeCtx.menu.item.dir && (
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-explorer"
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
@@ -2134,9 +2166,9 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
<FolderOpen className="h-3.5 w-3.5" />
</IconButton>
{/* The File Explorer's button lives in the panel header now (beside
Full screen); this instance serves the tree's "Open in File
Explorer". */}
<FileExplorer
open={!!explorer}
laneId={laneId}
@@ -2265,14 +2297,16 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
) : notRepo && full ? null : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
{(notRepo
? (["browse"] as const)
: full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m: Mode) => (
<button
key={m}
type="button"
@@ -2627,7 +2661,9 @@ export function LaneChangesPanel({
browsePane
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
) : !data.available && mode !== "file" ? (
// A file still opens from Files: its whole-file read is the disk's,
// not git's.
<p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
</p>
@@ -2878,7 +2914,7 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{range !== "commit" && openFile && (
{!local && range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
@@ -2970,7 +3006,9 @@ export function LaneChangesPanel({
collapse={!whole}
revertLabel={t("changes.revertBlock")}
value={draft}
original={baseText ?? draft}
// Outside a repo there is no HEAD: the edits are
// shown against the text as it was opened.
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}