Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,4 +703,9 @@ npm 仍用于发布包的 `pack` / clean-install 验证,因为用户通过 npm

`extensions/ai-providers/` 的部分协议实现改编自 [oh-my-pi](https://github.com/can1357/oh-my-pi);`extensions/sessions/` 改编自 [jayshah5696/pi-agent-extensions](https://github.com/jayshah5696/pi-agent-extensions)。独立可选的顶层 Session 通信 package 见 [pi-intercom](https://github.com/nicobailon/pi-intercom)。完整第三方说明见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。

Web workbench 的贡献包括:

- [QuinnWan (@somewan820)](https://github.com/somewan820):[#352](https://github.com/openpi-dev/openpi/pull/352) 提供界面恢复基线及复制回退;界面主体由 [#384](https://github.com/openpi-dev/openpi/pull/384) 迁移至 React,复制回退继续沿用并补充失败反馈。
- [@seekskyworld](https://github.com/seekskyworld):[#377](https://github.com/openpi-dev/openpi/pull/377) 提出浏览器请求超时保护,[#358](https://github.com/openpi-dev/openpi/pull/358) 提出 Pi 原生取消能力。当前 React 请求层和精确轮次取消协议已覆盖这些目标;保留当前实现,并补充响应体停滞和超时清理的回归覆盖。

本项目以 MIT 许可证发布(见 [`LICENSE`](LICENSE));`THIRD_PARTY_NOTICES.md` 记录第三方来源与各自许可。
64 changes: 64 additions & 0 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,67 @@ it("does not attribute current runtime activity to a historical session", () =>
screen.queryByText(/Current build|Current agent|Current workflow/),
).toBeNull();
});

it("reports failed copy honestly, supports retry, and cleans up feedback on unmount", async () => {
vi.useFakeTimers();
const scheduled = vi.spyOn(window, "setTimeout");
const cleared = vi.spyOn(window, "clearTimeout");
const writeText = vi
.fn()
.mockRejectedValueOnce(new Error("denied"))
.mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const snapshot = activeSnapshot();
snapshot.runtime.status = "idle";
const view = renderWithI18n(
createElement(Transcript, {
snapshot,
liveMessages: [
{
key: "answer",
message: { role: "assistant", content: "**original**" },
},
],
liveRunning: false,
livePhase: "idle",
liveRetry: null,
thinkingStarts: {},
thinkingDurations: {},
scrollToBottom: 0,
onResend: async () => true,
}),
);
try {
await act(async () =>
fireEvent.click(
screen.getByRole("button", { name: i18n.t("copyMessage") }),
),
);
expect(screen.getByRole("status").textContent).toBe(i18n.t("copyFailed"));
expect(
screen.queryByRole("button", { name: i18n.t("copiedMessage") }),
).toBeNull();
await act(async () =>
fireEvent.click(
screen.getByRole("button", { name: i18n.t("copyMessage") }),
),
);
expect(writeText).toHaveBeenLastCalledWith("**original**");
expect(
screen.getByRole("button", { name: i18n.t("copiedMessage") }),
).toBeTruthy();
expect(screen.queryByRole("status")).toBeNull();
const timerIndex = scheduled.mock.calls.findIndex(
(call) => call[1] === 1_200,
);
expect(timerIndex).toBeGreaterThanOrEqual(0);
const timer = scheduled.mock.results[timerIndex].value;
view.unmount();
expect(cleared).toHaveBeenCalledWith(timer);
} finally {
view.unmount();
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
108 changes: 108 additions & 0 deletions tests/web/clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from "vitest";
import { copyText } from "../../web/ui/src/lib/clipboard.ts";

const originalExec = Object.getOwnPropertyDescriptor(document, "execCommand");
afterEach(() => {
if (originalExec)
Object.defineProperty(document, "execCommand", originalExec);
else Reflect.deleteProperty(document, "execCommand");
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.replaceChildren();
});

it("copies original Markdown with the native API without moving focus", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const input = document.createElement("input");
document.body.append(input);
input.focus();
expect(await copyText("**original**")).toBe(true);
expect(writeText).toHaveBeenCalledWith("**original**");
expect(document.activeElement).toBe(input);
expect(document.querySelector("textarea")).toBeNull();
});

it.each(["missing", "rejected"])(
"falls back after %s API and restores composer selection",
async (mode) => {
vi.stubGlobal(
"navigator",
mode === "missing"
? {}
: {
clipboard: {
writeText: vi.fn().mockRejectedValue(new Error("denied")),
},
},
);
const input = document.createElement("input");
input.value = "draft message";
document.body.append(input);
input.focus();
input.setSelectionRange(1, 5, "backward");
Object.defineProperty(document, "execCommand", {
configurable: true,
value: vi.fn(() => {
expect(document.activeElement).toBeInstanceOf(HTMLTextAreaElement);
expect((document.activeElement as HTMLTextAreaElement).value).toBe(
"**original**",
);
return true;
}),
});
expect(await copyText("**original**")).toBe(true);
expect(document.activeElement).toBe(input);
expect([
input.selectionStart,
input.selectionEnd,
input.selectionDirection,
]).toEqual([1, 5, "backward"]);
expect(document.querySelector("textarea")).toBeNull();
},
);

it.each([false, "throw"])(
"reports fallback failure %s and removes temporary content",
async (outcome) => {
vi.stubGlobal("navigator", {});
Object.defineProperty(document, "execCommand", {
configurable: true,
value: () => {
if (outcome === "throw") throw new Error("blocked");
return false;
},
});
expect(await copyText("private message")).toBe(false);
expect(document.body.textContent).toBe("");
expect(document.querySelector("textarea")).toBeNull();
},
);

it("restores the current document selection after native rejection", async () => {
let rejectCopy: (reason: Error) => void = () => undefined;
vi.stubGlobal("navigator", {
clipboard: {
writeText: () =>
new Promise<void>((_, reject) => {
rejectCopy = reject;
}),
},
});
const pending = copyText("copy this");
const paragraph = document.createElement("p");
paragraph.textContent = "selected text";
document.body.append(paragraph);
const range = document.createRange();
range.selectNodeContents(paragraph);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
Object.defineProperty(document, "execCommand", {
configurable: true,
value: () => true,
});
rejectCopy(new Error("denied"));
expect(await pending).toBe(true);
expect(window.getSelection()?.toString()).toBe("selected text");
});
56 changes: 56 additions & 0 deletions tests/web/web-protocol.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,59 @@ it.each(["{", "{}", '{"id":"x","accepted":false}'])(
).rejects.toBeInstanceOf(Error);
},
);

it("keeps the HTTP deadline through a stalled body even with a caller signal", async () => {
vi.useFakeTimers();
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
vi.stubGlobal(
"fetch",
vi.fn((_path: string, options: RequestInit) => {
return Promise.resolve(
new Response(
new ReadableStream({
start(stream) {
stream.enqueue(new TextEncoder().encode('{"partial":'));
options.signal?.addEventListener(
"abort",
() => stream.error(new DOMException("aborted", "AbortError")),
{ once: true },
);
},
}),
),
);
}),
);
const request = new WebClient().request("/api/snapshot", {
signal: caller.signal,
});
const failure = expect(request).rejects.toThrow("Request timed out");
await vi.advanceTimersByTimeAsync(15_000);
await failure;
expect(caller.signal.aborted).toBe(false);
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
expect(vi.getTimerCount()).toBe(0);
});

it.each([200, 503])(
"cleans up request deadlines and caller listeners after HTTP %s",
async (status) => {
vi.useFakeTimers();
const caller = new AbortController();
const remove = vi.spyOn(caller.signal, "removeEventListener");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(new Response('{"error":"unavailable"}', { status })),
);
const request = new WebClient().request("/api/snapshot", {
signal: caller.signal,
});
if (status === 200) await request;
else await expect(request).rejects.toThrow("unavailable");
expect(remove).toHaveBeenCalledWith("abort", expect.any(Function));
expect(vi.getTimerCount()).toBe(0);
},
);
16 changes: 8 additions & 8 deletions web/dist/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/dist/styles.css

