Merge pull request #11667 from Harbor404/fix/studio-11605-copy-session-info

feat(studio): copy session handoff information
This commit is contained in:
YHH authored and GitHub committed 2026-10-04 11:02:22 -07:00
commit 54f4fbb19f
6 files changed
+107

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ export const ACTIONS: UIAction[] = [
// refused while a turn runs, so it never stops work.
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
+5
View File
@@ -1124,6 +1124,11 @@ export const EN: Record<string, string> = {
"读取": "Read",
"本地工作区": "Local workspace",
"暂停执行": "Pause run",
"复制会话信息": "Copy session information",
"会话 ID": "Session ID",
"会话上下文路径": "Session context path",
"任务路径": "Task path",
"任务日志": "Task log",
"导出会话": "Export session",
"取消置顶": "Unpin",
"置顶会话": "Pin session",
@@ -8,6 +8,8 @@ import { pinToViewport } from "./place";
import { useRailQuery } from "./railsearch";
import { StudioIcon } from "./StudioIcon";
import { Cross } from "./glyphs";
import { copyText } from "./CopyButton";
import { sessionInfoText } from "./sessionInfo";
import { download } from "../port/download";
import { host } from "../port/host";
import { useTreeKeys } from "./tree";
@@ -591,6 +593,9 @@ function WorkspacesView({ hub, tree, treeRead, runtimes, active, folded, reload,
</div>
<div className="session-pop-divider" />
<div className="session-pop-group">
<button role="menuitem" data-action="session.copy-info" data-target={session.path} onClick={() => { void copyText(sessionInfoText(session, ws.root)).catch(onError); setSessionMenu(""); }}>
<StudioIcon name="copy" /><span>{t("复制会话信息")}</span>
</button>
<button role="menuitem" data-action="session.export" data-target={session.path} disabled={busy === "export:" + session.path} onClick={() => void saveSession(session)}>
<StudioIcon name="download" /><span>{t("导出会话")}</span><small>JSON</small>
</button>
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Workspaces } from "./Workspaces";
import type { TreeWorkspace } from "../port/hub";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const SESSION = "/w/.reasonix/sessions/20260901-120000.jsonl";
it("copies the four handoff fields from the session menu", async () => {
const user = userEvent.setup();
const write = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
const tree: TreeWorkspace[] = [
{ root: "/w/project", name: "project", sessions: [{ path: SESSION, name: "20260901-120000", title: "handoff me" }] },
];
render(
<Workspaces
hub={{} as never}
tree={tree}
treeRead
runtimes={[]}
active=""
folded={new Set()}
onFold={() => {}}
reload={async () => {}}
onOpen={async () => {}}
onFocus={() => {}}
onClose={async () => {}}
liveIds={() => []}
runs={{}}
onRename={() => {}}
onError={() => {}}
adder={{ add: () => {}, close: () => {}, at: null } as never}
/>,
);
await user.pointer({ keys: "[MouseRight]", target: screen.getByRole("treeitem", { name: /handoff me/ }) });
await user.click(screen.getByRole("menuitem", { name: "复制会话信息" }));
expect(write).toHaveBeenCalledWith([
"会话 ID: 20260901-120000",
"会话上下文路径: /w/.reasonix/sessions",
"任务路径: /w/project",
"任务日志: /w/.reasonix/sessions/20260901-120000.jsonl",
].join("\n"));
expect(screen.queryByRole("menu")).toBeNull();
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { sessionInfoText } from "./sessionInfo";
describe("session handoff information", () => {
it("names the four paths another agent needs, with the context directory kept distinct from the task log", () => {
expect(sessionInfoText(
{ name: "20261002-handoff", path: "/home/me/.reasonix/sessions/20261002-handoff.jsonl" },
"/home/me/project",
)).toBe([
"会话 ID: 20261002-handoff",
"会话上下文路径: /home/me/.reasonix/sessions",
"任务路径: /home/me/project",
"任务日志: /home/me/.reasonix/sessions/20261002-handoff.jsonl",
].join("\n"));
});
it("keeps a Windows context directory intact", () => {
expect(sessionInfoText(
{ name: "20261002-win", path: String.raw`C:\Users\me\.reasonix\sessions\20261002-win.jsonl` },
String.raw`C:\work\project`,
)).toContain(String.raw`会话上下文路径: C:\Users\me\.reasonix\sessions`);
});
});
@@ -0,0 +1,20 @@
import { t } from "../i18n";
interface SessionInfo {
name: string;
path: string;
}
function parentPath(path: string): string {
const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return at > 0 ? path.slice(0, at) : path;
}
export function sessionInfoText(session: SessionInfo, taskPath: string): string {
return [
`${t("会话 ID")}: ${session.name}`,
`${t("会话上下文路径")}: ${parentPath(session.path)}`,
`${t("任务路径")}: ${taskPath}`,
`${t("任务日志")}: ${session.path}`,
].join("\n");
}