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
102 changes: 102 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

const { Children, harness } = vi.hoisted(
(): {
Children: (props: { children?: ReactNode }) => ReactNode;
harness: { cloudConfigured: boolean };
} => ({
Children: ({ children }) => children,
harness: { cloudConfigured: false },
}),
);

vi.mock("react-native", () => ({ View: Children, Platform: { OS: "ios" } }));
vi.mock("@clerk/expo", () => ({
useAuth: () => ({ isLoaded: true, isSignedIn: false }),
useUser: () => ({ user: null }),
}));
vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) }));
vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
vi.mock("../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => harness.cloudConfigured }));
vi.mock("../workspace/navigation-tab-order", () => ({
navigationCustomizationAvailable: () => false,
overflowNavigationTabs: () => [],
}));
vi.mock("../workspace/navigation-tabs", () => ({
NAVIGATION_TAB_ICONS: {},
WORKSPACE_TAB_LABELS: {},
useNavigationTabOrder: () => [],
useHiddenNavigationTabs: () => [],
}));
vi.mock("../workspace/workspace-selection-store", () => ({
currentWorkspaceSelections: () => ({}),
}));
vi.mock("../workspace/workspace-tab-navigation", () => ({ rememberedSettingsPage: () => null }));
vi.mock("../workspace/useSelectWorkspaceTab", () => ({ useSelectWorkspaceTab: () => vi.fn() }));
vi.mock("../workspace/WorkspaceTabsContext", () => ({ useWorkspaceTabs: () => null }));
vi.mock("../workspace/useWorkspaceSurface", () => ({
useWorkspaceSurface: () => ({ className: "bg-screen" }),
}));
vi.mock("../layout/adaptive-workspace-context", () => ({
useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: false } }),
}));
vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null }));
vi.mock("../../native/v5-workspace-context", async () => {
const { createContext } = await import("react");
return { NativePrimaryColumnContext: createContext(null) };
});
vi.mock("../../state/use-remote-environment-registry", () => ({
useSavedRemoteConnections: () => ({ savedConnectionsById: {} }),
}));
vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children }));
vi.mock("../../components/ui/EmptyState", () => ({ EmptyState: () => null }));
vi.mock("./components/SettingsScreen", () => ({ SettingsScreen: Children }));
vi.mock("./components/SettingsSection", async () => {
const { createContext } = await import("react");
return { SettingsSection: Children, SettingsSidebarContext: createContext(null) };
});
vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({
SettingsEnvironmentFilterHeader: () => null,
AndroidSettingsEnvironmentFilter: () => null,
}));
vi.mock("./components/SettingsRow", () => ({
SettingsRow: ({ label }: { label: string }) => <button>{label}</button>,
}));
vi.mock("./settings-environment-filter", () => ({
useSettingsEnvironmentFilter: () => ({
selectedTargets: [],
projectGroups: [],
selectedProjectKey: null,
}),
}));

import { SettingsListScreen } from "./SettingsRouteScreen";

interface TestScreen extends Disposable {
readonly container: HTMLDivElement;
}

function renderSettings(): TestScreen {
const container: HTMLDivElement = document.createElement("div");
const root: Root = createRoot(container);
try {
act(() => root.render(<SettingsListScreen sidebar={false} selected={null} />));
return { container, [Symbol.dispose]: () => act(() => root.unmount()) };
} catch (error) {
act(() => root.unmount());
throw error;
}
}

it.each([false, true])("shows notification settings with cloudConfigured=%s", (cloudConfigured) => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
harness.cloudConfigured = cloudConfigured;
using screen = renderSettings();
const notifications: HTMLButtonElement[] = Array.from(
screen.container.querySelectorAll("button"),
).filter((button) => button.textContent === "Notifications");
expect(notifications).toHaveLength(1);
});
Comment on lines +94 to +102
36 changes: 19 additions & 17 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,32 +127,34 @@ function ConfiguredSettingsRouteScreen() {
disabled: !isLoaded,
onPress: () => navigation.navigate("SettingsAuth"),
},
environmentsRow(Object.keys(savedConnectionsById).length),
{
id: "SettingsNotifications",
icon: "bell.badge",
label: "Notifications",
target: "SettingsNotifications",
},
...connectionRows(Object.keys(savedConnectionsById).length),
]}
/>
);
}

function LocalSettingsRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
return <SettingsList connections={[environmentsRow(Object.keys(savedConnectionsById).length)]} />;
return <SettingsList connections={connectionRows(Object.keys(savedConnectionsById).length)} />;
}

