Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
DesktopSnapShotEvent,
} from "@t3tools/contracts";
import { exposeClerkBridge } from "@clerk/electron/preload";
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, webFrame } from "electron";

import * as IpcChannels from "./ipc/channels.ts";

Expand All @@ -32,6 +32,19 @@ exposeClerkBridge({ passkeys: true });
// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
const clientPlatform = process.platform;

if (clientPlatform === "darwin") {
// Native window buttons do not scale with Chromium zoom. Keep their reserved
// space in native points, including when a zoomed page is reloaded.
const syncWindowControlInset = () => {
document.documentElement.style.setProperty(
"--desktop-window-controls-inset",
`${90 / webFrame.getZoomFactor()}px`,
);
};
window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true });
window.addEventListener("resize", syncWindowControlInset);
}

function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
typeof result === "object" &&
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() {
isDestroyed: vi.fn(() => false),
getURL: vi.fn(() => "t3code-dev://app/"),
getZoomLevel: vi.fn(() => zoomLevel),
getZoomFactor: vi.fn(() => 1.2 ** zoomLevel),
setZoomLevel: vi.fn((level: number) => {
zoomLevel = level;
}),
Expand Down Expand Up @@ -118,6 +119,7 @@ function makeFakeBrowserWindow() {
setOpacity: vi.fn(),
setTitle: vi.fn(),
setTitleBarOverlay: vi.fn(),
setWindowButtonPosition: vi.fn(),
show: vi.fn(),
webContents,
};
Expand All @@ -136,6 +138,7 @@ function makeFakeBrowserWindow() {
reload: webContents.reload,
send: webContents.send,
setZoomLevel: webContents.setZoomLevel,
setWindowButtonPosition: window.setWindowButtonPosition,
setBackgroundThrottling: webContents.setBackgroundThrottling,
setAutoHideCursor: window.setAutoHideCursor,
setFullScreen: window.setFullScreen,
Expand Down Expand Up @@ -742,6 +745,39 @@ describe("DesktopWindow", () => {
}),
);

it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
const createCount = yield* Ref.make(0);
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow });

yield* Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));

for (const direction of ["in", "in", "out", "reset", "out"] as const) {
yield* desktopWindow.zoomMain(direction);
const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0];
assert.isDefined(position);
// The 14-point native buttons should share the zoomed 52px header's center.
const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor();
assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5);
assert.equal(position.x, 16);
}

fakeWindow.isFullScreen.mockReturnValue(true);
fakeWindow.setWindowButtonPosition.mockClear();
yield* desktopWindow.zoomMain("reset");
assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0);

fakeWindow.isFullScreen.mockReturnValue(false);
fakeWindow.windowListeners.get("leave-full-screen")?.();
assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]);
}).pipe(Effect.provide(layer));
}),
);

it.effect("uses the persisted main window bounds when opening the window", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import { makeQuitShortcutHandler } from "./QuitHold.ts";

const TITLEBAR_HEIGHT = 40;
// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS
// buttons are 14 points tall and do not scale with the renderer's zoom.
const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52;
const MACOS_WINDOW_BUTTON_RADIUS = 7;

function syncMacosWindowButtons(window: Electron.BrowserWindow): void {
if (window.isDestroyed() || window.isFullScreen()) return;
window.setWindowButtonPosition({
x: 16,
y: Math.round(
(MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 -
MACOS_WINDOW_BUTTON_RADIUS,
),
});
}

const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
Expand Down Expand Up @@ -241,7 +257,10 @@ function getWindowTitleBarOptions(
if (platform === "darwin") {
return {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
trafficLightPosition: {
x: 16,
y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS,
},
};
}

Expand Down Expand Up @@ -660,6 +679,7 @@ export const make = Effect.gen(function* () {
window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true);
});
window.on("leave-full-screen", () => {
syncMacosWindowButtons(window);
window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false);
});
}
Expand Down Expand Up @@ -720,6 +740,7 @@ export const make = Effect.gen(function* () {
clearDevelopmentLoadRetry();
developmentLoadRetryIndex = 0;
window.setTitle(environment.displayName);
if (environment.platform === "darwin") syncMacosWindowButtons(window);
});
window.webContents.on(
"did-fail-load",
Expand Down Expand Up @@ -990,6 +1011,7 @@ export const make = Effect.gen(function* () {
webContents.setZoomLevel(
direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
);
if (environment.platform === "darwin") syncMacosWindowButtons(window.value);
// Chromium pushes the new level down to embedded guests, which would zoom
// the previewed page along with the app UI. The preview browser keeps its
// own zoom, so put each guest back where the preview left it.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
return false
}

emitInput("\u{7F}")
return false
}

Expand Down
83 changes: 63 additions & 20 deletions apps/mobile/src/components/ConfirmDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native";
import { Modal, Pressable, TextInput, View } from "react-native";

import { cn } from "../lib/cn";
import { AppText } from "./AppText";
Expand All @@ -14,7 +14,20 @@ export type ConfirmDialogRequest = {
readonly onCancel?: () => void;
};

let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
export type TextInputDialogRequest = {
readonly title: string;
readonly initialValue: string;
readonly cancelText?: string;
readonly confirmText: string;
readonly onConfirm: (value: string) => void;
readonly onCancel?: () => void;
};

type DialogRequest =
| { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
| { readonly kind: "text-input"; readonly request: TextInputDialogRequest };

let presentRequest: ((request: DialogRequest) => void) | null = null;

/**
* Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already
Expand All @@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null;
* once. Requires ConfirmDialogHost to be mounted at the app root.
*/
export function showConfirmDialog(request: ConfirmDialogRequest): void {
presentRequest?.(request);
presentRequest?.({ kind: "confirm", request });
}

export function showTextInputDialog(request: TextInputDialogRequest): void {
presentRequest?.({ kind: "text-input", request });
}

/**
Expand All @@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {
* button color and a dimmer message than the title.
*/
export function ConfirmDialogHost() {
const [request, setRequest] = useState<ConfirmDialogRequest | null>(null);
const [presented, setPresented] = useState<DialogRequest | null>(null);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
presentRequest = setRequest;
presentRequest = (request) => {
setInputValue(request.kind === "text-input" ? request.request.initialValue : "");
setPresented(request);
};
return () => {
presentRequest = null;
};
}, []);

const handleCancel = useCallback(() => {
request?.onCancel?.();
setRequest(null);
}, [request]);
presented?.request.onCancel?.();
setPresented(null);
}, [presented]);

