From f8fe5d3a89fd48bcd7b5791532e88d0f259ca261 Mon Sep 17 00:00:00 2001
From: Kiet <31864905+Kitenite@users.noreply.github.com>
Date: Sat, 25 Apr 2026 12:43:03 -0700
Subject: [PATCH 1/3] fix(desktop): drop branch row from v2 sidebar workspace
item (#3733)
Workspace name already falls back to branch when missing, so the dedicated branch row was redundant. Slightly bumps row padding for breathing room.
---
.../DashboardSidebarExpandedWorkspaceRow.tsx | 142 +++++++++---------
1 file changed, 68 insertions(+), 74 deletions(-)
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx
index 77496cadfc0..c2cddefe1eb 100644
--- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx
+++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx
@@ -122,7 +122,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
"relative flex w-full items-center pl-3 pr-2 text-left text-sm",
onClick && "cursor-pointer hover:bg-muted/50",
"group",
- "py-1.5",
+ "py-2",
isActive && "bg-muted",
className,
)}
@@ -211,86 +211,80 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
-
-
- {isRenaming ? (
-
- ) : (
+
+ {isRenaming ? (
+
+ ) : (
+
+ {name || branch}
+
+ )}
+
+
+ {creationStatusText ? (
- {name || branch}
+ {creationStatusText}
- )}
-
-
- {creationStatusText ? (
-
+ {diffStats &&
+ (diffStats.additions > 0 || diffStats.deletions > 0) && (
+
)}
- >
- {creationStatusText}
-
- ) : (
- <>
- {diffStats &&
- (diffStats.additions > 0 || diffStats.deletions > 0) && (
-
+ {shortcutLabel && (
+
+ {shortcutLabel}
+
+ )}
+
+
+
+
+
+
- )}
-
- {shortcutLabel && (
-
- {shortcutLabel}
-
- )}
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
- {branch}
-
+
+
+
+ >
+ )}
From 2640352cdeecfc946c5b782604813b4b0d9eefce Mon Sep 17 00:00:00 2001
From: Kiet <31864905+Kitenite@users.noreply.github.com>
Date: Sat, 25 Apr 2026 13:13:27 -0700
Subject: [PATCH 2/3] feat(desktop): add fade-edge mask utilities (#3735)
* feat(desktop): add fade-edge mask utilities
Add composable `.fade-edge-{t,r,b,l}` utility classes that fade out one
or more edges of an element via a linear-gradient mask. Useful for
indicating horizontal/vertical scroll affordance.
Lives in `apps/desktop/src/renderer/styles/fade-edge.css` (single
import in `globals.css`) so the file can be promoted to `packages/ui`
once a second consumer needs it.
* style: blank line before mask-image declaration
---
apps/desktop/src/renderer/globals.css | 1 +
.../desktop/src/renderer/styles/fade-edge.css | 70 +++++++++++++++++++
2 files changed, 71 insertions(+)
create mode 100644 apps/desktop/src/renderer/styles/fade-edge.css
diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css
index a541e0d7b31..c841b9c1b16 100644
--- a/apps/desktop/src/renderer/globals.css
+++ b/apps/desktop/src/renderer/globals.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "streamdown/styles.css";
+@import "./styles/fade-edge.css";
@source "./**/*.{ts,tsx}";
@source "../../../../packages/ui/src/**/*.{ts,tsx}";
diff --git a/apps/desktop/src/renderer/styles/fade-edge.css b/apps/desktop/src/renderer/styles/fade-edge.css
new file mode 100644
index 00000000000..801718a17d8
--- /dev/null
+++ b/apps/desktop/src/renderer/styles/fade-edge.css
@@ -0,0 +1,70 @@
+/**
+ * Edge-fade mask utilities.
+ *
+ * Fades out one or more edges of an element via a linear-gradient mask.
+ * Composable: `class="fade-edge-r fade-edge-b"` fades both edges.
+ * Override the fade size per element with `[--fade-edge-size:2rem]`.
+ *
+ * NOTE: This is a generic, app-agnostic utility. When a second consumer
+ * outside `apps/desktop` needs it, promote this file to
+ * `packages/ui/src/styles/fade-edge.css` and update consumers' imports.
+ */
+
+@layer utilities {
+ .fade-edge-t,
+ .fade-edge-r,
+ .fade-edge-b,
+ .fade-edge-l {
+ --fade-edge-size: 1.5rem;
+ --fade-edge-t-size: 0px;
+ --fade-edge-r-size: 0px;
+ --fade-edge-b-size: 0px;
+ --fade-edge-l-size: 0px;
+
+ mask-image:
+ linear-gradient(
+ to bottom,
+ transparent,
+ black var(--fade-edge-t-size),
+ black calc(100% - var(--fade-edge-b-size)),
+ transparent
+ ),
+ linear-gradient(
+ to right,
+ transparent,
+ black var(--fade-edge-l-size),
+ black calc(100% - var(--fade-edge-r-size)),
+ transparent
+ );
+ -webkit-mask-image:
+ linear-gradient(
+ to bottom,
+ transparent,
+ black var(--fade-edge-t-size),
+ black calc(100% - var(--fade-edge-b-size)),
+ transparent
+ ),
+ linear-gradient(
+ to right,
+ transparent,
+ black var(--fade-edge-l-size),
+ black calc(100% - var(--fade-edge-r-size)),
+ transparent
+ );
+ mask-composite: intersect;
+ -webkit-mask-composite: source-in;
+ }
+
+ .fade-edge-t {
+ --fade-edge-t-size: var(--fade-edge-size);
+ }
+ .fade-edge-r {
+ --fade-edge-r-size: var(--fade-edge-size);
+ }
+ .fade-edge-b {
+ --fade-edge-b-size: var(--fade-edge-size);
+ }
+ .fade-edge-l {
+ --fade-edge-l-size: var(--fade-edge-size);
+ }
+}
From ba0bacac9acf3324e65647052618943d667b5314 Mon Sep 17 00:00:00 2001
From: Kiet <31864905+Kitenite@users.noreply.github.com>
Date: Sat, 25 Apr 2026 13:17:29 -0700
Subject: [PATCH 3/3] fix v2 terminal osc links (#3736)
---
.../terminal/terminal-link-manager.test.ts | 105 ++++++++++++++++++
.../lib/terminal/terminal-link-manager.ts | 27 ++++-
2 files changed, 131 insertions(+), 1 deletion(-)
create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts
diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts
new file mode 100644
index 00000000000..950f51793ea
--- /dev/null
+++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it, mock } from "bun:test";
+import type { ILinkProvider, Terminal as XTerm } from "@xterm/xterm";
+import { TerminalLinkManager } from "./terminal-link-manager";
+
+function createMockTerminal() {
+ const registeredProviders: ILinkProvider[] = [];
+ const disposedProviders: ILinkProvider[] = [];
+ const terminal = {
+ options: {
+ linkHandler: null,
+ },
+ registerLinkProvider: (provider: ILinkProvider) => {
+ registeredProviders.push(provider);
+ return {
+ dispose: () => {
+ disposedProviders.push(provider);
+ },
+ };
+ },
+ buffer: {
+ active: {
+ getLine: () => null,
+ },
+ },
+ cols: 80,
+ } as unknown as XTerm;
+
+ return { terminal, registeredProviders, disposedProviders };
+}
+
+describe("TerminalLinkManager", () => {
+ it("routes OSC 8 hyperlinks through the terminal URL handler", () => {
+ const { terminal } = createMockTerminal();
+ const manager = new TerminalLinkManager(terminal);
+ const onUrlClick = mock();
+ const onLinkHover = mock();
+ const onLinkLeave = mock();
+
+ manager.setHandlers({
+ stat: async () => null,
+ onUrlClick,
+ onLinkHover,
+ onLinkLeave,
+ });
+
+ const linkHandler = terminal.options.linkHandler;
+ expect(linkHandler).toBeTruthy();
+ expect(linkHandler?.allowNonHttpProtocols).toBe(false);
+
+ const event = {} as MouseEvent;
+ linkHandler?.activate(event, "https://example.com", {
+ start: { x: 1, y: 1 },
+ end: { x: 20, y: 1 },
+ });
+ linkHandler?.hover?.(event, "https://example.com", {
+ start: { x: 1, y: 1 },
+ end: { x: 20, y: 1 },
+ });
+ linkHandler?.leave?.(event, "https://example.com", {
+ start: { x: 1, y: 1 },
+ end: { x: 20, y: 1 },
+ });
+
+ expect(onUrlClick).toHaveBeenCalledWith(event, "https://example.com");
+ expect(onLinkHover).toHaveBeenCalledWith(event, { kind: "url" });
+ expect(onLinkLeave).toHaveBeenCalled();
+ });
+
+ it("clears only the OSC link handler it installed", () => {
+ const { terminal, disposedProviders } = createMockTerminal();
+ const manager = new TerminalLinkManager(terminal);
+
+ manager.setHandlers({
+ stat: async () => null,
+ onUrlClick: mock(),
+ });
+
+ const installedHandler = terminal.options.linkHandler;
+ expect(installedHandler).toBeTruthy();
+
+ manager.dispose();
+
+ expect(terminal.options.linkHandler).toBeNull();
+ expect(disposedProviders.length).toBe(2);
+ });
+
+ it("does not clear a link handler installed by another owner", () => {
+ const { terminal } = createMockTerminal();
+ const manager = new TerminalLinkManager(terminal);
+
+ manager.setHandlers({
+ stat: async () => null,
+ onUrlClick: mock(),
+ });
+
+ const replacementHandler = {
+ activate: mock(),
+ };
+ terminal.options.linkHandler = replacementHandler;
+
+ manager.dispose();
+
+ expect(terminal.options.linkHandler).toBe(replacementHandler);
+ });
+});
diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts
index ea1ca56fb91..07d265713d8 100644
--- a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts
+++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts
@@ -7,7 +7,7 @@
* resolver caching, and priority ordering.
*--------------------------------------------------------------------------------------------*/
-import type { Terminal as XTerm } from "@xterm/xterm";
+import type { ILinkHandler, Terminal as XTerm } from "@xterm/xterm";
import { UrlLinkProvider } from "../../screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers";
import type { DetectedLink } from "./links";
import {
@@ -57,6 +57,7 @@ export class TerminalLinkManager {
private _disposables: LinkProviderDisposable[] = [];
private _resolver: TerminalLinkResolver | null = null;
private _handlers: TerminalLinkHandlers | null = null;
+ private _oscLinkHandler: ILinkHandler | null = null;
constructor(private readonly _terminal: XTerm) {}
@@ -83,11 +84,19 @@ export class TerminalLinkManager {
dispose(): void {
for (const d of this._disposables) d.dispose();
this._disposables = [];
+ this._clearOscLinkHandler();
this._resolver?.clearCache();
this._resolver = null;
this._handlers = null;
}
+ private _clearOscLinkHandler(): void {
+ if (this._terminal.options.linkHandler === this._oscLinkHandler) {
+ this._terminal.options.linkHandler = null;
+ }
+ this._oscLinkHandler = null;
+ }
+
private _register(): void {
const handlers = this._handlers;
if (!handlers?.stat) return;
@@ -95,6 +104,7 @@ export class TerminalLinkManager {
// Dispose old providers to prevent duplicates
for (const d of this._disposables) d.dispose();
this._disposables = [];
+ this._clearOscLinkHandler();
// Reuse resolver to preserve stat cache across re-registrations.
if (!this._resolver) {
@@ -135,6 +145,21 @@ export class TerminalLinkManager {
onLinkLeave,
);
this._disposables.push(this._terminal.registerLinkProvider(urlProvider));
+
+ // xterm always registers its own OSC 8 hyperlink provider first. Without
+ // this, OSC 8 links use xterm's default confirm() + window.open() path,
+ // which is blocked in Electron and also bypasses our link preferences.
+ this._oscLinkHandler = {
+ allowNonHttpProtocols: false,
+ activate: (event, uri) => {
+ onUrlClick(event, uri);
+ },
+ hover: onLinkHover
+ ? (event) => onLinkHover(event, { kind: "url" })
+ : undefined,
+ leave: onLinkLeave ? () => onLinkLeave() : undefined,
+ };
+ this._terminal.options.linkHandler = this._oscLinkHandler;
}
// 3. SUPERSET ADDITION: Word link detector (lowest priority).