From 385cc0a4c669aeae786ad47bfe3ecbbc352eff24 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:25:23 -0300 Subject: [PATCH 1/5] fix(web): show message copy buttons on touch devices (#11020) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e9635f89e4..7764f64ea4b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1582,7 +1582,7 @@ function UserTimelineRow({ row }: { row: Extract -
+
}> @@ -1734,7 +1734,7 @@ function AssistantMessageMeta({ "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200", alwaysVisible ? "opacity-100" - : "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100", + : "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100", className, )} > From d1eeb16247a0bd2eca8bbbfa5ab777e096af949c Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:25:56 -0300 Subject: [PATCH 2/5] fix(web): middle-click pastes in the terminal on Linux (#11018) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.test.ts | 29 ++++++++++- apps/web/src/terminal/ghostty/surface.ts | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7174261e67f..59150ee320a 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number, shiftKey = false) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY: 5, pointerId: 1, - button: 0, + button, buttons, shiftKey, }), @@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { + const harness = createHarness(); + const readText = vi.fn(async () => "clipboard text"); + vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } }); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); + expect(surface.getSelection()).toBe("hello"); + + // Without a selection there is no primary buffer to paste; the clipboard + // holds what the user copied and must not be substituted. + surface.clearSelection(); + harness.pointer("pointerdown", 5, 4, false, 1); + expect(readText).not.toHaveBeenCalled(); + }); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 6a069902918..be62ede4d06 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +/** + * Middle-click paste is an X11/Wayland convention. macOS and Windows have no + * primary selection and use the button for autoscroll, so only desktops that + * expect the gesture get it. + */ +function isMiddleClickPastePlatform(): boolean { + return /linux|bsd/i.test(navigator.platform); +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { if (encoded.length > 0) this.options.onData(encoded); } + /** + * Middle-click pastes the terminal's own selection, which is the only + * primary-selection-like buffer a browser can read. It goes through + * pasteFromClipboard so it joins the same paste race as every other path. + * With nothing selected here there is no buffer to paste, and CLIPBOARD is + * deliberately not substituted: middle-click must never emit text the user + * only ever copied. + */ + private pasteTerminalSelection(): void { + const selection = this.getSelection(); + if (selection.length === 0) return; + void this.pasteFromClipboard(() => Promise.resolve(selection)); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + if (event.button === 1 && isMiddleClickPastePlatform()) { + // Left uncancelled on purpose: cancelling pointerdown drops the + // compatibility mousedown, which is what activates a split pane. + this.pasteTerminalSelection(); + return; + } if (event.button !== 0) return; const clickCount = this.recordSelectionClick(event); const link = this.linkAt(event.clientX, event.clientY); @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.button === 1 && isMiddleClickPastePlatform()) { + event.preventDefault(); + return; + } if (event.button !== 0) return; if (!this.selectionMoved && this.selectionMode === "cell") { this.clearSelection(); @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - if (event.button === 0) event.preventDefault(); + // Cancelling the middle button here stops autoscroll while still letting + // the event bubble to the drawer handler that activates a split pane. + if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { + event.preventDefault(); + } this.focus(); }; + /** + * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and + * the hidden textarea is focused, so leaving the default alive would deliver + * a second paste through onPaste on top of the one onPointerDown sent. + */ + private readonly onMouseUp = (event: MouseEvent) => { + if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); + }; + private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mouseup", this.onMouseUp); this.canvas.addEventListener("contextmenu", this.onContextMenu); this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("contextmenu", this.onContextMenu); this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); From 0f602b3372b300ae94084bd3fe7dbaadaa58ba3a Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:26:51 -0300 Subject: [PATCH 3/5] fix(editors): open remote projects in Zed (#11022) Co-authored-by: Claude Opus 5 (1M context) --- .../src/electron/ElectronShell.test.ts | 30 ++++++++++++++++++- apps/desktop/src/electron/ElectronShell.ts | 16 ++++++---- apps/web/src/remoteOpen.test.ts | 12 +++++++- packages/contracts/src/editor.ts | 23 ++++++++++---- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0..75eea216df2 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -88,6 +88,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -100,9 +127,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index cda9c2567b3..2089be58c0d 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, +// `zed://ssh//`) must reach the OS handler; every other non-web +// scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3d..6f7c17d8177 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds Zed's ssh deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 72efd84a14d..6331a544d34 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -13,7 +13,8 @@ type EditorDefinition = { /** * URL scheme for editors that support VS Code's remote deep links * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. + * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own + * `zed://ssh/` shape. */ readonly remoteScheme?: string; }; @@ -49,7 +50,13 @@ export const EDITORS = [ launchStyle: "goto", remoteScheme: "vscodium", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { }; /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds a `://vscode-remote/ssh-remote+` deep link (Zed + * takes `zed://ssh/`) that opens `absolutePath` on `host` in the + * local editor over SSH. Returns undefined for editors without remote + * deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + return input.editor === "zed" + ? `${scheme}://ssh/${encodedHost}${encodedPath}` + : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /** From bb5e824c9fcbd76c93ef15b304f89f8b6999f32c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 02:18:01 -0300 Subject: [PATCH 4/5] feat: add blue and orange diff color palette (#10671) --- apps/web/src/components/GitActionsControl.tsx | 8 +-- apps/web/src/components/Sidebar.tsx | 4 +- .../web/src/components/chat/DiffStatLabel.tsx | 4 +- .../pullRequest/pullRequestPresentation.tsx | 6 +-- .../components/settings/SettingsPanels.tsx | 51 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/index.css | 26 ++++++++++ apps/web/src/lib/diffRendering.ts | 24 ++++----- apps/web/src/routes/__root.tsx | 5 ++ packages/contracts/src/settings.test.ts | 17 +++++++ packages/contracts/src/settings.ts | 6 +++ 11 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f816f60b402..aa3767a3155 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1878,9 +1878,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1891,11 +1891,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78c59e296b9..79573635212 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1932,8 +1932,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {diff ? ( - +{diff.insertions}{" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} -