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:
2026-10-09 17:24:14 +07:00
parent fa156d6a59
commit 96e4d28f7c
25 changed files with 589 additions and 208 deletions
@@ -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)