Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
5dfc78e
Add sidebar v2 beta: flat adaptive-density thread list with settled l…
t3dotgg Jul 14, 2026
e17a333
Iterate sidebar v2 from live feedback: card islands, project scope, c…
t3dotgg Jul 14, 2026
069f271
Address PR #3960 review findings and CI failures
t3dotgg Jul 14, 2026
ab23a82
Restyle sidebar v2: brutalist blocks, status edge strips, dimmed sett…
t3dotgg Jul 15, 2026
23d7a3d
Sidebar v2: per-card project title bars, static creation order
t3dotgg Jul 15, 2026
20a3eaa
Add Thread List v2 beta to the mobile app
t3dotgg Jul 15, 2026
f74e7ba
Fix mobile and sorting
t3dotgg Jul 15, 2026
a2f8028
Make v2 thread cards read as touchable, not as notifications
t3dotgg Jul 16, 2026
da08b58
Client-only sidebar v2: back settle with archive, revert all server c…
t3dotgg Jul 16, 2026
351a34b
Merge remote-tracking branch 'origin/main' into t3code/new-sidebar-cl…
t3dotgg Jul 16, 2026
af880a2
Address PR #4026 review findings
t3dotgg Jul 16, 2026
5354496
Address PR #4026 review findings, round two
t3dotgg Jul 16, 2026
b9cebff
Remove the "Thread settled" worktree-removal toast
t3dotgg Jul 16, 2026
856a097
Format import after toast removal
t3dotgg Jul 16, 2026
fc5065c
Settle advances to the next thread; settled tail pages behind Show more
t3dotgg Jul 16, 2026
751b547
Merge origin/main into sidebar v2 client-only branch
t3dotgg Jul 20, 2026
ce8dc31
Fix formatting in SidebarChrome
t3dotgg Jul 21, 2026
983f8f2
Remove plan docs from PR (archived on postplan)
t3dotgg Jul 21, 2026
9b59bab
Fade stage backdrop into sidebar v2's panel color
t3dotgg Jul 21, 2026
b9d9dc2
Address PR #4026 review findings, round three
t3dotgg Jul 21, 2026
d71b2e8
Address postplan review: settled-row activation, canSettle guard, qui…
t3dotgg Jul 21, 2026
a2482f8
Merge origin/main into sidebar v2 client-only branch
t3dotgg Jul 21, 2026
f74cc1c
Fix formatting in Sidebar.logic.test
t3dotgg Jul 21, 2026
5d0c6b5
Address Bugbot round: timestamp fallback order, v2 search empty state
t3dotgg Jul 21, 2026
0afc6b1
Address Bugbot round: bulk settle skips settled rows, hold-aware empt…
t3dotgg Jul 21, 2026
d3431e0
Add server-side settled model (ported from #3960)
t3dotgg Jul 21, 2026
13d459e
Migrate clients to the server-backed settled lifecycle
t3dotgg Jul 21, 2026
b8fba7f
Address #4243 review: pin clearing, capability gating, decider hardening
t3dotgg Jul 21, 2026
afcce06
Fix contracts test for user-only unsettle reason; deepen settled cove…
t3dotgg Jul 21, 2026
04c0791
Address round-2 stack review: queued turns, capability gating, races
t3dotgg Jul 22, 2026
3097c3f
Version skew: hide settle affordances entirely on pre-settlement servers
t3dotgg Jul 22, 2026
f315142
Bound the queued-turn settle block with an adoption grace window
t3dotgg Jul 22, 2026
820fe0d
Drop the project chip from the composer run-context strip
t3dotgg Jul 22, 2026
f97773b
fix(web): simplify project chrome labels
maria-rcks Jul 22, 2026
f2b297e
fix(web): round the chat composer shell
maria-rcks Jul 22, 2026
2493de4
fix(web): give the app sidebar its own surface palette
maria-rcks Jul 22, 2026
c384406
fix(web): polish sidebar v2 cards and filters
maria-rcks Jul 22, 2026
23a72f5
Merge branch 'pr-4252' into t3code/new-sidebar-client-only-rebased
t3dotgg Jul 22, 2026
f54899a
Merge PR #4252 (sidebar v2 chrome + composer polish); restore Settled…
t3dotgg Jul 22, 2026
aa2073f
Address post-merge review findings on the design pass
t3dotgg Jul 22, 2026
ed70339
Enforce pending-request blocking in the server settle invariant
t3dotgg Jul 22, 2026
635e8a6
Hide v2 jump hints when extra modifiers are held (⌘⇧ screenshots)
t3dotgg Jul 22, 2026
88d0d94
Give active thread cards a faint tonal fill (separation option B)
t3dotgg Jul 22, 2026
25b7d3f
Align mobile sidebar with desktop design
t3dotgg Jul 22, 2026
0f135b2
Address review findings on the mobile alignment pass
t3dotgg Jul 22, 2026
cb57915
Reject non-integer auto-settle input instead of truncating it
t3dotgg Jul 22, 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
2 changes: 2 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
favorites: [],
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
},
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
sidebarV2Enabled: false,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/archive/archivedThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function makeThread(
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
settledOverride: input.settledOverride ?? null,
settledAt: input.settledAt ?? null,
};
}

Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export function HomeRouteScreen() {
const { savedConnectionsById } = useSavedRemoteConnections();
const navigation = useNavigation();
const [searchQuery, setSearchQuery] = useState("");
const { archiveThread, confirmDeleteThread } = useThreadListActions();
const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
useThreadListActions();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand Down Expand Up @@ -111,6 +112,8 @@ export function HomeRouteScreen() {
}
onArchiveThread={archiveThread}
onDeleteThread={confirmDeleteThread}
onSettleThread={settleThread}
onUnsettleThread={unsettleThread}
onEnvironmentChange={setSelectedEnvironmentId}
onOpenEnvironments={() =>
navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
Expand All @@ -120,6 +123,8 @@ export function HomeRouteScreen() {
onProjectSortOrderChange={setProjectSortOrder}
onSearchQueryChange={setSearchQuery}
onSelectThread={(thread) => {
// Settled threads are live shells: opening one is plain
// navigation, and sending a message un-settles server-side.
navigation.navigate("Thread", {
environmentId: thread.environmentId,
threadId: thread.id,
Expand Down
248 changes: 246 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,29 @@ import type {
} from "@t3tools/contracts";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, View } from "react-native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useThemeColor } from "../../lib/useThemeColor";

import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
import type { WorkspaceState } from "../../state/workspaceModel";
import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
PendingTaskListRow,
ThreadListGroupHeader,
ThreadListRow,
ThreadListShowMoreRow,
} from "../threads/thread-list-items";
import { ThreadListV2Row } from "../threads/thread-list-v2-items";
import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2";
import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
import {
buildHomeListLayout,
Expand Down Expand Up @@ -74,6 +78,9 @@ interface HomeScreenProps {
readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
/** Resolves true iff the settle was dispatched and succeeded. */
readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
Expand All @@ -82,6 +89,10 @@ interface HomeScreenProps {
/* ─── Layout constants ───────────────────────────────────────────────── */

const ESTIMATED_THREAD_ROW_HEIGHT = 72;
// v2 settled-tail paging: recent history is the common lookup; the deep
// tail stays behind an explicit Show more.
const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
/**
* Top spacing between the list and the Android custom header. The Android
* header (AndroidHomeHeader) is rendered in-flow above this screen and
Expand Down Expand Up @@ -163,6 +174,9 @@ export function HomeScreen(props: HomeScreenProps) {
ReadonlyMap<string, HomeGroupDisplayState>
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled =
AsyncResult.isSuccess(preferencesResult) &&
preferencesResult.value.threadListV2Enabled === true;
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand Down Expand Up @@ -267,6 +281,152 @@ export function HomeScreen(props: HomeScreenProps) {
return map;
}, [props.projects]);

const projectByKey = useMemo(() => {
const map = new Map<string, EnvironmentProject>();
for (const project of props.projects) {
map.set(scopedProjectKey(project.environmentId, project.id), project);
}
return map;
}, [props.projects]);

// Thread List v2 (beta): one flat list in creation order, no grouping.
// Settled threads collapse into a recency tail below the card block.
// Settled threads stay in the live shell stream (settled ≠ archived), so
// the partition works directly off live shells — no snapshot merging or
// optimistic holds.
// PR states stream in per-row (rows own the VCS subscriptions); a merged or
// closed PR auto-settles its thread on the next partition (mirrors web).
const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
ReadonlyMap<string, "open" | "closed" | "merged">
>(() => new Map());
const handleChangeRequestState = useCallback(
(threadKey: string, state: "open" | "closed" | "merged" | null) => {
setChangeRequestStateByKey((current) => {
if ((current.get(threadKey) ?? null) === state) return current;
const next = new Map(current);
if (state === null) {
next.delete(threadKey);
} else {
next.set(threadKey, state);
}
return next;
});
},
[],
);
Comment thread
cursor[bot] marked this conversation as resolved.
const handleSettleThread = useCallback(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
(thread: EnvironmentThreadShell) => {
void props.onSettleThread(thread);
},
[props.onSettleThread],
);
Comment thread
cursor[bot] marked this conversation as resolved.
const handleDeleteThread = props.onDeleteThread;
const handleUnsettleThread = props.onUnsettleThread;
// The settled tail renders in pages; expansion resets when the filter
// context changes so environment/search flips never inherit a deep page.
const [settledVisibleCount, setSettledVisibleCount] = useState(
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
);
const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${props.searchQuery.trim()}`;
const lastSettledResetKeyRef = useRef(settledResetKey);
if (lastSettledResetKeyRef.current !== settledResetKey) {
lastSettledResetKeyRef.current = settledResetKey;
setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
}
const showMoreSettled = useCallback(
() => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
[],
);
// now is quantized to the minute and ticks so the inactivity auto-settle
// boundary is actually crossed while the app stays open (mirrors web);
// without a clock dependency the partition memoizes a frozen "now".
const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
useEffect(() => {
if (!threadListV2Enabled) return;
const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
return () => clearInterval(id);
}, [threadListV2Enabled]);
// Threads on servers without the settlement capability never classify as
// settled (the user could neither un-settle nor pin them).
const serverConfigs = useAtomValue(environmentServerConfigsAtom);
const settlementEnvironmentIds = useMemo(() => {
const supported = new Set<EnvironmentId>();
for (const [environmentId, config] of serverConfigs) {
if (config.environment.capabilities.threadSettlement === true) {
supported.add(environmentId);
}
}
return supported;
}, [serverConfigs]);
const threadListV2Layout = useMemo(() => {
if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
// Settled threads are live shells; archived threads keep their original
// "hidden from lists" meaning.
return buildThreadListV2Items({
threads: props.threads.filter((thread) => thread.archivedAt === null),
environmentId: props.selectedEnvironmentId,
searchQuery: props.searchQuery,
changeRequestStateByKey,
settlementEnvironmentIds,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
});
}, [
changeRequestStateByKey,
nowMinute,
settledVisibleCount,
settlementEnvironmentIds,
props.searchQuery,
props.selectedEnvironmentId,
props.threads,
threadListV2Enabled,
]);
const threadListV2Items = threadListV2Layout.items;

const renderV2Item = useCallback(
({ item }: LegendListRenderItemProps<ThreadListV2Item>) => (
<ThreadListV2Row
thread={item.thread}
variant={item.variant}
showSettledDivider={item.showSettledDivider}
project={
projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
null
}
onSelectThread={props.onSelectThread}
onDeleteThread={handleDeleteThread}
onArchiveThread={props.onArchiveThread}
settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)}
onSettleThread={handleSettleThread}
onUnsettleThread={handleUnsettleThread}
onChangeRequestState={handleChangeRequestState}
projectCwd={
projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
null
}
onSwipeableClose={handleSwipeableClose}
onSwipeableWillOpen={handleSwipeableWillOpen}
/>
),
[
handleChangeRequestState,
handleDeleteThread,
handleSettleThread,
handleSwipeableClose,
handleSwipeableWillOpen,
handleUnsettleThread,
projectByKey,
projectCwdByKey,
props.onArchiveThread,
props.onSelectThread,
settlementEnvironmentIds,
],
);
const v2KeyExtractor = useCallback(
(item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`,
[],
);

const extraData = useMemo(
() => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
[props.savedConnectionsById, projectCwdByKey],
Expand Down Expand Up @@ -360,6 +520,10 @@ export function HomeScreen(props: HomeScreenProps) {
const keyExtractor = useCallback((item: HomeListItem) => item.key, []);

/* Empty states */
// The signal must ignore the search/environment filters: an active query
// that matches nothing needs the in-list "No results" state, not the
// full-page "No threads yet". Settled threads are unarchived live shells,
// so the v1 check already covers v2.
const hasAnyThreads =
props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
const hasResults = projectGroups.length > 0;
Expand Down Expand Up @@ -436,6 +600,36 @@ export function HomeScreen(props: HomeScreenProps) {
</>
);

// v2 renders queued offline tasks above the thread cards — they are not
// thread shells, so the v2 item builder never sees them, but they must
// stay visible and deletable while their environment is offline. They
// respect the same environment scope and search filter as the list.
const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
const v2PendingTasks = props.pendingTasks.filter(
(pendingTask) =>
(props.selectedEnvironmentId === null ||
pendingTask.message.environmentId === props.selectedEnvironmentId) &&
(v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
);
const v2ListHeader = (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<>
{listHeader}
{v2PendingTasks.map((pendingTask, index) => (
<PendingTaskListRow
key={pendingTask.message.messageId}
variant="compact"
pendingTask={pendingTask}
environmentLabel={
props.savedConnectionsById[pendingTask.message.environmentId]?.environmentLabel ?? null
}
isLast={index === v2PendingTasks.length - 1}
onSelectPendingTask={props.onSelectPendingTask}
onDeletePendingTask={props.onDeletePendingTask}
/>
))}
</>
);

const listEmpty = !hasResults ? (
hasSearchQuery ? (
<EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
Expand All @@ -449,6 +643,56 @@ export function HomeScreen(props: HomeScreenProps) {
)
) : null;

if (threadListV2Enabled) {
return (
<View className="flex-1 bg-screen">
<SwipeableScrollGateProvider enabled={swipeEnabled}>
<LegendList
data={threadListV2Items}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
renderItem={renderV2Item}
keyExtractor={v2KeyExtractor}
drawDistance={500}
estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT}
extraData={projectByKey}
ListHeaderComponent={v2ListHeader}
ListFooterComponent={
threadListV2Layout.hiddenSettledCount > 0 ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
onPress={showMoreSettled}
className="mx-5 mt-1 items-center rounded-full bg-subtle py-2"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Text className="text-sm font-t3-medium text-foreground-muted">
Show more ({threadListV2Layout.hiddenSettledCount} settled hidden)
</Text>
</Pressable>
) : null
}
ListEmptyComponent={listEmpty}
style={{ flex: 1 }}
automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
showsVerticalScrollIndicator={false}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
{...scrollGateHandlers}
recycleItems
scrollEventThrottle={16}
contentContainerStyle={{
paddingBottom:
Platform.OS === "ios"
? Math.max(insets.bottom, 24) + 24
: Math.max(insets.bottom, 16) + 88,
}}
/>
</SwipeableScrollGateProvider>
{connectionStatus}
</View>
);
}

return (
<View className="flex-1 bg-screen">
{/* Sticky headers are deliberately not wired up: LegendList's JS sticky
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
createdAt: "2026-06-01T00:00:00.000Z",
updatedAt: "2026-06-01T00:00:00.000Z",
archivedAt: null,
settledOverride: null,
settledAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function makeThread(
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...input,
settledOverride: input.settledOverride ?? null,
settledAt: input.settledAt ?? null,
};
}

Expand Down
Loading
Loading