Skip to content
Open
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
122 changes: 66 additions & 56 deletions apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import type {
SidebarProjectGroupingMode,
SidebarThreadSortOrder,
} from "@t3tools/contracts";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useCallback, useRef } from "react";
import {
NativeHeaderToolbar,
NativeStackScreenOptions,
type AppNativeStackNavigationOptions,
} from "../../native/StackHeader";
import { useCallback, useMemo, useRef } from "react";
import { Platform } from "react-native";
import type { SearchBarCommands } from "react-native-screens";

Expand Down Expand Up @@ -43,70 +47,76 @@ export function HomeHeader(props: {
readonly onStartNewTask: () => void;
}) {
const searchBarRef = useRef<SearchBarCommands>(null);
const propsRef = useRef(props);
propsRef.current = props;
const iconColor = useThemeColor("--color-icon");
const hasCustomListOptions = hasCustomHomeListOptions(props);
const focusSearch = useCallback(() => {
searchBarRef.current?.focus();
return searchBarRef.current !== null;
}, []);
useHardwareKeyboardCommand("focusSearch", focusSearch);
const filterMenu = buildHomeListFilterMenu(props);
const screenOptions = useMemo((): AppNativeStackNavigationOptions => {
return {
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: () => propsRef.current.onOpenSettings(),
type: "button",
}),
]
: undefined,
unstable_headerToolbarItems:
Platform.OS === "ios"
? () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
// Build at invocation time so filter onPress handlers always
// read current callbacks via propsRef, matching compose/settings.
filterMenu: buildHomeListFilterMenu(propsRef.current),
filterButtonId: "home-filter",
filterSystemImageName: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease",
onComposePress: () => propsRef.current.onStartNewTask(),
onSearchTextChange: (query) => propsRef.current.onSearchQueryChange(query),
placeholder: "Search",
searchTextChangeId: "home-search-text",
}),
]
: undefined,
headerSearchBarOptions:
Platform.OS === "ios"
? undefined
: {
ref: searchBarRef,
allowToolbarIntegration: true,
hideNavigationBar: false,
placeholder: "Search",
onCancelButtonPress: () => propsRef.current.onSearchQueryChange(""),
onChangeText: (event) => propsRef.current.onSearchQueryChange(event.nativeEvent.text),
},
};
}, [
hasCustomListOptions,
iconColor,
props.environments,
props.projectGroupingMode,
props.projectSortOrder,
props.selectedEnvironmentId,
props.threadSortOrder,
]);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

return (
<>
<NativeStackScreenOptions
options={{
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: props.onOpenSettings,
type: "button",
}),
]
: undefined,
unstable_headerToolbarItems:
Platform.OS === "ios"
? () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
filterMenu,
filterButtonId: "home-filter",
filterSystemImageName: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease",
onComposePress: props.onStartNewTask,
onSearchTextChange: props.onSearchQueryChange,
placeholder: "Search",
searchTextChangeId: "home-search-text",
}),
]
: undefined,
headerSearchBarOptions:
Platform.OS === "ios"
? undefined
: {
ref: searchBarRef,
allowToolbarIntegration: true,
hideNavigationBar: false,
placeholder: "Search",
onCancelButtonPress: () => {
props.onSearchQueryChange("");
},
onChangeText: (event) => {
props.onSearchQueryChange(event.nativeEvent.text);
},
},
}}
/>
<NativeStackScreenOptions options={screenOptions} />

