Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5506f33
fix(preview): agent browser clicks no longer steal your composer focu…
t3dotgg Oct 3, 2026
97b9074
fix(desktop): markdown no longer loads invisible in new dark-mode bro…
shivamhwp Oct 2, 2026
55e7296
fix(markdown): keep Windows paths intact in link and image destinatio…
SunkenInTime Oct 2, 2026
b7b4a34
fix(shared): classify workspace previews by literal filenames (#10311)
yashranaway Oct 3, 2026
7433f96
fix(mobile): project picker cards match the settings card color
rynfar Oct 3, 2026
08e9fca
fix(web): diff panel opens on all branch changes, not just uncommitte…
t3dotgg Oct 3, 2026
b04dcb1
docs: describe the diff panel Changes default
rynfar Oct 3, 2026
ee1b820
fix(web): Woke dismiss syncs, preview mute is back, and subscription …
t3dotgg Oct 3, 2026
f8cc893
feat(web): mod+alt+enter on an existing thread sends and opens a new …
maria-rcks Oct 3, 2026
f967b0b
fix(web): mod+enter starts a new thread in the background again (#15060)
t3dotgg Oct 3, 2026
7e63d17
docs: explain mod+Enter background start with a modifier send shortcut
rynfar Oct 3, 2026
8a4b2ab
test(server): set the executable bit on disk for the Changes mode test
rynfar Oct 3, 2026
e94b71f
test(web): run the preview click focus test without jsdom
rynfar Oct 3, 2026
52a1545
test(server): cover late default keybindings alongside the numbered m…
rynfar Oct 3, 2026
1da4e00
chore(mobile): format the new task route screen
rynfar Oct 3, 2026
dfe6d21
fix(web): default the diff view to Uncommitted on servers without Cha…
rynfar Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,123 @@ describe("PreviewManager", () => {
),
);

const makeAttachingGuest = (id: number) => {
const listeners = new Map<string, () => void>();
const attach = vi.fn();
const detach = vi.fn();
const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>(
async () => undefined,
);
let destroyed = false;
const wc = {
id,
isDestroyed: () => destroyed,
isDevToolsOpened: () => {
// Electron throws from native methods once a WebContents is destroyed.
if (destroyed) throw new TypeError("Object has been destroyed");
return false;
},
getType: () => "webview",
getURL: () => "http://localhost:5173/README.md",
getTitle: () => "README.md",
isLoading: () => true,
getZoomFactor: () => 1,
setZoomFactor: vi.fn(),
setAudioMuted: vi.fn(),
isCurrentlyAudible: () => false,
on: vi.fn(),
off: vi.fn(),
once: (event: string, listener: () => void) => {
listeners.set(event, listener);
},
ipc: { on: vi.fn(), off: vi.fn() },
send: webviewSend,
navigationHistory: { canGoBack: () => false, canGoForward: () => false },
setIgnoreMenuShortcuts: vi.fn(),
setWindowOpenHandler: vi.fn(),
debugger: {
isAttached: () => attach.mock.calls.length > detach.mock.calls.length,
attach,
detach,
sendCommand,
on: vi.fn(),
off: vi.fn(),
},
};
return {
wc: wc as unknown as Electron.WebContents,
attach,
detach,
sendCommand,
destroy: () => {
destroyed = true;
listeners.get("destroyed")?.();
},
};
};

effectIt.effect("sets the opaque base as soon as a guest attaches", () =>
withManager((manager) =>
Effect.gen(function* () {
// The tab's first document can paint before the renderer registers the
// guest, so the opaque base has to be the first command on attach.
const claimed = makeAttachingGuest(44);
fromId.mockReturnValue(claimed.wc);
yield* manager.prepareWebview(claimed.wc);
expect(claimed.attach).toHaveBeenCalledTimes(1);
expect(claimed.sendCommand.mock.calls[0]).toEqual([
"Emulation.setDefaultBackgroundColorOverride",
{ color: { r: 255, g: 255, b: 255, a: 1 } },
]);

yield* manager.createTab("tab_early");
yield* manager.registerWebview("tab_early", 44);
yield* manager.setColorScheme("tab_early", "dark");
expect(claimed.attach).toHaveBeenCalledTimes(1);
expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-color-scheme", value: "dark" }],
});

// A guest destroyed before any tab claims it releases its session.
const unclaimed = makeAttachingGuest(45);
yield* manager.prepareWebview(unclaimed.wc);
expect(unclaimed.attach).toHaveBeenCalledTimes(1);
unclaimed.destroy();
yield* Effect.yieldNow;
expect(unclaimed.detach).toHaveBeenCalledTimes(1);
expect(claimed.detach).not.toHaveBeenCalled();
}),
),
);

