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:
@@ -4,9 +4,11 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
@@ -256,7 +258,19 @@ const LINK_CLASS =
|
||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
|
||||
// A relative path needs a folder AND an extension starting with a letter
|
||||
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
@@ -284,6 +298,17 @@ function UrlLink({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||
function decodeHref(href: string): string {
|
||||
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
/* a stray % stays as written */
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A path as a button that opens it, or as it was when nothing can. */
|
||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||
const open = React.useContext(OpenFileContext);
|
||||
@@ -292,7 +317,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(path)}
|
||||
onClick={() => open(stripLine(path))}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
@@ -307,7 +332,7 @@ export function linkify(text: string): React.ReactNode[] {
|
||||
for (let i = 0; i < text.length;) {
|
||||
const rest = text.slice(i);
|
||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && matchPath(rest);
|
||||
const hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
@@ -359,7 +384,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
matchPath(codeM[1]!) === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
@@ -408,9 +433,23 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
const href = linkM[2]!;
|
||||
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||
if (
|
||||
/^file:\/\//i.test(href) ||
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
@@ -431,10 +470,10 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||
i += pathM[0].length;
|
||||
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||||
i += pathM.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,52 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
|
||||
import { resolveIn } from "../../../lib/resolvePath";
|
||||
|
||||
function withOpen(text: string) {
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<OpenFileContext.Provider value={open}>
|
||||
<MarkdownContent text={text} />
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
return open;
|
||||
}
|
||||
|
||||
describe("file links", () => {
|
||||
it("opens a relative path with its line suffix stripped", () => {
|
||||
const open = withOpen("See server/lib/x.js:42 for it.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
|
||||
expect(open).toHaveBeenCalledWith("server/lib/x.js");
|
||||
});
|
||||
|
||||
it("opens an absolute path with a line suffix and one in inline code", () => {
|
||||
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
|
||||
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
|
||||
});
|
||||
|
||||
it("leaves prose with slashes as text", () => {
|
||||
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves a relative path against the pane's folder", () => {
|
||||
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
|
||||
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MarkdownContent />", () => {
|
||||
it("renders fenced code blocks with the language label", () => {
|
||||
|
||||
Reference in New Issue
Block a user