{Platform.OS === "ios" ? null : (
<NativeHeaderToolbar placement="right">
Expand Down
29 changes: 18 additions & 11 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Arr from "effect/Array";
import * as Order from "effect/Order";
import { useNavigation } from "@react-navigation/native";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";

import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects, useThreadShells } from "../../state/entities";
Expand All @@ -17,6 +17,9 @@ import { useHomeListOptions } from "./home-list-options";
import { usePendingTaskListActions } from "./usePendingTaskListActions";
import { useThreadListActions } from "./useThreadListActions";

const EMPTY_HOME_TITLE_OPTIONS = { title: "", headerTitle: "" } as const;
const THREADS_HOME_TITLE_OPTIONS = { title: "Threads", headerTitle: "Threads" } as const;

/* ─── Route screen ───────────────────────────────────────────────────── */

export function HomeRouteScreen() {
Expand Down Expand Up @@ -56,45 +59,49 @@ export function HomeRouteScreen() {
setThreadSortOrder,
} = useHomeListOptions(availableEnvironmentIds);
const selectedEnvironmentId = listOptions.selectedEnvironmentId;
const openSettings = useCallback(() => {
navigation.navigate("SettingsSheet", { screen: "Settings" });
}, [navigation]);
const openNewTask = useCallback(() => {
navigation.navigate("NewTaskSheet", { screen: "NewTask" });
}, [navigation]);

// In split layouts the persistent sidebar IS the thread list — Home becomes
// an empty detail pane so selecting a thread never transitions layouts.
if (layout.usesSplitView) {
return (
<>
<NativeStackScreenOptions options={{ title: "", headerTitle: "" }} />
<NativeStackScreenOptions options={EMPTY_HOME_TITLE_OPTIONS} />
<WorkspaceSidebarToolbar
afterSidebarButton={
<NativeHeaderToolbar.Button
accessibilityLabel="New task"
icon="square.and.pencil"
onPress={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
onPress={openNewTask}
/>
}
/>
<WorkspaceEmptyDetail
onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
/>
<WorkspaceEmptyDetail onStartNewTask={openNewTask} />
</>
);
}

return (
<>
{/* Restore the compact title in case the split branch blanked it. */}
<NativeStackScreenOptions options={{ title: "Threads", headerTitle: "Threads" }} />
<NativeStackScreenOptions options={THREADS_HOME_TITLE_OPTIONS} />
<HomeHeader
environments={environments}
selectedEnvironmentId={selectedEnvironmentId}
projectSortOrder={listOptions.projectSortOrder}
threadSortOrder={listOptions.threadSortOrder}
projectGroupingMode={listOptions.projectGroupingMode}
onEnvironmentChange={setSelectedEnvironmentId}
onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
onOpenSettings={openSettings}
onProjectGroupingModeChange={setProjectGroupingMode}
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
onStartNewTask={openNewTask}
onThreadSortOrderChange={setThreadSortOrder}
/>

Expand All @@ -110,7 +117,7 @@ export function HomeRouteScreen() {
onOpenEnvironments={() =>
navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
}
onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
onOpenSettings={openSettings}
onProjectGroupingModeChange={setProjectGroupingMode}
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
Expand All @@ -132,7 +139,7 @@ export function HomeRouteScreen() {
},
});
}}
onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
onStartNewTask={openNewTask}
onThreadSortOrderChange={setThreadSortOrder}
pendingTasks={pendingTasks}
projectGroupingMode={listOptions.projectGroupingMode}
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,54 @@ describe("buildHomeThreadGroups", () => {
expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]);
});

it("excludes subagent threads", () => {
const environmentId = EnvironmentId.make("environment-1");
const project = makeProject({
environmentId,
id: ProjectId.make("project-1"),
title: "T3 Code",
});
const parentThreadId = ThreadId.make("thread-parent");
const forkThreadId = ThreadId.make("thread-fork");
const threads = [
makeThread({
environmentId,
id: parentThreadId,
projectId: project.id,
title: "Parent thread",
}),
makeThread({
environmentId,
id: forkThreadId,
projectId: project.id,
title: "Forked thread",
lineage: {
rootThreadId: parentThreadId,
parentThreadId,
relationshipToParent: "fork",
},
}),
makeThread({
environmentId,
id: ThreadId.make("thread-subagent"),
projectId: project.id,
title: "Continue the delegated task",
lineage: {
rootThreadId: parentThreadId,
parentThreadId,
relationshipToParent: "subagent",
},
}),
];

const groups = buildGroups([project], threads);

expect(groups).toHaveLength(1);
expect(groups[0]?.threads.map((thread) => thread.id).toSorted()).toEqual(
[parentThreadId, forkThreadId].toSorted(),
);
});

it("matches web repository, repository-path, and separate grouping modes", () => {
const environmentId = EnvironmentId.make("environment-1");
const repositoryIdentity = {
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ export function buildHomeThreadGroups(input: {
if (thread.archivedAt !== null) {
continue;
}
if (thread.lineage.relationshipToParent === "subagent") {
continue;
}
if (input.environmentId !== null && thread.environmentId !== input.environmentId) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@t3tools/client-runtime/state/thread-relationships";
import { canDetachThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
import { copySorted } from "@t3tools/shared/Array";
import { useNavigation } from "@react-navigation/native";
import { useMemo, useState } from "react";
import { ActivityIndicator, Modal, Pressable, ScrollView, View } from "react-native";
Expand Down Expand Up @@ -78,7 +79,8 @@ export function ThreadRelationshipsBanner(props: {
const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
const rows = useMemo(
() =>
immediateThreadRelationships(graph, props.threadId).toSorted(
copySorted(
immediateThreadRelationships(graph, props.threadId),
(left, right) =>
Number(right.threadId === mergeTargetThreadId) -
Number(left.threadId === mergeTargetThreadId),
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/state/use-thread-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import * as Option from "effect/Option";
import { copySorted } from "@t3tools/shared/Array";

import { useProject, useThreadShell } from "../state/entities";
import { useEnvironmentThread } from "../state/threads";
Expand Down Expand Up @@ -56,7 +57,7 @@ function threadDetailToShell(
projection: OrchestrationV2ThreadProjection,
): EnvironmentThreadShell {
const thread = projection.thread;
const runsByOrdinal = projection.runs.toSorted((left, right) => right.ordinal - left.ordinal);
const runsByOrdinal = copySorted(projection.runs, (left, right) => right.ordinal - left.ordinal);
const latestRun = runsByOrdinal[0] ?? null;
const activeRun =
runsByOrdinal.find(
Expand Down
Loading
Loading