Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b59f7ae
feat: rebuild Home agent on linked environments
PixPMusic Oct 29, 2026
b75bbd9
fix(home): preserve linked identity and native navigation
PixPMusic Oct 29, 2026
3414f09
fix(home): keep watch coverage and enforce folder ownership
PixPMusic Oct 29, 2026
2ec725d
fix(home): retain offline watches and reject revoked work
PixPMusic Oct 29, 2026
8c4fb83
fix(home): serialize revocation checks with thread stop
PixPMusic Oct 29, 2026
255a86d
fix(home): recover watchers and retain empty-list navigation
PixPMusic Oct 29, 2026
48c0681
fix(home): preserve launch results and navigation entry points
PixPMusic Oct 29, 2026
4755801
fix(home): retain message matches in command palettes
PixPMusic Oct 29, 2026
fe188f7
fix(mobile): make Home the first navigation tab
PixPMusic Oct 29, 2026
a3409e3
test(home): align service fixtures with current main
PixPMusic Oct 29, 2026
4d8f521
fix(mobile): retain Home through split expansion
PixPMusic Oct 29, 2026
0f8cf29
feat(mobile): switch Homes from the title and compose new chats
PixPMusic Oct 29, 2026
6b4b012
fix(mobile): retain Home navigation through draft and reconnect flows
PixPMusic Oct 29, 2026
5ba335f
fix(mobile): keep the project picker cancellation marker while blurred
PixPMusic Oct 29, 2026
819d82f
test(home): update the idle scheduler fixture for managed folders
PixPMusic Oct 29, 2026
10a921a
test(web): isolate editor shortcut announcements between cases
PixPMusic Oct 29, 2026
96dac00
fix(home): guard metadata updates into the managed folder
PixPMusic Oct 30, 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
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class T3KeyboardCommandsView(
"copyThreadReference"
event.keyCode == KeyEvent.KEYCODE_H && event.isShiftPressed && !event.isAltPressed ->
"cycleHost"
event.keyCode == KeyEvent.KEYCODE_0 && event.isShiftPressed && !event.isAltPressed ->
"home.open"
else -> null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ class T3WorkspaceNavigationView(
private val onTabSelected by EventDispatcher()
private val onNewThread by EventDispatcher()
private val onOverlayChange by EventDispatcher()
var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings"))
var selectedTab by mutableStateOf("threads")
var tabs by mutableStateOf(listOf("home", "threads", "pullRequests", "usage", "settings"))
var selectedTab by mutableStateOf("home")
var rail by mutableStateOf(false)
var floating by mutableStateOf(false)
var dark by mutableStateOf(false)
Expand Down Expand Up @@ -321,7 +321,7 @@ class T3WorkspaceNavigationView(
}
}
// Other tabs keep the button's slot so the destinations below never move between tabs.
if (selectedTab == "threads") {
if (selectedTab == "threads" || selectedTab == "home") {
newThreadButton(fraction) {
expanded = false
onNewThread(emptyMap<String, Any>())
Expand Down Expand Up @@ -413,6 +413,7 @@ class T3WorkspaceNavigationView(
}

private fun tabTitle(tab: String) = when (tab) {
"home" -> "Home"
"threads" -> "Chats"
"pullRequests" -> "Pulls"
"usage" -> "Usage"
Expand All @@ -425,6 +426,7 @@ class T3WorkspaceNavigationView(
@Composable
private fun tabIconPainter(tab: String) = painterResource(
when (tab) {
"home" -> R.drawable.t3_home
"threads" -> R.drawable.t3_chats
"pullRequests" -> R.drawable.t3_pull_requests
"usage" -> R.drawable.t3_usage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000"
android:pathData="M12,3L2,12h3v9h6v-6h2v6h6v-9h3L12,3zM17,19h-2v-6H9v6H7v-8.8l5,-4.5l5,4.5V19z" />
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public final class T3KeyboardCommandsView: ExpoView {
),
enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"),
enabledCommand("cycleHost", input: "h", modifiers: [.command, .shift], action: #selector(cycleHost), title: "Next Machine"),
enabledCommand("home.open", input: "0", modifiers: [.command, .shift], action: #selector(openHome), title: "Open Home"),
].compactMap { $0 }
if isPad {
commands += (1...9).compactMap { index in
Expand Down Expand Up @@ -158,6 +159,7 @@ public final class T3KeyboardCommandsView: ExpoView {
@objc private func copyThreadReference() { emit("copyThreadReference") }
@objc private func handleToggleSidebar() { emit("toggleSidebar") }
@objc private func cycleHost() { emit("cycleHost") }
@objc private func openHome() { emit("home.open") }

private func emit(_ command: String) {
onCommand(["command": command])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
private let composeButton = UIButton(type: .system)
private lazy var tabBarTrailing = tabBar.trailingAnchor.constraint(equalTo: trailingAnchor)
private var itemsByTab: [String: UITabBarItem] = [:]
var tabs = ["threads", "pullRequests", "usage", "settings"] {
var tabs = ["home", "threads", "pullRequests", "usage", "settings"] {
didSet { if tabs != oldValue { updateItems() } }
}
var selectedTab = "threads" { didSet { updateSelection() } }
var selectedTab = "home" { didSet { updateSelection() } }
var selectedColor: UIColor = .systemBlue { didSet { tabBar.tintColor = selectedColor } }
var compact = false {
didSet {
Expand Down Expand Up @@ -99,13 +99,14 @@ final class T3SidebarTabBarView: ExpoView, UITabBarDelegate {
private func prepareItems() {
let iconBundle = Bundle(for: Self.self).url(forResource: "T3NativeControlIcons", withExtension: "bundle").flatMap(Bundle.init(url:))
let pullRequestIcon = UIImage(named: "pull-request", in: iconBundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate)
let labels = ["threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"]
let symbols = ["threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"]
let labels = ["home": "Home", "threads": "Chats", "pullRequests": "PRs", "usage": "Usage", "settings": "Settings"]
let symbols = ["home": "house", "threads": "text.bubble", "usage": "chart.bar.xaxis", "settings": "gearshape"]
for (tab, label) in labels {
let icon = tab == "pullRequests" ? pullRequestIcon : UIImage(systemName: symbols[tab] ?? "circle")
let item = UITabBarItem(title: nil, image: tabIcon(icon), tag: 0)
item.accessibilityLabel = label
item.accessibilityIdentifier = "workspace-tab-\(tab)"
if tab == "home" { item.selectedImage = tabIcon(UIImage(systemName: "house.fill")) }
if tab == "threads" { item.selectedImage = tabIcon(UIImage(systemName: "text.bubble.fill")) }
itemsByTab[tab] = item
}
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-na
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle";
import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen";
import { HomeAgentRouteScreen } from "./features/home-agent/HomeAgentRouteScreen";
import {
recordHomeFocus,
recordHomeTransitionEnd,
Expand Down Expand Up @@ -129,6 +130,7 @@ import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
import { SettingsAboutRouteScreen } from "./features/settings/SettingsAboutRouteScreen";
import { SettingsNotificationsRouteScreen } from "./features/settings/SettingsNotificationsRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { SettingsHomeRouteScreen } from "./features/settings/SettingsHomeRouteScreen";
import { SettingsLabsRouteScreen } from "./features/settings/ThreadSettingsSections";
import { SettingsEnvironmentFilterProvider } from "./features/settings/settings-environment-filter";
import { openSettings, settingsOpenIn } from "./features/settings/settings-navigation";
Expand Down Expand Up @@ -302,6 +304,11 @@ const SETTINGS_SCREENS = {
linking: "settings/thread-preferences",
options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" },
}),
SettingsHome: createNativeStackScreen({
screen: SettingsHomeRouteScreen,
linking: "settings/home",
options: { ...SETTINGS_PAGE_OPTIONS, title: "Home" },
}),
SettingsAbout: createNativeStackScreen({
screen: SettingsAboutRouteScreen,
linking: "settings/about",
Expand Down Expand Up @@ -726,6 +733,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
linking: { path: "threads/:environmentId?/:threadId?", alias: ["new/draft"] },
options: THREAD_HEADER_OPTIONS,
}),
HomeAgent: createNativeStackScreen({
screen: HomeAgentRouteScreen,
linking: "home-agent/:environmentId/:threadId",
options: { ...THREAD_HEADER_OPTIONS, gestureEnabled: false, animation: "none" },
}),
NewTaskProject: createNativeStackScreen({
screen: NewTaskRouteScreen,
linking: { path: "new", alias: ["new/project"] },
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/ui/AppBar.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function AppBar(props: AppBarProps) {
const header = (
<AndroidScreenHeader
title={props.title}
titleContent={props.titleContent}
subtitle={props.subtitle}
onBack={props.onBack}
leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/ui/AppBar.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface AppBarSearch {

export interface AppBarProps {
readonly title: string;
readonly titleContent?: ReactNode;
readonly subtitle?: string;
readonly actions?: ReadonlyArray<AppBarAction>;
readonly menus?: ReadonlyArray<AppBarMenu>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ const SUPPRESS: NotificationBehavior = {
export function foregroundNotificationBehavior(
notification: Notification,
deepLinkOnScreen: string | null,
handledByHome = false,
): NotificationBehavior {
const target = extractAgentNotificationDeepLink({ notification });
if (target !== null && target === deepLinkOnScreen) {
if (handledByHome || (target !== null && target === deepLinkOnScreen)) {
return SUPPRESS;
}
return SHOW;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { consumeLastAgentNotificationResponse } from "./notificationResponseCons
import {
extractAgentNotificationDeepLink,
routeAgentNotificationResponseOnce,
threadDeepLinkOnScreen,
} from "./notificationPayload";

function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
Expand Down Expand Up @@ -53,6 +54,9 @@ describe("consumeLastAgentNotificationResponse", () => {

describe("extractAgentNotificationDeepLink", () => {
it("uses explicit deep links from APNs payload data", () => {
expect(threadDeepLinkOnScreen("/home-agent/env/home%3Aagent")).toBe(
"/threads/env/home%3Aagent",
);
expect(
extractAgentNotificationDeepLink(
responseWithData({
Expand Down
24 changes: 21 additions & 3 deletions apps/mobile/src/features/agent-awareness/notificationNavigation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import * as Notifications from "expo-notifications";
import { isHomeWatchedLaunch } from "@t3tools/client-runtime/state/home";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import { parseActiveThreadPath } from "../keyboard/hardwareKeyboardCommands";

import { setAndroidThreadOnScreen } from "./androidNotifications";
import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior";
import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload";
import {
extractAgentNotificationDeepLink,
routeAgentNotificationResponseOnce,
threadDeepLinkOnScreen,
} from "./notificationPayload";
import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";

let deepLinkOnScreen: string | null = null;
Expand All @@ -21,8 +29,18 @@ export function setAgentNotificationPathname(pathname: string): void {
export function installAgentNotificationNavigation(navigate: (href: string) => void): () => void {
const handledResponseIds = new Set<string>();
Notifications.setNotificationHandler({
handleNotification: (notification) =>
Promise.resolve(foregroundNotificationBehavior(notification, deepLinkOnScreen)),
handleNotification: (notification) => {
const link = extractAgentNotificationDeepLink({ notification });
const target = link === null ? null : parseActiveThreadPath(link);
const handledByHome =
target !== null &&
[...appAtomRegistry.get(environmentServerConfigsAtom).values()].some((config) =>
isHomeWatchedLaunch(config.settings.home, target),
);
return Promise.resolve(
foregroundNotificationBehavior(notification, deepLinkOnScreen, handledByHome),
);
},
});

const handleResponse = (response: Notifications.NotificationResponse): void => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function normalizeThreadDeepLink(value: string): string | null {
}

export function threadDeepLinkOnScreen(pathname: string): string | null {
const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
const match = /^\/(?:threads|home-agent)\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
if (!match?.[1] || !match[2]) {
return null;
}
Expand Down
99 changes: 99 additions & 0 deletions apps/mobile/src/features/home-agent/HomeAgentRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
StackActions,
useFocusEffect,
useNavigation,
type StaticScreenProps,
} from "@react-navigation/native";
import { useCallback } from "react";
import { EnvironmentId } from "@t3tools/contracts";
import { LoadingScreen } from "../../components/LoadingScreen";
import { useEnvironmentServerConfig } from "../../state/entities";
import { resetAndroidWorkspace } from "../../lib/android-workspace-navigation";
import { useWindowDimensions } from "react-native";
import {
deriveFileInspectorPaneLayout,
deriveLayout,
deriveWorkspacePaneLayout,
} from "../../lib/layout";
import {
NativePrimaryColumnContext,
NativeWorkspaceModeContext,
} from "../../native/v5-workspace-context";
import {
AdaptiveWorkspaceContext,
useAdaptiveWorkspaceLayout,
} from "../layout/adaptive-workspace-context";
import { InspectorContentScope } from "../threads/thread-inspector-preview";
import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";

const layout = deriveLayout({ width: 0, height: 0 });
const fileInspector = deriveFileInspectorPaneLayout({ layout, viewportWidth: 0 });
const ignore = () => undefined;
const register = () => ignore;

export function HomeAgentRouteScreen(
props: StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
}>,
) {
const workspace = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const tabs = useWorkspaceTabs();
const config = useEnvironmentServerConfig(EnvironmentId.make(props.route.params.environmentId));
const configuredThreadId = config?.settings.home.threadId;
useFocusEffect(
useCallback(() => {
if (configuredThreadId === undefined) return;
if (configuredThreadId === null) {
tabs?.dispatch({ type: "select", tab: "home" });
resetAndroidWorkspace(navigation);
} else if (configuredThreadId !== props.route.params.threadId) {
navigation.dispatch(
StackActions.replace("HomeAgent", {
...props.route.params,
threadId: configuredThreadId,
}),
);
}
}, [configuredThreadId, navigation, props.route.params, tabs]),
);
const { width } = useWindowDimensions();
const panes = deriveWorkspacePaneLayout({
layout,
viewportWidth: width,
primarySidebarPreferredVisible: false,
auxiliaryPanePreferredVisible: false,
});
if (configuredThreadId !== undefined && configuredThreadId !== props.route.params.threadId)
return <LoadingScreen message="Opening Home…" />;
return (
<NativeWorkspaceModeContext value={false}>
<NativePrimaryColumnContext value={null}>
<AdaptiveWorkspaceContext
value={{
...workspace,
layout,
panes,
fileInspector,
selectDetail: undefined,
activateAuxiliaryPaneRole: register,
registerWorkspaceInspector: register,
showAuxiliaryPane: ignore,
toggleAuxiliaryPane: ignore,
togglePrimarySidebar: ignore,
setPrimarySidebarVisible: ignore,
setComposerEditing: ignore,
composerKeyboardActive: false,
sidebarByOrientation: null,
}}
>
<InspectorContentScope>
<ThreadRouteScreen {...props} />
</InspectorContentScope>
</AdaptiveWorkspaceContext>
</NativePrimaryColumnContext>
</NativeWorkspaceModeContext>
);
}
Loading
Loading