Skip to content
Closed
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/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ export function HomeRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");
const handleSelectThread = useHomeThreadSelection();
const selectThread = useHomeThreadSelection();
const handleSelectThread = useCallback(
(thread: EnvironmentThreadShell) => {
if (thread.archivedAt !== null) {
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
params: { screen: "SettingsArchive" },
});
return;
}
selectThread(thread);
},
[navigation, selectThread],
);
const handleNewThreadOnBranch = useCallback(
(thread: EnvironmentThreadShell) => {
navigation.navigate("NewTaskSheet", {
Expand Down
67 changes: 58 additions & 9 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
import {
type EnvironmentProject,
type EnvironmentThreadShell,
presentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import {
threadSearchMatchKey,
Expand Down Expand Up @@ -33,6 +34,8 @@ import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { useThreadSearch } from "../../state/queries";
import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery";
import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
import { usePendingThreadOrder } from "../../state/thread-order";
import { threadListEnvironmentsAtom } from "../../state/server";
Expand All @@ -49,13 +52,15 @@ import {
import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
import {
buildThreadListV2Items,
createMobileThreadSearchContext,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
threadListV2ListItemsAreEqual,
threadListInboxReturns,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
sortThreadsByActivity,
} from "../threads/threadListV2";
import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences";
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
Expand Down Expand Up @@ -222,6 +227,10 @@ function HomeTopContentSpacer() {
/* ─── Main screen ────────────────────────────────────────────────────── */

export function HomeScreen(props: HomeScreenProps) {
const parsedSearch = useMemo(
() => parseThreadSearchQuery(props.searchQuery, { now: new Date() }),
[props.searchQuery],
);
const queuedThreadKeys = useQueuedThreadKeys();
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const insets = useSafeAreaInsets();
Expand All @@ -245,7 +254,25 @@ export function HomeScreen(props: HomeScreenProps) {
: [],
[props.environments, props.selectedEnvironmentId],
);
const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery);
const archivedSnapshots = useArchivedThreadSnapshots(
parsedSearch.filters.archived ? searchEnvironmentIds : [],
);
const searchableThreads = useMemo(
() =>
parsedSearch.filters.archived
? sortThreadsByActivity(
archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) =>
snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)),
),
)
: props.threads,
[archivedSnapshots.snapshots, parsedSearch.filters.archived, props.threads],
);
const threadSearch = useThreadSearch(
searchEnvironmentIds,
parsedSearch.text,
parsedSearch.filters.archived,
);
const threadSearchMatchByKey = useMemo(() => {
const matches = new Map<string, EnvironmentThreadSearchMatch>();
for (const match of threadSearch.matches) {
Expand Down Expand Up @@ -504,6 +531,22 @@ export function HomeScreen(props: HomeScreenProps) {
} = listEnvironments;
const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
const threadSearchContext = useMemo(
() =>
createMobileThreadSearchContext({
projects: projectByKey,
providers: providersByEnvironmentId,
environmentNames: (thread) =>
[
props.environments.find(
(environment) => environment.environmentId === thread.environmentId,
)?.label,
props.savedConnectionsById[thread.environmentId]?.environmentLabel,
thread.environmentId,
].filter((name): name is string => name !== undefined),
}),
[projectByKey, props.environments, props.savedConnectionsById, providersByEnvironmentId],
);
// Up/down menu availability for every card, computed once per section per
// rebuild (see computeThreadMoveAvailability): per-thread planner calls made
// list construction quadratic, and this list rebuilds on every minute tick.
Expand Down Expand Up @@ -544,14 +587,18 @@ export function HomeScreen(props: HomeScreenProps) {
]);
const threadListV2Layout = useMemo(() => {
threadListInboxReturns.observe(workingShelfEnabled ? props.threads : null);
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
// Archive searches use the snapshot instead of the live shells.
return buildThreadListV2Items({
pendingOrder,
threads: props.threads.filter((thread) => thread.archivedAt === null),
threads: searchableThreads.filter(
(thread) => parsedSearch.filters.archived || thread.archivedAt === null,
),
environmentId: props.selectedEnvironmentId,
projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
searchQuery: props.searchQuery,
searchQuery: parsedSearch.text,
searchFilters: parsedSearch.filters,
searchFilterContext: threadSearchContext,
searchNow: new Date(),
matchedThreadKeys,
settlementEnvironmentIds,
snoozeEnvironmentIds,
Expand All @@ -577,11 +624,13 @@ export function HomeScreen(props: HomeScreenProps) {
settledVisibleCount,
settlementEnvironmentIds,
snoozeEnvironmentIds,
props.searchQuery,
parsedSearch,
props.selectedEnvironmentId,
props.threads,
searchableThreads,
matchedThreadKeys,
v2ScopedProjectGroup,
threadSearchContext,
]);
// Re-partition the moment the earliest snooze expires (clamped to the
// signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
Expand All @@ -601,7 +650,7 @@ export function HomeScreen(props: HomeScreenProps) {
// they are spliced in below the active block and stay visible and deletable
// while their environment is offline. Same environment scope and search
// filter as the list itself.
const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
const v2SearchQuery = parsedSearch.text.trim().toLocaleLowerCase();
const v2PendingTasks = useMemo(
() =>
props.pendingTasks.filter(
Expand Down Expand Up @@ -745,7 +794,7 @@ export function HomeScreen(props: HomeScreenProps) {
threadId: thread.id,
}),
)}
searchQuery={props.searchQuery}
searchQuery={parsedSearch.text}
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
Expand Down Expand Up @@ -830,7 +879,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
listEnvironments,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
searchQuery: parsedSearch.text,
threadSearchMatchByKey,
// Rows read it for their reorder menu items.
workingShelfEnabled,
Expand Down
74 changes: 61 additions & 13 deletions apps/mobile/src/features/keyboard/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,17 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { T3KeyboardCommands } from "../../native/T3KeyboardCommands";
import { useProjects, useThreadShell, useThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import {
matchesThreadSearchFilters,
parseThreadSearchQuery,
} from "@t3tools/client-runtime/state/threadSearchQuery";
import { createMobileThreadSearchContext, sortThreadsByActivity } from "../threads/threadListV2";
import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
import { useArchivedThreadSnapshots } from "../archive/useArchivedThreadSnapshots";
import { useWorkspaceEnvironments } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { threadListEnvironmentsAtom } from "../../state/server";
import { useAtomValue } from "@effect/atom-react";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match";
Expand Down Expand Up @@ -143,11 +152,20 @@ export function CommandPalette(props: {
const runCommand = props.onCommand;
const projects = useProjects();
const threads = useThreadShells();
const projectByKey = useMemo(
() =>
new Map(
projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
),
[projects],
);
const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]);
const activeThread = useThreadShell(activeThreadRef);
const environments = useWorkspaceEnvironments();
const { savedConnectionsById } = useSavedRemoteConnections();
const { providersByEnvironmentId } = useAtomValue(threadListEnvironmentsAtom);
const [query, setQuery] = useState("");
const parsedSearch = useMemo(() => parseThreadSearchQuery(query, { now: new Date() }), [query]);
const [selection, setSelection] = useState<string | null>(null);
const [visible, setVisible] = useState(true);
const pendingAction = useRef<(() => void) | null>(null);
Expand All @@ -162,7 +180,38 @@ export function CommandPalette(props: {
.map((environment) => environment.environmentId),
[environments],
);
const search = useThreadSearch(searchEnvironmentIds, query.startsWith(">") ? "" : query);
const archivedSnapshots = useArchivedThreadSnapshots(
parsedSearch.filters.archived ? searchEnvironmentIds : [],
);
const searchableThreads = useMemo(
() =>
parsedSearch.filters.archived
? archivedSnapshots.snapshots.flatMap(({ environmentId, snapshot }) =>
snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)),
)
: threads,
[archivedSnapshots.snapshots, parsedSearch.filters.archived, threads],
);
const threadSearchContext = useMemo(
() =>
createMobileThreadSearchContext({
projects: projectByKey,
providers: providersByEnvironmentId,
environmentNames: (thread) =>
[
environments.find((environment) => environment.environmentId === thread.environmentId)
?.environmentLabel,
savedConnectionsById[thread.environmentId]?.environmentLabel,
thread.environmentId,
].filter((name): name is string => name !== undefined),
}),
[environments, projectByKey, providersByEnvironmentId, savedConnectionsById],
);
const search = useThreadSearch(
searchEnvironmentIds,
query.startsWith(">") ? "" : parsedSearch.text,
parsedSearch.filters.archived,
);
const matchedThreadKeys = useMemo(
() =>
new Set(search.matches.map((match) => scopedThreadKey(match.environmentId, match.threadId))),
Expand Down Expand Up @@ -260,9 +309,6 @@ export function CommandPalette(props: {
}),
},
];
const projectByKey = new Map(
projects.map((project) => [scopedProjectKey(project.environmentId, project.id), project]),
);
const activeProject = activeThread
? projectByKey.get(scopedProjectKey(activeThread.environmentId, activeThread.projectId))
: null;
Expand Down Expand Up @@ -316,13 +362,8 @@ export function CommandPalette(props: {
},
}),
}));
const threadItems: CommandPaletteItem[] = threads
.filter((thread) => thread.archivedAt === null)
.sort((left, right) =>
(right.latestUserMessageAt ?? right.updatedAt).localeCompare(
left.latestUserMessageAt ?? left.updatedAt,
),
)
const threadItems: CommandPaletteItem[] = sortThreadsByActivity(searchableThreads)
.filter((thread) => parsedSearch.filters.archived || thread.archivedAt === null)
.map((thread) => {
const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId));
const environment =
Expand All @@ -338,6 +379,10 @@ export function CommandPalette(props: {
thread.branch ?? "",
...threadPullRequestSearchTerms(thread),
],
matchesSearch: () =>
matchesThreadSearchFilters(thread, parsedSearch.filters, threadSearchContext, {
now: new Date(),
}),
run: () => selectThread(thread),
};
});
Expand All @@ -346,11 +391,14 @@ export function CommandPalette(props: {
activeThread,
activeThreadRef,
navigation,
projectByKey,
projects,
parsedSearch,
runCommand,
savedConnectionsById,
selectThread,
threads,
searchableThreads,
threadSearchContext,
]);
const results = useMemo(
() => filterCommandPaletteItems(items, query, matchedThreadKeys),
Expand Down Expand Up @@ -497,7 +545,7 @@ export function CommandPalette(props: {
searchMatch={
item.kind === "thread" ? contentMatchByKey.get(item.key) : undefined
}
searchQuery={query}
searchQuery={parsedSearch.text}
onSelect={() => close(item.run)}
/>
)}
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/features/keyboard/commandPaletteItems.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vite-plus/test";
import {
matchesThreadSearchFilters,
parseThreadSearchQuery,
} from "@t3tools/client-runtime/state/threadSearchQuery";

import {
filterCommandPaletteItems,
Expand Down Expand Up @@ -63,6 +67,38 @@ describe("filterCommandPaletteItems", () => {
).toEqual(["siva:one"]);
expect(filterCommandPaletteItems(items, "> message content", matches)).toEqual([]);
});

it("applies qualifiers before including a server content match", () => {
const now = new Date("2026-10-07T12:00:00.000Z");
const context = {
projectName: () => "Mobile",
environmentNames: () => ["Local"],
providerNames: () => ["Codex"],
status: () => "ready",
activityAt: (thread: { updatedAt: string }) => thread.updatedAt,
};
const candidates = ["2026-10-06T12:00:00.000Z", "2026-09-01T12:00:00.000Z"].map(
(updatedAt, index) => ({
...item(`thread:${index}`, "Unrelated title", "thread"),
matchesSearch: (query: string) =>
matchesThreadSearchFilters(
{ updatedAt, branch: null },
parseThreadSearchQuery(query, { now }).filters,
context,
{ now },
),
}),
);
const matches = new Set(candidates.map((candidate) => candidate.key));
expect(
filterCommandPaletteItems(candidates, "since:7d", matches).map((candidate) => candidate.key),
).toEqual(["thread:0"]);
expect(
filterCommandPaletteItems(candidates, "since:7d message", matches).map(
(candidate) => candidate.key,
),
).toEqual(["thread:0"]);
});
});

describe("nextPaletteIndex", () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/keyboard/commandPaletteItems.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { parseThreadSearchQuery } from "@t3tools/client-runtime/state/threadSearchQuery";

export interface CommandPaletteItem {
readonly key: string;
readonly kind: "action" | "project" | "thread";
readonly title: string;
readonly detail?: string;
readonly searchTerms: ReadonlyArray<string>;
readonly run: () => void;
readonly matchesSearch?: (query: string) => boolean;
}

/** `>` narrows to actions, matching the desktop palette. Stable ties retain recent-thread order. */
Expand All @@ -14,11 +17,13 @@ export function filterCommandPaletteItems(
matchedThreadKeys: ReadonlySet<string>,
) {
const actionsOnly = query.startsWith(">");
const normalized = (actionsOnly ? query.slice(1) : query).trim().toLocaleLowerCase();
const parsed = parseThreadSearchQuery(actionsOnly ? query.slice(1) : query, { now: new Date() });
const normalized = parsed.text.trim().toLocaleLowerCase();
const tokens = normalized.split(/\s+/);
return items
.flatMap((item, index) => {
if (actionsOnly && item.kind !== "action") return [];
if (item.kind === "thread" && item.matchesSearch && !item.matchesSearch(query)) return [];
if (!normalized) return item.kind === "project" ? [] : [{ item, rank: 0, index }];
const title = item.title.toLocaleLowerCase();
const haystack = [title, ...item.searchTerms].join(" ").toLocaleLowerCase();
Expand Down
Loading
Loading