function environmentsRow(count: number): SettingsListRow {
return {
id: "SettingsEnvironments",
icon: "desktopcomputer",
label: "Environments",
value: `${count}`,
valuePosition: "trailing",
target: "SettingsEnvironments",
};
function connectionRows(count: number): SettingsListRow[] {
return [
{
id: "SettingsEnvironments",
icon: "desktopcomputer",
label: "Environments",
value: `${count}`,
valuePosition: "trailing",
target: "SettingsEnvironments",
},
{
id: "SettingsNotifications",
icon: "bell.badge",
label: "Notifications",
target: "SettingsNotifications",
},
];
}
Comment on lines +141 to 158

function SettingsList(props: { readonly connections: readonly SettingsListRow[] }) {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const reactTests = [
"src/features/files/ZipArchiveView.test.tsx",
"src/features/pull-requests/guide/useReviewGuide.test.tsx",
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
"src/features/settings/SettingsRouteScreen.test.tsx",
"src/features/settings/components/BranchNamingSettings.test.tsx",
"src/features/settings/components/SettingsDragHandle.test.tsx",
"src/features/threads/ThreadMarkdownImage.test.tsx",
Expand Down
4 changes: 4 additions & 0 deletions wiki/log.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Wiki update log

## 2026-11-04

- **Compatibility boundary**: [Paired Android notifications](paired-notifications.md) must remain reachable from mobile Settings without T3 Connect configuration. Recorded the local-only menu entry lost during the November upstream integration and its regression coverage.

## 2026-11-02

- **Integration**: [PR #52](https://github.com/NicholasZolton/t3code/pull/52) reached fork `main` at `9a73f5dd04`. Refreshed [upstream integration](upstream-integration.md#completed-november-2-sync): all 3,337 incoming commits through `dd6c05ec31` are incorporated with ancestry preserved and zero main gap. The main local checkout is fast-forwarded; remote deployment remains separate.
Expand Down
24 changes: 23 additions & 1 deletion wiki/paired-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ tags: [fork, android, notifications, privacy]
status: experimental
generated:
by: opencode/gpt-6.1-sol
at: 2026-10-12T00:22:28Z
at: 2026-11-04T00:47:48Z
sources:
- id: paired-delivery
resource: ../apps/server/src/notifications/PairedNotifications.ts
Expand All @@ -17,6 +17,18 @@ sources:
- id: desktop-credentials
resource: ../apps/desktop/src/shell/DesktopShellEnvironment.ts
title: macOS Keychain fallback before local backend startup
- id: paired-settings-entry
resource: ../apps/mobile/src/features/settings/SettingsRouteScreen.tsx
title: Shared notification settings entry for local-only and Connect-configured builds
- id: paired-settings-test
resource: ../apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx
title: Notification settings remain visible without cloud configuration
- id: original-settings-entry
resource: https://github.com/NicholasZolton/t3code/commit/faf154ec9fa11671e1cdf348637ddd8899c94551
title: October 10 paired notifications added the local-only settings entry
- id: settings-regression-integration
resource: https://github.com/NicholasZolton/t3code/commit/ed52b86b6a9ffb6afad94980cc3d7d8cb5c851aa
title: November upstream settings integration omitted the fork's local-only notification entry
---

# Paired Android notifications
Expand All @@ -25,6 +37,8 @@ The fork supports Google notification delivery without routing activity through

Reuse the shared Connect notification policy and native presentation, not a second set of alert rules. Rich titles, status and thread routes are allowed inside encrypted paired payloads; do not send them to FCM in plaintext or add conversation content. The phone retains the private key and supplies only its public key through the existing authenticated registration. Preserve registration binding, freshness, ordering, opt-out, session revocation and independent per-environment state. Connect sign-out must not clear paired keys or activity. Thread access after a notification tap still requires the environment's normal authentication.[^paired-delivery]

Keep the mobile notification settings entry available without T3 Connect configuration. The paired settings screen already handles that build mode; gating its navigation on cloud configuration makes Android opt-in unreachable. The October 10 fork added this entry, but the November upstream settings integration dropped it from the local-only list. Shared connection rows and a cloud-configuration regression test preserve this boundary.[^paired-settings-entry][^paired-settings-test][^original-settings-entry][^settings-regression-integration]

Google still processes device identifiers and delivery metadata. This reduces transmitted content, not a compliance guarantee. Private binaries must retain their package and signing identity and use the same Firebase project as the sending server.[^paired-operations]

Finder/Dock launches cannot rely on a fnox-wrapped shell. The macOS desktop can load the existing Firebase credential from login Keychain before spawning its local server, while preserving an explicitly supplied environment variable. Keep this fallback desktop-local: it must not forward credentials to SSH hosts or require a checkout-specific secret manager.[^desktop-credentials]
Expand All @@ -34,3 +48,11 @@ Finder/Dock launches cannot rely on a fnox-wrapped shell. The macOS desktop can
[^paired-operations]: [Android notification operations](../docs/operations/android-notifications.md#self-hosted-paired-delivery).

[^desktop-credentials]: [Desktop startup environment](../apps/desktop/src/shell/DesktopShellEnvironment.ts).

[^paired-settings-entry]: [Mobile settings connections](../apps/mobile/src/features/settings/SettingsRouteScreen.tsx).

[^paired-settings-test]: [Cloud-independent notification navigation test](../apps/mobile/src/features/settings/SettingsRouteScreen.test.tsx).

[^original-settings-entry]: [Original paired-notification settings entry](https://github.com/NicholasZolton/t3code/commit/faf154ec9fa11671e1cdf348637ddd8899c94551).

[^settings-regression-integration]: [Upstream settings integration](https://github.com/NicholasZolton/t3code/commit/ed52b86b6a9ffb6afad94980cc3d7d8cb5c851aa).