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
@@ -9,7 +9,8 @@
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
* an attached image or text file through `GET /api/run/drop/:name`, an image
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
+4 -1
View File
@@ -65,6 +65,7 @@ import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { resolveIn } from "../../lib/resolvePath";
import { Select } from "../Select";
import { Divider, showTerminal } from "./PaneGrid";
import {
@@ -471,7 +472,9 @@ export const PaneChat = memo(function PaneChat({
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openFile = useCallback(
(path: string) => {
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
+16 -2
View File
@@ -69,6 +69,7 @@ import {
addAttachments,
droppedAttachments,
droppedSkill,
droppedText,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -412,7 +413,8 @@ export function PromptEditor({
// else a `bare` editor's parent, the box every caller draws around it and
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
// `/command`, a file is uploaded.
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
// and any other text is inserted.
const rootRef = useRef<HTMLDivElement | null>(null);
const dropRef = useRef<(e: DragEvent) => void>(() => {});
dropRef.current = (e) => {
@@ -425,6 +427,17 @@ export function PromptEditor({
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
// On the textarea itself the browser inserts text at the drop point.
const dropped = droppedText(e.dataTransfer);
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
e.preventDefault();
if ("attachments" in dropped)
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
else insert(dropped.text);
}
};
useEffect(() => {
const zone =
@@ -433,7 +446,8 @@ export function PromptEditor({
if (!zone) return;
const over = (e: DragEvent) => {
const types = e.dataTransfer?.types ?? [];
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
if (!accepts.some((m) => types.includes(m))) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "copy";
};
@@ -406,6 +406,16 @@ export const TerminalView = memo(function TerminalView({
}, 0);
return false;
}
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
// codex — the pane drops to its shell and the session looks closed,
// with nothing on screen saying `fg` brings it back. In a browser
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
// and in bash/readline — and never suspends the program.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
ev.preventDefault();
term.input("\x1f", true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -434,6 +434,45 @@ describe("PaneChat — the unsent prompt", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
// A drag from outside the dashboard (a file manager, VS Code, a selection)
// carries only text: the terminal beneath pastes it, so the chat takes it too.
const textDrop = (data: Record<string, string>) => {
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
});
act(() => {
screen.getByTestId("pane-chat").dispatchEvent(drop);
});
return drop;
};
it("attaches the local files of a dropped file:// list", async () => {
renderChat();
const drop = textDrop({
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
"text/plain": "/home/u/My Docs/spec.md",
});
expect(drop.defaultPrevented).toBe(true);
expect(await screen.findByText("spec.md")).toBeInTheDocument();
expect(screen.getByText("b.ts")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
target: { value: "read" },
});
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
);
});
it("inserts dropped plain text into the prompt", () => {
renderChat();
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
expect(drop.defaultPrevented).toBe(true);
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
});
// Issue #73: before the session starts the CLI may be asking in the terminal
// (trust this folder? — "No, exit" preselected); an Enter sent from here
// would answer it, so a send asks first.
@@ -520,9 +520,15 @@ describe("TerminalView", () => {
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
"text/plain": "src/a.ts",
};
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
const dataTransfer = {
types: Object.keys(data),
files: [],
getData: (k: string) => data[k] ?? "",
};
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
"Drop to paste into the terminal"
);
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
expect(screen.queryByTestId("terminal-drop")).toBeNull();
@@ -582,6 +588,24 @@ describe("TerminalView", () => {
vi.useRealTimers();
});
// ^Z suspended claude: the pane fell to its shell and the session looked
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
true
);
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {