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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ describe("DesktopLifecycle", () => {
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.void,
zoomMain: () => Effect.void,
syncAppearance: Effect.void,
navigateToThread: () => Effect.void,
navigateToProject: () => Effect.void,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendPool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function makePoolLayer(
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: () => Effect.die("unexpected menu action"),
zoomMain: () => Effect.die("unexpected zoom"),
syncAppearance: Effect.void,
navigateToThread: () => Effect.void,
navigateToProject: () => Effect.void,
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarHideProviderIcons: false,
sidebarV2Enabled: false,
sidebarV2ConfiguredByUser: false,
legacySidebarEnabled: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
80 changes: 61 additions & 19 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
zoomMain: (direction) =>
Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
syncAppearance: Effect.void,
navigateToThread: () => Effect.void,
navigateToProject: () => Effect.void,
Expand All @@ -98,32 +100,38 @@ const makeElectronMenuLayer = (
showContextMenu: () => Effect.succeed(Option.none()),
} satisfies ElectronMenu.ElectronMenu["Service"]);

const configureMenu = (
selectedAction: Deferred.Deferred<string>,
applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
) =>
Effect.gen(function* () {
const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
yield* menu.configure;
}).pipe(
Effect.provide(
DesktopApplicationMenu.layer.pipe(
Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
Layer.provideMerge(desktopUpdatesLayer),
Layer.provideMerge(electronDialogLayer),
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(
DesktopEnvironment.layer(environmentInput).pipe(
Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
),
),
),
),
);

describe("DesktopApplicationMenu", () => {
it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* Effect.gen(function* () {
const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
yield* menu.configure;
}).pipe(
Effect.provide(
DesktopApplicationMenu.layer.pipe(
Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
Layer.provideMerge(desktopUpdatesLayer),
Layer.provideMerge(electronDialogLayer),
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(
DesktopEnvironment.layer(environmentInput).pipe(
Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
),
),
),
),
);
yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const fileMenu = template.find((item) => item.label === "File");
Expand All @@ -142,4 +150,38 @@ describe("DesktopApplicationMenu", () => {
assert.equal(yield* Deferred.await(selectedAction), "open-settings");
}),
);

// Zoom must route through DesktopWindow.zoomMain instead of the Electron
// zoom roles: the roles zoom whichever webContents has focus, which breaks
// app zoom while an embedded preview WebContentsView holds focus.
it.effect("routes View menu zoom to the main window instead of zoom roles", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const viewMenu = template.find((item) => item.label === "View");
assert.isDefined(viewMenu);
if (!Array.isArray(viewMenu.submenu)) {
throw new Error("Expected View menu submenu to be an array.");
}

assert.isUndefined(
viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")),
);

const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
assert.isDefined(zoomIn);
assert.equal(zoomIn.accelerator, "CmdOrCtrl+=");
if (typeof zoomIn.click !== "function") {
throw new Error("Expected Zoom In menu item to have a click handler.");
}

zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
}),
);
});
29 changes: 25 additions & 4 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function
yield* desktopWindow.dispatchMenuAction(action);
});

const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
direction: DesktopWindow.MainWindowZoomDirection,
): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.zoomMain(direction);
});

const checkForUpdatesFromMenu = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
Expand Down Expand Up @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
};
const template: Electron.MenuItemConstructorOptions[] = [];

if (environment.platform === "darwin") {
Expand Down Expand Up @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () {
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+=" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
{ role: "zoomOut" },
/*
Not the zoom roles: those act on the focused webContents, so with
an embedded preview WebContentsView focused they zoom the guest
page and the app UI appears stuck. These always zoom the main
window (see DesktopWindow.zoomMain).
*/
{ label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
{ label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
{
label: "Zoom In",
accelerator: "CmdOrCtrl+Plus",
visible: false,
click: zoomClick("in"),
},
{ label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
{ type: "separator" },
{ role: "togglefullscreen" },
],
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type DesktopWindowError =
| ElectronWindow.ElectronWindowCreateError
| PreviewManager.PreviewManagerError;

export type MainWindowZoomDirection = "in" | "out" | "reset";

export class DesktopWindow extends Context.Service<
DesktopWindow,
{
Expand All @@ -92,6 +94,12 @@ export class DesktopWindow extends Context.Service<
readonly handleBackendNotReady: Effect.Effect<void>;
readonly flushMainWindowBounds: Effect.Effect<void>;
readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
// Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut`
// menu roles act on whichever webContents has keyboard focus, so with an
// embedded preview WebContentsView (or DevTools) focused they zoom the
// guest page instead of the app UI. The menu routes here to always target
// the main window.
readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>;
readonly syncAppearance: Effect.Effect<void>;
/**
* Navigate the main window to the canonical thread route
Expand Down Expand Up @@ -910,6 +918,18 @@ export const make = Effect.gen(function* () {

send();
}),
zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) {
yield* Effect.annotateCurrentSpan({ direction });
const window = yield* focusedMainWindow;
if (Option.isNone(window) || window.value.isDestroyed()) {
return;
}
const webContents = window.value.webContents;
// Same step size as the Electron zoomIn/zoomOut menu roles.
webContents.setZoomLevel(
direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5),
);
}),
syncAppearance: Effect.gen(function* () {
const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
yield* electronWindow.syncAllAppearance((window) =>
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
resolveThreadListV2Enabled,
} from "../threads/threadListV2";
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
import { matchesEnvironmentFilter } from "./homeEnvironmentFilter";
Expand Down Expand Up @@ -232,10 +233,15 @@ export function HomeScreen(props: HomeScreenProps) {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
// Grouping changes V2 ordering only; the cards, pin block, and shelves are
// shared across project and recency modes.
// v2 is the default list since #5672; the legacy grouped list is the opt-in.
const threadListV2Enabled =
props.listMode === "threads" &&
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.threadListV2Enabled === true;
resolveThreadListV2Enabled({
legacyPreference: AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.legacyThreadListEnabled
: undefined,
preferencesLoaded: AsyncResult.isSuccess(preferencesResult),
});
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand Down
23 changes: 12 additions & 11 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ function LocalSettingsRouteScreen() {
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>

<BetaSettingsSection />
<LegacySettingsSection />

<ArchivedThreadsSettingsSection />

Expand Down Expand Up @@ -523,7 +523,7 @@ function ConfiguredSettingsRouteScreen() {
<SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
</SettingsSection>

<BetaSettingsSection />
<LegacySettingsSection />

<ArchivedThreadsSettingsSection />

Expand All @@ -542,26 +542,27 @@ function GeneralSettingsSection() {
}

/**
* Device-local beta toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → Beta backed by mobile preferences.
* Device-local legacy toggles. Mobile has no client-settings sync, so this is
* the counterpart of web's Settings → General → Legacy features backed by
* mobile preferences.
*/
function BetaSettingsSection() {
function LegacySettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();

return (
<View className="gap-3">
<SettingsSection title="Beta">
<SettingsSection title="Legacy">
<SettingsSwitchRow
icon="sidebar.left"
label="Thread List v2"
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
label="Legacy Thread List"
value={!threadListV2Enabled}
onValueChange={(value) => savePreferences({ legacyThreadListEnabled: value })}
/>
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
Brings back the original grouped thread list. The default list is flat, in creation order:
active work renders as cards; settled threads collapse to compact rows.
</Text>
</View>
);
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
resolveThreadListV2Enabled,
} from "./threadListV2";

/** The sidebar list serves both lists: v1 grouped items or, when the Thread
Expand Down Expand Up @@ -292,10 +293,16 @@ function ThreadNavigationSidebarPane(
);
// Grouping changes V2 ordering only; it must never swap in a different row
// renderer or remove pinning / shelves.
// v2 is the default list since #5672; legacyThreadListEnabled is the opt-out.
// Must match HomeScreen, or this surface and Home disagree about the list.
const threadListV2Enabled =
options.listMode === "threads" &&
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.threadListV2Enabled === true;
resolveThreadListV2Enabled({
legacyPreference: AsyncResult.isSuccess(preferencesResult)
? preferencesResult.value.legacyThreadListEnabled
: undefined,
preferencesLoaded: AsyncResult.isSuccess(preferencesResult),
});
const hideSettledOnRecent = AsyncResult.isSuccess(preferencesResult)
? resolveHideSettledOnRecent(preferencesResult.value)
: true;
Expand Down
22 changes: 13 additions & 9 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,24 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => {

describe("resolveThreadListV2Enabled", () => {
it("defaults on when the device has never chosen", () => {
expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe(
true,
);
expect(
resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false);
expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true);
it("honors an explicit legacy opt-in", () => {
expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe(
false,
);
expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe(
true,
);
});

it("holds the default while preferences are still loading so the list does not remount", () => {
expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe(
true,
);
expect(
resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }),
).toBe(true);
});
});

Expand Down
12 changes: 6 additions & 6 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,23 +108,23 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;

/**
* Thread List v2 is on by default on every app variant; the Settings → Beta
* toggle is an opt-out. Preferences persist as sparse patches, so `undefined`
* genuinely means "never chosen".
* The flat Thread List v2 is the default on every app variant; the Settings →
* Legacy toggle opts a device back into the grouped legacy list. Preferences
* persist as sparse patches, so `undefined` genuinely means "never chosen".
*
* `preferencesLoaded` guards the startup window: preferences load
* asynchronously, and rendering one list before the stored choice arrives would
* remount the whole thing a tick later. While loading, hold the default — that
* is where every device without an explicit opt-out lands anyway.
* is where every device without an explicit legacy opt-in lands anyway.
*/
export function resolveThreadListV2Enabled(input: {
readonly preference: boolean | undefined;
readonly legacyPreference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
return input.legacyPreference !== true;
}

export function resolveThreadListV2Status(
Expand Down
Loading
Loading