fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists. The server was forked from the dashboard's cwd (the plugin cache dir), outlives updates, and the old cache dir is pruned — after that every new pane started in a deleted directory, where `claude` died at once ("run not found") and a shell printed getcwd errors. tmux calls now run from `/`, and the pane's sh wrapper cds into its folder itself, which also heals a tmux server that is already stuck. - Changes panel: a workspace's extra folders are sections stacked under the workspace folder (VS Code Source Control style), with Close repository / Close other repositories on a header's right-click. - Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]` line folds into its turn; uploads under `<data dir>/drops/` are listed. - Update check: a checkout holding commits a newer release lacks reports `diverged_from_release` instead of offering a fast-forward that can only fail.
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -244,6 +246,71 @@ function parseBlocks(src: string): Block[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Opens a file a message names; provided by a view that can show one. */
|
||||
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
|
||||
|
||||
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 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]!);
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!open) return <>{children}</>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(path)}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
|
||||
export function linkify(text: string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let buf = "";
|
||||
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 hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
continue;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
buf = "";
|
||||
out.push(
|
||||
url ? (
|
||||
<UrlLink key={i} url={url} />
|
||||
) : (
|
||||
<PathLink key={i} path={hit}>
|
||||
{hit}
|
||||
</PathLink>
|
||||
)
|
||||
);
|
||||
i += hit.length;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
@@ -267,11 +334,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Inline code: `...`
|
||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||
if (codeM) {
|
||||
push(
|
||||
const code = (
|
||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -330,22 +404,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// Auto-link
|
||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
||||
const urlM = rest.match(URL_RE);
|
||||
if (urlM) {
|
||||
push(
|
||||
<a
|
||||
href={urlM[0]}
|
||||
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 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
);
|
||||
push(<UrlLink url={urlM[0]} />);
|
||||
i += urlM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||
i += pathM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||
* the Conversation tab on the Session detail page. An image the prompt box
|
||||
* attached reaches the transcript as its saved path; a user turn shows the
|
||||
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
|
||||
* image instead (`GET /api/run/drop/:name`), and Claude Code's own
|
||||
* `[Image: source: <drop>]` line for it joins that turn rather than showing as
|
||||
* a System row; URLs and absolute paths in a turn are links (a path opens
|
||||
* through `onOpenFile`). Each assistant turn that changed
|
||||
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
||||
@@ -124,7 +127,7 @@ const SENDER_STYLES: Record<
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { MarkdownContent, OpenFileContext, linkify } from "./MarkdownContent";
|
||||
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||
|
||||
@@ -440,7 +443,26 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
endTurn(key);
|
||||
return items.push({ kind: "user", key, msg });
|
||||
}
|
||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||
if (sender !== "assistant") {
|
||||
// Claude Code's line for the image a prompt attached: the image joins that prompt.
|
||||
const paths = sender === "system" ? imageSourcePaths(msg) : null;
|
||||
const prompt = paths ? [...items].reverse().find((i) => i.kind === "user") : undefined;
|
||||
if (paths && prompt?.kind === "user") {
|
||||
prompt.msg = {
|
||||
...prompt.msg,
|
||||
content: [
|
||||
...prompt.msg.content.map((c) =>
|
||||
c.type === "text" && c.text
|
||||
? { ...c, text: c.text.replace(/\[Image #\d+\]/g, "") }
|
||||
: c
|
||||
),
|
||||
{ type: "text", text: paths.join("\n") },
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
return items.push({ kind: "row", key, msg });
|
||||
}
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
@@ -546,40 +568,44 @@ export function MessageList({ messages, loading, onOpenFile, live = false }: Mes
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
<OpenFileContext.Provider value={onOpenFile}>
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<SessionEventRow
|
||||
key={item.key}
|
||||
title={item.msg.title}
|
||||
timestamp={item.msg.timestamp}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||
case "user":
|
||||
return <UserBubble key={item.key} msg={item.msg} />;
|
||||
case "text":
|
||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||
case "work":
|
||||
return (
|
||||
<WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||
</div>
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,18 +672,37 @@ function RunningStep({ block, since }: { block: TranscriptContent | null; since:
|
||||
const meta = "text-[10px] font-mono text-fg-muted";
|
||||
|
||||
// What the prompt box sends for an attached image: its saved path under the
|
||||
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
|
||||
// drop dir (`<data dir>/drops/<16 hex>[-name].png`, server/routes/run.js; the
|
||||
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||
const DROP_IMAGE =
|
||||
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
/(?<=^|\s)\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||
// Claude Code's own line for an image it read off a pasted path (`isMeta`).
|
||||
const IMAGE_SOURCE = /^\[Image: source: (.+)\]$/;
|
||||
|
||||
/** A user turn's text without its dropped-image paths, and those images' names. */
|
||||
/** A user turn's text without its dropped-image paths (and Claude Code's
|
||||
* `[Image #N]` placeholders for them), and those images' names. */
|
||||
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||
const images: string[] = [];
|
||||
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||
images.push(name);
|
||||
return "";
|
||||
});
|
||||
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
|
||||
return {
|
||||
text: images.length
|
||||
? rest
|
||||
.replace(/\[Image #\d+\]/g, "")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim()
|
||||
: text,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
/** The drop paths of a meta message made only of `[Image: source: <drop>]` lines. */
|
||||
function imageSourcePaths(msg: TranscriptMessage): string[] | null {
|
||||
const lines = msg.content.flatMap((c) => (c.type === "text" ? (c.text ?? "").split("\n") : []));
|
||||
const paths = lines.map((l) => IMAGE_SOURCE.exec(l.trim())?.[1] ?? "");
|
||||
return paths.length && paths.every((p) => splitDropImages(p).images.length) ? paths : null;
|
||||
}
|
||||
|
||||
function DropImage({ name }: { name: string }) {
|
||||
@@ -739,7 +784,7 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
||||
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||
return plain ? (
|
||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||
{stripAnsi(text).trim()}
|
||||
{linkify(stripAnsi(text).trim())}
|
||||
</p>
|
||||
) : (
|
||||
renderTextBlock(text, b)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||
* other path as typed.
|
||||
* other path as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -22,6 +24,11 @@ describe("dropped images in a user turn", () => {
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
]);
|
||||
// The drop dir since the data-dir move.
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
});
|
||||
// Not a drop, or not an image: left as typed.
|
||||
for (const text of [
|
||||
"/home/me/shot.png",
|
||||
@@ -51,4 +58,56 @@ describe("dropped images in a user turn", () => {
|
||||
fireEvent.click(img);
|
||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sender: "system",
|
||||
timestamp: "2026-10-09T09:43:11.000Z",
|
||||
content: [
|
||||
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.queryByText("System")).toBeNull();
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
|
||||
expect(bubble).not.toHaveTextContent("[Image #1]");
|
||||
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
|
||||
`/api/run/drop/${SHOT}`
|
||||
);
|
||||
});
|
||||
|
||||
it("makes URLs and absolute paths clickable", () => {
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
|
||||
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
|
||||
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user