diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index 7e07f9f88e..1858df6b3d 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -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" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index c749fb4675..b12a2bc33f 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1124,6 +1124,11 @@ export const EN: Record = { "读取": "Read", "本地工作区": "Local workspace", "暂停执行": "Pause run", + "复制会话信息": "Copy session information", + "会话 ID": "Session ID", + "会话上下文路径": "Session context path", + "任务路径": "Task path", + "任务日志": "Task log", "导出会话": "Export session", "取消置顶": "Unpin", "置顶会话": "Pin session", diff --git a/desktop/frontend-next/src/ui/Workspaces.tsx b/desktop/frontend-next/src/ui/Workspaces.tsx index 062eb55630..0efc14dde8 100644 --- a/desktop/frontend-next/src/ui/Workspaces.tsx +++ b/desktop/frontend-next/src/ui/Workspaces.tsx @@ -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,
+ diff --git a/desktop/frontend-next/src/ui/session-copy-info.interaction.test.tsx b/desktop/frontend-next/src/ui/session-copy-info.interaction.test.tsx new file mode 100644 index 0000000000..ae472aa15b --- /dev/null +++ b/desktop/frontend-next/src/ui/session-copy-info.interaction.test.tsx @@ -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( + {}} + 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(); +}); diff --git a/desktop/frontend-next/src/ui/sessionInfo.test.ts b/desktop/frontend-next/src/ui/sessionInfo.test.ts new file mode 100644 index 0000000000..9d8df8651f --- /dev/null +++ b/desktop/frontend-next/src/ui/sessionInfo.test.ts @@ -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`); + }); +}); diff --git a/desktop/frontend-next/src/ui/sessionInfo.ts b/desktop/frontend-next/src/ui/sessionInfo.ts new file mode 100644 index 0000000000..c8a48a4a51 --- /dev/null +++ b/desktop/frontend-next/src/ui/sessionInfo.ts @@ -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"); +}