From 73221ce5bd25b5f003efcb041a0d107d3c766805 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:14:42 +0200 Subject: [PATCH 01/23] fix(app): render local Markdown images with encoded paths (#6877) Decode assistant Markdown image destinations before resolving a filesystem read. Keep plain filesystem paths and direct image URIs unchanged so Markdown Preview and literal percent filenames are decoded only once. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../browser/agent-tab-image-stability.spec.ts | 37 ++++++++++++ .../e2e/support/helpers/assistant-images.ts | 3 +- packages/app/src/attachments/utils.test.ts | 12 ++++ packages/app/src/attachments/utils.ts | 11 +++- packages/app/src/components/message.tsx | 3 +- .../assistant-markdown-image-source.test.ts | 58 +++++++++++++++++++ 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/utils/assistant-markdown-image-source.test.ts diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 5f70bdafe3..841314343f 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/assistant-images"; import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import path from "node:path"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { @@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ }, }); +test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Angle destination image", + fileName: "My image Мои.png", + }); + const agent = await createSettledMockAgent(workspace, "Encoded image paths"); + await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, image); + + const encodedImage = { ...image, alt: "Encoded destination image" }; + await emitSettledAssistantImage( + workspace.client, + agent, + encodedImage, + encodeURI(path.join(workspace.repoPath, image.relativePath)), + ); + await expectAssistantImageRendered(page, encodedImage); + + const literalPercentImage = await createSmallAssistantPng(workspace, { + alt: "Literal percent filename image", + fileName: "literal%20name.png", + }); + await emitSettledAssistantImage( + workspace.client, + agent, + literalPercentImage, + encodeURI(literalPercentImage.relativePath), + ); + await expectAssistantImageRendered(page, literalPercentImage); +}); + test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 63bc182eaf..c4200c7c75 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -87,10 +87,11 @@ export async function emitSettledAssistantImage( client: SeedDaemonClient, agent: ArchiveTabAgent, image: AssistantImageFixture, + destination: string = image.relativePath, ): Promise { await client.sendAgentMessage( agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); const result = await client.waitForFinish(agent.id, 30_000); if (result.status !== "idle" || result.final?.lastError) { diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f2..9bf982c6e8 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -42,6 +42,18 @@ describe("fileUriToPath", () => { }); describe("localFileSourceToPath", () => { + it.each([ + "https://example.com/image%20name.png", + "data:image/png;base64,abc%20", + "blob:https://example.com/image%20name", + ])("preserves a direct Markdown image URI: %s", (source) => { + expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source); + }); + + it("preserves malformed percent escapes rather than throwing", () => { + expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png"); + }); + it("decodes markdown-encoded Windows drive-letter paths", () => { expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt"); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa0334359..46483bf0e7 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string { return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } -export function localFileSourceToPath(source: string): string { +export function localFileSourceToPath( + source: string, + // Markdown destinations are URI-encoded; plain filesystem paths are not. + { decodeHref = false }: { decodeHref?: boolean } = {}, +): string { + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } let path = source; if (source.startsWith("file://")) { path = fileUriToPath(source); - } else if (isMarkdownEncodedWindowsDrivePath(source)) { + } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) { path = decodeFilePathSource(source); } return normalizeWindowsDrivePath(path); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4587556ebe..7915832470 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -95,6 +95,7 @@ import { } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { localFileSourceToPath } from "@/attachments/utils"; import { AttachmentFrame, AttachmentLabel, @@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +}); From 6db47b3c37f702b8647a6c8667b685bfe74091f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 08:59:49 +0700 Subject: [PATCH 02/23] fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945) Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus. --- docs/floating-panels.md | 9 +- .../app/e2e/browser/composer-focus.spec.ts | 90 +++++++++++++++++++ .../e2e/browser/composer-whitespace.spec.ts | 12 +-- packages/app/e2e/support/helpers/composer.ts | 6 ++ packages/app/e2e/support/helpers/sidebar.ts | 38 ++++++++ packages/app/src/composer/index.tsx | 7 +- packages/app/src/composer/input/input.tsx | 26 +++--- packages/app/src/composer/keyboard-scope.tsx | 19 +++- 8 files changed, 181 insertions(+), 26 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index cb51c8f7dd..9241cec56d 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -217,7 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the user asks for the compact agent list or explorer, before the panel's motion settles. Pane focus itself stays put under those panels: the agent panel reads it as "the user is in this chat" for attention clearing, and opening the agent -list must not clear the attention it shows. +list must not clear the attention it shows. Focus restoration follows pane +focus, not the active composer. The editor keeps its focus intent only across +a blur that lands while its pane is not the user's (a retained tab hidden, a +sheet over it) and refocuses when the pane comes back. A blur while the pane is +theirs drops the intent in every layout mode, and the keyboard the compact +panels dismiss when they settle is such a blur, so closing the agent list or +explorer never reopens the keyboard. Keying restoration on the active composer +made that dismiss look like a hidden-tab blur and refocused on close. The band beside the inline editor where the fullscreen glyph sits is the input's own right padding, so a tap anywhere in it focuses the editor whether or not the glyph shows. On web the slot is a fixed box over the visible viewport, which is diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index b79cfd9255..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,13 +1,30 @@ import { expect, test, type Page } from "../support/fixtures"; import { + composerLocator, + blurComposerInput, + enterComposerFullscreen, + exitComposerFullscreen, expectComposerDraft, expectComposerFocused, + expectComposerFullscreenControls, expectComposerNotFocused, expectComposerVisible, + expectWorkspaceHeaderVisible, + resizeComposerViewport, submitMessage, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + closeMobileExplorer, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + expectMobileExplorerHidden, + expectMobileExplorerVisible, + swipeOpenMobileAgentSidebar, + swipeOpenMobileExplorer, +} from "../support/helpers/sidebar"; test("submitting a message leaves the composer ready for the next message", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => { } }); }); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 0c74fc7630..ca8704ed25 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -37,6 +37,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, } from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { @@ -165,17 +166,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } } }); -async function swipeOpenMobileAgentSidebar(page: Page): Promise { - const viewport = page.viewportSize(); - if (!viewport) throw new Error("viewport size is required"); - const y = Math.round(viewport.height / 2); - await page.mouse.move(8, y); - await page.mouse.down(); - await page.mouse.move(40, y, { steps: 4 }); - await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); - await page.mouse.up(); -} - /** Freeze the page's timers and animation frames until `page.clock.resume()`. */ async function pausePageClock(page: Page): Promise { const now = Date.now(); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index efd9266657..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise { await expect(composerInput(page)).not.toBeFocused(); } +/** Blur the editor without anything else taking focus. */ +export async function blurComposerInput(page: Page): Promise { + await composerInput(page).blur(); + await expectComposerNotFocused(page); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 1909b4f31b..500ce8bd41 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise export async function expectMobileAgentSidebarHidden(page: Page): Promise { await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } + +/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ +export async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Drag from the right edge to open the compact Explorer over the agent. */ +export async function swipeOpenMobileExplorer(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(viewport.width - 8, y); + await page.mouse.down(); + await page.mouse.move(viewport.width - 40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 }); + await page.mouse.up(); +} + +export async function closeMobileExplorer(page: Page): Promise { + const closeButton = page.getByTestId("explorer-close"); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); +} + +export async function expectMobileExplorerVisible(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); +} + +export async function expectMobileExplorerHidden(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 21bf7f6679..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) { // A compact overlay panel (agent list, explorer) covers the pane without taking its // focus. The composer gives up input from the moment the user asks for the panel, // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. const isCompact = useIsCompactFormFactor(); const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a1b8c0fdbb..01051bd52c 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -524,8 +524,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -1236,7 +1236,7 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); - const { isActiveComposer } = useComposerKeyboardScope(); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -1264,8 +1264,8 @@ export const MessageInput = forwardRef( const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); - const activeComposerRef = useRef(isActiveComposer); - activeComposerRef.current = isActiveComposer; + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); @@ -1285,9 +1285,13 @@ export const MessageInput = forwardRef( webTextareaRef.current = element; setWebTextArea(element); }, []); + // Focus intent outlives a blur only while the pane is not the user's pane (a retained + // tab hidden, a sheet over it); the pane coming back restores focus. A blur while the + // pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard, + // or a compact overlay panel did on their behalf, and nothing later refocuses. useLayoutEffect(() => { - if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); - }, [isActiveComposer]); + if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus(); + }, [isPaneFocused]); // The compact and desktop screens mount the editor in different trees, so a // viewport that crosses the breakpoint remounts it. The draft owner brings the // text back; the session brings the caret and focus intent back. On web the @@ -1305,7 +1309,7 @@ export const MessageInput = forwardRef( textInputRef.current?.setSelection(selection); if (retained.focused) { focusIntentRef.current = true; - if (activeComposerRef.current) textInputRef.current?.focus(); + if (paneFocusedRef.current) textInputRef.current?.focus(); } } return () => { @@ -1807,13 +1811,11 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { - // Fullscreen is nothing but the editor, so a blur there (a retained tab being - // hidden, the keyboard dismissed) keeps the intent: showing it again refocuses. - if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [isFullscreen, onFocusChange]); + }, [onFocusChange]); const handlePasteError = useCallback( (message: string) => { diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..20b57dde28 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,35 @@ import { } from "react"; interface ComposerKeyboardScopeValue { + /** + * The pane this composer sits in is the user's pane. Focus restoration follows it: + * the editor keeps its focus intent across blurs that happen while the pane is not + * the user's (a retained tab hidden, a sheet over it) and brings focus back when + * the pane is theirs again. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children} From 8ff2424da1ae807999fd84fa61b10250a97e34ed Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:08:39 +0200 Subject: [PATCH 03/23] Return checkout diff hunks from subdirectory workspaces (#6882) * fix(git): return checkout diff hunks from subdirectory workspaces Checkout paths are repository-relative, but one-shot diff reads used the workspace subdirectory for Git pathspecs. Resolve the worktree root once so patch selection and file reads use the same coordinates as the live subscription. * test(server): await bounded discard fixture cleanup on Windows The discard assertions pass but Windows can retain a directory lock during cleanup. Await asynchronous removal with bounded retries after daemon shutdown, preserving failure for persistent locks. Matches the verified cleanup correction in #6912 for #6918. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../checkout-diff-content.e2e.test.ts | 75 ++++++++++++++++++- .../server/daemon-e2e/filesystem.e2e.test.ts | 5 +- packages/server/src/utils/checkout-git.ts | 2 +- 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index beffa3b4ce..a69a2c3c2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index f0cd4495e1..75302d4168 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)( }, 60_000, ); + +test.each( + (["uncommitted", "base"] as const).flatMap((mode) => + (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), + ), +)( + "$mode $shape diff includes hunks from a repository subdirectory", + async ({ mode, shape }) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const subdirectory = join(cwd, "sub"); + mkdirSync(subdirectory); + const unchangedLines = "unchanged\n".repeat(10); + writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "old sibling\n"); + } + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + if (mode === "base") { + git(cwd, "checkout", "-b", "feature"); + } + const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; + if (shape === "rename") { + git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); + } + writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); + } + if (mode === "base") { + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 0c99744e62..da52c4dac4 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -8,6 +8,7 @@ import { readdirSync, readlinkSync, } from "fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "os"; import path from "path"; import { execFileSync } from "node:child_process"; @@ -100,7 +101,9 @@ describe("checkout discard", () => { } } finally { // afterEach shuts down the daemon and its repository observers first. - onTestFinished(() => rmSync(cwd, { recursive: true, force: true })); + onTestFinished(() => + rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }), + ); } }, ); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index bda89506b5..64148c26e4 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3423,7 +3423,7 @@ export async function getCheckoutDiff( compare: CheckoutDiffCompare, context?: CheckoutContext, ): Promise { - await requireGitRepo(cwd); + cwd = await requireGitWorktreeRoot(cwd); const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); if (!refsForDiff) { From 9ab99a6e1e004fdcffde43203e8c7adebf971f85 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 11:19:32 +0700 Subject: [PATCH 04/23] feat(website): show sponsors on paseo.sh and at the top of the README (#6956) --- README.ja.md | 17 ++- README.ko.md | 17 ++- README.md | 17 ++- README.zh-CN.md | 17 ++- packages/website/e2e/sponsors.spec.ts | 20 ++++ .../public/sponsors/hoplite-on-dark.svg | 1 + .../public/sponsors/hoplite-on-light.svg | 1 + .../src/components/docs-source-footer.tsx | 13 +- .../website/src/components/landing-page.tsx | 4 +- .../website/src/components/site-footer.tsx | 6 +- .../website/src/components/sponsorship.tsx | 112 +++++++++++------- packages/website/src/data/sponsors.test.ts | 64 ++++++++++ packages/website/src/data/sponsors.ts | 56 ++++++--- packages/website/src/routes/docs.tsx | 2 + packages/website/src/styles.css | 17 --- 15 files changed, 271 insertions(+), 93 deletions(-) create mode 100644 packages/website/e2e/sponsors.spec.ts create mode 100644 packages/website/public/sponsors/hoplite-on-dark.svg create mode 100644 packages/website/public/sponsors/hoplite-on-light.svg create mode 100644 packages/website/src/data/sponsors.test.ts diff --git a/README.ja.md b/README.ja.md index c2d1ad88a0..5d1c8f5ebb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -53,6 +53,17 @@ - **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。 - **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。 +## スポンサー + + + + + + + + +
Hoplite
Cloud coding agents
Paseo のスポンサーになるPaseo のスポンサーになる
+ ## はじめかた Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## スポンサー +## Paseo を支援する -Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 - - +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。 ## 関連プロジェクト diff --git a/README.ko.md b/README.ko.md index 60581767cd..7f2098221d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,6 +47,17 @@ - **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다. - **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. +## 스폰서 + + + + + + + + +
Hoplite
Cloud coding agents
Paseo 스폰서 되기Paseo 스폰서 되기
+ ## 시작하기 Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다. @@ -161,11 +172,9 @@ npm run build:server npm run typecheck ``` -## 스폰서 +## Paseo 후원하기 -Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. - - +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다. ## 관련 프로젝트 diff --git a/README.md b/README.md index 2fc0fc8f3d..91310cd7c7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web [Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. +## Sponsors + + + + + + + + +
Hoplite
Cloud coding agents
Become a sponsorBecome a sponsor
+ ## Plugins Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, @@ -207,11 +218,9 @@ npm run build:server npm run typecheck ``` -## Sponsors +## Support Paseo -Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. - - +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs. ## Related projects diff --git a/README.zh-CN.md b/README.zh-CN.md index dfdf673b2e..745071f0fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,17 @@ - **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。 - **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。 +## 赞助商 + + + + + + + + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 Paseo 赞助商
+ ## 快速开始 Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## 赞助 +## 支持 Paseo -Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 - - +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 ## 相关项目 diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts new file mode 100644 index 0000000000..5dc9aae92d --- /dev/null +++ b/packages/website/e2e/sponsors.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "playwright/test"; + +const PAGES = ["/", "/docs/connectivity", "/changelog"]; + +for (const path of PAGES) { + test(`${path} shows the sponsors and invites the next one`, async ({ page }) => { + await page.goto(path); + + const sponsors = page.getByRole("region", { name: /^(Sponsors|Sponsored by)$/ }); + await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute( + "href", + "https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible(); + await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute( + "href", + "/sponsor#spot", + ); + }); +} diff --git a/packages/website/public/sponsors/hoplite-on-dark.svg b/packages/website/public/sponsors/hoplite-on-dark.svg new file mode 100644 index 0000000000..55b1da0592 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/website/public/sponsors/hoplite-on-light.svg b/packages/website/public/sponsors/hoplite-on-light.svg new file mode 100644 index 0000000000..144d1a59c9 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-light.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/components/docs-source-footer.tsx b/packages/website/src/components/docs-source-footer.tsx index 69a9711e80..2b5ea3f131 100644 --- a/packages/website/src/components/docs-source-footer.tsx +++ b/packages/website/src/components/docs-source-footer.tsx @@ -1,3 +1,4 @@ +import { ExternalLink } from "lucide-react"; import type { Doc } from "~/docs"; const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main"; @@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) { const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`; return ( -