const handleConfirm = useCallback(() => {
request?.onConfirm();
setRequest(null);
}, [request]);
if (presented?.kind === "confirm") {
presented.request.onConfirm();
} else if (presented?.kind === "text-input") {
presented.request.onConfirm(inputValue);
}
setPresented(null);
}, [inputValue, presented]);

const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0;

return (
<Modal
visible={request !== null}
visible={presented !== null}
transparent
animationType="fade"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={handleCancel}
>
{request === null ? null : (
{presented === null ? null : (
<View className="flex-1 items-center justify-center bg-backdrop px-8">
<View className="w-full rounded-[24px] bg-card px-6 pb-4 pt-5">
<AppText className="text-lg font-t3-medium">{request.title}</AppText>
{request.message === undefined ? null : (
<AppText className="text-lg font-t3-medium">{presented.request.title}</AppText>
{presented.kind === "confirm" && presented.request.message !== undefined ? (
<AppText className="mt-2 text-sm text-foreground-secondary">
{request.message}
{presented.request.message}
</AppText>
)}
) : null}
{presented.kind === "text-input" ? (
<TextInput
accessibilityLabel={presented.request.title}
autoFocus
className="mt-4 rounded-xl border border-border bg-screen px-3 py-2.5 text-base text-foreground"
onChangeText={setInputValue}
onSubmitEditing={confirmDisabled ? undefined : handleConfirm}
returnKeyType="done"
selectTextOnFocus
value={inputValue}
/>
) : null}
<View className="mt-5 flex-row justify-end gap-1">
<View className="overflow-hidden rounded-full">
<Pressable
Expand All @@ -77,23 +116,27 @@ export function ConfirmDialogHost() {
onPress={handleCancel}
>
<AppText className="text-base font-t3-medium">
{request.cancelText ?? "Cancel"}
{presented.request.cancelText ?? "Cancel"}
</AppText>
</Pressable>
</View>
<View className="overflow-hidden rounded-full">
<Pressable
accessibilityRole="button"
disabled={confirmDisabled}
className="min-h-10 items-center justify-center px-4 active:bg-subtle"
onPress={handleConfirm}
>
<AppText
className={cn(
"text-base font-t3-medium",
request.destructive && "text-danger-foreground",
presented.kind === "confirm" &&
presented.request.destructive &&
"text-danger-foreground",
confirmDisabled && "text-foreground-tertiary",
)}
>
{request.confirmText}
{presented.request.confirmText}
</AppText>
</Pressable>
</View>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function HomeRouteScreen() {
pinThread,
unpinThread,
moveThread,
renameThread,
regenerateThreadTitle,
unsettleThread,
} = useThreadListActions();
Expand Down Expand Up @@ -200,6 +201,7 @@ export function HomeRouteScreen() {
onPinThread={pinThread}
onUnpinThread={unpinThread}
onMoveThread={moveThread}
onRenameThread={renameThread}
onRegenerateThreadTitle={regenerateThreadTitle}
onEnvironmentChange={setSelectedEnvironmentId}
onProjectChange={setSelectedProjectKey}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface HomeScreenProps {
thread: EnvironmentThreadShell,
direction: ThreadMoveDestination,
) => Promise<boolean>;
readonly onRenameThread: (thread: EnvironmentThreadShell) => void;
readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
Expand Down Expand Up @@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) {
},
[props.onRegenerateThreadTitle],
);
const handleRenameThread = useCallback(
(thread: EnvironmentThreadShell) => props.onRenameThread(thread),
[props.onRenameThread],
);
const handleDeleteThread = props.onDeleteThread;
const handleUnsettleThread = props.onUnsettleThread;
// The settled tail renders in pages; expansion resets when the filter
Expand Down Expand Up @@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) {
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
onRenameThread={handleRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
Expand Down Expand Up @@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleMoveThread,
handlePinThread,
handleRegenerateThreadTitle,
handleRenameThread,
handleSettleThread,
handleSnoozeThread,
handleUnpinThread,
Expand Down Expand Up @@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery={props.searchQuery}
onArchiveThread={props.onArchiveThread}
onDeleteThread={props.onDeleteThread}
onRenameThread={handleRenameThread}
onRegenerateThreadTitle={handleRegenerateThreadTitle}
titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
onSelectThread={props.onSelectThread}
Expand All @@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableClose,
handleSwipeableWillOpen,
handleRegenerateThreadTitle,
handleRenameThread,
machineByEnvironmentId,
queuedThreadKeys,
props.onArchiveThread,
Expand Down
Loading
Loading