Large diffs are not rendered by default.

38 changes: 34 additions & 4 deletions web/ui/src/features/transcript/Transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
WebSnapshot,
} from "../../../../protocol/types.ts";
import { Markdown } from "../../components/Markdown.tsx";
import { copyText } from "../../lib/clipboard.ts";
import {
compactSummary,
formatElapsedMs,
Expand Down Expand Up @@ -345,6 +346,17 @@ function MessageActions({
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [copying, setCopying] = useState(false);
const copyGeneration = useRef(0);
const copyTimer = useRef<number | undefined>(undefined);
useEffect(
() => () => {
copyGeneration.current += 1;
window.clearTimeout(copyTimer.current);
},
[],
);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(content);
const editInput = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -393,7 +405,7 @@ function MessageActions({
}
const time = formatTurnTime(timestamp);
return (
<div className="message-actions">
<div className={`message-actions${copyFailed ? " copy-failed" : ""}`}>
{time && <time dateTime={timestamp}>{time}</time>}
{editable && (
<button
Expand All @@ -409,15 +421,33 @@ function MessageActions({
type="button"
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
title={copied ? t("copiedMessage") : t("copyMessage")}
disabled={copying}
onClick={() => {
void navigator.clipboard.writeText(content).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1_200);
const generation = ++copyGeneration.current;
window.clearTimeout(copyTimer.current);
setCopying(true);
setCopied(false);
setCopyFailed(false);
void copyText(content).then((success) => {
if (generation !== copyGeneration.current) return;
setCopying(false);
setCopied(success);
setCopyFailed(!success);
if (success)
copyTimer.current = window.setTimeout(
() => setCopied(false),
1_200,
);
});
}}
>
{copied ? <Check /> : <Clipboard />}
</button>
{copyFailed && (
<span className="copy-error" role="status">
{t("copyFailed")}
</span>
)}
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions web/ui/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const resources = {
conversationTurns: "Conversation turns",
copiedMessage: "Copied",
copyMessage: "Copy message",
copyFailed: "Copy failed. Please select and copy the message manually.",
deleteWorkspace: "Delete workspace",
describeTask: "Describe a task",
editMessage: "Edit message",
Expand Down Expand Up @@ -95,6 +96,7 @@ const resources = {
conversationTurns: "会话轮次",
copiedMessage: "已复制",
copyMessage: "复制消息",
copyFailed: "复制失败,请选中消息后手动复制。",
deleteWorkspace: "删除工作区",
describeTask: "描述任务",
editMessage: "编辑消息",
Expand Down
61 changes: 61 additions & 0 deletions web/ui/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Adapted from QuinnWan's clipboard fallback in PR #352.
export async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Older browsers and non-secure origins may only support the click fallback.
}
const active = document.activeElement;
const selection = window.getSelection();
const ranges = selection
? Array.from({ length: selection.rangeCount }, (_, i) =>
selection.getRangeAt(i).cloneRange(),
)
: [];
const inputSelection =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? {
start: active.selectionStart,
end: active.selectionEnd,
direction: active.selectionDirection,
}
: null;
const area = document.createElement("textarea");
area.value = text;
area.readOnly = true;
area.style.cssText = "position:fixed;opacity:0;pointer-events:none";
try {
document.body.append(area);
area.focus({ preventScroll: true });
area.select();
return document.execCommand("copy");
} catch {
return false;
} finally {
area.remove();
if (active instanceof HTMLElement && active.isConnected) {
active.focus({ preventScroll: true });
if (
inputSelection &&
inputSelection.start !== null &&
inputSelection.end !== null &&
(active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement)
) {
active.setSelectionRange(
inputSelection.start,
inputSelection.end,
inputSelection.direction ?? undefined,
);
}
}
if (selection) {
selection.removeAllRanges();
for (const range of ranges) {
if (range.startContainer.isConnected && range.endContainer.isConnected)
selection.addRange(range);
}
}
}
}
Loading
Loading