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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ function HomeBody({
|
||||
onMenuAction,
|
||||
onRenamed,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
@@ -127,6 +128,9 @@ function HomeBody({
|
||||
onRenamed?: () => void;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere (the Changes panel's
|
||||
* Close repository): the Folder list re-reads. */
|
||||
sourcesVersion?: number;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -314,6 +318,19 @@ function HomeBody({
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,9 @@ export interface PreviewScope {
|
||||
}
|
||||
|
||||
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
// The drop dir (`<data dir>/drops/`), or the older `<tmp>/agenthub-drops-<uid>/`.
|
||||
const DROP_RE =
|
||||
/\/(?:agenthub-drops-\d+|drops)\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||
|
||||
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "Binary file — not shown.",
|
||||
"changes.collapse": "Collapse the changes panel",
|
||||
"changes.closeRepo": "Close repository",
|
||||
"changes.closeOtherRepos": "Close other repositories",
|
||||
"changes.collapseAll": "Collapse every file",
|
||||
"changes.collapseAllFolders": "Collapse all folders",
|
||||
"changes.collapseContext": "Show fewer surrounding lines",
|
||||
@@ -295,7 +297,6 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Folder to show changes for",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"changes.bin": "bin",
|
||||
"changes.binary": "File nhị phân — không hiển thị.",
|
||||
"changes.collapse": "Thu gọn bảng thay đổi",
|
||||
"changes.closeRepo": "Đóng repository",
|
||||
"changes.closeOtherRepos": "Đóng các repository khác",
|
||||
"changes.collapseAll": "Thu gọn tất cả file",
|
||||
"changes.collapseAllFolders": "Thu gọn tất cả thư mục",
|
||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||
@@ -295,7 +297,6 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.folder": "Thư mục để xem thay đổi",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
|
||||
@@ -1344,6 +1344,7 @@ export interface UpdateStatusPayload {
|
||||
situation?:
|
||||
| "plugin_managed"
|
||||
| "release_available"
|
||||
| "diverged_from_release"
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
|
||||
+187
-98
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||
import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ChartColumn,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
@@ -66,6 +74,7 @@ import type {
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
@@ -709,9 +718,27 @@ export function Workspace() {
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||
// again from the home's Folder list whenever it adds or removes one.
|
||||
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||||
// one is open the panel stays on the tree it was opened in — moving it
|
||||
// (another workspace, session or worktree) would remount it and drop the
|
||||
// edit — until the user says to discard it, or saves.
|
||||
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
|
||||
const changesDirty = Object.values(dirtyBy).some(Boolean);
|
||||
const dirtyFor = useCallback(
|
||||
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
|
||||
[]
|
||||
);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
|
||||
// The workspace's extra folders: each is its own repo, stacked under the
|
||||
// workspace folder's in the Changes panel (`?source=`). Read for the
|
||||
// workspace the panel shows, and again from the home's Folder list whenever
|
||||
// it adds or removes one.
|
||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||
null
|
||||
);
|
||||
@@ -723,35 +750,37 @@ export function Workspace() {
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const panelLaneId = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null) return;
|
||||
api.lanes.sources(selectedLaneId).then(
|
||||
(v) => takeGitFolders(selectedLaneId, v),
|
||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||
if (panelLaneId === null) return;
|
||||
api.lanes.sources(panelLaneId).then(
|
||||
(v) => takeGitFolders(panelLaneId, v),
|
||||
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||||
);
|
||||
}, [selectedLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||
// A pick holds for its own workspace, while that folder is still listed.
|
||||
const gitSource =
|
||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||
? gitSourcePick.id
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session, worktree or folder) would remount it and drop the edit — until
|
||||
// the user says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null
|
||||
? null
|
||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||
const keyOf = (p: typeof wantPanel) =>
|
||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
}, [panelLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
// Bumped after a close here, so the home's Folder list re-reads.
|
||||
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||
const closeSources = async (laneId: number, keys: string[]) => {
|
||||
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||
try {
|
||||
let view: LaneSourcesView | null = null;
|
||||
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||||
if (view) takeGitFolders(laneId, view);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
api.lanes.sources(laneId).then(
|
||||
(v) => takeGitFolders(laneId, v),
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
setSourcesVersion((n) => n + 1);
|
||||
};
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
@@ -764,7 +793,7 @@ export function Workspace() {
|
||||
void confirmDiscard().then((ok) => {
|
||||
asking.current = false;
|
||||
if (!ok) return;
|
||||
setChangesDirty(false);
|
||||
setDirtyBy({});
|
||||
setPanelAt(wantPanel);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
@@ -780,8 +809,12 @@ export function Workspace() {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: back from an extra folder first.
|
||||
setGitSourcePick(null);
|
||||
// Its path is the workspace folder's: unfold that section.
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete("");
|
||||
return next;
|
||||
});
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -792,10 +825,7 @@ export function Workspace() {
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
}, [changesOpen]);
|
||||
// Not on a folder pick: the request above makes one, to land in the panel
|
||||
// it remounts; the picker drops the request itself.
|
||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||
useEffect(() => setChangesRequest(null), [panelKey]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
@@ -1036,6 +1066,7 @@ export function Workspace() {
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
@@ -1268,34 +1299,6 @@ export function Workspace() {
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* Which repo the panel shows: the workspace's folder or
|
||||
one of its extra folders. Only when it has any. */}
|
||||
{folders.length > 0 && (
|
||||
<select
|
||||
data-testid="panel-git-folder"
|
||||
value={gitSource ?? ""}
|
||||
onChange={(e) => {
|
||||
setChangesRequest(null);
|
||||
setGitSourcePick(
|
||||
e.target.value
|
||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
aria-label={tLanes("panel.folder")}
|
||||
title={tLanes("panel.folder")}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||
>
|
||||
<option value="">
|
||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||
</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1350,30 +1353,114 @@ export function Workspace() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{panelAt && (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it. `panelAt` holds still while an edit is
|
||||
// unsaved (see above).
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={setChangesDirty}
|
||||
openRequest={panelAt.source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
{panelAt &&
|
||||
[
|
||||
{ key: "", source: undefined as number | undefined },
|
||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||
].map(({ key, source }, i) => {
|
||||
const panel = (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous
|
||||
// one can land in it. `panelAt` holds still while an
|
||||
// edit is unsaved (see above).
|
||||
key={`${panelKey}:${key}`}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={dirtyFor(key)}
|
||||
openRequest={source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo
|
||||
// when it has one (this lane is itself a worktree of
|
||||
// it); an adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// No extra folders: the one panel, as it always was.
|
||||
if (!folders.length) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const name = source
|
||||
? folders[i - 1]!.name
|
||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
data-testid="panel-git-source"
|
||||
className={`flex flex-col border-b border-border-light ${
|
||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||
{panel}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{panelAt && sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId="panel-git-source-menu"
|
||||
>
|
||||
{sourceCtx.menu.item !== "" && (
|
||||
<MenuItem
|
||||
testId="panel-git-source-close"
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(panelAt.laneId, [key]);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
testId="panel-git-source-close-others"
|
||||
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(
|
||||
panelAt.laneId,
|
||||
folders.map((f) => String(f.id)).filter((k) => k !== key)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeOtherRepos")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1397,14 +1484,16 @@ export function Workspace() {
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && (
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{panelAt &&
|
||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||
<LaneGitRailCounts
|
||||
key={source ?? ""}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
: [],
|
||||
})
|
||||
),
|
||||
// Closing a folder's section removes it from the workspace.
|
||||
removeSource: r({
|
||||
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
|
||||
}),
|
||||
gitTree: r({ entries: [] }),
|
||||
// A file opened in the panel on a chat's request.
|
||||
changes: r({ available: false }),
|
||||
@@ -244,32 +248,50 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||
});
|
||||
|
||||
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||
await openPanel();
|
||||
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||
it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => {
|
||||
vi.mocked(api.lanes.git).mockClear();
|
||||
fireEvent.change(pick, { target: { value: "7" } });
|
||||
await settle();
|
||||
await openPanel();
|
||||
const sections = screen.getAllByTestId("panel-git-source");
|
||||
// The workspace folder, then its folders — never a context file.
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A chat's file is the workspace folder's: the panel goes back there.
|
||||
// A folded section keeps its panel mounted, only hidden.
|
||||
const toggle = sections[0]!.querySelector("button")!;
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||
// A chat's file is the workspace folder's: its section unfolds.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(pick.value).toBe("");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||
});
|
||||
|
||||
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||
it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => {
|
||||
await openPanel();
|
||||
const [main, shop] = screen.getAllByTestId("panel-git-source");
|
||||
fireEvent.contextMenu(main!.querySelector("button")!);
|
||||
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
fireEvent.contextMenu(shop!.querySelector("button")!);
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-close"));
|
||||
await settle();
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
// The last folder gone: back to the one plain panel.
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("is one plain panel for a workspace with no extra folders", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||
expect(screen.queryByTestId("panel-git-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses from its narrow width too, back to the rail", async () => {
|
||||
|
||||
@@ -7174,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.6.0 (rebuild client to match)
|
||||
UI build v2.6.1 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user