mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80833a376b | ||
|
|
e85810e295 | ||
|
|
e3db7c3397 | ||
|
|
fe10c40b9a | ||
|
|
b6e8ca8974 | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f |
+1
-1
@@ -1 +1 @@
|
||||
sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0=
|
||||
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
|
||||
|
||||
@@ -155,6 +155,19 @@ async function selectAssistantElementFromEndOf(
|
||||
);
|
||||
}
|
||||
|
||||
/** A drag over an image alone selects it inside its rendered frame. */
|
||||
async function selectAssistantImage(page: Page): Promise<void> {
|
||||
await assistantMessageBlocks(page)
|
||||
.locator("img")
|
||||
.evaluate((element) => {
|
||||
const range = document.createRange();
|
||||
range.selectNode(element);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||
await selectAssistantTextRange(page, text, text);
|
||||
}
|
||||
@@ -649,3 +662,50 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "assistant-selection-copy-image-",
|
||||
title: "Assistant selection copy image",
|
||||
initialPrompt: "Render the image fixture.",
|
||||
featureValues: {
|
||||
mockAssistantResponse: [
|
||||
"Before the image.",
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
"After the image.",
|
||||
].join("\n"),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await allowRichClipboard(context);
|
||||
await agent.client.waitForAgentUpsert(
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
|
||||
|
||||
await selectAssistantTextRange(page, "Before the image.", "After the image.");
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(
|
||||
`Before the image.\n\n\n\nAfter the image.`,
|
||||
);
|
||||
|
||||
await selectAssistantImage(page);
|
||||
await copySelection(page);
|
||||
|
||||
expect((await readRichClipboard(page)).plainText).toBe(``);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function useLanguage(page: Page, language: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, language: value }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ language: value }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, language },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the placeholder out the way the textarea does, in a mirror element with
|
||||
* the same box and font, and reports whether any of its lines reach the hint.
|
||||
*/
|
||||
async function placeholderOverlaps(input: Locator, hint: Locator): Promise<boolean> {
|
||||
const hintBox = await hint.boundingBox();
|
||||
if (!hintBox) throw new Error("Focus hint has no layout box");
|
||||
return input.evaluate((element, box) => {
|
||||
const textarea = element as HTMLTextAreaElement;
|
||||
const style = getComputedStyle(textarea);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const mirror = document.createElement("div");
|
||||
for (const property of [
|
||||
"boxSizing",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"textIndent",
|
||||
"wordSpacing",
|
||||
] as const) {
|
||||
mirror.style[property] = style[property];
|
||||
}
|
||||
Object.assign(mirror.style, {
|
||||
position: "fixed",
|
||||
left: `${rect.left}px`,
|
||||
top: `${rect.top}px`,
|
||||
width: `${rect.width}px`,
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "break-word",
|
||||
visibility: "hidden",
|
||||
});
|
||||
const text = document.createTextNode(textarea.placeholder);
|
||||
mirror.appendChild(text);
|
||||
document.body.appendChild(mirror);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const lines = Array.from(range.getClientRects());
|
||||
mirror.remove();
|
||||
return lines.some(
|
||||
(line) =>
|
||||
line.right > box.x &&
|
||||
line.left < box.x + box.width &&
|
||||
line.bottom > box.y &&
|
||||
line.top < box.y + box.height,
|
||||
);
|
||||
}, hintBox);
|
||||
}
|
||||
|
||||
const FRENCH_LAYOUTS = [
|
||||
{ name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" },
|
||||
{
|
||||
name: "desktop width",
|
||||
width: 1024,
|
||||
placeholder:
|
||||
"Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills",
|
||||
},
|
||||
];
|
||||
|
||||
for (const layout of FRENCH_LAYOUTS) {
|
||||
test.describe(`French composer at ${layout.name}`, () => {
|
||||
test.use({ viewport: { width: layout.width, height: 844 } });
|
||||
|
||||
test("the focus hint does not cover the placeholder", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-focus-hint-",
|
||||
title: "Composer focus hint",
|
||||
});
|
||||
|
||||
try {
|
||||
await useLanguage(page, "fr");
|
||||
await openAgentRoute(page, agent);
|
||||
const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first();
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveAttribute("placeholder", layout.placeholder);
|
||||
await input.evaluate((element) => (element as HTMLTextAreaElement).blur());
|
||||
|
||||
const hint = page.getByText(/pour saisir$/).first();
|
||||
await expect(hint).toBeVisible();
|
||||
// The input takes the hint's width on the render after the hint lays out.
|
||||
await expect.poll(() => placeholderOverlaps(input, hint)).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -268,10 +268,16 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
|
||||
const refreshed = gate();
|
||||
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
|
||||
const card = contextWindowDetails(page);
|
||||
await refreshUsageCard(card, "Claude");
|
||||
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
|
||||
// Past the card's close grace, with the pointer still on the card.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(card).toBeVisible();
|
||||
refreshed.open();
|
||||
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
@@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
try {
|
||||
@@ -445,6 +445,11 @@ test.describe("Sidebar workspace list", () => {
|
||||
await expectWorkspaceHoverCardStaysOpen(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
|
||||
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await page.keyboard.press("Tab");
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -411,6 +411,20 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(content?.html).not.toContain("<pre>");
|
||||
});
|
||||
|
||||
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
|
||||
const message = mountFixture();
|
||||
const frame = document.createElement("div");
|
||||
frame.setAttribute("data-paseo-markdown-tag", "img");
|
||||
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
|
||||
frame.setAttribute("data-paseo-markdown-alt", "chart");
|
||||
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
|
||||
message.prepend(frame);
|
||||
|
||||
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("copies code without a fence when every character is selected inside the block", () => {
|
||||
const message = mountFixture();
|
||||
const blockCode = fixtureElement(
|
||||
@@ -674,7 +688,9 @@ describe("assistant selection copy inside highlighted code", () => {
|
||||
const message = mountHighlighted();
|
||||
const image = document.createElement("div");
|
||||
image.setAttribute("data-paseo-markdown-tag", "p");
|
||||
image.innerHTML = '<div><img src="https://example.test/chart.png" alt="chart"></div>';
|
||||
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
|
||||
image.innerHTML =
|
||||
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
|
||||
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
|
||||
const before = tokenText(message, "Before the block.");
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from "@/utils/rich-clipboard";
|
||||
import {
|
||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||
MARKDOWN_COPY_SRC_ATTRIBUTE,
|
||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||
TRAILING_CODE_LINE_BREAKS,
|
||||
@@ -293,7 +295,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
||||
return false;
|
||||
}
|
||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
||||
if (tag === "p" || isTableStructure(tag)) {
|
||||
// An image has no part to select, so touching it selects all of it.
|
||||
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
|
||||
return true;
|
||||
}
|
||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||
@@ -430,7 +433,7 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
|
||||
if (fragment.textContent) {
|
||||
return true;
|
||||
}
|
||||
const visibleVoidSelector = ["br", "hr"]
|
||||
const visibleVoidSelector = ["br", "hr", "img"]
|
||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||
.concat("img")
|
||||
.join(",");
|
||||
@@ -526,9 +529,13 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
||||
continue;
|
||||
}
|
||||
const semanticElement = document.createElement(tagName);
|
||||
if (tagName !== "br") {
|
||||
if (tagName !== "br" && tagName !== "img") {
|
||||
semanticElement.append(...element.childNodes);
|
||||
}
|
||||
if (tagName === "img") {
|
||||
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
|
||||
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
|
||||
}
|
||||
if (tagName === "ol") {
|
||||
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
||||
if (start) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
|
||||
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
|
||||
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
|
||||
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
|
||||
|
||||
/**
|
||||
* Trailing line breaks, with any indentation that followed the last one.
|
||||
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image copies as the Markdown it came from. The rendered `img` points at a preview
|
||||
* URL for data and workspace images, and carries no alt text of its own.
|
||||
*/
|
||||
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
|
||||
return {
|
||||
paseoMarkdownTag: "img",
|
||||
paseoMarkdownSrc: source,
|
||||
...(alt ? { paseoMarkdownAlt: alt } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
|
||||
const alignment =
|
||||
typeof style === "string"
|
||||
|
||||
@@ -108,6 +108,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import {
|
||||
markdownCopyDataSet,
|
||||
markdownCopyImageDataSet,
|
||||
markdownCopyOrderedListDataSet,
|
||||
markdownCopyTableCellDataSet,
|
||||
type MarkdownCopyInlineTag,
|
||||
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
|
||||
],
|
||||
[containerStyle, imageSizeStyle],
|
||||
);
|
||||
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={frameStyle} dataSet={copyDataSet}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -82,6 +82,8 @@ function WebHoverCard({
|
||||
const contentRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||
const keyboardFocusedRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -176,16 +178,20 @@ function WebHoverCard({
|
||||
setOpen(false);
|
||||
};
|
||||
const focusEntered = (event: FocusEvent) => {
|
||||
const target = event.target as Node;
|
||||
const target = event.target as Element;
|
||||
keyboardFocusedRef.current = target.matches(":focus-visible");
|
||||
if (trigger.contains(target)) {
|
||||
openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
};
|
||||
// Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button
|
||||
// that disables itself on press drops pointer focus while the pointer is still on the card.
|
||||
const focusLeft = (event: FocusEvent) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (
|
||||
keyboardFocusedRef.current &&
|
||||
!trigger.contains(next) &&
|
||||
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
|
||||
) {
|
||||
|
||||
@@ -608,18 +608,14 @@ function MessageInputOverlay({
|
||||
}
|
||||
|
||||
function FocusHint({
|
||||
visible,
|
||||
focusInputKeys,
|
||||
label,
|
||||
onLayout,
|
||||
}: {
|
||||
visible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
label: string;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}) {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null;
|
||||
return (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
@@ -654,6 +650,25 @@ interface ComposerTextSurfaceProps {
|
||||
* state of this composer rather than a second one.
|
||||
*/
|
||||
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const [focusHintWidth, setFocusHintWidth] = useState(0);
|
||||
const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setFocusHintWidth(event.nativeEvent.layout.width);
|
||||
}, []);
|
||||
const isFocusHintShown =
|
||||
isActiveComposer &&
|
||||
props.focusHintVisible &&
|
||||
Boolean(props.focusInputKeys) &&
|
||||
props.focusHintLabel.trim().length > 0;
|
||||
// The hint sits over the input's top-right corner, so the placeholder wraps
|
||||
// before it instead of running underneath.
|
||||
const textInputStyle = useMemo(
|
||||
() =>
|
||||
isFocusHintShown
|
||||
? [props.textInputStyle, { paddingRight: focusHintWidth }]
|
||||
: props.textInputStyle,
|
||||
[focusHintWidth, isFocusHintShown, props.textInputStyle],
|
||||
);
|
||||
if (props.readOnly) {
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
@@ -674,7 +689,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
style={props.textInputStyle}
|
||||
style={textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={props.scrollEnabled}
|
||||
editable={props.editable}
|
||||
@@ -684,11 +699,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
onPasteError={props.onPasteError}
|
||||
autoFocus={props.autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={props.focusHintVisible}
|
||||
focusInputKeys={props.focusInputKeys}
|
||||
label={props.focusHintLabel}
|
||||
/>
|
||||
{isFocusHintShown ? (
|
||||
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1955,6 +1968,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
paddingLeft: theme.spacing[2],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.5,
|
||||
|
||||
@@ -183,6 +183,23 @@ describe("translation resources", () => {
|
||||
expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님");
|
||||
});
|
||||
|
||||
it("uses the French navigation and Git meanings for reported labels", () => {
|
||||
expect(fr.common.back).toBe("Retour");
|
||||
expect(fr.common.actions.back).toBe("Retour");
|
||||
expect(fr.common.actions.copy).toBe("Copier");
|
||||
expect(fr.common.states.starting).toBe("Démarrage…");
|
||||
expect(fr.common.connectionStatus.connecting).toBe("Connexion…");
|
||||
expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications");
|
||||
expect(fr.sidebar.display.show.checks).toBe("Vérifications");
|
||||
});
|
||||
|
||||
it("separates French interpolation placeholders from neighboring words", () => {
|
||||
const glued = Object.entries(flattenStrings(fr)).filter(([, value]) =>
|
||||
/\p{L}\{\{|\}\}\p{L}{2,}/u.test(value),
|
||||
);
|
||||
expect(glued).toEqual([]);
|
||||
});
|
||||
|
||||
it("labels the immediate add-to-chat action without an ellipsis", () => {
|
||||
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
|
||||
});
|
||||
|
||||
+1144
-1147
File diff suppressed because it is too large
Load Diff
@@ -163,7 +163,7 @@ export const pluginSettings = {
|
||||
globalEnabled: "Plugins activés",
|
||||
globalDisabled: "Plugins désactivés",
|
||||
},
|
||||
removeConfirmTitle: "Supprimer {{id}} ?",
|
||||
removeConfirmTitle: "Supprimer {{id}} ?",
|
||||
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||
states: {
|
||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
|
||||
function terminalEvents(events: AgentStreamEvent[]) {
|
||||
return events.filter(
|
||||
@@ -210,6 +211,172 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("ends the turn when the user dismisses a question", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let form: FormInfo | null = null;
|
||||
harness.api.session.form.list = async () => (form ? [form] : []);
|
||||
harness.api.session.form.cancel = async () => {
|
||||
form = null;
|
||||
harness.active = false;
|
||||
// OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason.
|
||||
harness.push({
|
||||
id: "dismissed",
|
||||
created: 3,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "session", seq: 3, version: 1 },
|
||||
data: { sessionID: "session", reason: "shutdown" },
|
||||
});
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.startTurn("ask me something");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(1);
|
||||
form = {
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Color",
|
||||
fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }],
|
||||
};
|
||||
harness.push({
|
||||
id: "form",
|
||||
created: 2,
|
||||
type: "form.created",
|
||||
data: { form },
|
||||
});
|
||||
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
await session.respondToPermission("question", { behavior: "deny" });
|
||||
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
|
||||
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" });
|
||||
|
||||
await session.startTurn("next message");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(2);
|
||||
harness.finishExecution();
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(2);
|
||||
expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("ends the turn when the user denies a tool permission", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = null;
|
||||
harness.api.permission.list = async () => (permission ? [permission] : []);
|
||||
harness.api.permission.reply = async () => {
|
||||
permission = null;
|
||||
harness.active = false;
|
||||
// OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason.
|
||||
harness.push({
|
||||
id: "denied",
|
||||
created: 3,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "session", seq: 3, version: 1 },
|
||||
data: { sessionID: "session", reason: "shutdown" },
|
||||
});
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.startTurn("run printf");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(1);
|
||||
permission = {
|
||||
id: "approval",
|
||||
sessionID: "session",
|
||||
action: "shell",
|
||||
resources: ["printf TOOL_PERMISSION_QA"],
|
||||
};
|
||||
harness.push({
|
||||
id: "asked",
|
||||
created: 2,
|
||||
type: "permission.asked",
|
||||
data: permission,
|
||||
});
|
||||
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
await session.respondToPermission("approval", { behavior: "deny" });
|
||||
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
|
||||
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" });
|
||||
|
||||
await session.startTurn("next message");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(2);
|
||||
harness.finishExecution();
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(2);
|
||||
expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the turn resumable after the user denies a child session's permission", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
const child = { ...harness.info, id: "child", parentID: "session" };
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input?.parentID === "session" ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
let permission: Awaited<ReturnType<V2Api["permission"]["list"]>>[number] | null = {
|
||||
id: "child-approval",
|
||||
sessionID: "child",
|
||||
action: "shell",
|
||||
resources: ["pwd"],
|
||||
};
|
||||
harness.api.permission.list = async (input) =>
|
||||
permission && input.sessionID === "child" ? [permission] : [];
|
||||
harness.api.permission.reply = async () => {
|
||||
permission = null;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.startTurn("delegate work");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(1);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
await session.respondToPermission("child-approval", { behavior: "deny" });
|
||||
|
||||
harness.active = false;
|
||||
harness.push({
|
||||
id: "shutdown",
|
||||
created: 3,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "session", seq: 3, version: 1 },
|
||||
data: { sessionID: "session", reason: "shutdown" },
|
||||
});
|
||||
const reads = harness.activeReads;
|
||||
harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} });
|
||||
await expect.poll(() => harness.activeReads).toBeGreaterThan(reads);
|
||||
expect(terminalEvents(events)).toEqual([]);
|
||||
harness.startExecution();
|
||||
harness.finishExecution();
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
|
||||
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
|
||||
@@ -33,6 +33,12 @@ export class SessionPermissions {
|
||||
list() {
|
||||
return [...this.pending.values()];
|
||||
}
|
||||
isOwnedBy(requestId: string, sessionID: string) {
|
||||
return (
|
||||
this.forms.get(requestId)?.sessionID === sessionID ||
|
||||
this.permissionOwners.get(requestId) === sessionID
|
||||
);
|
||||
}
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
|
||||
const form = this.forms.get(requestId);
|
||||
if (form) {
|
||||
|
||||
@@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
|
||||
await this.reconnectIfExited();
|
||||
const deniesOwnRequest =
|
||||
response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id);
|
||||
await this.permissions.respondToPermission(requestId, response);
|
||||
if (deniesOwnRequest) this.turns.requestDenied();
|
||||
}
|
||||
private async reconcileSnapshot() {
|
||||
const [info, history] = await Promise.all([
|
||||
|
||||
@@ -60,6 +60,7 @@ interface Turn {
|
||||
completion: Promise<void>;
|
||||
settle(): void;
|
||||
accepted: boolean;
|
||||
requestDenied: boolean;
|
||||
}
|
||||
|
||||
export class SessionTurns {
|
||||
@@ -112,6 +113,7 @@ export class SessionTurns {
|
||||
settle,
|
||||
accepted,
|
||||
output,
|
||||
requestDenied: false,
|
||||
};
|
||||
this.turn = turn;
|
||||
this.execution = null;
|
||||
@@ -162,6 +164,13 @@ export class SessionTurns {
|
||||
this.sequence = event.durable.seq;
|
||||
this.requestReconciliation();
|
||||
}
|
||||
// OpenCode ends the execution when the user denies its permission or dismisses its question,
|
||||
// but reports the interruption as "shutdown", the same reason as an execution it will resume.
|
||||
requestDenied() {
|
||||
if (!this.turn) return;
|
||||
this.turn.requestDenied = true;
|
||||
this.requestReconciliation();
|
||||
}
|
||||
private requestReconciliation() {
|
||||
void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error));
|
||||
}
|
||||
@@ -216,7 +225,9 @@ export class SessionTurns {
|
||||
this.sequence = execution.durable.seq;
|
||||
if (
|
||||
execution.type === "session.execution.started" ||
|
||||
(execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown")
|
||||
(execution.type === "session.execution.interrupted" &&
|
||||
execution.data.reason === "shutdown" &&
|
||||
!turn.requestDenied)
|
||||
)
|
||||
return;
|
||||
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.
|
||||
|
||||
Reference in New Issue
Block a user