effectIt.effect("skips a guest destroyed while another guest's session opens", () =>
withManager((manager) =>
Effect.gen(function* () {
const slow = makeAttachingGuest(46);
let releaseSlow = () => {};
const slowCommand = new Promise<void>((resolve) => {
releaseSlow = resolve;
});
slow.sendCommand.mockImplementation(() => slowCommand);
const queued = makeAttachingGuest(47);

const slowFiber = yield* manager
.prepareWebview(slow.wc)
.pipe(Effect.forkChild({ startImmediately: true }));
const queuedFiber = yield* manager
.prepareWebview(queued.wc)
.pipe(Effect.forkChild({ startImmediately: true }));
queued.destroy();
releaseSlow();

expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true);
expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true);
expect(slow.attach).toHaveBeenCalledTimes(1);
expect(queued.attach).not.toHaveBeenCalled();
}),
),
);

const makeAudioWebContents = (id: number) => {
const listeners = new Map<string, (...args: never[]) => void>();
const setAudioMuted = vi.fn();
Expand Down
58 changes: 49 additions & 9 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
> => {
const existing = sessions.get(wc.id);
if (existing) return Effect.succeed([existing, sessions] as const);
// A guest can be destroyed while it waits for this lock, and its native
// methods throw once it is.
if (wc.isDestroyed()) {
return Effect.fail(
new PreviewOperationError({
operation: "ensureControlSession",
webContentsId: wc.id,
cause: new Error("WebContents was destroyed"),
}),
);
}
if (wc.isDevToolsOpened()) {
return Effect.fail(
new PreviewAutomationDevToolsOpenError({
Expand Down Expand Up @@ -1434,28 +1445,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wcDebugger.on("message", onMessage);
wcDebugger.attach("1.3");
});
yield* Effect.forEach(
["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
(method) =>
attemptPromise(
{ operation: `initializeDebugger.${method}`, webContentsId: wc.id },
() => wcDebugger.sendCommand(method),
),
{ concurrency: "unbounded", discard: true },
);
// Electron gives `<webview>` guests a transparent base background, and
// Chromium only paints a dark canvas for dark color-scheme pages over an
// opaque base. Without this, dark-scheme pages with no background of
// their own (text/plain, e.g. .md files) render white text on white.
// White matches the webview's existing white backing, so light pages look
// the same; Chromium still swaps in its dark canvas for dark-scheme pages.
// Sent first because a document that paints before it arrives keeps the
// transparent base until its next load.
yield* attemptPromise(
{ operation: "initializeDebugger.defaultBackground", webContentsId: wc.id },
() =>
wcDebugger.sendCommand("Emulation.setDefaultBackgroundColorOverride", {
color: { r: 255, g: 255, b: 255, a: 1 },
}),
);
yield* Effect.forEach(
["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"],
(method) =>
attemptPromise(
{ operation: `initializeDebugger.${method}`, webContentsId: wc.id },
() => wcDebugger.sendCommand(method),
),
{ concurrency: "unbounded", discard: true },
);
return [
control,
replaceMap(sessions, (copy) => {
Expand Down Expand Up @@ -2446,6 +2459,30 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
);
});

// Called when a guest attaches to the window, before its first document
// paints. A tab opened straight to a URL often paints before the renderer
// gets to registerWebview, which would leave that page on the transparent
// base. registerWebview reuses the session opened here.
const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* (
wc: Electron.WebContents,
) {
const webContentsId = wc.id;
// A guest destroyed before any tab claims it has no other cleanup path.
wc.once("destroyed", () => {
runFork(detachControlSession(webContentsId));
});
// Runs detached from the attach event, so nothing may escape. registerWebview
// opens the session again if this one did not.
yield* ensureControlSession(wc).pipe(
Effect.catchCause((cause) =>
Effect.logDebug("Preview webview control session was not opened on attach.", {
webContentsId,
cause,
}),
),
);
});

const navigate = Effect.fn("PreviewManager.navigate")(function* (
tabId: string,
rawUrl: string,
Expand Down Expand Up @@ -4821,6 +4858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
openPictureInPicture,
openDevTools,
pickElement,
prepareWebview,
reapplyZoom,
refresh,
registerWebview,
Expand Down Expand Up @@ -5157,6 +5195,7 @@ export class PreviewManager extends Context.Service<
tabId: string,
webContentsId: number,
) => Effect.Effect<void, PreviewManagerError>;
readonly prepareWebview: (webContents: Electron.WebContents) => Effect.Effect<void>;
readonly navigate: (
tabId: string,
url: string,
Expand Down Expand Up @@ -5299,6 +5338,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
createTab: operations.createTab,
closeTab: operations.closeTab,
registerWebview: operations.registerWebview,
prepareWebview: operations.prepareWebview,
navigate: operations.navigate,
goBack: operations.goBack,
goForward: operations.goForward,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ function makeTestLayer(input: {
Layer.mock(PreviewManager.PreviewManager)({
getBrowserSession: () => Effect.succeed({} as Electron.Session),
setMainWindow: () => Effect.void,
prepareWebview: () => Effect.void,
isBrowserPartition: (partition) => partition.startsWith("persist:pylon-code-preview-"),
getBrowserPartition: () => Effect.succeed("persist:pylon-code-preview-test"),
reapplyZoom: () =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export const make = Effect.gen(function* () {
installContextMenu(window, window.webContents);
window.webContents.on("did-attach-webview", (_event, contents) => {
installContextMenu(window, contents);
void runPromise(previewManager.prepareWebview(contents));
});

window.webContents.setWindowOpenHandler(({ url }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
nativeMarkdownChunkSpacing,
nativeMarkdownDocumentChunks,
nativeMarkdownDocumentRuns,
nativeMarkdownWithAuthoredWindowsPaths,
nativeMarkdownWithPreservedSoftBreaks,
} from "./nativeMarkdownText";
import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock";
Expand Down Expand Up @@ -51,11 +52,10 @@ export function SelectableMarkdownText({
marginBottom = 0,
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
const parsedDocument = parseMarkdownWithOptions(markdown, {
gfm: true,
html: true,
math: false,
});
const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
markdown,
);
const document = preserveSoftBreaks
? nativeMarkdownWithPreservedSoftBreaks(parsedDocument)
: parsedDocument;
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import { imageMimeType } from "@t3tools/shared/image";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
import { videoMimeType } from "@t3tools/shared/video";
/**
* Every accent shares a lightness so no kind reads heavier than another; only hue carries
Expand Down Expand Up @@ -527,6 +528,35 @@ export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): Markd
};
}

const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*<?((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g;
const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g;

/**
* md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination.
* Every backslash in a Windows path is a separator, so link and image paths go back to
* the destination as written in `markdown`. A parsed path that more than one written
* destination could have produced stays as parsed.
*/
export function nativeMarkdownWithAuthoredWindowsPaths(
node: MarkdownNode,
markdown: string,
): MarkdownNode {
const authoredByParsed = new Map<string, string | null>();
for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) {
if (!isWindowsAbsolutePath(authored)) continue;
const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
const known = authoredByParsed.get(parsed);
authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null);
}
if (authoredByParsed.size === 0) return node;
const restore = (current: MarkdownNode): MarkdownNode => {
const href = current.href && authoredByParsed.get(current.href);
const children = current.children?.map(restore);
return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) };
};
return restore(node);
}

function appendBlockTerminator(
runs: NativeMarkdownTextRun[],
context: RunContext,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ function ArchivedThreadRow(props: {
readonly thread: EnvironmentThreadShell;
}) {
const { width: windowWidth } = useWindowDimensions();
const cardColor = useUniwindTheme()["--color-card"];
const cardColor = useUniwindTheme()["--color-grouped-card"];
const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
Boolean(part),
Expand Down Expand Up @@ -450,7 +450,7 @@ function ArchivedThreadRow(props: {
>
{() => (
<View
className={`flex-row items-center gap-3 bg-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
>
<View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
<SymbolView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function ConfiguredConnectOnboardingRouteScreen() {
showHeader={false}
/>
) : (
<View collapsable={false} className="rounded-[24px] bg-card p-5">
<View collapsable={false} className="rounded-[24px] bg-grouped-card p-5">
<Text className="text-sm leading-normal text-foreground-muted">
Sign in to your Pylon account to set up Pylon Connect.
</Text>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export function ConnectionsNewRouteScreen({
/>
</View>
) : (
<View className="items-center gap-3 rounded-[24px] border-continuous bg-card px-5 py-8">
<View className="items-center gap-3 rounded-[24px] border-continuous bg-grouped-card px-5 py-8">
<Text className="text-center text-sm leading-normal text-foreground-muted">
Camera permission is required to scan a QR code.
</Text>
Expand All @@ -261,7 +261,7 @@ export function ConnectionsNewRouteScreen({
</View>
)
) : (
<View collapsable={false} className="gap-4 rounded-[24px] bg-card p-4">
<View collapsable={false} className="gap-4 rounded-[24px] bg-grouped-card p-4">
<View collapsable={false} className="gap-1.5">
<Text
accessibilityElementsHidden
Expand Down
Loading
Loading