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
23 changes: 21 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useThreadVisits } from "../../state/thread-visits";
import {
PendingTaskListRow,
ThreadListGroupHeader,
Expand Down Expand Up @@ -190,6 +191,7 @@ export function HomeScreen(props: HomeScreenProps) {
ReadonlyMap<string, HomeGroupDisplayState>
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const { lastVisitedAtByThreadKey } = useThreadVisits();
const threadListV2Enabled = useThreadListV2Enabled();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
Expand Down Expand Up @@ -664,6 +666,9 @@ export function HomeScreen(props: HomeScreenProps) {
(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId),
)?.driver ?? null
}
lastVisitedAt={
lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null
}
environmentLabel={
Object.keys(props.savedConnectionsById).length > 1
? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
Expand Down Expand Up @@ -698,6 +703,7 @@ export function HomeScreen(props: HomeScreenProps) {
handleSwipeableClose,
handleSwipeableWillOpen,
handleUnsettleThread,
lastVisitedAtByThreadKey,
projectByKey,
projectCwdByKey,
props.onArchiveThread,
Expand All @@ -722,6 +728,7 @@ export function HomeScreen(props: HomeScreenProps) {
projectByKey,
projectCwdByKey,
projectTitleByProjectKey: v2ProjectTitleByProjectKey,
lastVisitedAtByThreadKey,
serverConfigs,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
Expand All @@ -730,6 +737,7 @@ export function HomeScreen(props: HomeScreenProps) {
[
projectByKey,
projectCwdByKey,
lastVisitedAtByThreadKey,
props.searchQuery,
props.savedConnectionsById,
serverConfigs,
Expand All @@ -741,11 +749,18 @@ export function HomeScreen(props: HomeScreenProps) {
const extraData = useMemo(
() => ({
projectCwdByKey,
lastVisitedAtByThreadKey,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
threadSearchMatchByKey,
}),
[projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey],
[
projectCwdByKey,
lastVisitedAtByThreadKey,
props.savedConnectionsById,
props.searchQuery,
threadSearchMatchByKey,
],
);

const renderItem = useCallback(
Expand Down Expand Up @@ -797,6 +812,9 @@ export function HomeScreen(props: HomeScreenProps) {
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
null
}
lastVisitedAt={
lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null
}
isLast={item.isLast}
searchMatch={threadSearchMatchByKey.get(
threadSearchMatchKey({
Expand Down Expand Up @@ -828,6 +846,7 @@ export function HomeScreen(props: HomeScreenProps) {
[
handleSwipeableClose,
handleSwipeableWillOpen,
lastVisitedAtByThreadKey,
projectCwdByKey,
props.onArchiveThread,
props.onDeletePendingTask,
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useThreadVisits } from "../../state/thread-visits";
import { useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
Expand Down Expand Up @@ -185,6 +186,7 @@ function ThreadNavigationSidebarPane(
const colorScheme = useColorScheme() === "dark" ? "dark" : "light";
const projects = useProjects();
const threads = useThreadShells();
const { lastVisitedAtByThreadKey } = useThreadVisits();
const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
const { savedConnectionsById } = useSavedRemoteConnections();
const [headerIsOverContent, setHeaderIsOverContent] = useState(false);
Expand Down Expand Up @@ -729,6 +731,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectCwdByKey,
projectTitleByProjectKey,
lastVisitedAtByThreadKey,
savedConnectionsById,
serverConfigs,
threadSearchMatchByKey,
Expand All @@ -738,6 +741,7 @@ function ThreadNavigationSidebarPane(
projectByKey,
projectCwdByKey,
projectTitleByProjectKey,
lastVisitedAtByThreadKey,
savedConnectionsById,
serverConfigs,
threadSearchMatchByKey,
Expand Down Expand Up @@ -850,6 +854,9 @@ function ThreadNavigationSidebarPane(
}),
)}
searchQuery={props.searchQuery}
lastVisitedAt={
lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null
}
pane="sidebar"
selected={
scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
Expand Down Expand Up @@ -928,6 +935,9 @@ function ThreadNavigationSidebarPane(
projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ??
null
}
lastVisitedAt={
lastVisitedAtByThreadKey[scopedThreadKey(thread.environmentId, thread.id)] ?? null
}
isLast={item.isLast}
searchMatch={threadSearchMatchByKey.get(
threadSearchMatchKey({
Expand Down Expand Up @@ -969,6 +979,7 @@ function ThreadNavigationSidebarPane(
handleSelectThread,
handleSwipeableClose,
handleSwipeableWillOpen,
lastVisitedAtByThreadKey,
openPendingTask,
projectByKey,
projectCwdByKey,
Expand Down
46 changes: 44 additions & 2 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
StackActions,
useFocusEffect,
useIsFocused,
useNavigation,
type StaticScreenProps,
} from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import * as Option from "effect/Option";
import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { Platform, ScrollView, View } from "react-native";
import { AppState, Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useWorkspaceState } from "../../state/workspace";
import { useEnvironmentQuery } from "../../state/query";
Expand Down Expand Up @@ -58,6 +67,11 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
import {
resolveOpenThreadVisitedAt,
shouldMarkThreadVisited,
useThreadVisits,
} from "../../state/thread-visits";
import { threadEnvironment } from "../../state/threads";
import { projectThreadContentPresentation } from "./threadContentPresentation";
import {
Expand All @@ -79,6 +93,15 @@ interface ThreadInspectorSelection {

type NativeHeaderItems = ReadonlyArray<Record<string, unknown>>;

function subscribeToAppState(onStoreChange: () => void): () => void {
const subscription = AppState.addEventListener("change", onStoreChange);
return () => subscription.remove();
}

function getAppStateSnapshot() {
return AppState.currentState;
}

function InspectorPaneRoleActivation() {
useAdaptiveWorkspacePaneRole("inspector");
return null;
Expand Down Expand Up @@ -188,6 +211,13 @@ function ThreadRouteContent(
const { onReconnectEnvironment } = useRemoteConnections();
const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
useThreadSelection();
const isFocused = useIsFocused();
const appState = useSyncExternalStore(
subscribeToAppState,
getAppStateSnapshot,
getAppStateSnapshot,
);
const { markVisited } = useThreadVisits();
const selectedThreadDetailState = props.selectedThreadDetailState;
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
const { selectedThreadCwd } = useSelectedThreadWorktree();
Expand All @@ -203,6 +233,18 @@ function ThreadRouteContent(
const threadId = firstRouteParam(params.threadId);
const routeThreadIdentity =
environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;
const openThreadVisitedAt =
selectedThread === null ? null : resolveOpenThreadVisitedAt(selectedThread);
useEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (
!shouldMarkThreadVisited({ appState, isFocused }) ||
routeThreadIdentity === null ||
openThreadVisitedAt === null
) {
return;
}
markVisited(routeThreadIdentity, openThreadVisitedAt);
}, [appState, isFocused, markVisited, openThreadVisitedAt, routeThreadIdentity]);
const [inspectorSelection, setInspectorSelection] = useState<ThreadInspectorSelection | null>(
() => (props.renderInspector ? { routeThreadIdentity, mode: "route" } : null),
);
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
readonly projectCwd: string | null;
readonly searchMatch?: EnvironmentThreadSearchMatch;
readonly searchQuery?: string;
readonly lastVisitedAt?: string | null;
readonly isLast: boolean;
/** Sidebar only: the thread currently open in the detail pane. */
readonly selected?: boolean;
Expand Down Expand Up @@ -450,7 +451,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
const selectedBackgroundColor = useThemeColor("--color-user-bubble");

const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props;
const status = resolveThreadStatus(thread);
const status = resolveThreadStatus(thread, props.lastVisitedAt);
const pr = useThreadPr(thread, props.projectCwd);
const timestamp = relativeTime(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const STATUS_LABEL_BY_STATUS: Partial<
input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" },
working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
done: { label: "Done", className: "text-emerald-700 dark:text-emerald-300" },
};

function threadTimeLabel(thread: EnvironmentThreadShell): string {
Expand Down Expand Up @@ -212,6 +213,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
readonly project: EnvironmentProject | null;
readonly projectTitle?: string;
readonly providerDriver: string | null;
readonly lastVisitedAt?: string | null;
/** Which machine hosts the thread. Null when only one environment is
connected — repeating the same label on every row is noise. Mirrors
the web sidebar's remote-environment cloud icon, but as text since
Expand Down Expand Up @@ -277,7 +279,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;

const status = resolveThreadListV2Status(thread);
const status = resolveThreadListV2Status(thread, { lastVisitedAt: props.lastVisitedAt });
const statusLabel = STATUS_LABEL_BY_STATUS[status];
const timeLabel = threadTimeLabel(thread);

Expand Down
94 changes: 94 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,100 @@ describe("resolveThreadListV2Status", () => {
"ready",
);
});

it("resolves done when a turn completed after the thread was last visited", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "completed",
requestedAt: "2026-06-01T12:00:00.000Z",
startedAt: "2026-06-01T12:00:01.000Z",
completedAt: "2026-06-01T12:01:00.000Z",
assistantMessageId: MessageId.make("assistant-1"),
},
});

expect(
resolveThreadListV2Status(thread, {
lastVisitedAt: "2026-06-01T12:00:30.000Z",
}),
).toBe("done");
});

it("keeps a completion read when the thread was visited at completion", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "completed",
requestedAt: "2026-06-01T12:00:00.000Z",
startedAt: "2026-06-01T12:00:01.000Z",
completedAt: "2026-06-01T12:01:00.000Z",
assistantMessageId: MessageId.make("assistant-1"),
},
});

expect(
resolveThreadListV2Status(thread, {
lastVisitedAt: "2026-06-01T12:01:00.000Z",
}),
).toBe("ready");
});

it("does not label an interrupted turn Done", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "interrupted",
requestedAt: "2026-06-01T12:00:00.000Z",
startedAt: "2026-06-01T12:00:01.000Z",
completedAt: "2026-06-01T12:01:00.000Z",
assistantMessageId: null,
},
});

expect(
resolveThreadListV2Status(thread, {
lastVisitedAt: "2026-06-01T12:00:30.000Z",
}),
).toBe("ready");
});

it("prioritizes an active session over an unread completion", () => {
const thread = makeThread({
id: ThreadId.make("t"),
title: "t",
latestTurn: {
turnId: TurnId.make("turn-1"),
state: "completed",
requestedAt: "2026-06-01T12:00:00.000Z",
startedAt: "2026-06-01T12:00:01.000Z",
completedAt: "2026-06-01T12:01:00.000Z",
assistantMessageId: MessageId.make("assistant-1"),
},
session: {
threadId: ThreadId.make("t"),
status: "running",
providerName: "Codex",
providerInstanceId: ProviderInstanceId.make("codex"),
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
});

expect(
resolveThreadListV2Status(thread, {
lastVisitedAt: "2026-06-01T12:00:30.000Z",
}),
).toBe("working");
});
});

describe("sortThreadsForListV2", () => {
Expand Down
Loading
Loading