authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-22 03:36:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 03:36:34-07:00
log32c6012dabdbd0eb178b25ea4225d889ec8f6475
treee8c0668d62c8431664afee2a6e2f7251f1c6849f
parent6f34ad3e87eba2ffba66cac5593dae8b680e5b84
signature Signed by PGP key B5690EEEBB952194

Sidebar v2 beta: flat thread list with a server-backed settled lifecycle (#4026)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: maria-rcks <maria@kuuro.net>

79 files changed, 5834 insertions(+), 165 deletions(-)

apps/desktop/src/settings/DesktopClientSettings.test.ts+2
......@@ -20,6 +20,7 @@ const clientSettings: ClientSettings = {
2020 diffIgnoreWhitespace: true,
2121 favorites: [],
2222 providerModelPreferences: {},
23 sidebarAutoSettleAfterDays: 3,
2324 sidebarProjectGroupingMode: "repository_path",
2425 sidebarProjectGroupingOverrides: {
2526 "environment-1:/tmp/project-a": "separate",
......@@ -27,6 +28,7 @@ const clientSettings: ClientSettings = {
2728 sidebarProjectSortOrder: "manual",
2829 sidebarThreadSortOrder: "created_at",
2930 sidebarThreadPreviewCount: 6,
31 sidebarV2Enabled: false,
3032 timestampFormat: "24-hour",
3133 wordWrap: true,
3234};
apps/mobile/src/features/archive/archivedThreadList.test.ts+2
......@@ -41,6 +41,8 @@ function makeThread(
4141 hasPendingUserInput: false,
4242 hasActionableProposedPlan: false,
4343 ...input,
44 settledOverride: input.settledOverride ?? null,
45 settledAt: input.settledAt ?? null,
4446 };
4547}
4648
apps/mobile/src/features/home/HomeRouteScreen.tsx+6-1
......@@ -28,7 +28,8 @@ export function HomeRouteScreen() {
2828 const { savedConnectionsById } = useSavedRemoteConnections();
2929 const navigation = useNavigation();
3030 const [searchQuery, setSearchQuery] = useState("");
31 const { archiveThread, confirmDeleteThread } = useThreadListActions();
31 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
32 useThreadListActions();
3233 const pendingTasks = usePendingNewTasks();
3334 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
3435 const environments = useMemo(
......@@ -111,6 +112,8 @@ export function HomeRouteScreen() {
111112 }
112113 onArchiveThread={archiveThread}
113114 onDeleteThread={confirmDeleteThread}
115 onSettleThread={settleThread}
116 onUnsettleThread={unsettleThread}
114117 onEnvironmentChange={setSelectedEnvironmentId}
115118 onOpenEnvironments={() =>
116119 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })
......@@ -120,6 +123,8 @@ export function HomeRouteScreen() {
120123 onProjectSortOrderChange={setProjectSortOrder}
121124 onSearchQueryChange={setSearchQuery}
122125 onSelectThread={(thread) => {
126 // Settled threads are live shells: opening one is plain
127 // navigation, and sending a message un-settles server-side.
123128 navigation.navigate("Thread", {
124129 environmentId: thread.environmentId,
125130 threadId: thread.id,
apps/mobile/src/features/home/HomeScreen.tsx+384-2
......@@ -14,18 +14,22 @@ import type {
1414} from "@t3tools/contracts";
1515import { useAtomSet, useAtomValue } from "@effect/atom-react";
1616import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useMemo, useRef, useState } from "react";
18import { ActivityIndicator, Platform, View } from "react-native";
17import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native";
1919import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2020import { useSafeAreaInsets } from "react-native-safe-area-context";
2121import { useThemeColor } from "../../lib/useThemeColor";
2222
23import { AppText as Text } from "../../components/AppText";
2324import { EmptyState } from "../../components/EmptyState";
25import { ProjectFavicon } from "../../components/ProjectFavicon";
2426import type { WorkspaceState } from "../../state/workspaceModel";
2527import type { SavedRemoteConnection } from "../../lib/connection";
28import { cn } from "../../lib/cn";
2629import { scopedProjectKey } from "../../lib/scopedEntities";
2730import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
2831import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
32import { environmentServerConfigsAtom } from "../../state/server";
2933import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3034import {
3135 PendingTaskListRow,
......@@ -33,6 +37,8 @@ import {
3337 ThreadListRow,
3438 ThreadListShowMoreRow,
3539} from "../threads/thread-list-items";
40import { ThreadListV2Row } from "../threads/thread-list-v2-items";
41import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2";
3642import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu";
3743import {
3844 buildHomeListLayout,
......@@ -74,6 +80,9 @@ interface HomeScreenProps {
7480 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
7581 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
7682 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
83 /** Resolves true iff the settle was dispatched and succeeded. */
84 readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
85 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
7786 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
7887 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
7988 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
......@@ -82,6 +91,10 @@ interface HomeScreenProps {
8291/* ─── Layout constants ───────────────────────────────────────────────── */
8392
8493const ESTIMATED_THREAD_ROW_HEIGHT = 72;
94// v2 settled-tail paging: recent history is the common lookup; the deep
95// tail stays behind an explicit Show more.
96const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
97const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
8598/**
8699 * Top spacing between the list and the Android custom header. The Android
87100 * header (AndroidHomeHeader) is rendered in-flow above this screen and
......@@ -156,6 +169,80 @@ function HomeTopContentSpacer() {
156169 return <View className="h-4" />;
157170}
158171
172function ThreadListV2ProjectScope(props: {
173 readonly projects: ReadonlyArray<EnvironmentProject>;
174 readonly selectedKey: string | null;
175 readonly onChange: (key: string | null) => void;
176}) {
177 if (props.projects.length === 0) return null;
178
179 return (
180 <ScrollView
181 horizontal
182 contentContainerStyle={{
183 gap: 8,
184 paddingHorizontal: 16,
185 paddingBottom: 8,
186 paddingTop: Platform.OS === "ios" ? 12 : 4,
187 }}
188 keyboardShouldPersistTaps="handled"
189 showsHorizontalScrollIndicator={false}
190 >
191 {props.projects.length > 1 ? (
192 <Pressable
193 accessibilityLabel="Show all threads"
194 accessibilityRole="button"
195 accessibilityState={{ selected: props.selectedKey === null }}
196 hitSlop={4}
197 onPress={() => props.onChange(null)}
198 className={cn(
199 "min-h-8 items-center justify-center rounded-lg border px-3",
200 props.selectedKey === null
201 ? "border-border bg-subtle-strong"
202 : "border-black/15 dark:border-white/15",
203 )}
204 >
205 <Text className="text-sm font-t3-medium text-foreground">All</Text>
206 </Pressable>
207 ) : null}
208 {props.projects.map((project) => {
209 const key = scopedProjectKey(project.environmentId, project.id);
210 const selected = props.selectedKey === key;
211 return (
212 <Pressable
213 key={key}
214 accessibilityLabel={`Show ${project.title} threads`}
215 accessibilityRole="button"
216 accessibilityState={{ selected }}
217 hitSlop={4}
218 onPress={() => props.onChange(selected ? null : key)}
219 className={cn(
220 "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3",
221 selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15",
222 )}
223 >
224 <ProjectFavicon
225 environmentId={project.environmentId}
226 projectTitle={project.title}
227 size={15}
228 workspaceRoot={project.workspaceRoot}
229 />
230 <Text
231 className={cn(
232 "max-w-36 text-sm font-t3-medium",
233 selected ? "text-foreground" : "text-foreground-muted",
234 )}
235 numberOfLines={1}
236 >
237 {project.title}
238 </Text>
239 </Pressable>
240 );
241 })}
242 </ScrollView>
243 );
244}
245
159246/* ─── Main screen ────────────────────────────────────────────────────── */
160247
161248export function HomeScreen(props: HomeScreenProps) {
......@@ -163,6 +250,9 @@ export function HomeScreen(props: HomeScreenProps) {
163250 ReadonlyMap<string, HomeGroupDisplayState>
164251 >(() => new Map());
165252 const preferencesResult = useAtomValue(mobilePreferencesAtom);
253 const threadListV2Enabled =
254 AsyncResult.isSuccess(preferencesResult) &&
255 preferencesResult.value.threadListV2Enabled === true;
166256 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
167257 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
168258 const listRef = useRef<LegendListRef | null>(null);
......@@ -267,6 +357,193 @@ export function HomeScreen(props: HomeScreenProps) {
267357 return map;
268358 }, [props.projects]);
269359
360 const projectByKey = useMemo(() => {
361 const map = new Map<string, EnvironmentProject>();
362 for (const project of props.projects) {
363 map.set(scopedProjectKey(project.environmentId, project.id), project);
364 }
365 return map;
366 }, [props.projects]);
367
368 const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState<string | null>(null);
369 const v2ScopeProjects = useMemo(
370 () =>
371 props.selectedEnvironmentId === null
372 ? props.projects
373 : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId),
374 [props.projects, props.selectedEnvironmentId],
375 );
376 const v2ScopedProject = useMemo(
377 () =>
378 v2ProjectScopeKey === null
379 ? null
380 : (v2ScopeProjects.find(
381 (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey,
382 ) ?? null),
383 [v2ProjectScopeKey, v2ScopeProjects],
384 );
385 useEffect(() => {
386 if (v2ProjectScopeKey !== null && v2ScopedProject === null) {
387 setV2ProjectScopeKey(null);
388 }
389 }, [v2ProjectScopeKey, v2ScopedProject]);
390
391 // Thread List v2 (beta): one flat list in creation order, no grouping.
392 // Settled threads collapse into a recency tail below the card block.
393 // Settled threads stay in the live shell stream (settled ≠ archived), so
394 // the partition works directly off live shells — no snapshot merging or
395 // optimistic holds.
396 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
397 // closed PR auto-settles its thread on the next partition (mirrors web).
398 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
399 ReadonlyMap<string, "open" | "closed" | "merged">
400 >(() => new Map());
401 const handleChangeRequestState = useCallback(
402 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
403 setChangeRequestStateByKey((current) => {
404 if ((current.get(threadKey) ?? null) === state) return current;
405 const next = new Map(current);
406 if (state === null) {
407 next.delete(threadKey);
408 } else {
409 next.set(threadKey, state);
410 }
411 return next;
412 });
413 },
414 [],
415 );
416 const handleSettleThread = useCallback(
417 (thread: EnvironmentThreadShell) => {
418 void props.onSettleThread(thread);
419 },
420 [props.onSettleThread],
421 );
422 const handleDeleteThread = props.onDeleteThread;
423 const handleUnsettleThread = props.onUnsettleThread;
424 // The settled tail renders in pages; expansion resets when the filter
425 // context changes so environment/search flips never inherit a deep page.
426 const [settledVisibleCount, setSettledVisibleCount] = useState(
427 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
428 );
429 const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`;
430 const lastSettledResetKeyRef = useRef(settledResetKey);
431 if (lastSettledResetKeyRef.current !== settledResetKey) {
432 lastSettledResetKeyRef.current = settledResetKey;
433 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
434 }
435 const showMoreSettled = useCallback(
436 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
437 [],
438 );
439 // now is quantized to the minute and ticks so the inactivity auto-settle
440 // boundary is actually crossed while the app stays open (mirrors web);
441 // without a clock dependency the partition memoizes a frozen "now".
442 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
443 useEffect(() => {
444 if (!threadListV2Enabled) return;
445 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
446 return () => clearInterval(id);
447 }, [threadListV2Enabled]);
448 // Threads on servers without the settlement capability never classify as
449 // settled (the user could neither un-settle nor pin them).
450 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
451 const settlementEnvironmentIds = useMemo(() => {
452 const supported = new Set<EnvironmentId>();
453 for (const [environmentId, config] of serverConfigs) {
454 if (config.environment.capabilities.threadSettlement === true) {
455 supported.add(environmentId);
456 }
457 }
458 return supported;
459 }, [serverConfigs]);
460 const threadListV2Layout = useMemo(() => {
461 if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 };
462 // Settled threads are live shells; archived threads keep their original
463 // "hidden from lists" meaning.
464 return buildThreadListV2Items({
465 threads: props.threads.filter((thread) => thread.archivedAt === null),
466 environmentId: props.selectedEnvironmentId,
467 projectRef:
468 v2ScopedProject === null
469 ? null
470 : {
471 environmentId: v2ScopedProject.environmentId,
472 projectId: v2ScopedProject.id,
473 },
474 searchQuery: props.searchQuery,
475 changeRequestStateByKey,
476 settlementEnvironmentIds,
477 settledLimit: settledVisibleCount,
478 now: `${nowMinute}:00.000Z`,
479 });
480 }, [
481 changeRequestStateByKey,
482 nowMinute,
483 settledVisibleCount,
484 settlementEnvironmentIds,
485 props.searchQuery,
486 props.selectedEnvironmentId,
487 props.threads,
488 threadListV2Enabled,
489 v2ScopedProject,
490 ]);
491 const threadListV2Items = threadListV2Layout.items;
492
493 const renderV2Item = useCallback(
494 ({ item }: { readonly item: ThreadListV2Item }) => (
495 <ThreadListV2Row
496 thread={item.thread}
497 variant={item.variant}
498 showSettledDivider={item.showSettledDivider}
499 project={
500 projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
501 null
502 }
503 providerDriver={
504 serverConfigs
505 .get(item.thread.environmentId)
506 ?.providers.find(
507 (provider) =>
508 provider.instanceId ===
509 (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId),
510 )?.driver ?? null
511 }
512 onSelectThread={props.onSelectThread}
513 onDeleteThread={handleDeleteThread}
514 onArchiveThread={props.onArchiveThread}
515 settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)}
516 onSettleThread={handleSettleThread}
517 onUnsettleThread={handleUnsettleThread}
518 onChangeRequestState={handleChangeRequestState}
519 projectCwd={
520 projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ??
521 null
522 }
523 onSwipeableClose={handleSwipeableClose}
524 onSwipeableWillOpen={handleSwipeableWillOpen}
525 />
526 ),
527 [
528 handleChangeRequestState,
529 handleDeleteThread,
530 handleSettleThread,
531 handleSwipeableClose,
532 handleSwipeableWillOpen,
533 handleUnsettleThread,
534 projectByKey,
535 projectCwdByKey,
536 props.onArchiveThread,
537 props.onSelectThread,
538 serverConfigs,
539 settlementEnvironmentIds,
540 ],
541 );
542 const v2KeyExtractor = useCallback(
543 (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`,
544 [],
545 );
546
270547 const extraData = useMemo(
271548 () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }),
272549 [props.savedConnectionsById, projectCwdByKey],
......@@ -360,6 +637,10 @@ export function HomeScreen(props: HomeScreenProps) {
360637 const keyExtractor = useCallback((item: HomeListItem) => item.key, []);
361638
362639 /* Empty states */
640 // The signal must ignore the search/environment filters: an active query
641 // that matches nothing needs the in-list "No results" state, not the
642 // full-page "No threads yet". Settled threads are unarchived live shells,
643 // so the v1 check already covers v2.
363644 const hasAnyThreads =
364645 props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0;
365646 const hasResults = projectGroups.length > 0;
......@@ -436,6 +717,44 @@ export function HomeScreen(props: HomeScreenProps) {
436717 </>
437718 );
438719
720 // v2 renders queued offline tasks above the thread cards — they are not
721 // thread shells, so the v2 item builder never sees them, but they must
722 // stay visible and deletable while their environment is offline. They
723 // respect the same environment scope and search filter as the list.
724 const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
725 const v2PendingTasks = props.pendingTasks.filter(
726 (pendingTask) =>
727 (props.selectedEnvironmentId === null ||
728 pendingTask.message.environmentId === props.selectedEnvironmentId) &&
729 (v2ScopedProject === null ||
730 (pendingTask.message.environmentId === v2ScopedProject.environmentId &&
731 pendingTask.creation.projectId === v2ScopedProject.id)) &&
732 (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
733 );
734 const v2ListHeader = (
735 <>
736 {listHeader}
737 <ThreadListV2ProjectScope
738 projects={v2ScopeProjects}
739 selectedKey={v2ProjectScopeKey}
740 onChange={setV2ProjectScopeKey}
741 />
742 {v2PendingTasks.map((pendingTask, index) => (
743 <PendingTaskListRow
744 key={pendingTask.message.messageId}
745 variant="compact"
746 pendingTask={pendingTask}
747 environmentLabel={
748 props.savedConnectionsById[pendingTask.message.environmentId]?.environmentLabel ?? null
749 }
750 isLast={index === v2PendingTasks.length - 1}
751 onSelectPendingTask={props.onSelectPendingTask}
752 onDeletePendingTask={props.onDeletePendingTask}
753 />
754 ))}
755 </>
756 );
757
439758 const listEmpty = !hasResults ? (
440759 hasSearchQuery ? (
441760 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
......@@ -448,6 +767,69 @@ export function HomeScreen(props: HomeScreenProps) {
448767 <EmptyState title="No threads yet" detail="Create a task to start a new coding session." />
449768 )
450769 ) : null;
770 // Self-contained: v1's listEmpty keys off projectGroups, which ignores the
771 // v2 project scope, so it can be null (results elsewhere) while this list
772 // is empty. Search outranks the scope — "No results" names the actionable
773 // fact when a query is active. Pending tasks render in the header, so the
774 // list showing them isn't empty in the user's eyes.
775 const v2ListEmpty =
776 v2PendingTasks.length > 0 ? null : hasSearchQuery ? (
777 <EmptyState title="No results" detail={`No threads matching "${props.searchQuery}".`} />
778 ) : v2ScopedProject !== null ? (
779 <EmptyState
780 title={`No threads in ${v2ScopedProject.title}`}
781 detail="Choose another project or create a new task."
782 />
783 ) : (
784 listEmpty
785 );
786
787 if (threadListV2Enabled) {
788 return (
789 <View className="flex-1 bg-screen">
790 <SwipeableScrollGateProvider enabled={swipeEnabled}>
791 <FlatList
792 data={threadListV2Items}
793 renderItem={renderV2Item}
794 keyExtractor={v2KeyExtractor}
795 extraData={{ projectByKey, serverConfigs }}
796 ListHeaderComponent={v2ListHeader}
797 ListFooterComponent={
798 threadListV2Layout.hiddenSettledCount > 0 ? (
799 <Pressable
800 accessibilityRole="button"
801 accessibilityLabel={`Show ${Math.min(threadListV2Layout.hiddenSettledCount, THREAD_LIST_V2_SETTLED_PAGE_COUNT)} more settled threads`}
802 onPress={showMoreSettled}
803 className="mx-4 mt-2 items-center rounded-lg border border-dashed border-border py-2.5"
804 style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
805 >
806 <Text className="text-xs font-t3-medium text-foreground-muted">
807 Show more ({threadListV2Layout.hiddenSettledCount} settled hidden)
808 </Text>
809 </Pressable>
810 ) : null
811 }
812 ListEmptyComponent={v2ListEmpty}
813 style={{ flex: 1 }}
814 automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"}
815 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
816 showsVerticalScrollIndicator={false}
817 keyboardDismissMode="on-drag"
818 keyboardShouldPersistTaps="handled"
819 {...scrollGateHandlers}
820 scrollEventThrottle={16}
821 contentContainerStyle={{
822 paddingBottom:
823 Platform.OS === "ios"
824 ? Math.max(insets.bottom, 24) + 96
825 : Math.max(insets.bottom, 16) + 88,
826 }}
827 />
828 </SwipeableScrollGateProvider>
829 {connectionStatus}
830 </View>
831 );
832 }
451833
452834 return (
453835 <View className="flex-1 bg-screen">
apps/mobile/src/features/home/homeListItems.test.ts+2
......@@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
4747 createdAt: "2026-06-01T00:00:00.000Z",
4848 updatedAt: "2026-06-01T00:00:00.000Z",
4949 archivedAt: null,
50 settledOverride: null,
51 settledAt: null,
5052 session: null,
5153 latestUserMessageAt: null,
5254 hasPendingApprovals: false,
apps/mobile/src/features/home/homeThreadList.test.ts+2
......@@ -41,6 +41,8 @@ function makeThread(
4141 hasPendingUserInput: false,
4242 hasActionableProposedPlan: false,
4343 ...input,
44 settledOverride: input.settledOverride ?? null,
45 settledAt: input.settledAt ?? null,
4446 };
4547}
4648
apps/mobile/src/features/home/thread-swipe-actions.tsx+17-3
......@@ -167,6 +167,13 @@ export function ThreadSwipeable(props: {
167167 /** Disables NEW swipe activations (e.g. while the list scrolls). */
168168 readonly enabled?: boolean;
169169 readonly enableTrackpadSwipe?: boolean;
170 /**
171 * What a full swipe commits: "delete" (default, v1 behavior — the Delete
172 * button stretches) or "primary" — the advertised primary action fires and
173 * its button stretches instead. A full swipe must always match the action
174 * the stretching button advertises.
175 */
176 readonly fullSwipeAction?: "delete" | "primary";
170177 readonly fullSwipeWidth: number;
171178 readonly onDelete: () => void;
172179 readonly onSwipeableClose?: (methods: SwipeableMethods) => void;
......@@ -239,7 +246,11 @@ export function ThreadSwipeable(props: {
239246 if (fullSwipeArmedRef.current) {
240247 fullSwipeArmedRef.current = false;
241248 methods.close();
242 props.onDelete();
249 if (props.fullSwipeAction === "primary") {
250 props.primaryAction.onPress();
251 } else {
252 props.onDelete();
253 }
243254 }
244255 }}
245256 overshootFriction={1}
......@@ -247,6 +258,7 @@ export function ThreadSwipeable(props: {
247258 renderRightActions={(_progress, translation, methods) => (
248259 <ThreadSwipeActions
249260 backgroundColor={props.backgroundColor}
261 fullSwipeAction={props.fullSwipeAction ?? "delete"}
250262 fullSwipeThreshold={fullSwipeThreshold}
251263 onDelete={props.onDelete}
252264 onFullSwipeArmedChange={handleFullSwipeArmedChange}
......@@ -422,6 +434,7 @@ function SwipeActionButton(props: {
422434
423435export function ThreadSwipeActions(props: {
424436 readonly backgroundColor: ColorValue;
437 readonly fullSwipeAction?: "delete" | "primary";
425438 readonly fullSwipeThreshold: number;
426439 readonly onDelete: () => void;
427440 readonly onFullSwipeArmedChange: (armed: boolean) => void;
......@@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: {
430443 readonly threadTitle: string;
431444 readonly translation: SharedValue<number>;
432445}) {
446 const fullSwipeIsPrimary = props.fullSwipeAction === "primary";
433447 useAnimatedReaction(
434448 () => -props.translation.value >= props.fullSwipeThreshold,
435449 (armed, previous) => {
......@@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: {
457471 icon={props.primaryAction.icon}
458472 label={props.primaryAction.label}
459473 onPress={props.primaryAction.onPress}
460 stretchesOnFullSwipe={false}
474 stretchesOnFullSwipe={fullSwipeIsPrimary}
461475 translation={props.translation}
462476 />
463477 <SwipeActionButton
......@@ -471,7 +485,7 @@ export function ThreadSwipeActions(props: {
471485 props.swipeableMethods.close();
472486 props.onDelete();
473487 }}
474 stretchesOnFullSwipe
488 stretchesOnFullSwipe={!fullSwipeIsPrimary}
475489 translation={props.translation}
476490 />
477491 </View>
apps/mobile/src/features/home/useThreadListActions.ts+109-19
......@@ -1,4 +1,5 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
23import * as Cause from "effect/Cause";
34import * as Haptics from "expo-haptics";
45import { useCallback, useRef } from "react";
......@@ -6,19 +7,37 @@ import { Alert } from "react-native";
67
78import { showConfirmDialog } from "../../components/ConfirmDialogHost";
89import { scopedThreadKey } from "../../lib/scopedEntities";
10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
11import { appAtomRegistry } from "../../state/atom-registry";
12import { environmentServerConfigsAtom } from "../../state/server";
913import { threadEnvironment } from "../../state/threads";
1014import { useAtomCommand } from "../../state/use-atom-command";
1115
12type ThreadListAction = "archive" | "unarchive" | "delete";
16/** Version skew: never send settle/unsettle to a server that predates them
17 (capability defaults false on decode for older servers). */
18function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) {
19 return (
20 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
21 .threadSettlement === true
22 );
23}
24
25type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle";
26
27const ACTION_VERBS: Record<ThreadListAction, string> = {
28 archive: "archived",
29 unarchive: "unarchived",
30 delete: "deleted",
31 settle: "settled",
32 unsettle: "un-settled",
33};
1334
1435function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause<unknown>): string {
1536 const error = Cause.squash(cause);
1637 if (error instanceof Error && error.message.trim().length > 0) {
1738 return error.message;
1839 }
19 const verb =
20 action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted";
21 return `The thread could not be ${verb}.`;
40 return `The thread could not be ${ACTION_VERBS[action]}.`;
2241}
2342
2443function selectionHaptic(): void {
......@@ -28,54 +47,115 @@ function selectionHaptic(): void {
2847function actionFailureTitle(action: ThreadListAction): string {
2948 if (action === "archive") return "Could not archive thread";
3049 if (action === "unarchive") return "Could not unarchive thread";
50 if (action === "settle") return "Could not settle thread";
51 if (action === "unsettle") return "Could not un-settle thread";
3152 return "Could not delete thread";
3253}
3354
55/** Resolves to true iff the action was dispatched and succeeded. */
3456function useThreadActionExecutor(
3557 onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
3658) {
3759 const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false });
3860 const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false });
3961 const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
62 const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false });
63 const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false });
4064 const inFlightThreadKeys = useRef(new Set<string>());
4165
4266 const executeAction = useCallback(
4367 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
4468 const key = scopedThreadKey(thread.environmentId, thread.id);
4569 if (inFlightThreadKeys.current.has(key)) {
46 return;
70 return false;
4771 }
4872
4973 inFlightThreadKeys.current.add(key);
5074 selectionHaptic();
5175 try {
52 const mutation =
53 action === "archive"
54 ? archiveMutation
55 : action === "unarchive"
56 ? unarchiveMutation
57 : deleteMutation;
58 const result = await mutation({
59 environmentId: thread.environmentId,
60 input: { threadId: thread.id },
61 });
76 if (
77 (action === "settle" || action === "unsettle") &&
78 !environmentSupportsSettlement(thread.environmentId)
79 ) {
80 Alert.alert(
81 actionFailureTitle(action),
82 "This environment's server does not support settling yet. Update the server to use Settle.",
83 );
84 return false;
85 }
86 // Settle may only target what effectiveSettled could classify as
87 // settled: not starting/running sessions, not threads waiting on
88 // approvals or user input. Anything else would hide live work.
89 if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) {
90 Alert.alert(
91 actionFailureTitle(action),
92 "This thread still needs attention. Resolve or interrupt it first, then try again.",
93 );
94 return false;
95 }
96 // Archive keeps its original, narrower guard: never interrupt a
97 // thread mid-turn.
98 if (
99 action === "archive" &&
100 thread.session?.status === "running" &&
101 thread.session.activeTurnId != null
102 ) {
103 Alert.alert(
104 actionFailureTitle(action),
105 "This thread is working. Interrupt it first, then try again.",
106 );
107 return false;
108 }
109 const result =
110 action === "unsettle"
111 ? // reason "user" pins the thread active: auto-settle stays
112 // suppressed until real activity clears the pin server-side.
113 await unsettleMutation({
114 environmentId: thread.environmentId,
115 input: { threadId: thread.id, reason: "user" },
116 })
117 : await (
118 action === "settle"
119 ? settleMutation
120 : action === "archive"
121 ? archiveMutation
122 : action === "unarchive"
123 ? unarchiveMutation
124 : deleteMutation
125 )({
126 environmentId: thread.environmentId,
127 input: { threadId: thread.id },
128 });
62129 if (result._tag === "Failure") {
63130 Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause));
64 return;
131 return false;
132 }
133 // Settled threads stay in the live shell stream; only the archive
134 // lifecycle still feeds the archived-snapshot surface.
135 if (action === "archive" || action === "unarchive" || action === "delete") {
136 refreshArchivedThreadsForEnvironment(thread.environmentId);
65137 }
66138 onCompleted?.(action, thread);
139 return true;
67140 } finally {
68141 inFlightThreadKeys.current.delete(key);
69142 }
70143 },
71 [archiveMutation, deleteMutation, onCompleted, unarchiveMutation],
144 [
145 archiveMutation,
146 deleteMutation,
147 onCompleted,
148 settleMutation,
149 unarchiveMutation,
150 unsettleMutation,
151 ],
72152 );
73153
74154 return executeAction;
75155}
76156
77157function useConfirmDeleteThread(
78 executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<void>,
158 executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise<boolean>,
79159) {
80160 return useCallback(
81161 (thread: EnvironmentThreadShell) => {
......@@ -111,6 +191,8 @@ function useConfirmDeleteThread(
111191export function useThreadListActions(): {
112192 readonly archiveThread: (thread: EnvironmentThreadShell) => void;
113193 readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void;
194 readonly settleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
195 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
114196} {
115197 const executeAction = useThreadActionExecutor();
116198
......@@ -120,10 +202,18 @@ export function useThreadListActions(): {
120202 },
121203 [executeAction],
122204 );
205 const settleThread = useCallback(
206 async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true,
207 [executeAction],
208 );
209 const unsettleThread = useCallback(
210 async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true,
211 [executeAction],
212 );
123213
124214 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
125215
126 return { archiveThread, confirmDeleteThread };
216 return { archiveThread, confirmDeleteThread, settleThread, unsettleThread };
127217}
128218
129219export function useArchivedThreadListActions(
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+33
......@@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() {
123123 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
124124 </SettingsSection>
125125
126 <BetaSettingsSection />
127
126128 <ArchivedThreadsSettingsSection />
127129
128130 <AppSettingsSection />
......@@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() {
506508 <SettingsRow icon="paintbrush" label="Appearance" target="SettingsAppearance" />
507509 </SettingsSection>
508510
511 <BetaSettingsSection />
512
509513 <ArchivedThreadsSettingsSection />
510514
511515 <AppSettingsSection />
......@@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() {
514518 );
515519}
516520
521/**
522 * Device-local beta toggles. Mobile has no client-settings sync, so this is
523 * the counterpart of web's Settings → Beta backed by mobile preferences.
524 */
525function BetaSettingsSection() {
526 const preferencesResult = useAtomValue(mobilePreferencesAtom);
527 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
528 const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult)
529 ? preferencesResult.value.threadListV2Enabled === true
530 : false;
531
532 return (
533 <View className="gap-3">
534 <SettingsSection title="Beta">
535 <SettingsSwitchRow
536 icon="sidebar.left"
537 label="Thread List v2"
538 value={threadListV2Enabled}
539 onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
540 />
541 </SettingsSection>
542 <Text className="px-2 text-sm text-foreground-muted">
543 One flat thread list in creation order. Active work renders as cards; settled threads
544 collapse to compact rows. Switch back any time.
545 </Text>
546 </View>
547 );
548}
549
517550function AppSettingsSection() {
518551 const icon = useThemeColor("--color-icon");
519552
apps/mobile/src/features/threads/thread-list-v2-items.tsx created+329
......@@ -0,0 +1,329 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import type { MenuAction } from "@react-native-menu/menu";
6import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
7import { Platform, Pressable, useWindowDimensions, View } from "react-native";
8import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
9
10import { AppText as Text } from "../../components/AppText";
11import { ControlPillMenu } from "../../components/ControlPill";
12import { ProjectFavicon } from "../../components/ProjectFavicon";
13import { ProviderIcon } from "../../components/ProviderIcon";
14import { cn } from "../../lib/cn";
15import { relativeTime } from "../../lib/time";
16import { useThemeColor } from "../../lib/useThemeColor";
17import { useThreadPr } from "../../state/use-thread-pr";
18import { ThreadSwipeable } from "../home/thread-swipe-actions";
19import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2";
20
21/**
22 * Thread List v2 rows mirror the web sidebar's compact tonal cards and
23 * receded settled tail while retaining native swipe and long-press actions.
24 */
25
26const MONO_FONT = Platform.select({
27 ios: "Menlo",
28 android: "monospace",
29 default: "monospace",
30});
31
32const STATUS_LABEL_BY_STATUS: Partial<
33 Record<ThreadListV2Status, { label: string; className: string }>
34> = {
35 approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },
36 input: { label: "Input", className: "text-amber-700 dark:text-amber-300" },
37 working: { label: "Working", className: "text-blue-600 dark:text-blue-400" },
38 failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
39};
40
41function threadTimeLabel(thread: EnvironmentThreadShell): string {
42 return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
43}
44
45// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps
46// its own surface (thread screen / settings) rather than crowding the row.
47const CARD_MENU_ACTIONS: MenuAction[] = [
48 { id: "settle", title: "Settle", image: "checkmark" },
49 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
50];
51
52const SLIM_MENU_ACTIONS: MenuAction[] = [
53 { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" },
54 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
55];
56
57// Pre-settlement servers: no lifecycle items, archive fills the gap.
58const LEGACY_MENU_ACTIONS: MenuAction[] = [
59 { id: "archive", title: "Archive", image: "archivebox" },
60 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
61];
62
63export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() {
64 const borderColor = useThemeColor("--color-border");
65 return (
66 <View className="mb-1.5 mt-4 flex-row items-center gap-2.5 px-5">
67 <Text className="text-xs font-t3-medium text-foreground-tertiary">Settled</Text>
68 <View className="h-px flex-1" style={{ backgroundColor: borderColor }} />
69 </View>
70 );
71});
72
73export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
74 readonly thread: EnvironmentThreadShell;
75 readonly variant: "card" | "slim";
76 readonly showSettledDivider: boolean;
77 readonly project: EnvironmentProject | null;
78 readonly providerDriver: string | null;
79 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
80 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
81 readonly onSettleThread: (thread: EnvironmentThreadShell) => void;
82 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
83 readonly onArchiveThread: (thread: EnvironmentThreadShell) => void;
84 /** False on environments whose server predates thread.settle/unsettle:
85 swipe + menu fall back to Archive instead of failing on use. */
86 readonly settlementSupported: boolean;
87 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
88 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
89 /** Reports this row's live PR state up so the partition can auto-settle
90 merged/closed work (mirrors web's onChangeRequestState). */
91 readonly onChangeRequestState?: (
92 threadKey: string,
93 state: "open" | "closed" | "merged" | null,
94 ) => void;
95 readonly projectCwd?: string | null;
96 readonly simultaneousSwipeGesture?: ComponentProps<
97 typeof ThreadSwipeable
98 >["simultaneousWithExternalGesture"];
99}) {
100 const { width: windowWidth } = useWindowDimensions();
101 const {
102 thread,
103 variant,
104 onSelectThread,
105 onDeleteThread,
106 onSettleThread,
107 onUnsettleThread,
108 onArchiveThread,
109 onChangeRequestState,
110 } = props;
111
112 const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null);
113 const prState = pr?.state ?? null;
114 const threadKey = `${thread.environmentId}:${thread.id}`;
115 useEffect(() => {
116 onChangeRequestState?.(threadKey, prState);
117 }, [onChangeRequestState, prState, threadKey]);
118
119 const screenColor = useThemeColor("--color-screen");
120
121 const status = resolveThreadListV2Status(thread);
122 const statusLabel = STATUS_LABEL_BY_STATUS[status];
123 const timeLabel = threadTimeLabel(thread);
124
125 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
126 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
127 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
128 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
129 const handleMenuAction = useCallback(
130 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
131 if (nativeEvent.event === "settle") handleSettle();
132 if (nativeEvent.event === "unsettle") handleUnsettle();
133 if (nativeEvent.event === "archive") handleArchive();
134 if (nativeEvent.event === "delete") handleDelete();
135 },
136 [handleArchive, handleDelete, handleSettle, handleUnsettle],
137 );
138
139 // Swipe: the v2 primary action is the lifecycle transition. Every settled
140 // row can un-settle — explicit settles clear the override, auto-settled
141 // rows get pinned active until real activity clears the pin.
142 const canUnsettle = variant === "slim";
143 const primaryAction = useMemo(() => {
144 // Pre-settlement server: archive is the swipe action, as in v1. (Slim
145 // rows cannot occur here — unsupported environments never classify as
146 // settled.)
147 if (!props.settlementSupported) {
148 return {
149 accessibilityLabel: `Archive ${thread.title}`,
150 icon: "archivebox" as const,
151 label: "Archive",
152 onPress: handleArchive,
153 };
154 }
155 return canUnsettle
156 ? {
157 accessibilityLabel: `Un-settle ${thread.title}`,
158 icon: "arrow.uturn.backward" as const,
159 label: "Un-settle",
160 onPress: handleUnsettle,
161 }
162 : {
163 accessibilityLabel: `Settle ${thread.title}`,
164 icon: "checkmark" as const,
165 label: "Settle",
166 onPress: handleSettle,
167 };
168 }, [
169 canUnsettle,
170 handleArchive,
171 handleSettle,
172 handleUnsettle,
173 props.settlementSupported,
174 thread.title,
175 ]);
176
177 const rowContent = (close: () => void) =>
178 variant === "card" ? (
179 <Pressable
180 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
181 accessibilityLabel={thread.title}
182 accessibilityRole="button"
183 onPress={() => {
184 close();
185 onSelectThread(thread);
186 }}
187 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
188 >
189 <View className="bg-screen px-4 py-1">
190 <View
191 className={cn(
192 "overflow-hidden",
193 status === "ready"
194 ? "bg-black/[0.025] dark:bg-white/[0.025]"
195 : "bg-black/[0.04] dark:bg-white/[0.04]",
196 )}
197 style={{ borderRadius: 12, borderCurve: "continuous", minHeight: 84 }}
198 >
199 <View className="px-3 py-2.5">
200 <View className="flex-row items-center gap-1.5">
201 {props.project ? (
202 <ProjectFavicon
203 environmentId={thread.environmentId}
204 size={15}
205 projectTitle={props.project.title}
206 workspaceRoot={props.project.workspaceRoot}
207 />
208 ) : null}
209 <Text
210 className="flex-1 text-sm font-t3-medium text-foreground-muted"
211 numberOfLines={1}
212 >
213 {props.project?.title ?? ""}
214 </Text>
215 <Text
216 className={cn(
217 "text-xs tabular-nums",
218 statusLabel?.className ?? "text-foreground-tertiary",
219 )}
220 >
221 {statusLabel?.label ?? timeLabel}
222 </Text>
223 </View>
224 <Text className="mt-1 text-base font-t3-medium text-foreground" numberOfLines={2}>
225 {thread.title}
226 </Text>
227 <View className="mt-1 flex-row items-center gap-2">
228 {status === "failed" && thread.session?.lastError ? (
229 <Text
230 className="flex-1 text-xs text-red-600/80 dark:text-red-400/80"
231 numberOfLines={1}
232 >
233 {thread.session.lastError}
234 </Text>
235 ) : thread.branch ? (
236 <Text
237 className="flex-1 text-xs text-foreground-muted"
238 numberOfLines={1}
239 style={{ fontFamily: MONO_FONT }}
240 >
241 {thread.branch}
242 </Text>
243 ) : (
244 <View className="flex-1" />
245 )}
246 {props.providerDriver ? (
247 <View className="opacity-60">
248 <ProviderIcon provider={props.providerDriver} size={14} />
249 </View>
250 ) : null}
251 </View>
252 </View>
253 </View>
254 </View>
255 </Pressable>
256 ) : (
257 <Pressable
258 accessibilityHint={`Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`}
259 accessibilityLabel={thread.title}
260 accessibilityRole="button"
261 className="bg-screen"
262 onPress={() => {
263 close();
264 onSelectThread(thread);
265 }}
266 style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
267 >
268 {/* Settled history recedes: dimmed favicon + muted title. */}
269 <View className="min-h-[44px] flex-row items-center gap-2.5 px-5 py-2">
270 {props.project ? (
271 <View className="opacity-40">
272 <ProjectFavicon
273 environmentId={thread.environmentId}
274 size={15}
275 projectTitle={props.project.title}
276 workspaceRoot={props.project.workspaceRoot}
277 />
278 </View>
279 ) : null}
280 <Text className="flex-1 text-base text-foreground-muted" numberOfLines={1}>
281 {thread.title}
282 </Text>
283 <Text
284 className="text-sm tabular-nums text-foreground-tertiary"
285 style={{ fontFamily: MONO_FONT }}
286 >
287 {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
288 </Text>
289 </View>
290 </Pressable>
291 );
292
293 return (
294 <>
295 {props.showSettledDivider ? <ThreadListV2SettledDivider /> : null}
296 <ThreadSwipeable
297 backgroundColor={screenColor}
298 enableTrackpadSwipe
299 // Full swipe commits the advertised lifecycle action (Settle /
300 // Un-settle), never the destructive delete.
301 fullSwipeAction="primary"
302 fullSwipeWidth={windowWidth - 32}
303 onDelete={handleDelete}
304 onSwipeableClose={props.onSwipeableClose}
305 onSwipeableWillOpen={props.onSwipeableWillOpen}
306 primaryAction={primaryAction}
307 resetKey={`${thread.environmentId}:${thread.id}`}
308 simultaneousWithExternalGesture={props.simultaneousSwipeGesture}
309 threadTitle={thread.title}
310 >
311 {(close) => (
312 <ControlPillMenu
313 actions={
314 !props.settlementSupported
315 ? LEGACY_MENU_ACTIONS
316 : canUnsettle
317 ? SLIM_MENU_ACTIONS
318 : CARD_MENU_ACTIONS
319 }
320 onPressAction={handleMenuAction}
321 shouldOpenOnLongPress
322 >
323 {rowContent(close)}
324 </ControlPillMenu>
325 )}
326 </ThreadSwipeable>
327 </>
328 );
329});
apps/mobile/src/features/threads/threadListV2.test.ts created+220
......@@ -0,0 +1,220 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 buildThreadListV2Items,
7 resolveThreadListV2Status,
8 sortThreadsForListV2,
9} from "./threadListV2";
10
11const environmentId = EnvironmentId.make("environment-1");
12
13function makeThread(
14 input: Partial<EnvironmentThreadShell> & Pick<EnvironmentThreadShell, "id" | "title">,
15): EnvironmentThreadShell {
16 return {
17 environmentId,
18 projectId: ProjectId.make("project-1"),
19 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
20 runtimeMode: "full-access",
21 interactionMode: "default",
22 branch: null,
23 worktreePath: null,
24 latestTurn: null,
25 createdAt: "2026-06-01T00:00:00.000Z",
26 updatedAt: "2026-06-01T00:00:00.000Z",
27 archivedAt: null,
28 settledOverride: null,
29 settledAt: null,
30 session: null,
31 latestUserMessageAt: null,
32 hasPendingApprovals: false,
33 hasPendingUserInput: false,
34 hasActionableProposedPlan: false,
35 ...input,
36 };
37}
38
39const NOW = "2026-06-02T00:00:00.000Z";
40
41describe("resolveThreadListV2Status", () => {
42 it("prioritizes approval over a running session", () => {
43 const thread = makeThread({
44 id: ThreadId.make("t"),
45 title: "t",
46 hasPendingApprovals: true,
47 session: {
48 threadId: ThreadId.make("t"),
49 status: "running",
50 providerName: "Codex",
51 providerInstanceId: ProviderInstanceId.make("codex"),
52 runtimeMode: "full-access",
53 activeTurnId: null,
54 lastError: null,
55 updatedAt: NOW,
56 },
57 });
58 expect(resolveThreadListV2Status(thread)).toBe("approval");
59 });
60
61 it("resolves ready for quiescent threads", () => {
62 expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
63 "ready",
64 );
65 });
66});
67
68describe("sortThreadsForListV2", () => {
69 it("orders by creation time, newest first, ignoring activity", () => {
70 const sorted = sortThreadsForListV2([
71 { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
72 { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" },
73 { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" },
74 ]);
75 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
76 });
77});
78
79describe("buildThreadListV2Items", () => {
80 it("partitions settled threads into a slim tail with one divider", () => {
81 const { items } = buildThreadListV2Items({
82 threads: [
83 makeThread({ id: ThreadId.make("active"), title: "Active" }),
84 makeThread({
85 id: ThreadId.make("settled"),
86 title: "Settled",
87 settledOverride: "settled",
88 settledAt: NOW,
89 }),
90 makeThread({
91 id: ThreadId.make("settled-2"),
92 title: "Settled 2",
93 settledOverride: "settled",
94 settledAt: NOW,
95 }),
96 ],
97 environmentId: null,
98 searchQuery: "",
99 now: NOW,
100 });
101
102 expect(items.map((item) => [item.thread.id, item.variant])).toEqual([
103 ["active", "card"],
104 ["settled", "slim"],
105 ["settled-2", "slim"],
106 ]);
107 expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]);
108 expect(items.map((item) => item.isLast)).toEqual([false, false, true]);
109 });
110
111 it("keeps cards in creation order while settled sorts by recency", () => {
112 const { items } = buildThreadListV2Items({
113 threads: [
114 makeThread({
115 id: ThreadId.make("older-created"),
116 title: "Older",
117 createdAt: "2026-06-01T08:00:00.000Z",
118 updatedAt: NOW, // recent activity must NOT promote it
119 }),
120 makeThread({
121 id: ThreadId.make("newer-created"),
122 title: "Newer",
123 createdAt: "2026-06-01T12:00:00.000Z",
124 }),
125 ],
126 environmentId: null,
127 searchQuery: "",
128 now: NOW,
129 });
130
131 expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]);
132 });
133
134 it("keeps settled threads in the tail and filters by search query", () => {
135 const { items } = buildThreadListV2Items({
136 threads: [
137 makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }),
138 makeThread({ id: ThreadId.make("miss"), title: "Greeting" }),
139 makeThread({
140 id: ThreadId.make("settled"),
141 title: "Fix login again",
142 settledOverride: "settled",
143 settledAt: NOW,
144 }),
145 ],
146 environmentId: null,
147 searchQuery: "login",
148 now: NOW,
149 });
150
151 expect(items.map((item) => [item.thread.id, item.variant])).toEqual([
152 ["match", "card"],
153 ["settled", "slim"],
154 ]);
155 });
156
157 it("scopes the flat list to one project", () => {
158 const otherProjectId = ProjectId.make("project-2");
159 const { items } = buildThreadListV2Items({
160 threads: [
161 makeThread({ id: ThreadId.make("included"), title: "Included" }),
162 makeThread({
163 id: ThreadId.make("excluded"),
164 projectId: otherProjectId,
165 title: "Excluded",
166 }),
167 ],
168 environmentId: null,
169 projectRef: { environmentId, projectId: ProjectId.make("project-1") },
170 searchQuery: "",
171 now: NOW,
172 });
173
174 expect(items.map((item) => item.thread.id)).toEqual(["included"]);
175 });
176});
177
178describe("buildThreadListV2Items settled paging", () => {
179 it("caps the settled tail at settledLimit and reports the hidden count", () => {
180 const threads = [
181 makeThread({ id: ThreadId.make("active"), title: "Active" }),
182 ...Array.from({ length: 4 }, (_, index) =>
183 makeThread({
184 id: ThreadId.make(`settled-${index}`),
185 title: `Settled ${index}`,
186 settledOverride: "settled",
187 settledAt: NOW,
188 latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
189 // A turn adopted the message (same requestedAt): without it the
190 // thread reads as a queued turn start, which never settles.
191 latestTurn: {
192 turnId: TurnId.make(`turn-${index}`),
193 state: "completed",
194 requestedAt: `2026-06-01T0${index}:00:00.000Z`,
195 startedAt: `2026-06-01T0${index}:00:00.000Z`,
196 completedAt: `2026-06-01T0${index}:10:00.000Z`,
197 assistantMessageId: null,
198 },
199 }),
200 ),
201 ];
202
203 const layout = buildThreadListV2Items({
204 threads,
205 environmentId: null,
206 searchQuery: "",
207 settledLimit: 2,
208 now: NOW,
209 });
210
211 expect(layout.hiddenSettledCount).toBe(2);
212 expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2);
213 // Most recent settled first — the hidden ones are the oldest.
214 expect(layout.items.map((item) => item.thread.id)).toEqual([
215 "active",
216 "settled-3",
217 "settled-2",
218 ]);
219 });
220});
apps/mobile/src/features/threads/threadListV2.ts created+167
......@@ -0,0 +1,167 @@
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
4
5/**
6 * Thread List v2 model, ported from the web sidebar v2
7 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
8 *
9 * Four visual states, three colors: color is reserved for "act now"
10 * (approval), "in motion" (working), and "broken" (failed). Ready is the
11 * unlabeled resting state.
12 */
13export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready";
14
15export function resolveThreadListV2Status(
16 thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
17): ThreadListV2Status {
18 if (thread.hasPendingApprovals) {
19 return "approval";
20 }
21 if (thread.hasPendingUserInput) {
22 return "input";
23 }
24 if (thread.session?.status === "running" || thread.session?.status === "starting") {
25 return "working";
26 }
27 if (thread.session?.status === "error") {
28 return "failed";
29 }
30 return "ready";
31}
32
33/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not
34 poison the whole ordering, so it sinks to the epoch instead. */
35function parseTimestampMs(isoDate: string): number {
36 const parsed = Date.parse(isoDate);
37 return Number.isNaN(parsed) ? 0 : parsed;
38}
39
40/** First VALID timestamp wins: a present-yet-malformed string falls through
41 to the next candidate rather than sinking the row to the epoch. */
42function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | undefined>): number {
43 for (const candidate of candidates) {
44 if (candidate == null) continue;
45 const parsed = Date.parse(candidate);
46 if (!Number.isNaN(parsed)) return parsed;
47 }
48 return 0;
49}
50
51/**
52 * v2 sort: static creation order, newest thread on top. Activity NEVER
53 * reorders the list — a row holds its position from open until settled, so
54 * the screen only moves at lifecycle transitions. Mirrors web's
55 * sortThreadsForSidebarV2.
56 */
57export function sortThreadsForListV2<T extends { readonly id: string; readonly createdAt: string }>(
58 threads: readonly T[],
59): T[] {
60 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
61 // change-by-copy array methods.
62 return [...threads].sort(
63 (left, right) =>
64 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||
65 left.id.localeCompare(right.id),
66 );
67}
68
69export interface ThreadListV2Item {
70 readonly thread: EnvironmentThreadShell;
71 readonly variant: "card" | "slim";
72 /** First settled row after the card block draws the SETTLED divider. */
73 readonly showSettledDivider: boolean;
74 readonly isLast: boolean;
75}
76
77export interface ThreadListV2Layout {
78 readonly items: ThreadListV2Item[];
79 /** Settled threads beyond the render limit (behind "Show more"). */
80 readonly hiddenSettledCount: number;
81}
82
83/**
84 * Partitions visible threads into the active card block (creation order) and
85 * the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
86 * mirrors the web default of 3 — mobile has no client-settings sync yet, so
87 * the default is fixed here rather than user-configurable.
88 */
89export function buildThreadListV2Items(input: {
90 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
91 readonly environmentId: EnvironmentId | null;
92 readonly projectRef?: {
93 readonly environmentId: EnvironmentId;
94 readonly projectId: ProjectId;
95 } | null;
96 readonly searchQuery: string;
97 /** Per-row PR state reported up by visible rows ("env:threadId" keys). */
98 readonly changeRequestStateByKey?: ReadonlyMap<string, "open" | "closed" | "merged">;
99 /** Environments whose server supports thread.settle/unsettle. Threads on
100 other environments never classify as settled — the user could neither
101 un-settle nor pin them. Absent = no gating (tests). */
102 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
103 readonly autoSettleAfterDays?: number;
104 /** Max settled rows to render; the rest are counted, not built. */
105 readonly settledLimit?: number;
106 /** Injectable for tests; defaults to now. */
107 readonly now?: string;
108}): ThreadListV2Layout {
109 const now = input.now ?? new Date().toISOString();
110 const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
111 const query = input.searchQuery.trim().toLocaleLowerCase();
112
113 const active: EnvironmentThreadShell[] = [];
114 const settled: EnvironmentThreadShell[] = [];
115 for (const thread of input.threads) {
116 // Callers pass live (unarchived) shells; settled threads are among them
117 // and partition into the tail via effectiveSettled.
118 if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue;
119 if (
120 input.projectRef != null &&
121 (thread.environmentId !== input.projectRef.environmentId ||
122 thread.projectId !== input.projectRef.projectId)
123 ) {
124 continue;
125 }
126 if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue;
127 const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true;
128 const changeRequestState =
129 input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
130 if (
131 supportsSettlement &&
132 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
133 ) {
134 settled.push(thread);
135 } else {
136 active.push(thread);
137 }
138 }
139
140 const orderedActive = sortThreadsForListV2(active);
141 const orderedSettled = [...settled].sort(
142 (left, right) =>
143 firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -
144 firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),
145 );
146 const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
147 const visibleSettled =
148 orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
149
150 const items: ThreadListV2Item[] = [];
151 for (const thread of orderedActive) {
152 items.push({ thread, variant: "card", showSettledDivider: false, isLast: false });
153 }
154 for (const [index, thread] of visibleSettled.entries()) {
155 items.push({
156 thread,
157 variant: "slim",
158 showSettledDivider: index === 0,
159 isLast: false,
160 });
161 }
162 const last = items.at(-1);
163 if (last) {
164 items[items.length - 1] = { ...last, isLast: true };
165 }
166 return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length };
167}
apps/mobile/src/lib/repositoryGroups.test.ts+2
......@@ -38,6 +38,8 @@ function makeThread(
3838 hasPendingUserInput: false,
3939 hasActionableProposedPlan: false,
4040 ...input,
41 settledOverride: input.settledOverride ?? null,
42 settledAt: input.settledAt ?? null,
4143 };
4244}
4345
apps/mobile/src/lib/threadActivity.test.ts+2
......@@ -50,6 +50,8 @@ function makeThread(
5050 checkpoints: [],
5151 session: null,
5252 ...input,
53 settledOverride: input.settledOverride ?? null,
54 settledAt: input.settledAt ?? null,
5355 };
5456}
5557
apps/mobile/src/persistence/mobile-preferences.ts+10
......@@ -22,6 +22,12 @@ export interface Preferences {
2222 readonly codeWordBreak?: boolean;
2323 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
2424 readonly collapsedProjectGroups?: readonly string[];
25 /**
26 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
27 * client-settings sync, so the flat v2 thread list is opted into per
28 * device.
29 */
30 readonly threadListV2Enabled?: boolean;
2531}
2632
2733export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
......@@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
7177 codeWordBreak?: boolean;
7278 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
7379 collapsedProjectGroups?: readonly string[];
80 threadListV2Enabled?: boolean;
7481 } = {};
7582
7683 if (typeof parsed.liveActivitiesEnabled === "boolean") {
......@@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
97104 (key): key is string => typeof key === "string",
98105 );
99106 }
107 if (typeof parsed.threadListV2Enabled === "boolean") {
108 preferences.threadListV2Enabled = parsed.threadListV2Enabled;
109 }
100110 return preferences;
101111}
102112
apps/mobile/src/state/use-thread-selection.ts+2
......@@ -58,6 +58,8 @@ function threadDetailToShell(
5858 createdAt: thread.createdAt,
5959 updatedAt: thread.updatedAt,
6060 archivedAt: thread.archivedAt,
61 settledOverride: thread.settledOverride,
62 settledAt: thread.settledAt,
6163 session: thread.session,
6264 latestUserMessageAt: latestUserMessageAt(thread),
6365 hasPendingApprovals: false,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -136,6 +136,7 @@ export const make = Effect.gen(function* () {
136136 capabilities: {
137137 repositoryIdentity: true,
138138 connectionProbe: true,
139 threadSettlement: true,
139140 },
140141 };
141142
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+2
......@@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => {
146146 createdAt: "2026-03-03T00:00:02.000Z",
147147 updatedAt: "2026-03-03T00:00:03.000Z",
148148 archivedAt: null,
149 settledOverride: null,
150 settledAt: null,
149151 deletedAt: null,
150152 messages: [],
151153 proposedPlans: [],
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+64
......@@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
171171 for (const row of stateRows) {
172172 assert.equal(row.lastAppliedSequence, 3);
173173 }
174
175 // Settled lifecycle through the DB pipeline: thread.settled writes the
176 // override + timestamp, thread.unsettled(user) flips to the active pin.
177 yield* eventStore.append({
178 type: "thread.settled",
179 eventId: EventId.make("evt-settle-1"),
180 aggregateKind: "thread",
181 aggregateId: ThreadId.make("thread-1"),
182 occurredAt: "2026-01-01T00:00:01.000Z",
183 commandId: CommandId.make("cmd-settle-1"),
184 causationEventId: null,
185 correlationId: CommandId.make("cmd-settle-1"),
186 metadata: {},
187 payload: {
188 threadId: ThreadId.make("thread-1"),
189 settledAt: "2026-01-01T00:00:01.000Z",
190 updatedAt: "2026-01-01T00:00:01.000Z",
191 },
192 });
193 yield* projectionPipeline.bootstrap;
194
195 const settledRows = yield* sql<{
196 readonly settledOverride: string | null;
197 readonly settledAt: string | null;
198 }>`
199 SELECT
200 settled_override AS "settledOverride",
201 settled_at AS "settledAt"
202 FROM projection_threads
203 WHERE thread_id = 'thread-1'
204 `;
205 assert.deepEqual(settledRows, [
206 { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" },
207 ]);
208
209 yield* eventStore.append({
210 type: "thread.unsettled",
211 eventId: EventId.make("evt-unsettle-1"),
212 aggregateKind: "thread",
213 aggregateId: ThreadId.make("thread-1"),
214 occurredAt: "2026-01-01T00:00:02.000Z",
215 commandId: CommandId.make("cmd-unsettle-1"),
216 causationEventId: null,
217 correlationId: CommandId.make("cmd-unsettle-1"),
218 metadata: {},
219 payload: {
220 threadId: ThreadId.make("thread-1"),
221 reason: "user",
222 updatedAt: "2026-01-01T00:00:02.000Z",
223 },
224 });
225 yield* projectionPipeline.bootstrap;
226
227 const unsettledRows = yield* sql<{
228 readonly settledOverride: string | null;
229 readonly settledAt: string | null;
230 }>`
231 SELECT
232 settled_override AS "settledOverride",
233 settled_at AS "settledAt"
234 FROM projection_threads
235 WHERE thread_id = 'thread-1'
236 `;
237 assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]);
174238 }),
175239 );
176240});
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+34
......@@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
607607 createdAt: event.payload.createdAt,
608608 updatedAt: event.payload.updatedAt,
609609 archivedAt: null,
610 settledOverride: null,
611 settledAt: null,
610612 latestUserMessageAt: null,
611613 pendingApprovalCount: 0,
612614 pendingUserInputCount: 0,
......@@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
645647 return;
646648 }
647649
650 case "thread.settled": {
651 const existingRow = yield* projectionThreadRepository.getById({
652 threadId: event.payload.threadId,
653 });
654 if (Option.isNone(existingRow)) {
655 return;
656 }
657 yield* projectionThreadRepository.upsert({
658 ...existingRow.value,
659 settledOverride: "settled",
660 settledAt: event.payload.settledAt,
661 updatedAt: event.payload.updatedAt,
662 });
663 return;
664 }
665
666 case "thread.unsettled": {
667 const existingRow = yield* projectionThreadRepository.getById({
668 threadId: event.payload.threadId,
669 });
670 if (Option.isNone(existingRow)) {
671 return;
672 }
673 yield* projectionThreadRepository.upsert({
674 ...existingRow.value,
675 settledOverride: event.payload.reason === "user" ? "active" : null,
676 settledAt: null,
677 updatedAt: event.payload.updatedAt,
678 });
679 return;
680 }
681
648682 case "thread.meta-updated": {
649683 const existingRow = yield* projectionThreadRepository.getById({
650684 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+113
......@@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
308308 createdAt: "2026-02-24T00:00:02.000Z",
309309 updatedAt: "2026-02-24T00:00:03.000Z",
310310 archivedAt: null,
311 settledOverride: null,
312 settledAt: null,
311313 deletedAt: null,
312314 messages: [
313315 {
......@@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
418420 createdAt: "2026-02-24T00:00:02.000Z",
419421 updatedAt: "2026-02-24T00:00:03.000Z",
420422 archivedAt: null,
423 settledOverride: null,
424 settledAt: null,
421425 session: {
422426 threadId: ThreadId.make("thread-1"),
423427 status: "running",
......@@ -562,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
562566 }),
563567 );
564568
569 it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () =>
570 Effect.gen(function* () {
571 const snapshotQuery = yield* ProjectionSnapshotQuery;
572 const sql = yield* SqlClient.SqlClient;
573
574 yield* sql`DELETE FROM projection_projects`;
575 yield* sql`DELETE FROM projection_threads`;
576 yield* sql`DELETE FROM projection_state`;
577
578 yield* sql`
579 INSERT INTO projection_projects (
580 project_id,
581 title,
582 workspace_root,
583 default_model_selection_json,
584 scripts_json,
585 created_at,
586 updated_at,
587 deleted_at
588 )
589 VALUES (
590 'project-settled-test',
591 'Settled Test',
592 '/tmp/settled-test',
593 '{"provider":"codex","model":"gpt-5-codex"}',
594 '[]',
595 '2026-04-06T00:00:00.000Z',
596 '2026-04-06T00:00:01.000Z',
597 NULL
598 )
599 `;
600
601 yield* sql`
602 INSERT INTO projection_threads (
603 thread_id,
604 project_id,
605 title,
606 model_selection_json,
607 runtime_mode,
608 interaction_mode,
609 branch,
610 worktree_path,
611 latest_turn_id,
612 latest_user_message_at,
613 pending_approval_count,
614 pending_user_input_count,
615 has_actionable_proposed_plan,
616 created_at,
617 updated_at,
618 archived_at,
619 settled_override,
620 settled_at,
621 deleted_at
622 )
623 VALUES (
624 'thread-settled',
625 'project-settled-test',
626 'Settled Thread',
627 '{"provider":"codex","model":"gpt-5-codex"}',
628 'full-access',
629 'default',
630 NULL,
631 NULL,
632 NULL,
633 NULL,
634 0,
635 0,
636 0,
637 '2026-04-06T00:00:02.000Z',
638 '2026-04-06T00:00:05.000Z',
639 NULL,
640 'settled',
641 '2026-04-06T00:00:04.000Z',
642 NULL
643 )
644 `;
645
646 yield* sql`
647 INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
648 VALUES
649 (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'),
650 (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'),
651 (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'),
652 (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'),
653 (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'),
654 (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'),
655 (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z')
656 `;
657
658 // Settled ≠ archived: the thread must appear in the LIVE shell
659 // snapshot, carrying its settlement fields through the row aliases.
660 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
661 assert.deepEqual(
662 shellSnapshot.threads.map((thread) => thread.id),
663 [ThreadId.make("thread-settled")],
664 );
665 assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled");
666 assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z");
667
668 // And the full command read model carries them too.
669 const readModel = yield* snapshotQuery.getCommandReadModel();
670 const thread = readModel.threads.find(
671 (candidate) => candidate.id === ThreadId.make("thread-settled"),
672 );
673 assert.equal(thread?.settledOverride, "settled");
674 assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z");
675 }),
676 );
677
565678 it.effect(
566679 "reads targeted project, thread, and count queries without hydrating the full snapshot",
567680 () =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+20
......@@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
334334 created_at AS "createdAt",
335335 updated_at AS "updatedAt",
336336 archived_at AS "archivedAt",
337 settled_override AS "settledOverride",
338 settled_at AS "settledAt",
337339 latest_user_message_at AS "latestUserMessageAt",
338340 pending_approval_count AS "pendingApprovalCount",
339341 pending_user_input_count AS "pendingUserInputCount",
......@@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
362364 created_at AS "createdAt",
363365 updated_at AS "updatedAt",
364366 archived_at AS "archivedAt",
367 settled_override AS "settledOverride",
368 settled_at AS "settledAt",
365369 latest_user_message_at AS "latestUserMessageAt",
366370 pending_approval_count AS "pendingApprovalCount",
367371 pending_user_input_count AS "pendingUserInputCount",
......@@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
392396 created_at AS "createdAt",
393397 updated_at AS "updatedAt",
394398 archived_at AS "archivedAt",
399 settled_override AS "settledOverride",
400 settled_at AS "settledAt",
395401 latest_user_message_at AS "latestUserMessageAt",
396402 pending_approval_count AS "pendingApprovalCount",
397403 pending_user_input_count AS "pendingUserInputCount",
......@@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
754760 created_at AS "createdAt",
755761 updated_at AS "updatedAt",
756762 archived_at AS "archivedAt",
763 settled_override AS "settledOverride",
764 settled_at AS "settledAt",
757765 latest_user_message_at AS "latestUserMessageAt",
758766 pending_approval_count AS "pendingApprovalCount",
759767 pending_user_input_count AS "pendingUserInputCount",
......@@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
11861194 createdAt: row.createdAt,
11871195 updatedAt: row.updatedAt,
11881196 archivedAt: row.archivedAt,
1197 settledOverride: row.settledOverride,
1198 settledAt: row.settledAt,
11891199 deletedAt: row.deletedAt,
11901200 messages: messagesByThread.get(row.threadId) ?? [],
11911201 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
13841394 createdAt: row.createdAt,
13851395 updatedAt: row.updatedAt,
13861396 archivedAt: row.archivedAt,
1397 settledOverride: row.settledOverride,
1398 settledAt: row.settledAt,
13871399 deletedAt: row.deletedAt,
13881400 messages: [],
13891401 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15131525 createdAt: row.createdAt,
15141526 updatedAt: row.updatedAt,
15151527 archivedAt: row.archivedAt,
1528 settledOverride: row.settledOverride,
1529 settledAt: row.settledAt,
15161530 session: sessionByThread.get(row.threadId) ?? null,
15171531 latestUserMessageAt: row.latestUserMessageAt,
15181532 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16471661 createdAt: row.createdAt,
16481662 updatedAt: row.updatedAt,
16491663 archivedAt: row.archivedAt,
1664 settledOverride: row.settledOverride,
1665 settledAt: row.settledAt,
16501666 session: sessionByThread.get(row.threadId) ?? null,
16511667 latestUserMessageAt: row.latestUserMessageAt,
16521668 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
18871903 createdAt: threadRow.value.createdAt,
18881904 updatedAt: threadRow.value.updatedAt,
18891905 archivedAt: threadRow.value.archivedAt,
1906 settledOverride: threadRow.value.settledOverride,
1907 settledAt: threadRow.value.settledAt,
18901908 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
18911909 latestUserMessageAt: threadRow.value.latestUserMessageAt,
18921910 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
......@@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19811999 createdAt: threadRow.value.createdAt,
19822000 updatedAt: threadRow.value.updatedAt,
19832001 archivedAt: threadRow.value.archivedAt,
2002 settledOverride: threadRow.value.settledOverride,
2003 settledAt: threadRow.value.settledAt,
19842004 deletedAt: null,
19852005 messages: messageRows.map((row) => {
19862006 const message = {
apps/server/src/orchestration/Schemas.ts+4
......@@ -4,11 +4,13 @@ import {
44 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
55 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
66 ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema,
7 ThreadSettledPayload as ContractsThreadSettledPayloadSchema,
78 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
89 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
910 ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema,
1011 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
1112 ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema,
13 ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema,
1214 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
1315 ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema,
1416 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2931
3032export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
3133export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema;
34export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema;
3235export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
3336export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
3437export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema;
3538export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
3639export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema;
40export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema;
3741
3842export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
3943export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+4
......@@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = {
6868 createdAt: now,
6969 updatedAt: now,
7070 archivedAt: null,
71 settledOverride: null,
72 settledAt: null,
7173 latestTurn: null,
7274 messages: [],
7375 session: null,
......@@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = {
9193 createdAt: now,
9294 updatedAt: now,
9395 archivedAt: null,
96 settledOverride: null,
97 settledAt: null,
9498 latestTurn: null,
9599 messages: [],
96100 session: null,
apps/server/src/orchestration/decider.settled.test.ts created+521
......@@ -0,0 +1,521 @@
1import {
2 CommandId,
3 EventId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 type OrchestrationReadModel,
9 type OrchestrationSession,
10 type OrchestrationThread,
11} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { expect, it } from "@effect/vitest";
14import * as Effect from "effect/Effect";
15
16import { decideOrchestrationCommand } from "./decider.ts";
17
18const NOW = "2026-01-01T00:00:00.000Z";
19const SETTLED_AT = "2025-12-30T00:00:00.000Z";
20
21function makeReadModel(
22 settledOverride: OrchestrationThread["settledOverride"],
23 archivedAt: string | null = null,
24 session: OrchestrationSession | null = null,
25 activities: OrchestrationThread["activities"] = [],
26 messages: OrchestrationThread["messages"] = [],
27): OrchestrationReadModel {
28 return {
29 snapshotSequence: 0,
30 projects: [],
31 threads: [
32 {
33 id: ThreadId.make("thread-1"),
34 projectId: ProjectId.make("project-1"),
35 title: "Thread",
36 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
37 runtimeMode: "full-access",
38 interactionMode: "default",
39 branch: null,
40 worktreePath: null,
41 latestTurn: null,
42 createdAt: NOW,
43 updatedAt: NOW,
44 archivedAt,
45 settledOverride,
46 settledAt: settledOverride === "settled" ? SETTLED_AT : null,
47 deletedAt: null,
48 messages,
49 proposedPlans: [],
50 activities,
51 checkpoints: [],
52 session,
53 },
54 ],
55 updatedAt: NOW,
56 };
57}
58
59function makeSession(status: OrchestrationSession["status"]): OrchestrationSession {
60 return {
61 threadId: ThreadId.make("thread-1"),
62 status,
63 providerName: "Codex",
64 runtimeMode: "full-access",
65 activeTurnId: null,
66 lastError: null,
67 updatedAt: NOW,
68 };
69}
70
71it.layer(NodeServices.layer)("settled thread decider", (it) => {
72 it.effect("settles active threads and re-emits idempotently for settled ones", () =>
73 Effect.gen(function* () {
74 const event = yield* decideOrchestrationCommand({
75 command: {
76 type: "thread.settle",
77 commandId: CommandId.make("cmd-settle"),
78 threadId: ThreadId.make("thread-1"),
79 },
80 readModel: makeReadModel(null),
81 });
82 const events = Array.isArray(event) ? event : [event];
83 expect(events).toHaveLength(1);
84 expect(events[0]?.type).toBe("thread.settled");
85 if (events[0]?.type === "thread.settled") {
86 expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt);
87 }
88
89 // Already settled: the engine rejects zero-event commands, so idempotency
90 // is by re-emission — preserving the original settledAt.
91 const reEmit = yield* decideOrchestrationCommand({
92 command: {
93 type: "thread.settle",
94 commandId: CommandId.make("cmd-settle-again"),
95 threadId: ThreadId.make("thread-1"),
96 },
97 readModel: makeReadModel("settled"),
98 });
99 const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit];
100 expect(reEmitEvents).toHaveLength(1);
101 expect(reEmitEvents[0]?.type).toBe("thread.settled");
102 if (reEmitEvents[0]?.type === "thread.settled") {
103 expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT);
104 // updatedAt must NOT rewind to the historical settledAt: sorting and
105 // relative-time labels key on it.
106 expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT);
107 }
108 }),
109 );
110
111 it.effect("rejects settling a thread with a live session", () =>
112 Effect.gen(function* () {
113 for (const status of ["starting", "running"] as const) {
114 const error = yield* decideOrchestrationCommand({
115 command: {
116 type: "thread.settle",
117 commandId: CommandId.make(`cmd-settle-live-${status}`),
118 threadId: ThreadId.make("thread-1"),
119 },
120 readModel: makeReadModel(null, null, makeSession(status)),
121 }).pipe(Effect.flip);
122 expect(error._tag).toBe("OrchestrationCommandInvariantError");
123 }
124 // Stopped/error sessions are settleable — only live work is protected.
125 const settled = yield* decideOrchestrationCommand({
126 command: {
127 type: "thread.settle",
128 commandId: CommandId.make("cmd-settle-stopped"),
129 threadId: ThreadId.make("thread-1"),
130 },
131 readModel: makeReadModel(null, null, makeSession("stopped")),
132 });
133 const settledEvents = Array.isArray(settled) ? settled : [settled];
134 expect(settledEvents[0]?.type).toBe("thread.settled");
135 }),
136 );
137
138 it.effect("rejects settling a thread with an open approval or user-input request", () =>
139 Effect.gen(function* () {
140 const requestActivity = (kind: string, requestId: string, at: string) =>
141 ({
142 id: EventId.make(`activity-${requestId}-${kind}`),
143 tone: "approval" as const,
144 kind,
145 summary: kind,
146 payload: { requestId },
147 turnId: null,
148 createdAt: at,
149 }) as OrchestrationThread["activities"][number];
150
151 // Open approval request: settle rejected.
152 const openError = yield* decideOrchestrationCommand({
153 command: {
154 type: "thread.settle",
155 commandId: CommandId.make("cmd-settle-pending"),
156 threadId: ThreadId.make("thread-1"),
157 },
158 readModel: makeReadModel(null, null, null, [
159 requestActivity("approval.requested", "req-1", NOW),
160 ]),
161 }).pipe(Effect.flip);
162 expect(openError._tag).toBe("OrchestrationCommandInvariantError");
163
164 // Same request later resolved: settleable again.
165 const settled = yield* decideOrchestrationCommand({
166 command: {
167 type: "thread.settle",
168 commandId: CommandId.make("cmd-settle-resolved"),
169 threadId: ThreadId.make("thread-1"),
170 },
171 readModel: makeReadModel(null, null, null, [
172 requestActivity("approval.requested", "req-1", NOW),
173 requestActivity("approval.resolved", "req-1", NOW),
174 ]),
175 });
176 const settledEvents = Array.isArray(settled) ? settled : [settled];
177 expect(settledEvents[0]?.type).toBe("thread.settled");
178
179 // Open user-input request: also rejected.
180 const inputError = yield* decideOrchestrationCommand({
181 command: {
182 type: "thread.settle",
183 commandId: CommandId.make("cmd-settle-pending-input"),
184 threadId: ThreadId.make("thread-1"),
185 },
186 readModel: makeReadModel(null, null, null, [
187 requestActivity("user-input.requested", "req-2", NOW),
188 ]),
189 }).pipe(Effect.flip);
190 expect(inputError._tag).toBe("OrchestrationCommandInvariantError");
191 }),
192 );
193
194 it.effect("clears an open request when its respond failure marks it stale", () =>
195 Effect.gen(function* () {
196 const activity = (
197 kind: string,
198 requestId: string,
199 payload: Record<string, unknown>,
200 ): OrchestrationThread["activities"][number] =>
201 ({
202 id: EventId.make(`activity-${requestId}-${kind}`),
203 tone: "approval" as const,
204 kind,
205 summary: kind,
206 payload: { requestId, ...payload },
207 turnId: null,
208 createdAt: NOW,
209 }) as OrchestrationThread["activities"][number];
210
211 // Stale-failure detail clears the request — mirrors the projection's
212 // pending accounting, which is what the client's canSettle sees.
213 const settled = yield* decideOrchestrationCommand({
214 command: {
215 type: "thread.settle",
216 commandId: CommandId.make("cmd-settle-stale-failed"),
217 threadId: ThreadId.make("thread-1"),
218 },
219 readModel: makeReadModel(null, null, null, [
220 activity("approval.requested", "req-1", {}),
221 activity("provider.approval.respond.failed", "req-1", {
222 detail: "Unknown pending approval request req-1",
223 }),
224 activity("user-input.requested", "req-2", {}),
225 activity("provider.user-input.respond.failed", "req-2", {
226 detail: "stale pending user-input request req-2",
227 }),
228 ]),
229 });
230 const settledEvents = Array.isArray(settled) ? settled : [settled];
231 expect(settledEvents[0]?.type).toBe("thread.settled");
232
233 // A non-stale respond failure (transient provider error) keeps the
234 // request open: the user can retry, so it is still blocked-on-you.
235 const stillOpen = yield* decideOrchestrationCommand({
236 command: {
237 type: "thread.settle",
238 commandId: CommandId.make("cmd-settle-transient-failed"),
239 threadId: ThreadId.make("thread-1"),
240 },
241 readModel: makeReadModel(null, null, null, [
242 activity("approval.requested", "req-3", {}),
243 activity("provider.approval.respond.failed", "req-3", {
244 detail: "provider connection reset",
245 }),
246 ]),
247 }).pipe(Effect.flip);
248 expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError");
249 }),
250 );
251
252 it.effect("bounds the queued-turn grace window against client clock skew", () =>
253 Effect.gen(function* () {
254 const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({
255 id: MessageId.make("message-queued"),
256 role: "user",
257 text: "Continue",
258 turnId: null,
259 streaming: false,
260 createdAt,
261 updatedAt: createdAt,
262 });
263
264 // The decider's clock is the Effect test clock, pinned to the epoch:
265 // timestamps here are relative to 1970-01-01T00:00:00.000Z.
266
267 // Within the grace window: genuinely queued, settle rejected.
268 const queuedError = yield* decideOrchestrationCommand({
269 command: {
270 type: "thread.settle",
271 commandId: CommandId.make("cmd-settle-queued"),
272 threadId: ThreadId.make("thread-1"),
273 },
274 readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]),
275 }).pipe(Effect.flip);
276 expect(queuedError._tag).toBe("OrchestrationCommandInvariantError");
277
278 // Message timestamp far in the FUTURE (client clock ahead of server):
279 // a negative age must not read as queued forever — past the grace
280 // bound in either direction the thread is settleable.
281 const skewed = yield* decideOrchestrationCommand({
282 command: {
283 type: "thread.settle",
284 commandId: CommandId.make("cmd-settle-skewed"),
285 threadId: ThreadId.make("thread-1"),
286 },
287 readModel: makeReadModel(null, null, null, [], [userMessage("1970-01-01T01:00:00.000Z")]),
288 });
289 const skewedEvents = Array.isArray(skewed) ? skewed : [skewed];
290 expect(skewedEvents[0]?.type).toBe("thread.settled");
291 }),
292 );
293
294 it.effect("rejects settling and unsettling archived threads", () =>
295 Effect.gen(function* () {
296 const settleError = yield* decideOrchestrationCommand({
297 command: {
298 type: "thread.settle",
299 commandId: CommandId.make("cmd-settle-archived"),
300 threadId: ThreadId.make("thread-1"),
301 },
302 readModel: makeReadModel(null, NOW),
303 }).pipe(Effect.flip);
304 expect(settleError._tag).toBe("OrchestrationCommandInvariantError");
305
306 const unsettleError = yield* decideOrchestrationCommand({
307 command: {
308 type: "thread.unsettle",
309 commandId: CommandId.make("cmd-unsettle-archived"),
310 threadId: ThreadId.make("thread-1"),
311 reason: "user",
312 },
313 readModel: makeReadModel("settled", NOW),
314 }).pipe(Effect.flip);
315 expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError");
316 }),
317 );
318
319 it.effect("maps unsettle reasons to overrides and re-emits idempotently", () =>
320 Effect.gen(function* () {
321 const userEvent = yield* decideOrchestrationCommand({
322 command: {
323 type: "thread.unsettle",
324 commandId: CommandId.make("cmd-unsettle-user"),
325 threadId: ThreadId.make("thread-1"),
326 reason: "user",
327 },
328 readModel: makeReadModel("settled"),
329 });
330 const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent];
331 expect(userEvents).toHaveLength(1);
332 expect(userEvents[0]?.type).toBe("thread.unsettled");
333 if (userEvents[0]?.type === "thread.unsettled") {
334 expect(userEvents[0].payload.reason).toBe("user");
335 }
336
337 // Re-dispatching against the already-reached state re-emits rather than
338 // producing zero events (the engine rejects empty commands).
339 const userAgain = yield* decideOrchestrationCommand({
340 command: {
341 type: "thread.unsettle",
342 commandId: CommandId.make("cmd-unsettle-user-again"),
343 threadId: ThreadId.make("thread-1"),
344 reason: "user",
345 },
346 readModel: makeReadModel("active"),
347 });
348 const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain];
349 expect(userAgainEvents).toHaveLength(1);
350 expect(userAgainEvents[0]?.type).toBe("thread.unsettled");
351 }),
352 );
353
354 it.effect("prepends activity unsets for turn starts and live session updates", () =>
355 Effect.gen(function* () {
356 const turnResult = yield* decideOrchestrationCommand({
357 command: {
358 type: "thread.turn.start",
359 commandId: CommandId.make("cmd-turn-start"),
360 threadId: ThreadId.make("thread-1"),
361 message: {
362 messageId: MessageId.make("message-1"),
363 role: "user",
364 text: "Continue",
365 attachments: [],
366 },
367 runtimeMode: "full-access",
368 interactionMode: "default",
369 createdAt: NOW,
370 },
371 readModel: makeReadModel("settled"),
372 });
373 const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult];
374 expect(turnEvents.map((event) => event.type)).toEqual([
375 "thread.unsettled",
376 "thread.message-sent",
377 "thread.turn-start-requested",
378 ]);
379
380 const sessionResult = yield* decideOrchestrationCommand({
381 command: {
382 type: "thread.session.set",
383 commandId: CommandId.make("cmd-session-set"),
384 threadId: ThreadId.make("thread-1"),
385 session: makeSession("running"),
386 createdAt: NOW,
387 },
388 // A keep-active pin is also an override: real activity clears it
389 // back to neutral so auto-settle can apply again later.
390 readModel: makeReadModel("active"),
391 });
392 const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult];
393 expect(sessionEvents.map((event) => event.type)).toEqual([
394 "thread.unsettled",
395 "thread.session-set",
396 ]);
397 }),
398 );
399
400 it.effect("clears a keep-active pin on real activity", () =>
401 Effect.gen(function* () {
402 const turnResult = yield* decideOrchestrationCommand({
403 command: {
404 type: "thread.turn.start",
405 commandId: CommandId.make("cmd-active-turn-start"),
406 threadId: ThreadId.make("thread-1"),
407 message: {
408 messageId: MessageId.make("message-active"),
409 role: "user",
410 text: "Continue",
411 attachments: [],
412 },
413 runtimeMode: "full-access",
414 interactionMode: "default",
415 createdAt: NOW,
416 },
417 readModel: makeReadModel("active"),
418 });
419 const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult];
420 // The pin exists to suppress AUTO-settle, not to survive real work:
421 // activity resets it to neutral, restoring the default lifecycle.
422 expect(turnEvents.map((event) => event.type)).toEqual([
423 "thread.unsettled",
424 "thread.message-sent",
425 "thread.turn-start-requested",
426 ]);
427
428 const activityResult = yield* decideOrchestrationCommand({
429 command: {
430 type: "thread.activity.append",
431 commandId: CommandId.make("cmd-active-approval"),
432 threadId: ThreadId.make("thread-1"),
433 activity: {
434 id: EventId.make("activity-active"),
435 tone: "approval",
436 kind: "approval.requested",
437 summary: "Command approval requested",
438 payload: null,
439 turnId: null,
440 createdAt: NOW,
441 },
442 createdAt: NOW,
443 },
444 readModel: makeReadModel("active"),
445 });
446 const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult];
447 expect(activityEvents.map((event) => event.type)).toEqual([
448 "thread.unsettled",
449 "thread.activity-appended",
450 ]);
451 }),
452 );
453
454 it.effect("does not unsettle for session stop/error status writes", () =>
455 Effect.gen(function* () {
456 for (const status of ["stopped", "error", "ready", "idle"] as const) {
457 const result = yield* decideOrchestrationCommand({
458 command: {
459 type: "thread.session.set",
460 commandId: CommandId.make(`cmd-session-${status}`),
461 threadId: ThreadId.make("thread-1"),
462 session: makeSession(status),
463 createdAt: NOW,
464 },
465 readModel: makeReadModel("settled"),
466 });
467 const events = Array.isArray(result) ? result : [result];
468 expect(events.map((event) => event.type)).toEqual(["thread.session-set"]);
469 }
470 }),
471 );
472
473 it.effect("unsettles for approval and user-input activities but not others", () =>
474 Effect.gen(function* () {
475 const approvalResult = yield* decideOrchestrationCommand({
476 command: {
477 type: "thread.activity.append",
478 commandId: CommandId.make("cmd-activity-approval"),
479 threadId: ThreadId.make("thread-1"),
480 activity: {
481 id: EventId.make("activity-1"),
482 tone: "approval",
483 kind: "approval.requested",
484 summary: "Command approval requested",
485 payload: null,
486 turnId: null,
487 createdAt: NOW,
488 },
489 createdAt: NOW,
490 },
491 readModel: makeReadModel("settled"),
492 });
493 const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult];
494 expect(approvalEvents.map((event) => event.type)).toEqual([
495 "thread.unsettled",
496 "thread.activity-appended",
497 ]);
498
499 const routineResult = yield* decideOrchestrationCommand({
500 command: {
501 type: "thread.activity.append",
502 commandId: CommandId.make("cmd-activity-routine"),
503 threadId: ThreadId.make("thread-1"),
504 activity: {
505 id: EventId.make("activity-2"),
506 tone: "info",
507 kind: "tool.completed",
508 summary: "Tool completed",
509 payload: null,
510 turnId: null,
511 createdAt: NOW,
512 },
513 createdAt: NOW,
514 },
515 readModel: makeReadModel("settled"),
516 });
517 const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult];
518 expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]);
519 }),
520 );
521});
apps/server/src/orchestration/decider.ts+254-5
......@@ -24,6 +24,61 @@ import { projectEvent } from "./projector.ts";
2424
2525const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
2626
27// Session adoption takes seconds; a user message still unadopted after this
28// window is a failed/stale start, not pending work. Mirrors the client's
29// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts.
30const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
31
32/**
33 * Blocked-on-you work derived from the thread's retained activities: an
34 * approval or user-input request with no later resolution for the same
35 * requestId. The server-side twin of the shell's hasPendingApprovals /
36 * hasPendingUserInput flags, which the decider read model does not carry.
37 * The clearing rules MUST match ProjectionPipeline's pending accounting —
38 * resolved activities always clear, respond.failed clears only when the
39 * failure detail marks the request stale/unknown — or settle would be
40 * rejected on threads whose shell flags read as clear.
41 */
42function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): boolean {
43 const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
44 if (detail === null) return false;
45 return (
46 detail.includes("stale pending approval request") ||
47 detail.includes("unknown pending approval request") ||
48 detail.includes("unknown pending permission request") ||
49 detail.includes("stale pending user-input request") ||
50 detail.includes("unknown pending user-input request") ||
51 detail.includes("unknown pending user input request") ||
52 detail.includes("unknown pending codex user input request")
53 );
54}
55
56function hasOpenBlockingRequest(thread: {
57 readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>;
58}): boolean {
59 const openRequestIds = new Set<string>();
60 for (const activity of thread.activities) {
61 const payload =
62 typeof activity.payload === "object" && activity.payload !== null
63 ? (activity.payload as Record<string, unknown>)
64 : null;
65 const requestId = typeof payload?.requestId === "string" ? payload.requestId : null;
66 if (requestId === null) continue;
67 if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") {
68 openRequestIds.add(requestId);
69 } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") {
70 openRequestIds.delete(requestId);
71 } else if (
72 (activity.kind === "provider.approval.respond.failed" ||
73 activity.kind === "provider.user-input.respond.failed") &&
74 isStaleRequestFailureDetail(payload)
75 ) {
76 openRequestIds.delete(requestId);
77 }
78 }
79 return openRequestIds.size > 0;
80}
81
2782function withEventBase(
2883 input: Pick<OrchestrationCommand, "commandId"> & {
2984 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
......@@ -327,6 +382,132 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
327382 };
328383 }
329384
385 case "thread.settle": {
386 const thread = yield* requireThreadNotArchived({
387 readModel,
388 command,
389 threadId: command.threadId,
390 });
391 // Server-side twin of the client's canSettle session check: a stale
392 // or raced client must not settle a thread whose session is coming
393 // alive or working.
394 if (thread.session?.status === "starting" || thread.session?.status === "running") {
395 return yield* Effect.fail(
396 new OrchestrationCommandInvariantError({
397 commandType: command.type,
398 detail: `thread ${command.threadId} has an active session and cannot be settled`,
399 }),
400 );
401 }
402 // Pending approval / user-input requests are blocked-on-you work: a
403 // raced or stale client must not park them behind a settled override
404 // that would surface only after the request resolves.
405 if (hasOpenBlockingRequest(thread)) {
406 return yield* Effect.fail(
407 new OrchestrationCommandInvariantError({
408 commandType: command.type,
409 detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`,
410 }),
411 );
412 }
413 const occurredAt = yield* nowIso;
414 // A queued turn start — a user message no turn has picked up yet — is
415 // work in flight even though session is still null (turn.start emits
416 // message-sent + turn-start-requested; the session arrives later).
417 // Settling in that window would hide just-requested work. Detection
418 // mirrors the client's hasQueuedTurnStart: the newest user message is
419 // strictly newer than every latestTurn timestamp (adoption stamps the
420 // new turn's requestedAt with the message time, clearing this), and
421 // only within the adoption grace window — historical threads whose
422 // last user message postdates their turn timestamps (older-server
423 // data, mid-turn messages) must stay settleable. A failed session
424 // start (status "error") clears the block immediately.
425 const latestUserMessageAtMs = thread.messages.reduce(
426 (latest, message) =>
427 message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest,
428 Number.NEGATIVE_INFINITY,
429 );
430 const latestTurnAtMs =
431 thread.latestTurn === null
432 ? Number.NEGATIVE_INFINITY
433 : Math.max(
434 ...[
435 thread.latestTurn.requestedAt,
436 thread.latestTurn.startedAt,
437 thread.latestTurn.completedAt,
438 ].map((candidate) =>
439 candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate),
440 ),
441 );
442 // The age check is bounded on BOTH sides: message timestamps are
443 // client-supplied, so a client clock ahead of the server yields a
444 // negative age. Without the lower bound that negative age satisfies
445 // `<= grace` for as long as the skew lasts, extending the settle
446 // block far past the intended two minutes.
447 const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs;
448 const hasQueuedTurnStart =
449 thread.session?.status !== "error" &&
450 Number.isFinite(latestUserMessageAtMs) &&
451 latestUserMessageAtMs > latestTurnAtMs &&
452 Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS;
453 if (hasQueuedTurnStart) {
454 return yield* Effect.fail(
455 new OrchestrationCommandInvariantError({
456 commandType: command.type,
457 detail: `thread ${command.threadId} has a queued turn start and cannot be settled`,
458 }),
459 );
460 }
461 // Settling an already-settled thread re-emits with the original
462 // settledAt: the engine rejects zero-event commands, and bulk-settle /
463 // double-click must stay silent no-ops rather than surface errors.
464 const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null;
465 return {
466 ...(yield* withEventBase({
467 aggregateKind: "thread",
468 aggregateId: command.threadId,
469 occurredAt,
470 commandId: command.commandId,
471 })),
472 type: "thread.settled",
473 payload: {
474 threadId: command.threadId,
475 settledAt: alreadySettled ? thread.settledAt : occurredAt,
476 // A re-emission is a projected no-op: keep the existing updatedAt
477 // so duplicate settles neither rewind nor churn ordering. A fresh
478 // settle stamps the command time.
479 updatedAt: alreadySettled ? thread.updatedAt : occurredAt,
480 },
481 };
482 }
483
484 case "thread.unsettle": {
485 const thread = yield* requireThreadNotArchived({
486 readModel,
487 command,
488 threadId: command.threadId,
489 });
490 // Idempotent by re-emission (see thread.settle): reducing the event a
491 // second time lands on the same override state. A re-emission keeps
492 // the existing updatedAt so duplicates do not churn ordering.
493 const alreadyPinnedActive = thread.settledOverride === "active";
494 const occurredAt = yield* nowIso;
495 return {
496 ...(yield* withEventBase({
497 aggregateKind: "thread",
498 aggregateId: command.threadId,
499 occurredAt,
500 commandId: command.commandId,
501 })),
502 type: "thread.unsettled",
503 payload: {
504 threadId: command.threadId,
505 reason: command.reason,
506 updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt,
507 },
508 };
509 }
510
330511 case "thread.meta.update": {
331512 const thread = yield* requireThread({
332513 readModel,
......@@ -479,7 +660,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
479660 createdAt: command.createdAt,
480661 },
481662 };
482 return [userMessageEvent, turnStartRequestedEvent];
663 // Real activity resets ANY override: it wakes an explicitly settled
664 // thread, and it clears a keep-active pin back to neutral so the
665 // thread can auto-settle again after this burst of work goes stale.
666 if (targetThread.settledOverride === null) {
667 return [userMessageEvent, turnStartRequestedEvent];
668 }
669 const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
670 ...(yield* withEventBase({
671 aggregateKind: "thread",
672 aggregateId: command.threadId,
673 occurredAt: command.createdAt,
674 commandId: command.commandId,
675 })),
676 type: "thread.unsettled",
677 payload: {
678 threadId: command.threadId,
679 reason: "activity",
680 updatedAt: command.createdAt,
681 },
682 };
683 return [unsettledEvent, userMessageEvent, turnStartRequestedEvent];
483684 }
484685
485686 case "thread.turn.interrupt": {
......@@ -600,12 +801,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
600801 }
601802
602803 case "thread.session.set": {
603 yield* requireThread({
804 const thread = yield* requireThread({
604805 readModel,
605806 command,
606807 threadId: command.threadId,
607808 });
608 return {
809 const sessionSetEvent: Omit<OrchestrationEvent, "sequence"> = {
609810 ...(yield* withEventBase({
610811 aggregateKind: "thread",
611812 aggregateId: command.threadId,
......@@ -619,6 +820,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
619820 session: command.session,
620821 },
621822 };
823 // Only a session coming alive is activity worth waking a settled thread
824 // for — status writes like ready/stopped/error arrive after the fact and
825 // must not fight a user's explicit settle.
826 const isSessionActivity =
827 command.session.status === "starting" || command.session.status === "running";
828 // Real activity resets ANY override (settled wakes, active unpins).
829 if (thread.settledOverride === null || !isSessionActivity) {
830 return sessionSetEvent;
831 }
832 const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
833 ...(yield* withEventBase({
834 aggregateKind: "thread",
835 aggregateId: command.threadId,
836 occurredAt: command.createdAt,
837 commandId: command.commandId,
838 })),
839 type: "thread.unsettled",
840 payload: {
841 threadId: command.threadId,
842 reason: "activity",
843 updatedAt: command.createdAt,
844 },
845 };
846 return [unsettledEvent, sessionSetEvent];
622847 }
623848
624849 case "thread.message.assistant.delta": {
......@@ -745,7 +970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
745970 }
746971
747972 case "thread.activity.append": {
748 yield* requireThread({
973 const thread = yield* requireThread({
749974 readModel,
750975 command,
751976 threadId: command.threadId,
......@@ -758,7 +983,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
758983 ? ((command.activity.payload as { requestId: string })
759984 .requestId as OrchestrationEvent["metadata"]["requestId"])
760985 : undefined;
761 return {
986 const activityAppendedEvent: Omit<OrchestrationEvent, "sequence"> = {
762987 ...(yield* withEventBase({
763988 aggregateKind: "thread",
764989 aggregateId: command.threadId,
......@@ -772,6 +997,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
772997 activity: command.activity,
773998 },
774999 };
1000 // An approval or user-input request is blocked-on-you work — it must
1001 // never stay hidden inside a settled slim row.
1002 const wakesSettledThread =
1003 command.activity.kind === "approval.requested" ||
1004 command.activity.kind === "user-input.requested";
1005 // Real activity resets ANY override (settled wakes, active unpins).
1006 if (thread.settledOverride === null || !wakesSettledThread) {
1007 return activityAppendedEvent;
1008 }
1009 const unsettledEvent: Omit<OrchestrationEvent, "sequence"> = {
1010 ...(yield* withEventBase({
1011 aggregateKind: "thread",
1012 aggregateId: command.threadId,
1013 occurredAt: command.createdAt,
1014 commandId: command.commandId,
1015 })),
1016 type: "thread.unsettled",
1017 payload: {
1018 threadId: command.threadId,
1019 reason: "activity",
1020 updatedAt: command.createdAt,
1021 },
1022 };
1023 return [unsettledEvent, activityAppendedEvent];
7751024 }
7761025
7771026 default: {
apps/server/src/orchestration/projector.settled.test.ts created+88
......@@ -0,0 +1,88 @@
1import {
2 CommandId,
3 EventId,
4 ProjectId,
5 ThreadId,
6 type OrchestrationEvent,
7} from "@t3tools/contracts";
8import { expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10
11import { createEmptyReadModel, projectEvent } from "./projector.ts";
12
13function makeEvent(input: {
14 readonly sequence: number;
15 readonly type: OrchestrationEvent["type"];
16 readonly payload: unknown;
17}): OrchestrationEvent {
18 return {
19 sequence: input.sequence,
20 eventId: EventId.make(`event-${input.sequence}`),
21 type: input.type,
22 aggregateKind: "thread",
23 aggregateId: ThreadId.make("thread-1"),
24 occurredAt: "2026-01-01T00:00:00.000Z",
25 commandId: CommandId.make(`command-${input.sequence}`),
26 causationEventId: null,
27 correlationId: null,
28 metadata: {},
29 payload: input.payload as never,
30 } as OrchestrationEvent;
31}
32
33it.effect("projects settled lifecycle events", () =>
34 Effect.gen(function* () {
35 const now = "2026-01-01T00:00:00.000Z";
36 const created = yield* projectEvent(
37 createEmptyReadModel(now),
38 makeEvent({
39 sequence: 1,
40 type: "thread.created",
41 payload: {
42 threadId: ThreadId.make("thread-1"),
43 projectId: ProjectId.make("project-1"),
44 title: "Thread",
45 modelSelection: { provider: "codex", model: "gpt-5.4" },
46 runtimeMode: "full-access",
47 interactionMode: "default",
48 branch: null,
49 worktreePath: null,
50 createdAt: now,
51 updatedAt: now,
52 },
53 }),
54 );
55 const settled = yield* projectEvent(
56 created,
57 makeEvent({
58 sequence: 2,
59 type: "thread.settled",
60 payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now },
61 }),
62 );
63 expect(settled.threads[0]?.settledOverride).toBe("settled");
64 expect(settled.threads[0]?.settledAt).toBe(now);
65
66 const userUnsettled = yield* projectEvent(
67 settled,
68 makeEvent({
69 sequence: 3,
70 type: "thread.unsettled",
71 payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now },
72 }),
73 );
74 expect(userUnsettled.threads[0]?.settledOverride).toBe("active");
75 expect(userUnsettled.threads[0]?.settledAt).toBeNull();
76
77 const activityUnsettled = yield* projectEvent(
78 userUnsettled,
79 makeEvent({
80 sequence: 4,
81 type: "thread.unsettled",
82 payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now },
83 }),
84 );
85 expect(activityUnsettled.threads[0]?.settledOverride).toBeNull();
86 expect(activityUnsettled.threads[0]?.settledAt).toBeNull();
87 }),
88);
apps/server/src/orchestration/projector.test.ts+2
......@@ -89,6 +89,8 @@ describe("orchestration projector", () => {
8989 createdAt: now,
9090 updatedAt: now,
9191 archivedAt: null,
92 settledOverride: null,
93 settledAt: null,
9294 deletedAt: null,
9395 messages: [],
9496 proposedPlans: [],
apps/server/src/orchestration/projector.ts+28
......@@ -22,7 +22,9 @@ import {
2222 ThreadMetaUpdatedPayload,
2323 ThreadProposedPlanUpsertedPayload,
2424 ThreadRuntimeModeSetPayload,
25 ThreadSettledPayload,
2526 ThreadUnarchivedPayload,
27 ThreadUnsettledPayload,
2628 ThreadRevertedPayload,
2729 ThreadSessionSetPayload,
2830 ThreadTurnDiffCompletedPayload,
......@@ -286,6 +288,8 @@ export function projectEvent(
286288 createdAt: payload.createdAt,
287289 updatedAt: payload.updatedAt,
288290 archivedAt: null,
291 settledOverride: null,
292 settledAt: null,
289293 deletedAt: null,
290294 messages: [],
291295 activities: [],
......@@ -337,6 +341,30 @@ export function projectEvent(
337341 })),
338342 );
339343
344 case "thread.settled":
345 return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe(
346 Effect.map((payload) => ({
347 ...nextBase,
348 threads: updateThread(nextBase.threads, payload.threadId, {
349 settledOverride: "settled",
350 settledAt: payload.settledAt,
351 updatedAt: payload.updatedAt,
352 }),
353 })),
354 );
355
356 case "thread.unsettled":
357 return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe(
358 Effect.map((payload) => ({
359 ...nextBase,
360 threads: updateThread(nextBase.threads, payload.threadId, {
361 settledOverride: payload.reason === "user" ? "active" : null,
362 settledAt: null,
363 updatedAt: payload.updatedAt,
364 }),
365 })),
366 );
367
340368 case "thread.meta-updated":
341369 return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe(
342370 Effect.map((payload) => ({
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+56
......@@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
9191 createdAt: "2026-03-24T00:00:00.000Z",
9292 updatedAt: "2026-03-24T00:00:00.000Z",
9393 archivedAt: null,
94 settledOverride: null,
95 settledAt: null,
9496 latestUserMessageAt: null,
9597 pendingApprovalCount: 0,
9698 pendingUserInputCount: 0,
......@@ -128,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
128130 });
129131 }),
130132 );
133
134 it.effect("round-trips non-null settlement values through the thread row", () =>
135 Effect.gen(function* () {
136 const threads = yield* ProjectionThreadRepository;
137
138 yield* threads.upsert({
139 threadId: ThreadId.make("thread-settled"),
140 projectId: ProjectId.make("project-1"),
141 title: "Settled thread",
142 modelSelection: {
143 instanceId: ProviderInstanceId.make("codex"),
144 model: "gpt-5.4",
145 },
146 runtimeMode: "full-access",
147 interactionMode: "default",
148 branch: null,
149 worktreePath: null,
150 latestTurnId: null,
151 createdAt: "2026-03-24T00:00:00.000Z",
152 updatedAt: "2026-03-25T00:00:00.000Z",
153 archivedAt: null,
154 settledOverride: "settled",
155 settledAt: "2026-03-25T00:00:00.000Z",
156 latestUserMessageAt: null,
157 pendingApprovalCount: 0,
158 pendingUserInputCount: 0,
159 hasActionableProposedPlan: 0,
160 deletedAt: null,
161 });
162
163 const persisted = yield* threads.getById({
164 threadId: ThreadId.make("thread-settled"),
165 });
166 const row = Option.getOrNull(persisted);
167 if (!row) {
168 return yield* Effect.die("Expected settled projection_threads row to exist.");
169 }
170 assert.strictEqual(row.settledOverride, "settled");
171 assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z");
172
173 // Un-settle to the keep-active pin and confirm the flip persists.
174 yield* threads.upsert({
175 ...row,
176 settledOverride: "active",
177 settledAt: null,
178 });
179 const repersisted = yield* threads.getById({
180 threadId: ThreadId.make("thread-settled"),
181 });
182 const updated = Option.getOrNull(repersisted);
183 assert.strictEqual(updated?.settledOverride, "active");
184 assert.strictEqual(updated?.settledAt, null);
185 }),
186 );
131187});
apps/server/src/persistence/Layers/ProjectionThreads.ts+10
......@@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4343 created_at,
4444 updated_at,
4545 archived_at,
46 settled_override,
47 settled_at,
4648 latest_user_message_at,
4749 pending_approval_count,
4850 pending_user_input_count,
......@@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6264 ${row.createdAt},
6365 ${row.updatedAt},
6466 ${row.archivedAt},
67 ${row.settledOverride},
68 ${row.settledAt},
6569 ${row.latestUserMessageAt},
6670 ${row.pendingApprovalCount},
6771 ${row.pendingUserInputCount},
......@@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8185 created_at = excluded.created_at,
8286 updated_at = excluded.updated_at,
8387 archived_at = excluded.archived_at,
88 settled_override = excluded.settled_override,
89 settled_at = excluded.settled_at,
8490 latest_user_message_at = excluded.latest_user_message_at,
8591 pending_approval_count = excluded.pending_approval_count,
8692 pending_user_input_count = excluded.pending_user_input_count,
......@@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
107113 created_at AS "createdAt",
108114 updated_at AS "updatedAt",
109115 archived_at AS "archivedAt",
116 settled_override AS "settledOverride",
117 settled_at AS "settledAt",
110118 latest_user_message_at AS "latestUserMessageAt",
111119 pending_approval_count AS "pendingApprovalCount",
112120 pending_user_input_count AS "pendingUserInputCount",
......@@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
135143 created_at AS "createdAt",
136144 updated_at AS "updatedAt",
137145 archived_at AS "archivedAt",
146 settled_override AS "settledOverride",
147 settled_at AS "settledAt",
138148 latest_user_message_at AS "latestUserMessageAt",
139149 pending_approval_count AS "pendingApprovalCount",
140150 pending_user_input_count AS "pendingUserInputCount",
apps/server/src/persistence/Migrations.ts+2
......@@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe
4545import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
4646import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
4747import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
4849
4950/**
5051 * Migration loader with all migrations defined inline.
......@@ -89,6 +90,7 @@ export const migrationEntries = [
8990 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
9091 [31, "AuthAuthorizationScopes", Migration0031],
9192 [32, "AuthPairingProofKeyThumbprint", Migration0032],
93 [33, "ProjectionThreadsSettled", Migration0033],
9294] as const;
9395
9496export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts created+23
......@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "settled_override")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN settled_override TEXT
14 `;
15 }
16
17 if (!columns.some((column) => column.name === "settled_at")) {
18 yield* sql`
19 ALTER TABLE projection_threads
20 ADD COLUMN settled_at TEXT
21 `;
22 }
23});
apps/server/src/persistence/Services/ProjectionThreads.ts+2
......@@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({
3636 createdAt: IsoDateTime,
3737 updatedAt: IsoDateTime,
3838 archivedAt: Schema.NullOr(IsoDateTime),
39 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])),
40 settledAt: Schema.NullOr(IsoDateTime),
3941 latestUserMessageAt: Schema.NullOr(IsoDateTime),
4042 pendingApprovalCount: NonNegativeInt,
4143 pendingUserInputCount: NonNegativeInt,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+2
......@@ -100,6 +100,8 @@ function makeReadModel(
100100 createdAt: now,
101101 updatedAt: now,
102102 archivedAt: null,
103 settledOverride: null,
104 settledAt: null,
103105 latestUserMessageAt: null,
104106 hasPendingApprovals: false,
105107 hasPendingUserInput: false,
apps/server/src/relay/AgentAwarenessRelay.test.ts+6
......@@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
305305 createdAt: now,
306306 updatedAt: now,
307307 archivedAt: null,
308 settledOverride: null,
309 settledAt: null,
308310 session: null,
309311 latestUserMessageAt: null,
310312 hasPendingApprovals: false,
......@@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
451453 createdAt: now,
452454 updatedAt: now,
453455 archivedAt: null,
456 settledOverride: null,
457 settledAt: null,
454458 session: {
455459 threadId,
456460 status: "running",
......@@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
607611 createdAt: now,
608612 updatedAt: now,
609613 archivedAt: null,
614 settledOverride: null,
615 settledAt: null,
610616 session: {
611617 threadId,
612618 status: "running",
apps/server/src/server.test.ts+6
......@@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => {
164164 createdAt: now,
165165 updatedAt: now,
166166 archivedAt: null,
167 settledOverride: null,
168 settledAt: null,
167169 latestTurn: null,
168170 messages: [],
169171 session: null,
......@@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = (
193195 createdAt: now,
194196 updatedAt: now,
195197 archivedAt: null,
198 settledOverride: null,
199 settledAt: null,
196200 session: null,
197201 latestUserMessageAt: null,
198202 hasPendingApprovals: false,
......@@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
54995503 createdAt: now,
55005504 updatedAt: now,
55015505 archivedAt: null,
5506 settledOverride: null,
5507 settledAt: null,
55025508 latestTurn: null,
55035509 messages: [],
55045510 session: null,
apps/web/src/components/AppSidebarLayout.tsx+12-2
......@@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage";
88import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
99import { cn, isMacPlatform } from "../lib/utils";
1010import { primaryServerKeybindingsAtom } from "../state/server";
11import { useClientSettings } from "../hooks/useSettings";
1112import ThreadSidebar from "./Sidebar";
13import ThreadSidebarV2 from "./SidebarV2";
1214import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
1315import {
1416 resolveInitialThreadSidebarWidth,
......@@ -98,7 +100,12 @@ function SidebarControl() {
98100
99101export function AppSidebarLayout({ children }: { children: ReactNode }) {
100102 const navigate = useNavigate();
103 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
104 // Settings routes render the settings nav, which lives in the v1 component
105 // and is identical for both sidebars — so v1 stays mounted there.
101106 const pathname = useLocation({ select: (location) => location.pathname });
107 const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
108 const useSidebarV2 = sidebarV2Enabled && !isOnSettings;
102109 const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
103110 const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
104111 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth);
......@@ -157,7 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
157164 <Sidebar
158165 side="left"
159166 collapsible="offcanvas"
160 className="border-r border-border bg-card text-foreground"
167 className={cn(
168 "border-r border-border text-foreground",
169 useSidebarV2 ? "app-sidebar" : "bg-card",
170 )}
161171 resizable={{
162172 maxWidth: sidebarMaximumWidth,
163173 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
......@@ -168,7 +178,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
168178 onResize: setSidebarWidth,
169179 }}
170180 >
171 <ThreadSidebar />
181 {useSidebarV2 ? <ThreadSidebarV2 /> : <ThreadSidebar />}
172182 <SidebarRail />
173183 </Sidebar>
174184 {children}
apps/web/src/components/ChatView.logic.test.ts+2
......@@ -49,6 +49,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
4949 createdAt: now,
5050 updatedAt: now,
5151 archivedAt: null,
52 settledOverride: null,
53 settledAt: null,
5254 deletedAt: null,
5355 latestTurn: null,
5456 branch: null,
apps/web/src/components/ChatView.logic.ts+2
......@@ -45,6 +45,8 @@ export function buildLocalDraftThread(
4545 createdAt: draftThread.createdAt,
4646 updatedAt: draftThread.createdAt,
4747 archivedAt: null,
48 settledOverride: null,
49 settledAt: null,
4850 deletedAt: null,
4951 latestTurn: null,
5052 branch: draftThread.branch,
apps/web/src/components/ChatView.tsx+1
......@@ -5220,6 +5220,7 @@ function ChatViewContent(props: ChatViewProps) {
52205220 {...(routeKind === "draft" && draftId ? { draftId } : {})}
52215221 activeThreadTitle={activeThread.title}
52225222 activeProjectName={activeProject?.title}
5223 activeProjectCwd={activeProject?.workspaceRoot ?? null}
52235224 openInCwd={gitCwd}
52245225 activeProjectScripts={activeProject?.scripts}
52255226 preferredScriptId={
apps/web/src/components/CommandPalette.logic.test.ts+2
......@@ -24,6 +24,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2424 proposedPlans: [],
2525 createdAt: "2026-03-01T00:00:00.000Z",
2626 archivedAt: null,
27 settledOverride: null,
28 settledAt: null,
2729 deletedAt: null,
2830 updatedAt: "2026-03-01T00:00:00.000Z",
2931 latestTurn: null,
apps/web/src/components/Sidebar.logic.test.ts+98
......@@ -18,8 +18,10 @@ import {
1818 resolveSidebarNewThreadEnvMode,
1919 resolveSidebarStageBadgeLabel,
2020 resolveThreadRowClassName,
21 resolveSidebarV2Status,
2122 resolveThreadStatusPill,
2223 shouldClearThreadSelectionOnMouseDown,
24 sortThreadsForSidebarV2,
2325 sortProjectsForSidebar,
2426 sortScopedProjectsForSidebar,
2527 THREAD_JUMP_HINT_SHOW_DELAY_MS,
......@@ -634,6 +636,100 @@ describe("isContextMenuPointerDown", () => {
634636 });
635637});
636638
639describe("resolveSidebarV2Status", () => {
640 const session = {
641 threadId: ThreadId.make("thread-1"),
642 status: "running" as const,
643 providerName: "Codex",
644 providerInstanceId: ProviderInstanceId.make("codex"),
645 runtimeMode: DEFAULT_RUNTIME_MODE,
646 activeTurnId: "turn-1" as never,
647 lastError: null,
648 updatedAt: "2026-03-09T10:00:00.000Z",
649 };
650
651 const idle = { hasPendingApprovals: false, hasPendingUserInput: false };
652
653 it("prioritizes approval over a running session", () => {
654 expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe(
655 "approval",
656 );
657 });
658
659 it("prioritizes awaiting input over a running session, below approval", () => {
660 expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input");
661 expect(
662 resolveSidebarV2Status({
663 ...idle,
664 hasPendingApprovals: true,
665 hasPendingUserInput: true,
666 session,
667 }),
668 ).toBe("approval");
669 });
670
671 it("reports working for running and starting sessions", () => {
672 expect(resolveSidebarV2Status({ ...idle, session })).toBe("working");
673 expect(
674 resolveSidebarV2Status({
675 ...idle,
676 session: { ...session, status: "starting" as const },
677 }),
678 ).toBe("working");
679 });
680
681 it("reports failed only while the session status is error", () => {
682 expect(
683 resolveSidebarV2Status({
684 ...idle,
685 session: { ...session, status: "error" as const, lastError: "boom" },
686 }),
687 ).toBe("failed");
688 expect(
689 resolveSidebarV2Status({
690 ...idle,
691 session: { ...session, status: "stopped" as const, lastError: "persisted" },
692 }),
693 ).toBe("ready");
694 expect(
695 resolveSidebarV2Status({
696 ...idle,
697 session: { ...session, status: "ready" as const, lastError: "persisted" },
698 }),
699 ).toBe("ready");
700 });
701
702 it("defaults to ready with no session", () => {
703 expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready");
704 });
705});
706
707describe("sortThreadsForSidebarV2", () => {
708 const sortable = (input: { id: string; createdAt: string }) => ({
709 id: input.id,
710 createdAt: input.createdAt,
711 });
712
713 it("orders by creation time, newest first, ignoring activity", () => {
714 const sorted = sortThreadsForSidebarV2([
715 sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }),
716 sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
717 sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
718 ]);
719
720 expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
721 });
722
723 it("breaks creation-time ties by id so the order is stable", () => {
724 const sorted = sortThreadsForSidebarV2([
725 sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }),
726 sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }),
727 ]);
728
729 expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
730 });
731});
732
637733describe("resolveThreadStatusPill", () => {
638734 const baseThread = {
639735 hasActionableProposedPlan: false,
......@@ -900,6 +996,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
900996 proposedPlans: [],
901997 createdAt: "2026-03-09T10:00:00.000Z",
902998 archivedAt: null,
999 settledOverride: null,
1000 settledAt: null,
9031001 deletedAt: null,
9041002 updatedAt: "2026-03-09T10:00:00.000Z",
9051003 latestTurn: null,
apps/web/src/components/Sidebar.logic.ts+65
......@@ -417,6 +417,71 @@ export function resolveThreadRowClassName(input: {
417417 return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground");
418418}
419419
420// ── Sidebar v2 status model ─────────────────────────────────────────
421// Five visual states, three colors: color is reserved for "act now"
422// (approval), "in motion" (working), and "broken" (failed). Ready is the
423// unlabeled resting state — the agent stopped and is waiting on the user,
424// whether it finished, asked a question, or proposed a plan.
425// Unread completion is tracked separately: it describes whether a ready
426// thread needs attention, not what the thread is currently doing.
427export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready";
428
429type SidebarV2StatusInput = Pick<
430 SidebarThreadSummary,
431 "hasPendingApprovals" | "hasPendingUserInput" | "session"
432>;
433
434export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {
435 if (thread.hasPendingApprovals) {
436 return "approval";
437 }
438 if (thread.hasPendingUserInput) {
439 return "input";
440 }
441 if (thread.session?.status === "running" || thread.session?.status === "starting") {
442 return "working";
443 }
444 if (thread.session?.status === "error") {
445 return "failed";
446 }
447 return "ready";
448}
449
450/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not
451 poison the whole ordering, so it sinks to the epoch instead. */
452export function parseTimestampMs(isoDate: string): number {
453 const parsed = Date.parse(isoDate);
454 return Number.isNaN(parsed) ? 0 : parsed;
455}
456
457/** First VALID timestamp wins: `a ?? b` falls through on null, but a present-
458 yet-malformed string must also fall through to the next candidate rather
459 than sink the row to the epoch. */
460export function firstValidTimestampMs(
461 ...candidates: ReadonlyArray<string | null | undefined>
462): number {
463 for (const candidate of candidates) {
464 if (candidate == null) continue;
465 const parsed = Date.parse(candidate);
466 if (!Number.isNaN(parsed)) return parsed;
467 }
468 return 0;
469}
470
471// v2 sort: static creation order, newest thread on top. Activity NEVER
472// reorders the list — a row holds its position from open until settled, so
473// the screen only moves at lifecycle transitions. Status (including pending
474// approval) is carried by each card's edge strip, not by position.
475export function sortThreadsForSidebarV2<
476 T extends { readonly id: string; readonly createdAt: string },
477>(threads: readonly T[]): T[] {
478 return [...threads].toSorted(
479 (left, right) =>
480 parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) ||
481 left.id.localeCompare(right.id),
482 );
483}
484
420485export function resolveThreadStatusPill(input: {
421486 thread: ThreadStatusInput;
422487}): ThreadStatusPill | null {
apps/web/src/components/Sidebar.tsx+4-118
......@@ -8,7 +8,6 @@ import {
88 Globe2Icon,
99 LoaderIcon,
1010 SearchIcon,
11 SettingsIcon,
1211 SquarePenIcon,
1312 TerminalIcon,
1413 TriangleAlertIcon,
......@@ -63,7 +62,7 @@ import {
6362 settlePromise,
6463 squashAtomCommandFailure,
6564} from "@t3tools/client-runtime/state/runtime";
66import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
65import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
6766import {
6867 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
6968 MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
......@@ -74,10 +73,9 @@ import {
7473import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
7574import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
7675import { isElectron } from "../env";
77import { APP_STAGE_LABEL } from "../branding";
7876import { useOpenPrLink } from "../lib/openPullRequestLink";
7977import { isTerminalFocused } from "../lib/terminalFocus";
80import { cn, isMacPlatform } from "../lib/utils";
78import { isMacPlatform } from "../lib/utils";
8179import {
8280 readThreadShell,
8381 useProject,
......@@ -126,7 +124,6 @@ import {
126124import { stackedThreadToast, toastManager } from "./ui/toast";
127125import { formatRelativeTimeLabel } from "../timestampFormat";
128126import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
129import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
130127import { Kbd } from "./ui/kbd";
131128import {
132129 getArm64IntelBuildWarningDescription,
......@@ -169,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u
169166import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
170167import {
171168 SidebarContent,
172 SidebarFooter,
173169 SidebarGroup,
174 SidebarHeader,
175170 SidebarMenu,
176171 SidebarMenuButton,
177172 SidebarMenuItem,
......@@ -179,7 +174,6 @@ import {
179174 SidebarMenuSubButton,
180175 SidebarMenuSubItem,
181176 SidebarSeparator,
182 SidebarTrigger,
183177 useSidebar,
184178} from "./ui/sidebar";
185179import { useThreadSelectionStore } from "../threadSelectionStore";
......@@ -194,7 +188,6 @@ import {
194188 resolveProjectStatusIndicator,
195189 resolveSidebarNewThreadSeedContext,
196190 resolveSidebarNewThreadEnvMode,
197 resolveSidebarStageBadgeLabel,
198191 resolveThreadRowClassName,
199192 resolveThreadStatusPill,
200193 orderItemsByPreferredIds,
......@@ -204,12 +197,12 @@ import {
204197 ThreadStatusPill,
205198} from "./Sidebar.logic";
206199import { sortThreads } from "../lib/threadSort";
207import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
200import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
208201import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
209202import { useIsMobile } from "~/hooks/useMediaQuery";
210203import { CommandDialogTrigger } from "./ui/command";
211204import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
212import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server";
205import { primaryServerKeybindingsAtom } from "../state/server";
213206import {
214207 derivePhysicalProjectKey,
215208 deriveProjectGroupingOverrideKey,
......@@ -223,7 +216,6 @@ import {
223216 type SidebarProjectGroupMember,
224217 type SidebarProjectSnapshot,
225218} from "../sidebarProjectGrouping";
226import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
227219const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
228220 updated_at: "Last user message",
229221 created_at: "Created at",
......@@ -2787,112 +2779,6 @@ function SortableProjectItem({
27872779 );
27882780}
27892781
2790const SidebarChromeHeader = memo(function SidebarChromeHeader({
2791 isElectron,
2792}: {
2793 isElectron: boolean;
2794}) {
2795 const stageLabel = useSidebarStageLabel();
2796 const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);
2797
2798 return (
2799 <SidebarHeader
2800 className={cn(
2801 "@container/sidebar-header relative h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0",
2802 isElectron && "drag-region",
2803 )}
2804 >
2805 {backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null}
2806 <SidebarTrigger
2807 className={cn(
2808 "relative z-10 md:hidden",
2809 backdropVariant && "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!",
2810 )}
2811 />
2812 <SidebarBrand onBackdrop={backdropVariant !== null} />
2813 </SidebarHeader>
2814 );
2815});
2816
2817function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
2818 return (
2819 <Link
2820 aria-label="Go to threads"
2821 className={cn(
2822 "sidebar-brand relative z-10 ml-[var(--workspace-titlebar-content-left)] h-7 w-fit min-w-0 shrink-0 items-center gap-1 overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2",
2823 onBackdrop ? "text-white" : "text-foreground",
2824 )}
2825 to="/"
2826 >
2827 <T3Wordmark />
2828 <span
2829 className={cn(
2830 "truncate text-sm font-medium tracking-tight",
2831 onBackdrop ? "text-white/70" : "text-muted-foreground",
2832 )}
2833 >
2834 Code
2835 </span>
2836 </Link>
2837 );
2838}
2839
2840function useSidebarStageLabel() {
2841 const primaryServerVersion =
2842 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
2843
2844 return resolveSidebarStageBadgeLabel({
2845 primaryServerVersion,
2846 fallbackStageLabel: APP_STAGE_LABEL,
2847 });
2848}
2849
2850function T3Wordmark() {
2851 return (
2852 <svg
2853 aria-label="T3"
2854 className="h-2.5 w-auto shrink-0"
2855 viewBox="15.5309 37 94.3941 56.96"
2856 xmlns="http://www.w3.org/2000/svg"
2857 >
2858 <path
2859 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
2860 fill="currentColor"
2861 />
2862 </svg>
2863 );
2864}
2865
2866const SidebarChromeFooter = memo(function SidebarChromeFooter() {
2867 const navigate = useNavigate();
2868 const { isMobile, setOpenMobile } = useSidebar();
2869 const handleSettingsClick = useCallback(() => {
2870 if (isMobile) {
2871 setOpenMobile(false);
2872 }
2873 void navigate({ to: "/settings" });
2874 }, [isMobile, navigate, setOpenMobile]);
2875
2876 return (
2877 <SidebarFooter className="p-2">
2878 <SidebarProviderUpdatePill />
2879 <SidebarUpdatePill />
2880 <SidebarMenu>
2881 <SidebarMenuItem>
2882 <SidebarMenuButton
2883 size="sm"
2884 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
2885 onClick={handleSettingsClick}
2886 >
2887 <SettingsIcon className="size-3.5" />
2888 <span className="text-xs">Settings</span>
2889 </SidebarMenuButton>
2890 </SidebarMenuItem>
2891 </SidebarMenu>
2892 </SidebarFooter>
2893 );
2894});
2895
28962782interface SidebarProjectsContentProps {
28972783 showArm64IntelBuildWarning: boolean;
28982784 arm64IntelBuildWarningDescription: string | null;
apps/web/src/components/SidebarV2.tsx created+1707
......@@ -0,0 +1,1707 @@
1import { autoAnimate } from "@formkit/auto-animate";
2import { useAtomValue } from "@effect/atom-react";
3import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
4import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
5import {
6 scopeProjectRef,
7 scopeThreadRef,
8 scopedThreadKey,
9} from "@t3tools/client-runtime/environment";
10import type { ScopedThreadRef } from "@t3tools/contracts";
11import {
12 CheckIcon,
13 CircleCheckIcon,
14 CircleDashedIcon,
15 CloudIcon,
16 FolderPlusIcon,
17 PlusIcon,
18 SearchIcon,
19 SquarePenIcon,
20 Undo2Icon,
21} from "lucide-react";
22import {
23 memo,
24 useCallback,
25 useEffect,
26 useMemo,
27 useRef,
28 useState,
29 type KeyboardEvent as ReactKeyboardEvent,
30 type MouseEvent as ReactMouseEvent,
31} from "react";
32import { useParams, useRouter } from "@tanstack/react-router";
33
34import {
35 isAtomCommandInterrupted,
36 settlePromise,
37 squashAtomCommandFailure,
38} from "@t3tools/client-runtime/state/runtime";
39import { isElectron } from "../env";
40import {
41 resolveShortcutCommand,
42 shortcutLabelForCommand,
43 shouldShowThreadJumpHintsForModifiers,
44 threadJumpCommandForIndex,
45 threadJumpIndexFromCommand,
46 threadTraversalDirectionFromCommand,
47} from "../keybindings";
48import { useShortcutModifierState } from "../shortcutModifierState";
49import { isTerminalFocused } from "../lib/terminalFocus";
50import { isModelPickerOpen } from "../modelPickerVisibility";
51import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
52import { isMacPlatform } from "~/lib/utils";
53import { useOpenPrLink } from "../lib/openPullRequestLink";
54import { readLocalApi } from "../localApi";
55import { useUiStateStore } from "../uiStateStore";
56import { useThreadSelectionStore } from "../threadSelectionStore";
57import { useThreadActions } from "../hooks/useThreadActions";
58import { useHandleNewThread } from "../hooks/useHandleNewThread";
59import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
60import { onOpenNewThreadPicker } from "../newThreadPickerBus";
61import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog";
62import {
63 resolveThreadActionProjectRef,
64 startNewThreadFromContext,
65 startNewThreadInProjectFromContext,
66} from "../lib/chatThreadActions";
67import { useClientSettings } from "../hooks/useSettings";
68import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
69import { useProjects, useThreadShells } from "../state/entities";
70import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server";
71import { vcsEnvironment } from "../state/vcs";
72import { threadEnvironment } from "../state/threads";
73import { useEnvironmentQuery } from "../state/query";
74import { useAtomCommand } from "../state/use-atom-command";
75import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
76import { formatRelativeTimeLabel } from "../timestampFormat";
77import type { SidebarThreadSummary } from "../types";
78import { cn } from "~/lib/utils";
79import {
80 firstValidTimestampMs,
81 hasUnseenCompletion,
82 isTrailingDoubleClick,
83 resolveAdjacentThreadId,
84 resolveSidebarV2Status,
85 sortThreadsForSidebarV2,
86} from "./Sidebar.logic";
87import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators";
88import { ProjectFavicon } from "./ProjectFavicon";
89import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
90import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
91import { primaryServerProvidersAtom } from "../state/server";
92import { stackedThreadToast, toastManager } from "./ui/toast";
93import { CommandDialogTrigger } from "./ui/command";
94import { Kbd } from "./ui/kbd";
95import {
96 SidebarContent,
97 SidebarGroup,
98 SidebarMenu,
99 SidebarMenuButton,
100 SidebarMenuItem,
101 SidebarSeparator,
102 useSidebar,
103} from "./ui/sidebar";
104import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
105import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
106
107// Settled-tail paging: recent history is the common lookup; the deep tail
108// stays behind an explicit Show more.
109const SETTLED_TAIL_INITIAL_COUNT = 10;
110const SETTLED_TAIL_PAGE_COUNT = 25;
111
112function compactSidebarTimeLabel(label: string): string {
113 if (label === "just now") return "now";
114 return label.endsWith(" ago") ? label.slice(0, -4) : label;
115}
116
117function threadTimeLabel(thread: SidebarThreadSummary): string {
118 const timestamp = thread.latestUserMessageAt ?? thread.updatedAt;
119 return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp));
120}
121
122const SidebarV2Row = memo(function SidebarV2Row(props: {
123 thread: SidebarThreadSummary;
124 variant: "card" | "slim";
125 // Slim rows are either settled (action: un-settle) or merely quiet
126 // (seen Ready threads — action: settle).
127 variantAction: "settle" | "unsettle";
128 // False on environments whose server predates thread.settle/unsettle:
129 // the lifecycle affordances hide entirely rather than fail on click.
130 settlementSupported: boolean;
131 // Marks where active work transitions into history: a quiet labeled
132 // rule above the first settled row, so the tail reads as a named zone
133 // rather than an unexplained gap.
134 showSettledGap?: boolean;
135 isActive: boolean;
136 jumpLabel: string | null;
137 currentEnvironmentId: string | null;
138 environmentLabel: string | null;
139 projectCwd: string | null;
140 projectTitle: string | null;
141 providerEntryByInstanceId: ReadonlyMap<string, ProviderInstanceEntry>;
142 onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void;
143 onThreadActivate: (threadRef: ScopedThreadRef) => void;
144 onStartRename: (threadRef: ScopedThreadRef, title: string) => void;
145 onRenameTitleChange: (title: string) => void;
146 onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void;
147 onCancelRename: () => void;
148 isRenaming: boolean;
149 renamingTitle: string;
150 onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void;
151 onSettle: (threadRef: ScopedThreadRef) => void;
152 onUnsettle: (threadRef: ScopedThreadRef) => void;
153 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;
154}) {
155 const {
156 isRenaming,
157 onChangeRequestState,
158 onCancelRename,
159 onCommitRename,
160 onContextMenu,
161 onRenameTitleChange,
162 onSettle,
163 onStartRename,
164 onThreadActivate,
165 onThreadClick,
166 onUnsettle,
167 renamingTitle,
168 thread,
169 variant,
170 variantAction,
171 } = props;
172 const threadRef = useMemo(
173 () => scopeThreadRef(thread.environmentId, thread.id),
174 [thread.environmentId, thread.id],
175 );
176 const threadKey = scopedThreadKey(threadRef);
177 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
178 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
179 const openPrLink = useOpenPrLink();
180
181 // Same semantics as v1 (never-visited counts as read): flipping the beta
182 // flag must not light up every historical thread as unread.
183 const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt });
184 const status = resolveSidebarV2Status(thread);
185 const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected;
186 const topStatus =
187 status === "working"
188 ? {
189 label: "Working",
190 icon: "working" as const,
191 className:
192 "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400",
193 }
194 : status === "approval"
195 ? {
196 label: "Approval",
197 icon: null,
198 className: "font-semibold text-amber-700 dark:text-amber-300",
199 }
200 : status === "input"
201 ? {
202 label: "Input",
203 icon: null,
204 className: "font-semibold text-amber-700 dark:text-amber-300",
205 }
206 : status === "failed"
207 ? {
208 label: "Failed",
209 icon: null,
210 className: "font-semibold text-red-700 dark:text-red-300",
211 }
212 : isUnread
213 ? {
214 label: "Done",
215 icon: "done" as const,
216 className: "font-semibold text-emerald-700 dark:text-emerald-300",
217 }
218 : null;
219
220 const gitCwd = thread.worktreePath ?? props.projectCwd;
221 const gitStatus = useEnvironmentQuery(
222 thread.branch != null && gitCwd !== null
223 ? vcsEnvironment.status({
224 environmentId: thread.environmentId,
225 input: { cwd: gitCwd },
226 })
227 : null,
228 );
229 const pr = resolveThreadPr(thread.branch, gitStatus.data);
230 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
231 // Report the PR state up: the parent partitions rows with effectiveSettled,
232 // and a merged/closed PR auto-settles a thread — data only rows have.
233 const prState = pr?.state ?? null;
234 useEffect(() => {
235 onChangeRequestState(threadKey, prState);
236 }, [onChangeRequestState, prState, threadKey]);
237
238 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
239 const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null;
240
241 const isRemote =
242 props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
243
244 const handleClick = useCallback(
245 (event: ReactMouseEvent) => {
246 onThreadClick(event, threadRef);
247 },
248 [onThreadClick, threadRef],
249 );
250 const handleContextMenu = useCallback(
251 (event: ReactMouseEvent) => {
252 event.preventDefault();
253 onContextMenu(threadRef, { x: event.clientX, y: event.clientY });
254 },
255 [onContextMenu, threadRef],
256 );
257 const handleKeyDown = useCallback(
258 (event: ReactKeyboardEvent) => {
259 if (event.target !== event.currentTarget) return;
260 if (event.key !== "Enter" && event.key !== " ") return;
261 event.preventDefault();
262 onThreadActivate(threadRef);
263 },
264 [onThreadActivate, threadRef],
265 );
266 const handleDoubleClick = useCallback(
267 (event: ReactMouseEvent) => {
268 if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
269 return;
270 }
271 if ((event.target as HTMLElement).closest("button, a, input")) return;
272 event.preventDefault();
273 onStartRename(threadRef, thread.title);
274 },
275 [isRenaming, onStartRename, thread.title, threadRef],
276 );
277 const renameCommittedRef = useRef(false);
278 useEffect(() => {
279 if (isRenaming) renameCommittedRef.current = false;
280 }, [isRenaming]);
281 const handleRenameKeyDown = useCallback(
282 (event: ReactKeyboardEvent<HTMLInputElement>) => {
283 event.stopPropagation();
284 if (event.key === "Enter") {
285 event.preventDefault();
286 renameCommittedRef.current = true;
287 onCommitRename(threadRef, renamingTitle, thread.title);
288 } else if (event.key === "Escape") {
289 event.preventDefault();
290 renameCommittedRef.current = true;
291 onCancelRename();
292 }
293 },
294 [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef],
295 );
296 const handleRenameBlur = useCallback(() => {
297 if (!renameCommittedRef.current) {
298 onCommitRename(threadRef, renamingTitle, thread.title);
299 }
300 }, [onCommitRename, renamingTitle, thread.title, threadRef]);
301 const handleSettleClick = useCallback(
302 (event: ReactMouseEvent) => {
303 event.preventDefault();
304 event.stopPropagation();
305 onSettle(threadRef);
306 },
307 [onSettle, threadRef],
308 );
309 const handleUnsettleClick = useCallback(
310 (event: ReactMouseEvent) => {
311 event.preventDefault();
312 event.stopPropagation();
313 onUnsettle(threadRef);
314 },
315 [onUnsettle, threadRef],
316 );
317 const handlePrClick = useCallback(
318 (event: ReactMouseEvent<HTMLElement>) => {
319 if (pr?.url) openPrLink(event, pr.url);
320 },
321 [openPrLink, pr],
322 );
323
324 const rowClassName = cn(
325 "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left",
326 props.isActive
327 ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]"
328 : isSelected
329 ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]"
330 : "hover:bg-accent/65",
331 );
332
333 const title = isRenaming ? (
334 <input
335 autoFocus
336 value={renamingTitle}
337 aria-label="Thread title"
338 onChange={(event) => onRenameTitleChange(event.target.value)}
339 onFocus={(event) => event.currentTarget.select()}
340 onKeyDown={handleRenameKeyDown}
341 onBlur={handleRenameBlur}
342 onClick={(event) => event.stopPropagation()}
343 onDoubleClick={(event) => event.stopPropagation()}
344 className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground"
345 />
346 ) : (
347 <span
348 className={cn(
349 "min-w-0 flex-1 text-[13px] leading-5",
350 variant === "card"
351 ? cn(
352 "line-clamp-2 break-words",
353 isUnread
354 ? "font-semibold text-foreground"
355 : status !== "ready"
356 ? "font-semibold text-foreground/95"
357 : shouldRecede
358 ? "font-normal text-muted-foreground/75"
359 : "font-medium text-foreground/90",
360 )
361 : cn(
362 "truncate transition-colors group-hover/v2-row:text-foreground",
363 props.isActive
364 ? "text-foreground"
365 : isUnread
366 ? "font-medium text-muted-foreground"
367 : "text-muted-foreground/60",
368 ),
369 )}
370 >
371 {thread.title}
372 </span>
373 );
374
375 const prBadge =
376 prStatus && pr ? (
377 <button
378 type="button"
379 onClick={handlePrClick}
380 className={cn(
381 "shrink-0 font-mono text-[10px] hover:underline",
382 variant === "slim" && variantAction === "unsettle"
383 ? props.isActive
384 ? "text-muted-foreground/70"
385 : "text-muted-foreground/35 transition-colors group-hover/v2-row:text-muted-foreground/65"
386 : prStatus.colorClass,
387 )}
388 aria-label={prStatus.tooltip}
389 >
390 #{pr.number}
391 </button>
392 ) : null;
393
394 if (variant === "slim") {
395 return (
396 <li
397 data-thread-item
398 className="list-none [content-visibility:auto] [contain-intrinsic-size:auto_34px]"
399 >
400 {props.showSettledGap ? (
401 <div aria-hidden className="mb-1 mt-3 flex items-center gap-2 px-2.5">
402 <span className="text-[10px] font-medium text-muted-foreground/50">Settled</span>
403 <span className="h-px flex-1 bg-border/60" />
404 </div>
405 ) : null}
406 <div
407 role="button"
408 tabIndex={0}
409 data-testid="sidebar-v2-row-slim"
410 className={cn(rowClassName, "flex h-[34px] items-center gap-2.5 px-2.5")}
411 onClick={handleClick}
412 onDoubleClick={handleDoubleClick}
413 onKeyDown={handleKeyDown}
414 onContextMenu={handleContextMenu}
415 >
416 {/* Settled history recedes: dimmed favicon at rest, restored on
417 hover so the tail stays scannable when you're hunting. */}
418 <span
419 className={cn(
420 "shrink-0 transition-opacity",
421 !props.isActive &&
422 "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0",
423 )}
424 >
425 <ProjectFavicon
426 environmentId={thread.environmentId}
427 cwd={props.projectCwd ?? ""}
428 className="size-3.5"
429 />
430 </span>
431 {title}
432 {/* The PR badge stays outside the hover-fading slot: it must
433 remain visible AND clickable while the row is hovered. Only
434 the time/jump label yields to the settle affordance. */}
435 {prBadge}
436 <span className="relative ml-auto flex h-6 min-w-8 shrink-0 items-center justify-end">
437 <span className="inline-flex justify-end tabular-nums text-muted-foreground/40 transition-opacity group-hover/v2-row:opacity-0">
438 <span className="text-[13px]">
439 {props.jumpLabel ??
440 compactSidebarTimeLabel(
441 formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt),
442 )}
443 </span>
444 </span>
445 {!props.settlementSupported ? null : variantAction === "unsettle" ? (
446 <button
447 type="button"
448 aria-label="Un-settle thread"
449 onClick={handleUnsettleClick}
450 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
451 >
452 <Undo2Icon className="size-3" />
453 </button>
454 ) : (
455 <button
456 type="button"
457 aria-label="Settle thread"
458 onClick={handleSettleClick}
459 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[11px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
460 >
461 <CheckIcon className="size-3" />
462 </button>
463 )}
464 </span>
465 </div>
466 </li>
467 );
468 }
469
470 const diff = latestTurnDiff(thread);
471
472 return (
473 <li
474 data-thread-item
475 className="list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_96px]"
476 >
477 <div
478 role="button"
479 tabIndex={0}
480 data-testid="sidebar-v2-row-card"
481 className={cn(
482 // Every card carries a faint tonal fill so threads read as
483 // discrete objects; active/selected/hover are brighter tones of
484 // the same treatment rather than a different shape.
485 "group/v2-row relative w-full cursor-pointer select-none overflow-hidden rounded-lg text-left transition-colors",
486 props.isActive
487 ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]"
488 : isSelected
489 ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]"
490 : shouldRecede
491 ? "bg-foreground/[0.025] hover:bg-accent/45 dark:bg-white/[0.025]"
492 : "bg-foreground/[0.035] hover:bg-accent/65 dark:bg-white/[0.035]",
493 )}
494 onClick={handleClick}
495 onDoubleClick={handleDoubleClick}
496 onKeyDown={handleKeyDown}
497 onContextMenu={handleContextMenu}
498 >
499 <div className="relative z-10 px-2.5 py-2">
500 <div className="flex h-5 min-w-0 items-center gap-1.5">
501 <ProjectFavicon
502 environmentId={thread.environmentId}
503 cwd={props.projectCwd ?? ""}
504 className="size-3.5 shrink-0"
505 />
506 {props.projectTitle ? (
507 <span
508 className={cn(
509 "min-w-0 flex-1 truncate text-[13px] leading-5 text-muted-foreground/70",
510 isUnread || status !== "ready"
511 ? "font-semibold"
512 : shouldRecede
513 ? "font-normal"
514 : "font-medium",
515 )}
516 >
517 {props.projectTitle}
518 </span>
519 ) : (
520 <span className="flex-1" />
521 )}
522 <span className="relative ml-auto flex h-5 min-w-8 shrink-0 items-center justify-end pl-1 text-[13px]">
523 <span className="tabular-nums text-muted-foreground/55 transition-opacity group-hover/v2-row:opacity-0">
524 {props.jumpLabel ? (
525 props.jumpLabel
526 ) : topStatus ? (
527 <span
528 role="status"
529 className={cn(
530 "inline-flex items-center gap-1 text-[11px]",
531 topStatus.className,
532 )}
533 >
534 {topStatus.icon === "working" ? (
535 <CircleDashedIcon aria-hidden className="size-3" />
536 ) : topStatus.icon === "done" ? (
537 <CircleCheckIcon aria-hidden className="size-3" />
538 ) : null}
539 {topStatus.label}
540 </span>
541 ) : (
542 threadTimeLabel(thread)
543 )}
544 </span>
545 {props.settlementSupported ? (
546 <button
547 type="button"
548 aria-label="Settle thread"
549 onClick={handleSettleClick}
550 className="absolute inset-y-0 right-0 inline-flex items-center gap-1 rounded-md border border-border bg-background px-2 text-[10px] text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100"
551 >
552 <CheckIcon className="size-3" />
553 Settle
554 </button>
555 ) : null}
556 </span>
557 </div>
558 <div className="mt-1 flex min-w-0">{title}</div>
559 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground/65">
560 {thread.branch ? (
561 <span className="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono [mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-1rem),transparent_100%)] [-webkit-mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-1rem),transparent_100%)]">
562 {thread.branch}
563 </span>
564 ) : (
565 <span className="flex-1" />
566 )}
567 {prBadge}
568 {diff ? (
569 <span className="shrink-0 font-mono">
570 <span className="text-emerald-600 dark:text-emerald-400">+{diff.insertions}</span>{" "}
571 <span className="text-red-600 dark:text-red-400">−{diff.deletions}</span>
572 </span>
573 ) : null}
574 <span className="ml-auto inline-flex shrink-0 items-center gap-1">
575 {driverKind ? (
576 <Tooltip>
577 <TooltipTrigger
578 render={<span className="inline-flex shrink-0 items-center opacity-60" />}
579 >
580 <ProviderInstanceIcon
581 driverKind={driverKind}
582 displayName={thread.session?.providerName ?? modelInstanceId}
583 iconClassName="size-3"
584 />
585 </TooltipTrigger>
586 <TooltipPopup side="top">{thread.modelSelection.model}</TooltipPopup>
587 </Tooltip>
588 ) : null}
589 {isRemote ? (
590 <Tooltip>
591 <TooltipTrigger
592 render={
593 <span className="inline-flex shrink-0 items-center text-muted-foreground/50" />
594 }
595 >
596 <CloudIcon className="size-3" />
597 </TooltipTrigger>
598 <TooltipPopup side="top">
599 Running on {props.environmentLabel ?? "a remote environment"}
600 </TooltipPopup>
601 </Tooltip>
602 ) : null}
603 </span>
604 </div>
605 {status === "failed" && thread.session?.lastError ? (
606 <div
607 className="mt-0.5 min-w-0 truncate text-[10px] text-red-600/75 dark:text-red-400/70"
608 title={thread.session.lastError}
609 >
610 {thread.session.lastError}
611 </div>
612 ) : null}
613 </div>
614 </div>
615 </li>
616 );
617});
618
619function latestTurnDiff(
620 thread: SidebarThreadSummary,
621): { insertions: number; deletions: number } | null {
622 // Shells don't carry checkpoint summaries; diff stats render only when the
623 // shell projection grows them. Kept as a seam so the row layout is ready.
624 void thread;
625 return null;
626}
627
628export default function SidebarV2() {
629 const projects = useProjects();
630 const threads = useThreadShells();
631 const router = useRouter();
632 const { isMobile, setOpenMobile } = useSidebar();
633 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
634 const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays);
635 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
636 const { settleThread, unsettleThread, deleteThread } = useThreadActions();
637 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
638 reportFailure: false,
639 });
640 const newThreadContext = useHandleNewThread();
641 const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();
642 const { environments } = useEnvironments();
643 const primaryEnvironmentId = usePrimaryEnvironmentId();
644 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
645 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
646 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
647 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
648 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
649 const routeThreadRef = useParams({
650 strict: false,
651 select: (params) => resolveThreadRouteRef(params),
652 });
653 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
654 // Post-settle navigation validates against the CURRENT route, not the one
655 // captured when the settle started: if the user navigated elsewhere while
656 // the command was in flight, completing it must not yank them away.
657 const routeThreadKeyRef = useRef(routeThreadKey);
658 routeThreadKeyRef.current = routeThreadKey;
659
660 const environmentLabelById = useMemo(
661 () =>
662 new Map(
663 environments.map((environment) => [environment.environmentId, environment.label] as const),
664 ),
665 [environments],
666 );
667 const serverProviders = useAtomValue(primaryServerProvidersAtom);
668 const providerEntryByInstanceId = useMemo(
669 () =>
670 new Map(
671 deriveProviderInstanceEntries(serverProviders).map(
672 (entry) => [entry.instanceId as string, entry] as const,
673 ),
674 ),
675 [serverProviders],
676 );
677 const projectCwdByKey = useMemo(
678 () =>
679 new Map(
680 projects.map((project) => [
681 `${project.environmentId}:${project.id}`,
682 project.workspaceRoot,
683 ]),
684 ),
685 [projects],
686 );
687 const projectTitleByKey = useMemo(
688 () =>
689 new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])),
690 [projects],
691 );
692
693 // now is quantized to the minute so effectiveSettled memoization doesn't
694 // churn on every render; auto-settle thresholds are day-granular anyway.
695 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
696 useEffect(() => {
697 const id = window.setInterval(
698 () => setNowMinute(new Date().toISOString().slice(0, 16)),
699 60_000,
700 );
701 return () => window.clearInterval(id);
702 }, []);
703
704 // PR states stream in per-row (rows own the VCS subscriptions); a merged or
705 // closed PR auto-settles its thread on the next partition.
706 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
707 ReadonlyMap<string, "open" | "closed" | "merged">
708 >(() => new Map());
709 const handleChangeRequestState = useCallback(
710 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
711 setChangeRequestStateByKey((current) => {
712 if ((current.get(threadKey) ?? null) === state) return current;
713 const next = new Map(current);
714 if (state === null) {
715 next.delete(threadKey);
716 } else {
717 next.set(threadKey, state);
718 }
719 return next;
720 });
721 },
722 [],
723 );
724
725 // Project scope: chips above the list. Scoping filters the list AND
726 // becomes the new-thread target — one visible control doing both jobs the
727 // old per-project headers did.
728 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
729 const scopedProject = useMemo(
730 () =>
731 projectScopeKey === null
732 ? null
733 : (projects.find(
734 (project) => `${project.environmentId}:${project.id}` === projectScopeKey,
735 ) ?? null),
736 [projectScopeKey, projects],
737 );
738 useEffect(() => {
739 if (
740 projectScopeKey !== null &&
741 !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey)
742 ) {
743 setProjectScopeKey(null);
744 }
745 }, [projectScopeKey, projects]);
746 // Scope flips drop the selection: rows selected under the old scope may be
747 // hidden now, and bulk actions must never count or touch invisible rows.
748 useEffect(() => {
749 clearSelection();
750 }, [clearSelection, projectScopeKey]);
751
752 // Settled threads stay in the live shell stream (settled ≠ archived), so
753 // the partition works directly off live shells: no archived-snapshot
754 // merging, no optimistic holds. Archived threads remain hidden here —
755 // archive keeps its original "remove from sidebar" meaning.
756 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
757 const { activeThreads, settledThreads } = useMemo(() => {
758 const now = `${nowMinute}:00.000Z`;
759 const visible = threads.filter(
760 (thread) =>
761 thread.archivedAt === null &&
762 (scopedProject === null ||
763 (thread.environmentId === scopedProject.environmentId &&
764 thread.projectId === scopedProject.id)),
765 );
766 const active: EnvironmentThreadShell[] = [];
767 const settled: EnvironmentThreadShell[] = [];
768 for (const thread of visible) {
769 // Threads on servers without the settlement capability (old server,
770 // or descriptor not loaded yet) never classify as settled: the user
771 // could neither un-settle nor pin them, so auto-settling them would
772 // strand rows in a tail with no working affordances.
773 const supportsSettlement =
774 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true;
775 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
776 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
777 if (
778 supportsSettlement &&
779 effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
780 ) {
781 settled.push(thread);
782 } else {
783 active.push(thread);
784 }
785 }
786 return {
787 activeThreads: sortThreadsForSidebarV2(active),
788 settledThreads: settled.toSorted(
789 (left, right) =>
790 firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -
791 firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),
792 ),
793 };
794 }, [
795 autoSettleAfterDays,
796 changeRequestStateByKey,
797 nowMinute,
798 scopedProject,
799 serverConfigs,
800 threads,
801 ]);
802
803 // The settled tail renders in pages: history shouldn't dominate the
804 // sidebar, and the common lookups are recent. Expansion resets when the
805 // filter context changes so a scope/search flip never inherits a deep
806 // page state.
807 const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT);
808 const settledResetKey = `${projectScopeKey ?? "all"}`;
809 const lastSettledResetKeyRef = useRef(settledResetKey);
810 if (lastSettledResetKeyRef.current !== settledResetKey) {
811 lastSettledResetKeyRef.current = settledResetKey;
812 setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT);
813 }
814 const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount);
815 const visibleSettledThreads = useMemo(
816 () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads),
817 [hiddenSettledCount, settledThreads, settledVisibleCount],
818 );
819 const showMoreSettled = useCallback(
820 () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT),
821 [],
822 );
823
824 const orderedThreads = useMemo(
825 () => [...activeThreads, ...visibleSettledThreads],
826 [activeThreads, visibleSettledThreads],
827 );
828 const orderedThreadKeys = useMemo(
829 () =>
830 orderedThreads.map((thread) =>
831 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
832 ),
833 [orderedThreads],
834 );
835 // Rows call back into the click handler without carrying the ordered list as
836 // a prop — a fresh array identity per shell update would defeat every row's
837 // memoization. The ref keeps shift-range-select working against the list as
838 // rendered at click time.
839 const orderedThreadKeysRef = useRef(orderedThreadKeys);
840 orderedThreadKeysRef.current = orderedThreadKeys;
841 const threadByKey = useMemo(
842 () =>
843 new Map(
844 orderedThreads.map(
845 (thread) =>
846 [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const,
847 ),
848 ),
849 [orderedThreads],
850 );
851 // Handlers read these through refs: depending on per-update Map/Set
852 // identities would give every row a fresh callback prop on each shell
853 // event and defeat row memoization during streaming.
854 const threadByKeyRef = useRef(threadByKey);
855 threadByKeyRef.current = threadByKey;
856 // handleNewThread is inherently unstable (depends on the projects list);
857 // a ref keeps it out of attemptSettle's dependency array.
858 const handleNewThreadRef = useRef(newThreadContext.handleNewThread);
859 handleNewThreadRef.current = newThreadContext.handleNewThread;
860 const settledThreadKeys = useMemo(
861 () =>
862 new Set(
863 settledThreads.map((thread) =>
864 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
865 ),
866 ),
867 [settledThreads],
868 );
869 const settledThreadKeysRef = useRef(settledThreadKeys);
870 settledThreadKeysRef.current = settledThreadKeys;
871
872 const jumpLabelByKey = useMemo(() => {
873 const mapping = new Map<string, string>();
874 for (const [index, threadKey] of orderedThreadKeys.entries()) {
875 const jumpCommand = threadJumpCommandForIndex(index);
876 if (!jumpCommand) break;
877 const label = shortcutLabelForCommand(keybindings, jumpCommand);
878 if (label) mapping.set(threadKey, label);
879 }
880 return mapping;
881 }, [keybindings, orderedThreadKeys]);
882 const [showJumpHints, setShowJumpHints] = useState(false);
883
884 // Settled threads are live shells, so opening one is plain navigation:
885 // history stays readable without un-settling, and sending a message or
886 // starting a session un-settles server-side.
887 const navigateToThread = useCallback(
888 (threadRef: ScopedThreadRef) => {
889 if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) {
890 clearSelection();
891 }
892 setSelectionAnchor(scopedThreadKey(threadRef));
893 if (isMobile) {
894 setOpenMobile(false);
895 }
896 void router.navigate({
897 to: "/$environmentId/$threadId",
898 params: buildThreadRouteParams(threadRef),
899 });
900 },
901 [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor],
902 );
903
904 const [renamingThreadKey, setRenamingThreadKey] = useState<string | null>(null);
905 const [renamingTitle, setRenamingTitle] = useState("");
906 const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => {
907 setRenamingThreadKey(scopedThreadKey(threadRef));
908 setRenamingTitle(title);
909 }, []);
910 const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []);
911 const commitThreadRename = useCallback(
912 (threadRef: ScopedThreadRef, title: string, originalTitle: string) => {
913 void (async () => {
914 const trimmed = title.trim();
915 setRenamingThreadKey(null);
916 if (trimmed.length === 0) {
917 toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
918 return;
919 }
920 if (trimmed === originalTitle) return;
921 const result = await updateThreadMetadata({
922 environmentId: threadRef.environmentId,
923 input: { threadId: threadRef.threadId, title: trimmed },
924 });
925 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
926 const error = squashAtomCommandFailure(result);
927 toastManager.add(
928 stackedThreadToast({
929 type: "error",
930 title: "Failed to rename thread",
931 description: error instanceof Error ? error.message : "An error occurred.",
932 }),
933 );
934 }
935 })();
936 },
937 [updateThreadMetadata],
938 );
939
940 const handleThreadClick = useCallback(
941 (event: ReactMouseEvent, threadRef: ScopedThreadRef) => {
942 const isMac = isMacPlatform(navigator.platform);
943 const isModClick = isMac ? event.metaKey : event.ctrlKey;
944 const threadKey = scopedThreadKey(threadRef);
945 if (isModClick) {
946 event.preventDefault();
947 toggleThreadSelection(threadKey);
948 return;
949 }
950 if (event.shiftKey) {
951 event.preventDefault();
952 rangeSelectTo(threadKey, orderedThreadKeysRef.current);
953 return;
954 }
955 if (isTrailingDoubleClick(event.detail)) {
956 return;
957 }
958 navigateToThread(threadRef);
959 },
960 [navigateToThread, rangeSelectTo, toggleThreadSelection],
961 );
962
963 // A settle per thread at a time: double clicks and repeated menu picks
964 // must not dispatch a second settle that fails and toasts a false error.
965 const settlingThreadKeysRef = useRef(new Set<string>());
966 const attemptSettle = useCallback(
967 (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet<string> } = {}) => {
968 void (async () => {
969 const threadKey = scopedThreadKey(threadRef);
970 if (settlingThreadKeysRef.current.has(threadKey)) return;
971 settlingThreadKeysRef.current.add(threadKey);
972 try {
973 // Settling the thread you're looking at moves you forward: the next
974 // remaining card (never a settled row, never one settling in the
975 // same batch), or a fresh draft in this project when it was the
976 // last active one. Snapshot the target before the settle mutates
977 // the partition. Background settles never navigate.
978 const shell = threadByKeyRef.current.get(threadKey);
979 let navigateAfterSettle: (() => void) | null = null;
980 if (routeThreadKey === threadKey) {
981 const orderedKeys = orderedThreadKeysRef.current;
982 const settledKeys = settledThreadKeysRef.current;
983 const currentIndex = orderedKeys.indexOf(threadKey);
984 const nextCardKey =
985 currentIndex === -1
986 ? null
987 : ([
988 ...orderedKeys.slice(currentIndex + 1),
989 ...orderedKeys.slice(0, currentIndex),
990 ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null);
991 const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null;
992 navigateAfterSettle = nextThread
993 ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id))
994 : shell
995 ? () =>
996 void handleNewThreadRef.current(
997 scopeProjectRef(shell.environmentId, shell.projectId),
998 )
999 : () => void router.navigate({ to: "/" });
1000 }
1001 const result = await settleThread(threadRef);
1002 if (result._tag === "Failure") {
1003 // Never navigate away from a thread that did not settle.
1004 if (!isAtomCommandInterrupted(result)) {
1005 const error = squashAtomCommandFailure(result);
1006 toastManager.add(
1007 stackedThreadToast({
1008 type: "error",
1009 title: "Failed to settle thread",
1010 description: error instanceof Error ? error.message : "An error occurred.",
1011 }),
1012 );
1013 }
1014 return;
1015 }
1016 // Only move forward if the user is still on the settled thread —
1017 // a navigation made during the await wins over ours.
1018 if (routeThreadKeyRef.current === threadKey) {
1019 navigateAfterSettle?.();
1020 }
1021 } finally {
1022 settlingThreadKeysRef.current.delete(threadKey);
1023 }
1024 })();
1025 },
1026 [navigateToThread, routeThreadKey, router, settleThread],
1027 );
1028 const attemptUnsettle = useCallback(
1029 (threadRef: ScopedThreadRef) => {
1030 void (async () => {
1031 const result = await unsettleThread(threadRef);
1032 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1033 const error = squashAtomCommandFailure(result);
1034 toastManager.add(
1035 stackedThreadToast({
1036 type: "error",
1037 title: "Failed to un-settle thread",
1038 description: error instanceof Error ? error.message : "An error occurred.",
1039 }),
1040 );
1041 }
1042 })();
1043 },
1044 [unsettleThread],
1045 );
1046
1047 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
1048 const handleMultiSelectContextMenu = useCallback(
1049 async (position: { x: number; y: number }) => {
1050 const api = readLocalApi();
1051 if (!api) return;
1052 // One exact actionable set: keys whose rows are actually rendered
1053 // right now. Selections can outlive their rows (settled-tail paging,
1054 // thread deletion elsewhere) and the menu labels must count only what
1055 // the actions will touch.
1056 const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter(
1057 (threadKey) => threadByKeyRef.current.has(threadKey),
1058 );
1059 if (threadKeys.length === 0) return;
1060 const count = threadKeys.length;
1061 const clicked = await settlePromise(() =>
1062 api.contextMenu.show(
1063 [
1064 { id: "settle", label: `Settle (${count})` },
1065 { id: "mark-unread", label: `Mark unread (${count})` },
1066 { id: "delete", label: `Delete (${count})`, destructive: true },
1067 ],
1068 position,
1069 ),
1070 );
1071 if (clicked._tag === "Failure") return;
1072 if (clicked.value === "settle") {
1073 // Post-settle navigation must skip threads settling in this same
1074 // batch — they are all leaving the card block together. Rows that
1075 // are already explicitly settled are skipped: nothing to do on a
1076 // valid mixed selection.
1077 const coSettlingKeys = new Set(threadKeys);
1078 for (const threadKey of threadKeys) {
1079 const thread = threadByKeyRef.current.get(threadKey);
1080 if (!thread || thread.settledOverride === "settled") continue;
1081 attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys });
1082 }
1083 clearSelection();
1084 return;
1085 }
1086 if (clicked.value === "mark-unread") {
1087 for (const threadKey of threadKeys) {
1088 const thread = threadByKeyRef.current.get(threadKey);
1089 markThreadUnread(threadKey, thread?.latestTurn?.completedAt);
1090 }
1091 clearSelection();
1092 return;
1093 }
1094 if (clicked.value !== "delete") return;
1095 if (confirmThreadDelete) {
1096 const confirmed = await settlePromise(() =>
1097 api.dialogs.confirm(
1098 [
1099 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1100 "This permanently clears conversation history for these threads.",
1101 ].join("\n"),
1102 ),
1103 );
1104 if (confirmed._tag === "Failure" || !confirmed.value) return;
1105 }
1106 // Grown as deletions actually land, never seeded with the whole batch:
1107 // orphaned-worktree detection must only discount threads that are
1108 // really gone, or the first delete would treat still-alive batch mates
1109 // as deleted and remove a worktree they still point at.
1110 const deletedThreadKeys = new Set<string>();
1111 for (const threadKey of threadKeys) {
1112 const thread = threadByKeyRef.current.get(threadKey);
1113 if (!thread) continue;
1114 const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), {
1115 deletedThreadKeys,
1116 });
1117 if (result._tag === "Failure") {
1118 if (!isAtomCommandInterrupted(result)) {
1119 const error = squashAtomCommandFailure(result);
1120 toastManager.add(
1121 stackedThreadToast({
1122 type: "error",
1123 title: "Failed to delete threads",
1124 description: error instanceof Error ? error.message : "An error occurred.",
1125 }),
1126 );
1127 }
1128 return;
1129 }
1130 deletedThreadKeys.add(threadKey);
1131 }
1132 removeFromSelection(threadKeys);
1133 },
1134 [
1135 attemptSettle,
1136 clearSelection,
1137 confirmThreadDelete,
1138 deleteThread,
1139 markThreadUnread,
1140 removeFromSelection,
1141 ],
1142 );
1143
1144 const handleThreadContextMenu = useCallback(
1145 (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
1146 void (async () => {
1147 const api = readLocalApi();
1148 if (!api) return;
1149 const threadKey = scopedThreadKey(threadRef);
1150 const selectionState = useThreadSelectionStore.getState();
1151 if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) {
1152 await handleMultiSelectContextMenu(position);
1153 return;
1154 }
1155 const thread = threadByKeyRef.current.get(threadKey);
1156 if (!thread) return;
1157 // Un-settle works on every settled row: for explicit settles it
1158 // clears the override, for auto-settled rows it pins the thread
1159 // active until real activity clears the pin. Environments without
1160 // the settlement capability get no lifecycle items at all.
1161 const supportsSettlement =
1162 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement ===
1163 true;
1164 const isSettled = settledThreadKeysRef.current.has(threadKey);
1165 const clicked = await settlePromise(() =>
1166 api.contextMenu.show(
1167 [
1168 ...(supportsSettlement
1169 ? [
1170 isSettled
1171 ? { id: "unsettle", label: "Un-settle thread" }
1172 : { id: "settle", label: "Settle thread" },
1173 ]
1174 : []),
1175 { id: "rename", label: "Rename thread" },
1176 { id: "mark-unread", label: "Mark unread" },
1177 { id: "delete", label: "Delete", destructive: true, icon: "trash" },
1178 ],
1179 position,
1180 ),
1181 );
1182 if (clicked._tag === "Failure") return;
1183 switch (clicked.value) {
1184 case "settle":
1185 attemptSettle(threadRef);
1186 return;
1187 case "unsettle":
1188 attemptUnsettle(threadRef);
1189 return;
1190 case "rename":
1191 startThreadRename(threadRef, thread.title);
1192 return;
1193 case "mark-unread":
1194 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
1195 return;
1196 case "delete": {
1197 if (confirmThreadDelete) {
1198 const confirmed = await settlePromise(() =>
1199 api.dialogs.confirm(
1200 [
1201 `Delete thread "${thread.title}"?`,
1202 "This permanently clears conversation history for this thread.",
1203 ].join("\n"),
1204 ),
1205 );
1206 if (confirmed._tag === "Failure" || !confirmed.value) return;
1207 }
1208 const result = await deleteThread(threadRef);
1209 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1210 const error = squashAtomCommandFailure(result);
1211 toastManager.add(
1212 stackedThreadToast({
1213 type: "error",
1214 title: "Failed to delete thread",
1215 description: error instanceof Error ? error.message : "An error occurred.",
1216 }),
1217 );
1218 return;
1219 }
1220 return;
1221 }
1222 default:
1223 return;
1224 }
1225 })();
1226 },
1227 [
1228 attemptSettle,
1229 attemptUnsettle,
1230 confirmThreadDelete,
1231 deleteThread,
1232 handleMultiSelectContextMenu,
1233 markThreadUnread,
1234 serverConfigs,
1235 startThreadRename,
1236 ],
1237 );
1238
1239 // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
1240 // v1 — the keybinding layer is shared, only the ordered list differs.
1241 const routeTerminalOpen = useTerminalUiStateStore((state) =>
1242 routeThreadRef
1243 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
1244 : false,
1245 );
1246 useEffect(() => {
1247 const onWindowKeyDown = (event: KeyboardEvent) => {
1248 if (event.defaultPrevented || event.repeat) return;
1249 const command = resolveShortcutCommand(event, keybindings, {
1250 platform: navigator.platform,
1251 context: {
1252 terminalFocus: isTerminalFocused(),
1253 terminalOpen: routeTerminalOpen,
1254 modelPickerOpen: isModelPickerOpen(),
1255 },
1256 });
1257 const navigateToThreadKey = (targetThreadKey: string | null) => {
1258 if (!targetThreadKey) return false;
1259 const targetThread = threadByKey.get(targetThreadKey);
1260 if (!targetThread) return false;
1261 event.preventDefault();
1262 event.stopPropagation();
1263 navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id));
1264 return true;
1265 };
1266 const traversalDirection = threadTraversalDirectionFromCommand(command);
1267 if (traversalDirection !== null) {
1268 navigateToThreadKey(
1269 resolveAdjacentThreadId({
1270 threadIds: orderedThreadKeys,
1271 currentThreadId: routeThreadKey,
1272 direction: traversalDirection,
1273 }),
1274 );
1275 return;
1276 }
1277 const jumpIndex = threadJumpIndexFromCommand(command ?? "");
1278 if (jumpIndex === null) return;
1279 navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null);
1280 };
1281 window.addEventListener("keydown", onWindowKeyDown);
1282 return () => window.removeEventListener("keydown", onWindowKeyDown);
1283 }, [
1284 keybindings,
1285 navigateToThread,
1286 orderedThreadKeys,
1287 routeTerminalOpen,
1288 routeThreadKey,
1289 threadByKey,
1290 ]);
1291
1292 // Same predicate as v1: hints show only while the held modifiers exactly
1293 // match a thread-jump binding. Adding Shift (screenshots) or Alt no
1294 // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4.
1295 const shortcutModifiers = useShortcutModifierState();
1296 const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers(
1297 shortcutModifiers,
1298 keybindings,
1299 { platform: navigator.platform },
1300 );
1301 useEffect(() => {
1302 setShowJumpHints(shouldShowJumpHintsNow);
1303 }, [shouldShowJumpHintsNow]);
1304
1305 const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => {
1306 if (!node) return;
1307 autoAnimate(node, { duration: 150, easing: "ease-out" });
1308 }, []);
1309
1310 // New thread defaults to the project you're in (active thread's project,
1311 // falling back to the top project) — same resolution the command palette
1312 // uses. The chevron menu is the explicit project picker the flat list no
1313 // longer gets from per-project headers.
1314 const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false);
1315 // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in
1316 // v2 with multiple projects it opens this picker via the event bus.
1317 useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []);
1318 const handleNewThreadClick = useCallback(() => {
1319 // One project: nothing to pick, create immediately.
1320 if (projects.length <= 1) {
1321 if (isMobile) setOpenMobile(false);
1322 void startNewThreadFromContext({
1323 activeDraftThread: newThreadContext.activeDraftThread,
1324 activeThread: newThreadContext.activeThread ?? undefined,
1325 defaultProjectRef: newThreadContext.defaultProjectRef,
1326 handleNewThread: newThreadContext.handleNewThread,
1327 });
1328 return;
1329 }
1330 setNewThreadPickerOpen(true);
1331 }, [isMobile, newThreadContext, projects.length, setOpenMobile]);
1332 const createThreadInProject = useCallback(
1333 (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => {
1334 setNewThreadPickerOpen(false);
1335 if (isMobile) setOpenMobile(false);
1336 const project = projects.find(
1337 (candidate) => candidate.environmentId === environmentId && candidate.id === projectId,
1338 );
1339 if (!project) return;
1340 void startNewThreadInProjectFromContext(
1341 {
1342 activeDraftThread: newThreadContext.activeDraftThread,
1343 activeThread: newThreadContext.activeThread ?? undefined,
1344 defaultProjectRef: newThreadContext.defaultProjectRef,
1345 handleNewThread: newThreadContext.handleNewThread,
1346 },
1347 scopeProjectRef(project.environmentId, project.id),
1348 );
1349 },
1350 [isMobile, newThreadContext, projects, setOpenMobile],
1351 );
1352 const contextualProjectRef = resolveThreadActionProjectRef({
1353 activeDraftThread: newThreadContext.activeDraftThread,
1354 activeThread: newThreadContext.activeThread ?? undefined,
1355 defaultProjectRef: newThreadContext.defaultProjectRef,
1356 handleNewThread: newThreadContext.handleNewThread,
1357 });
1358 const newThreadTargetRef = scopedProject
1359 ? scopeProjectRef(scopedProject.environmentId, scopedProject.id)
1360 : contextualProjectRef;
1361 const newThreadTargetProject = newThreadTargetRef
1362 ? (projects.find(
1363 (project) =>
1364 project.environmentId === newThreadTargetRef.environmentId &&
1365 project.id === newThreadTargetRef.projectId,
1366 ) ?? null)
1367 : null;
1368 // Picker order: the contextual default first (preselected), everything else
1369 // after — the common case is Enter/click on the top row.
1370 const newThreadPickerProjects = useMemo(() => {
1371 if (!newThreadTargetProject) return projects;
1372 return [
1373 newThreadTargetProject,
1374 ...projects.filter(
1375 (project) =>
1376 project.environmentId !== newThreadTargetProject.environmentId ||
1377 project.id !== newThreadTargetProject.id,
1378 ),
1379 ];
1380 }, [newThreadTargetProject, projects]);
1381
1382 const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle");
1383 // Same resolution as v1: prefer the local-thread binding, fall back to
1384 // chat.new, no platform gating — web users have working shortcuts too.
1385 const newThreadShortcutLabel =
1386 shortcutLabelForCommand(keybindings, "chat.newLocal") ??
1387 shortcutLabelForCommand(keybindings, "chat.new");
1388 const projectScrollerRef = useRef<HTMLDivElement>(null);
1389 const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false);
1390 const updateProjectScrollFade = useCallback(() => {
1391 const scroller = projectScrollerRef.current;
1392 if (!scroller) return;
1393 setCanScrollProjectsRight(
1394 scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1,
1395 );
1396 }, []);
1397 useEffect(() => {
1398 const scroller = projectScrollerRef.current;
1399 if (!scroller) return;
1400
1401 updateProjectScrollFade();
1402 const resizeObserver = new ResizeObserver(updateProjectScrollFade);
1403 resizeObserver.observe(scroller);
1404 return () => resizeObserver.disconnect();
1405 }, [projects, updateProjectScrollFade]);
1406
1407 return (
1408 <>
1409 <SidebarChromeHeader isElectron={isElectron} />
1410 <SidebarContent className="gap-0">
1411 <SidebarGroup className="px-2 pb-2 pt-3">
1412 <SidebarMenu className="flex-row gap-1">
1413 <SidebarMenuItem className="min-w-0 flex-1">
1414 <CommandDialogTrigger
1415 render={
1416 <SidebarMenuButton
1417 size="sm"
1418 className="gap-2 border border-border bg-background/60 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:ring-0"
1419 data-testid="command-palette-trigger"
1420 />
1421 }
1422 >
1423 <SearchIcon className="size-3.5 text-muted-foreground/70" />
1424 <span className="flex-1 truncate text-left text-xs">Search</span>
1425 {commandPaletteShortcutLabel ? (
1426 <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">
1427 {commandPaletteShortcutLabel}
1428 </Kbd>
1429 ) : null}
1430 </CommandDialogTrigger>
1431 </SidebarMenuItem>
1432 <SidebarMenuItem className="shrink-0">
1433 <SidebarMenuButton
1434 size="sm"
1435 className="size-7 justify-center border border-border bg-background/60 p-0 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
1436 onClick={handleNewThreadClick}
1437 disabled={projects.length === 0}
1438 aria-label="New thread"
1439 tooltip={{
1440 children: newThreadShortcutLabel
1441 ? `New thread (${newThreadShortcutLabel})`
1442 : "New thread",
1443 side: "right",
1444 }}
1445 >
1446 <SquarePenIcon className="size-3.5 text-muted-foreground/70" />
1447 </SidebarMenuButton>
1448 </SidebarMenuItem>
1449 </SidebarMenu>
1450 </SidebarGroup>
1451 {projects.length > 0 ? (
1452 <SidebarGroup className="px-2 pb-1 pt-1">
1453 <div className="relative">
1454 <div
1455 ref={projectScrollerRef}
1456 onScroll={updateProjectScrollFade}
1457 className={cn(
1458 "flex items-center gap-1 overflow-x-auto pr-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
1459 canScrollProjectsRight &&
1460 "[mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-4.5rem),transparent_calc(100%-2rem))] [-webkit-mask-image:linear-gradient(to_right,#000_0,#000_calc(100%-4.5rem),transparent_calc(100%-2rem))]",
1461 )}
1462 role="tablist"
1463 aria-label="Filter threads by project"
1464 >
1465 {projects.length > 1 ? (
1466 <button
1467 type="button"
1468 role="tab"
1469 aria-selected={projectScopeKey === null}
1470 onClick={() => setProjectScopeKey(null)}
1471 className={cn(
1472 "shrink-0 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors",
1473 projectScopeKey === null
1474 ? "border-foreground/15 bg-accent text-foreground"
1475 : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40",
1476 )}
1477 >
1478 All
1479 </button>
1480 ) : null}
1481 {projects.map((project) => {
1482 const scopeKey = `${project.environmentId}:${project.id}`;
1483 const isScoped = projectScopeKey === scopeKey;
1484 return (
1485 <button
1486 key={scopeKey}
1487 type="button"
1488 role="tab"
1489 aria-selected={isScoped}
1490 onClick={() => setProjectScopeKey(isScoped ? null : scopeKey)}
1491 className={cn(
1492 "flex shrink-0 items-center gap-1.5 rounded-md border py-1 pl-1.5 pr-2.5 text-[11px] font-medium transition-colors",
1493 isScoped
1494 ? "border-foreground/15 bg-accent text-foreground"
1495 : "border-black/15 text-muted-foreground hover:border-black/40 hover:text-foreground dark:border-white/15 dark:hover:border-white/40",
1496 )}
1497 >
1498 <ProjectFavicon
1499 environmentId={project.environmentId}
1500 cwd={project.workspaceRoot}
1501 className="size-3.5"
1502 />
1503 <span className="max-w-28 truncate">{project.title}</span>
1504 </button>
1505 );
1506 })}
1507 </div>
1508 <div className="pointer-events-none absolute inset-y-0 right-0 flex w-12 items-center justify-end bg-gradient-to-r from-transparent via-card/90 to-card">
1509 <Tooltip>
1510 <TooltipTrigger
1511 render={
1512 <button
1513 type="button"
1514 aria-label="Add project"
1515 onClick={openAddProjectCommandPalette}
1516 className="pointer-events-auto flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-card text-muted-foreground/70 shadow-sm transition-colors hover:bg-accent hover:text-foreground"
1517 />
1518 }
1519 >
1520 <FolderPlusIcon className="size-3.5" />
1521 </TooltipTrigger>
1522 <TooltipPopup side="bottom">Add project</TooltipPopup>
1523 </Tooltip>
1524 </div>
1525 </div>
1526 </SidebarGroup>
1527 ) : null}
1528 <SidebarGroup className="min-h-0 flex-1 overflow-y-auto px-2 py-1">
1529 <ul ref={attachListAutoAnimateRef} className="flex flex-col gap-px">
1530 {orderedThreads.map((thread, threadIndex) => {
1531 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1532 const isSettledRow = settledThreadKeys.has(threadKey);
1533 // Settled is the ONLY thing that collapses a row: every
1534 // not-settled thread is a full card. Density comes from users
1535 // (or the auto rules) actually settling work, not from the
1536 // sidebar second-guessing what still matters.
1537 const isCard = !isSettledRow;
1538 const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null;
1539 const previousWasCard =
1540 previousThread != null &&
1541 !settledThreadKeys.has(
1542 scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)),
1543 );
1544 const showSettledGap = !isCard && previousWasCard;
1545 return (
1546 <SidebarV2Row
1547 showSettledGap={showSettledGap}
1548 key={threadKey}
1549 thread={thread}
1550 variant={isCard ? "card" : "slim"}
1551 // Every settled row can un-settle: explicit settles clear
1552 // the override, auto-settled rows get pinned active.
1553 variantAction={isSettledRow ? "unsettle" : "settle"}
1554 settlementSupported={
1555 serverConfigs.get(thread.environmentId)?.environment.capabilities
1556 .threadSettlement === true
1557 }
1558 isActive={routeThreadKey === threadKey}
1559 jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null}
1560 currentEnvironmentId={primaryEnvironmentId}
1561 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
1562 projectCwd={
1563 projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1564 }
1565 projectTitle={
1566 projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null
1567 }
1568 providerEntryByInstanceId={providerEntryByInstanceId}
1569 onThreadClick={handleThreadClick}
1570 onThreadActivate={navigateToThread}
1571 onStartRename={startThreadRename}
1572 onRenameTitleChange={setRenamingTitle}
1573 onCommitRename={commitThreadRename}
1574 onCancelRename={cancelThreadRename}
1575 isRenaming={renamingThreadKey === threadKey}
1576 renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""}
1577 onContextMenu={handleThreadContextMenu}
1578 onSettle={attemptSettle}
1579 onUnsettle={attemptUnsettle}
1580 onChangeRequestState={handleChangeRequestState}
1581 />
1582 );
1583 })}
1584 {hiddenSettledCount > 0 ? (
1585 <li className="list-none">
1586 <button
1587 type="button"
1588 onClick={showMoreSettled}
1589 className="mt-1 flex h-[30px] w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-black/15 font-mono text-[11px] text-muted-foreground transition-colors hover:border-solid hover:border-black/30 hover:text-foreground dark:border-white/15 dark:hover:border-white/30"
1590 >
1591 Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
1592 <span className="text-muted-foreground/50">
1593 ({hiddenSettledCount} settled hidden)
1594 </span>
1595 </button>
1596 </li>
1597 ) : null}
1598 </ul>
1599 {orderedThreads.length === 0 ? (
1600 <div className="flex flex-col items-center gap-2 px-2 py-6 text-center text-xs text-muted-foreground/60">
1601 {projects.length === 0 ? (
1602 <>
1603 <span>No projects yet</span>
1604 <button
1605 type="button"
1606 onClick={openAddProjectCommandPalette}
1607 className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
1608 >
1609 <PlusIcon className="size-3" />
1610 Add project
1611 </button>
1612 </>
1613 ) : scopedProject ? (
1614 `No threads in ${scopedProject.title} yet`
1615 ) : (
1616 "No threads yet"
1617 )}
1618 </div>
1619 ) : null}
1620 </SidebarGroup>
1621 </SidebarContent>
1622 <SidebarSeparator />
1623 <SidebarChromeFooter />
1624 <Dialog open={newThreadPickerOpen} onOpenChange={setNewThreadPickerOpen}>
1625 <DialogPopup className="max-w-sm p-0">
1626 <DialogHeader className="px-4 pb-2 pt-4">
1627 <DialogTitle className="text-sm">New thread in…</DialogTitle>
1628 </DialogHeader>
1629 <div
1630 className="flex flex-col gap-0.5 px-2 pb-3"
1631 role="listbox"
1632 aria-label="Choose a project for the new thread"
1633 onKeyDown={(event) => {
1634 if (
1635 event.key !== "ArrowDown" &&
1636 event.key !== "ArrowUp" &&
1637 event.key !== "Home" &&
1638 event.key !== "End"
1639 ) {
1640 return;
1641 }
1642 const container = event.currentTarget;
1643 const options = [...container.querySelectorAll<HTMLButtonElement>("button")];
1644 if (options.length === 0) return;
1645 const currentIndex = options.findIndex((option) => option === document.activeElement);
1646 const nextIndex =
1647 event.key === "Home"
1648 ? 0
1649 : event.key === "End"
1650 ? options.length - 1
1651 : event.key === "ArrowDown"
1652 ? (currentIndex + 1) % options.length
1653 : (currentIndex - 1 + options.length) % options.length;
1654 event.preventDefault();
1655 options[nextIndex]?.focus();
1656 }}
1657 >
1658 {newThreadPickerProjects.map((project, index) => {
1659 const isDefault = index === 0 && newThreadTargetProject !== null;
1660 return (
1661 <button
1662 key={`${project.environmentId}:${project.id}`}
1663 type="button"
1664 autoFocus={isDefault}
1665 onClick={() => createThreadInProject(project.environmentId, project.id)}
1666 className={cn(
1667 "flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
1668 "hover:bg-accent focus:bg-accent focus:outline-none",
1669 isDefault && "bg-accent/50",
1670 )}
1671 >
1672 <ProjectFavicon
1673 environmentId={project.environmentId}
1674 cwd={project.workspaceRoot}
1675 className="size-4"
1676 />
1677 <span className="min-w-0 flex-1">
1678 <span className="block truncate text-[13px] font-medium text-foreground">
1679 {project.title}
1680 </span>
1681 <span className="block truncate font-mono text-[10px] text-muted-foreground/60">
1682 {project.workspaceRoot}
1683 </span>
1684 </span>
1685 {isDefault ? (
1686 <Kbd className="h-4 shrink-0 rounded-sm px-1.5 text-[10px]">↵</Kbd>
1687 ) : null}
1688 </button>
1689 );
1690 })}
1691 <button
1692 type="button"
1693 onClick={() => {
1694 setNewThreadPickerOpen(false);
1695 openAddProjectCommandPalette();
1696 }}
1697 className="mt-1 flex items-center gap-2.5 rounded-lg border border-dashed border-border px-2.5 py-2 text-left text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:bg-accent focus:text-foreground focus:outline-none"
1698 >
1699 <PlusIcon className="size-4" />
1700 Add project
1701 </button>
1702 </div>
1703 </DialogPopup>
1704 </Dialog>
1705 </>
1706 );
1707}
apps/web/src/components/chat/ChatComposer.tsx+4-2
......@@ -2143,8 +2143,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21432143 ref={composerSurfaceRef}
21442144 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
21452145 className={cn(
2146 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45",
2147 isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",
2146 "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40",
2147 isDragOverComposer
2148 ? "border-primary/70 bg-accent/45"
2149 : "border-black/12 dark:border-white/12",
21482150 projectSelectionRequired ? "opacity-75" : null,
21492151 composerProviderState.composerSurfaceClassName,
21502152 )}
apps/web/src/components/chat/ChatHeader.tsx+23
......@@ -16,6 +16,7 @@ import ProjectScriptsControl, {
1616} from "../ProjectScriptsControl";
1717import { OpenInPicker } from "./OpenInPicker";
1818import { usePrimaryEnvironmentId } from "../../state/environments";
19import { ProjectFavicon } from "../ProjectFavicon";
1920import { cn } from "~/lib/utils";
2021
2122interface ChatHeaderProps {
......@@ -24,6 +25,7 @@ interface ChatHeaderProps {
2425 draftId?: DraftId;
2526 activeThreadTitle: string;
2627 activeProjectName: string | undefined;
28 activeProjectCwd: string | null;
2729 openInCwd: string | null;
2830 activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
2931 preferredScriptId: string | null;
......@@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({
5860 draftId,
5961 activeThreadTitle,
6062 activeProjectName,
63 activeProjectCwd,
6164 openInCwd,
6265 activeProjectScripts,
6366 preferredScriptId,
......@@ -79,6 +82,26 @@ export const ChatHeader = memo(function ChatHeader({
7982 return (
8083 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
8184 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
85 {/* The project always leads the header: knowing which project a
86 thread lives in is priority zero, and the thread title alone
87 doesn't answer it. */}
88 {activeProjectName ? (
89 <span className="inline-flex shrink-0 items-center gap-2">
90 <span className="inline-flex min-w-0 items-center gap-1.5">
91 <ProjectFavicon
92 environmentId={activeThreadEnvironmentId}
93 cwd={activeProjectCwd ?? ""}
94 className="size-3.5"
95 />
96 <span className="max-w-40 truncate text-sm font-medium text-muted-foreground">
97 {activeProjectName}
98 </span>
99 </span>
100 <span aria-hidden className="text-muted-foreground/40">
101 /
102 </span>
103 </span>
104 ) : null}
82105 <Tooltip>
83106 <TooltipTrigger
84107 render={
apps/web/src/components/settings/BetaSettingsPanel.tsx created+108
......@@ -0,0 +1,108 @@
1import { useEffect, useState } from "react";
2
3import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
4import { Input } from "../ui/input";
5import { Switch } from "../ui/switch";
6import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
7
8const AUTO_SETTLE_MIN_DAYS = 1;
9const AUTO_SETTLE_MAX_DAYS = 90;
10const AUTO_SETTLE_DEFAULT_DAYS = 3;
11
12function AutoSettleDaysInput({
13 value,
14 onCommit,
15}: {
16 value: number;
17 onCommit: (days: number) => void;
18}) {
19 // Local draft so the field can be emptied mid-edit; the setting only moves
20 // on valid input and snaps back to the persisted value on blur.
21 const [draft, setDraft] = useState(String(value));
22 useEffect(() => {
23 setDraft(String(value));
24 }, [value]);
25
26 return (
27 <Input
28 type="number"
29 min={AUTO_SETTLE_MIN_DAYS}
30 max={AUTO_SETTLE_MAX_DAYS}
31 className="w-full sm:w-24"
32 value={draft}
33 onChange={(event) => {
34 setDraft(event.target.value);
35 // Number(), not parseInt: "3.5" must be rejected (not truncated to a
36 // committed 3 while the field shows 3.5) — commit only when the
37 // persisted value matches the displayed one.
38 const parsed = Number(event.target.value);
39 if (
40 Number.isInteger(parsed) &&
41 parsed >= AUTO_SETTLE_MIN_DAYS &&
42 parsed <= AUTO_SETTLE_MAX_DAYS
43 ) {
44 onCommit(parsed);
45 }
46 }}
47 onBlur={() => setDraft(String(value))}
48 aria-label="Days of inactivity before auto-settle"
49 />
50 );
51}
52
53export function BetaSettingsPanel() {
54 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
55 const sidebarAutoSettleAfterDays = useClientSettings(
56 (settings) => settings.sidebarAutoSettleAfterDays,
57 );
58 const updateSettings = useUpdateClientSettings();
59
60 return (
61 <SettingsPageContainer>
62 <SettingsSection title="Beta features">
63 <SettingsRow
64 title="Sidebar v2"
65 description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time."
66 control={
67 <Switch
68 checked={sidebarV2Enabled}
69 onCheckedChange={(checked) => updateSettings({ sidebarV2Enabled: Boolean(checked) })}
70 aria-label="Enable the sidebar v2 beta"
71 />
72 }
73 />
74 {sidebarV2Enabled ? (
75 <>
76 <SettingsRow
77 title="Auto-settle inactive threads"
78 description="Threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle."
79 control={
80 <Switch
81 checked={sidebarAutoSettleAfterDays !== null}
82 onCheckedChange={(checked) =>
83 updateSettings({
84 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
85 })
86 }
87 aria-label="Auto-settle inactive threads"
88 />
89 }
90 />
91 {sidebarAutoSettleAfterDays !== null ? (
92 <SettingsRow
93 title="Days of inactivity before auto-settle"
94 description="Any new activity un-settles a thread automatically."
95 control={
96 <AutoSettleDaysInput
97 value={sidebarAutoSettleAfterDays}
98 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
99 />
100 }
101 />
102 ) : null}
103 </>
104 ) : null}
105 </SettingsSection>
106 </SettingsPageContainer>
107 );
108}
apps/web/src/components/settings/SettingsSidebarNav.tsx+3
......@@ -3,6 +3,7 @@ import {
33 ArchiveIcon,
44 ArrowLeftIcon,
55 BotIcon,
6 FlaskConicalIcon,
67 GitBranchIcon,
78 KeyboardIcon,
89 Link2Icon,
......@@ -28,6 +29,7 @@ export type SettingsSectionPath =
2829 | "/settings/providers"
2930 | "/settings/source-control"
3031 | "/settings/connections"
32 | "/settings/beta"
3133 | "/settings/archived";
3234
3335export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
......@@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
4042 { label: "Providers", to: "/settings/providers", icon: BotIcon },
4143 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
4244 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
45 { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon },
4346 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
4447];
4548
apps/web/src/components/sidebar/SidebarChrome.tsx created+127
......@@ -0,0 +1,127 @@
1import { useAtomValue } from "@effect/atom-react";
2import { SettingsIcon } from "lucide-react";
3import { memo, useCallback } from "react";
4import { Link, useNavigate } from "@tanstack/react-router";
5
6import { APP_STAGE_LABEL } from "../../branding";
7import { cn } from "../../lib/utils";
8import { primaryServerConfigAtom } from "../../state/server";
9import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic";
10import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
11import {
12 SidebarFooter,
13 SidebarHeader,
14 SidebarMenu,
15 SidebarMenuButton,
16 SidebarMenuItem,
17 SidebarTrigger,
18 useSidebar,
19} from "../ui/sidebar";
20import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
21import { SidebarUpdatePill } from "./SidebarUpdatePill";
22
23export const SidebarChromeHeader = memo(function SidebarChromeHeader({
24 isElectron,
25}: {
26 isElectron: boolean;
27}) {
28 const stageLabel = useSidebarStageLabel();
29 const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel);
30
31 return (
32 <SidebarHeader
33 className={cn(
34 "@container/sidebar-header relative h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0",
35 isElectron && "drag-region",
36 )}
37 >
38 {backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null}
39 <SidebarTrigger
40 className={cn(
41 "relative z-10 md:hidden",
42 backdropVariant && "hover:bg-white/15 [&_svg]:text-white/85! [&_svg]:hover:text-white!",
43 )}
44 />
45 <SidebarBrand onBackdrop={backdropVariant !== null} />
46 </SidebarHeader>
47 );
48});
49
50function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
51 return (
52 <Link
53 aria-label="Go to threads"
54 className={cn(
55 "sidebar-brand relative z-10 ml-[var(--workspace-titlebar-content-left)] h-7 w-fit min-w-0 shrink-0 items-center gap-1 overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2",
56 onBackdrop ? "text-white" : "text-foreground",
57 )}
58 to="/"
59 >
60 <T3Wordmark />
61 <span
62 className={cn(
63 "truncate text-sm font-medium tracking-tight",
64 onBackdrop ? "text-white/70" : "text-muted-foreground",
65 )}
66 >
67 Code
68 </span>
69 </Link>
70 );
71}
72
73function useSidebarStageLabel() {
74 const primaryServerVersion =
75 useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null;
76
77 return resolveSidebarStageBadgeLabel({
78 primaryServerVersion,
79 fallbackStageLabel: APP_STAGE_LABEL,
80 });
81}
82
83function T3Wordmark() {
84 return (
85 <svg
86 aria-label="T3"
87 className="h-2.5 w-auto shrink-0"
88 viewBox="15.5309 37 94.3941 56.96"
89 xmlns="http://www.w3.org/2000/svg"
90 >
91 <path
92 d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z"
93 fill="currentColor"
94 />
95 </svg>
96 );
97}
98
99export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
100 const navigate = useNavigate();
101 const { isMobile, setOpenMobile } = useSidebar();
102 const handleSettingsClick = useCallback(() => {
103 if (isMobile) {
104 setOpenMobile(false);
105 }
106 void navigate({ to: "/settings" });
107 }, [isMobile, navigate, setOpenMobile]);
108
109 return (
110 <SidebarFooter className="p-2">
111 <SidebarProviderUpdatePill />
112 <SidebarUpdatePill />
113 <SidebarMenu>
114 <SidebarMenuItem>
115 <SidebarMenuButton
116 size="sm"
117 className="gap-2 px-2 py-1.5 text-muted-foreground/70 hover:bg-accent hover:text-foreground"
118 onClick={handleSettingsClick}
119 >
120 <SettingsIcon className="size-3.5" />
121 <span className="text-xs">Settings</span>
122 </SidebarMenuButton>
123 </SidebarMenuItem>
124 </SidebarMenu>
125 </SidebarFooter>
126 );
127});
apps/web/src/hooks/useThreadActions.ts+107-2
......@@ -4,6 +4,7 @@ import {
44 scopeThreadRef,
55} from "@t3tools/client-runtime/environment";
66import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7import { canSettle } from "@t3tools/client-runtime/state/thread-settled";
78import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
89import * as Cause from "effect/Cause";
910import * as Schema from "effect/Schema";
......@@ -19,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs";
1920import { useNewThreadHandler } from "./useHandleNewThread";
2021import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
2122import { readLocalApi } from "../localApi";
22import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities";
23import {
24 readEnvironmentSupportsSettlement,
25 readEnvironmentThreadRefs,
26 readProject,
27 readThreadShell,
28} from "../state/entities";
2329import { useTerminalUiStateStore } from "../terminalUiStateStore";
2430import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
2531import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
......@@ -39,6 +45,30 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass<ThreadArc
3945 }
4046}
4147
48export class ThreadSettlementUnsupportedError extends Schema.TaggedErrorClass<ThreadSettlementUnsupportedError>()(
49 "ThreadSettlementUnsupportedError",
50 {
51 environmentId: EnvironmentId,
52 threadId: ThreadId,
53 },
54) {
55 override get message(): string {
56 return "This environment's server does not support settling yet. Update the server to use Settle.";
57 }
58}
59
60export class ThreadSettleBlockedError extends Schema.TaggedErrorClass<ThreadSettleBlockedError>()(
61 "ThreadSettleBlockedError",
62 {
63 environmentId: EnvironmentId,
64 threadId: ThreadId,
65 },
66) {
67 override get message(): string {
68 return "This thread still needs attention. Resolve or interrupt it first, then try again.";
69 }
70}
71
4272export function useThreadActions() {
4373 const closeTerminal = useAtomCommand(terminalEnvironment.close);
4474 const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, {
......@@ -50,6 +80,12 @@ export function useThreadActions() {
5080 const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, {
5181 reportFailure: false,
5282 });
83 const settleThreadMutation = useAtomCommand(threadEnvironment.settle, {
84 reportFailure: false,
85 });
86 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
87 reportFailure: false,
88 });
5389 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession);
5490 const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, {
5591 reportFailure: false,
......@@ -345,6 +381,66 @@ export function useThreadActions() {
345381 ],
346382 );
347383
384 const settleThread = useCallback(
385 async (target: ScopedThreadRef) => {
386 // Version skew: never send the command to a server that predates it —
387 // the raw protocol rejection would read as a random failure.
388 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
389 return AsyncResult.failure(
390 Cause.fail(
391 new ThreadSettlementUnsupportedError({
392 environmentId: target.environmentId,
393 threadId: target.threadId,
394 }),
395 ),
396 );
397 }
398 const resolved = resolveThreadTarget(target);
399 // Settle may only target what effectiveSettled could classify as
400 // settled: not starting/running sessions, not threads waiting on
401 // approvals or user input. Anything else would hide live work.
402 if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) {
403 return AsyncResult.failure(
404 Cause.fail(
405 new ThreadSettleBlockedError({
406 environmentId: resolved.threadRef.environmentId,
407 threadId: resolved.threadRef.threadId,
408 }),
409 ),
410 );
411 }
412 // Settle is a high-frequency lifecycle action and stays silent — no
413 // toast.
414 return settleThreadMutation({
415 environmentId: target.environmentId,
416 input: { threadId: target.threadId },
417 });
418 },
419 [resolveThreadTarget, settleThreadMutation],
420 );
421
422 const unsettleThread = useCallback(
423 async (target: ScopedThreadRef) => {
424 if (!readEnvironmentSupportsSettlement(target.environmentId)) {
425 return AsyncResult.failure(
426 Cause.fail(
427 new ThreadSettlementUnsupportedError({
428 environmentId: target.environmentId,
429 threadId: target.threadId,
430 }),
431 ),
432 );
433 }
434 // reason "user" pins the thread active: auto-settle (PR merged /
435 // inactivity) stays suppressed until real activity clears the pin.
436 return unsettleThreadMutation({
437 environmentId: target.environmentId,
438 input: { threadId: target.threadId, reason: "user" },
439 });
440 },
441 [unsettleThreadMutation],
442 );
443
348444 const confirmAndDeleteThread = useCallback(
349445 async (target: ScopedThreadRef) => {
350446 const localApi = readLocalApi();
......@@ -379,7 +475,16 @@ export function useThreadActions() {
379475 unarchiveThread,
380476 deleteThread,
381477 confirmAndDeleteThread,
478 settleThread,
479 unsettleThread,
382480 }),
383 [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread],
481 [
482 archiveThread,
483 confirmAndDeleteThread,
484 deleteThread,
485 settleThread,
486 unarchiveThread,
487 unsettleThread,
488 ],
384489 );
385490}
apps/web/src/index.css+63-10
......@@ -108,11 +108,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
108108
109109@theme inline {
110110 --animate-skeleton: skeleton 2s -1s infinite linear;
111 /* Duty-cycled indicator animations: long opacity holds with short stepped
112 ramps, so the compositor only produces frames while the value changes
113 (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */
111 /* Duty-cycled indicator animations: long holds with stepped ramps, so the
112 compositor updates discrete frames instead of every vsync. */
114113 --animate-status-pulse: status-pulse 2s infinite;
115114 --animate-status-ping: status-ping 2s infinite;
115 --animate-sidebar-working-text: sidebar-working-text 3.4s infinite;
116116 --font-sans:
117117 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
118118 sans-serif;
......@@ -185,6 +185,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
185185 scale: 2;
186186 }
187187 }
188 @keyframes sidebar-working-text {
189 0%,
190 36% {
191 opacity: 1;
192 animation-timing-function: steps(10);
193 }
194 50%,
195 86% {
196 opacity: 0.75;
197 animation-timing-function: steps(10);
198 }
199 100% {
200 opacity: 1;
201 }
202 }
188203}
189204
190205@layer base {
......@@ -232,8 +247,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
232247 }
233248
234249 /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the
235 mask lets the surface grain show through at the boundary. */
250 mask lets the surface grain show through at the boundary. Panels whose
251 background differs from the app chrome (e.g. sidebar v2) override
252 --sidebar-stage-fade so the art fades into their own surface color. */
236253 .sidebar-stage-backdrop {
254 --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background));
237255 mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
238256 -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
239257 }
......@@ -246,12 +264,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
246264 to bottom,
247265 transparent 0%,
248266 transparent 28%,
249 color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%,
250 color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%,
251 color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%,
252 color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%,
253 color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%,
254 var(--app-chrome-background) 93%
267 color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%,
268 color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%,
269 color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%,
270 color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%,
271 color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%,
272 var(--stage-fade) 93%
255273 );
256274 }
257275
......@@ -450,6 +468,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
450468 }
451469}
452470
471/* Give the navigation rail its own cool graphite surface instead of borrowing
472 the workspace card color. The scoped semantic tokens keep sidebar v2's
473 controls in the same palette without changing v1 or the settings nav. */
474.app-sidebar {
475 --background: oklch(0.965 0.009 265);
476 --foreground: oklch(0.27 0.025 265);
477 --card: oklch(0.945 0.014 265);
478 --card-foreground: var(--foreground);
479 --accent: oklch(0.875 0.008 265);
480 --accent-foreground: oklch(0.24 0.012 265);
481 --muted: oklch(0.9 0.018 265);
482 --muted-foreground: oklch(0.49 0.03 265);
483 --border: oklch(0.72 0.025 265 / 28%);
484 --input: oklch(0.62 0.03 265 / 28%);
485 --sidebar-stage-fade: var(--card);
486 background-color: var(--card);
487}
488
489.dark .app-sidebar {
490 --background: #000;
491 --foreground: #f1f3f7;
492 --card: #000;
493 --card-foreground: var(--foreground);
494 --accent: #191a1d;
495 --accent-foreground: #f7f9ff;
496 --muted: #0a0a0a;
497 --muted-foreground: #a3a3a3;
498 --border: rgb(255 255 255 / 14%);
499 --input: rgb(255 255 255 / 18%);
500 /* The stage-channel header art must ramp to THIS panel's surface, not the
501 global chrome background, or the fade shows a seam (same rule as the
502 light palette above). */
503 --sidebar-stage-fade: var(--card);
504}
505
453506body {
454507 font-family:
455508 "DM Sans Variable",
apps/web/src/lib/chatThreadActions.test.ts+21
......@@ -6,6 +6,7 @@ import {
66 resolveNewDraftStartFromOrigin,
77 startNewLocalThreadFromContext,
88 startNewThreadFromContext,
9 startNewThreadInProjectFromContext,
910 type ChatThreadActionContext,
1011} from "./chatThreadActions";
1112
......@@ -117,6 +118,26 @@ describe("chatThreadActions", () => {
117118 });
118119 });
119120
121 it("does not carry branch or worktree context into a different project", async () => {
122 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
123 const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID);
124
125 await startNewThreadInProjectFromContext(
126 createContext({
127 activeThread: {
128 environmentId: ENVIRONMENT_ID,
129 projectId: PROJECT_ID,
130 branch: "feature/refactor",
131 worktreePath: "/tmp/worktree",
132 },
133 handleNewThread,
134 }),
135 targetProjectRef,
136 );
137
138 expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef);
139 });
140
120141 it("delegates the target environment defaults to the new-thread handler", async () => {
121142 const handleNewThread = vi.fn<ChatThreadActionContext["handleNewThread"]>(async () => {});
122143
apps/web/src/lib/chatThreadActions.ts+8
......@@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext(
7575 context: ChatThreadActionContext,
7676 projectRef: ScopedProjectRef,
7777): Promise<void> {
78 const contextualProjectRef = resolveThreadActionProjectRef(context);
79 const matchesContext =
80 contextualProjectRef?.environmentId === projectRef.environmentId &&
81 contextualProjectRef.projectId === projectRef.projectId;
82 if (!matchesContext) {
83 await context.handleNewThread(projectRef);
84 return;
85 }
7886 await context.handleNewThread(projectRef, buildContextualThreadOptions(context));
7987}
8088
apps/web/src/lib/threadSort.test.ts+2
......@@ -26,6 +26,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2626 proposedPlans: [],
2727 createdAt: "2026-03-09T10:00:00.000Z",
2828 archivedAt: null,
29 settledOverride: null,
30 settledAt: null,
2931 deletedAt: null,
3032 updatedAt: "2026-03-09T10:00:00.000Z",
3133 latestTurn: null,
apps/web/src/newThreadPickerBus.ts created+14
......@@ -0,0 +1,14 @@
1// Tiny event bus connecting the global chat.new shortcut (handled in the
2// _chat route layout) to SidebarV2's project picker dialog. The route layer
3// can't render the picker itself — it lives with the sidebar — and the
4// sidebar can't own the window keydown handler without racing the layout's.
5const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker";
6
7export function openNewThreadPicker(): void {
8 window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT));
9}
10
11export function onOpenNewThreadPicker(listener: () => void): () => void {
12 window.addEventListener(NEW_THREAD_PICKER_EVENT, listener);
13 return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener);
14}
apps/web/src/routeTree.gen.ts+21
......@@ -20,6 +20,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi
2020import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2121import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2222import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
23import { Route as SettingsBetaRouteImport } from './routes/settings.beta'
2324import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2425import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback'
2526import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
......@@ -79,6 +80,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
7980 path: '/connections',
8081 getParentRoute: () => SettingsRoute,
8182} as any)
83const SettingsBetaRoute = SettingsBetaRouteImport.update({
84 id: '/beta',
85 path: '/beta',
86 getParentRoute: () => SettingsRoute,
87} as any)
8288const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
8389 id: '/archived',
8490 path: '/archived',
......@@ -108,6 +114,7 @@ export interface FileRoutesByFullPath {
108114 '/settings': typeof SettingsRouteWithChildren
109115 '/connect/callback': typeof ConnectCallbackRoute
110116 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/beta': typeof SettingsBetaRoute
111118 '/settings/connections': typeof SettingsConnectionsRoute
112119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
113120 '/settings/general': typeof SettingsGeneralRoute
......@@ -123,6 +130,7 @@ export interface FileRoutesByTo {
123130 '/settings': typeof SettingsRouteWithChildren
124131 '/connect/callback': typeof ConnectCallbackRoute
125132 '/settings/archived': typeof SettingsArchivedRoute
133 '/settings/beta': typeof SettingsBetaRoute
126134 '/settings/connections': typeof SettingsConnectionsRoute
127135 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
128136 '/settings/general': typeof SettingsGeneralRoute
......@@ -141,6 +149,7 @@ export interface FileRoutesById {
141149 '/settings': typeof SettingsRouteWithChildren
142150 '/connect_/callback': typeof ConnectCallbackRoute
143151 '/settings/archived': typeof SettingsArchivedRoute
152 '/settings/beta': typeof SettingsBetaRoute
144153 '/settings/connections': typeof SettingsConnectionsRoute
145154 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
146155 '/settings/general': typeof SettingsGeneralRoute
......@@ -160,6 +169,7 @@ export interface FileRouteTypes {
160169 | '/settings'
161170 | '/connect/callback'
162171 | '/settings/archived'
172 | '/settings/beta'
163173 | '/settings/connections'
164174 | '/settings/diagnostics'
165175 | '/settings/general'
......@@ -175,6 +185,7 @@ export interface FileRouteTypes {
175185 | '/settings'
176186 | '/connect/callback'
177187 | '/settings/archived'
188 | '/settings/beta'
178189 | '/settings/connections'
179190 | '/settings/diagnostics'
180191 | '/settings/general'
......@@ -192,6 +203,7 @@ export interface FileRouteTypes {
192203 | '/settings'
193204 | '/connect_/callback'
194205 | '/settings/archived'
206 | '/settings/beta'
195207 | '/settings/connections'
196208 | '/settings/diagnostics'
197209 | '/settings/general'
......@@ -290,6 +302,13 @@ declare module '@tanstack/react-router' {
290302 preLoaderRoute: typeof SettingsConnectionsRouteImport
291303 parentRoute: typeof SettingsRoute
292304 }
305 '/settings/beta': {
306 id: '/settings/beta'
307 path: '/beta'
308 fullPath: '/settings/beta'
309 preLoaderRoute: typeof SettingsBetaRouteImport
310 parentRoute: typeof SettingsRoute
311 }
293312 '/settings/archived': {
294313 id: '/settings/archived'
295314 path: '/archived'
......@@ -337,6 +356,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
337356
338357interface SettingsRouteChildren {
339358 SettingsArchivedRoute: typeof SettingsArchivedRoute
359 SettingsBetaRoute: typeof SettingsBetaRoute
340360 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
341361 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
342362 SettingsGeneralRoute: typeof SettingsGeneralRoute
......@@ -347,6 +367,7 @@ interface SettingsRouteChildren {
347367
348368const SettingsRouteChildren: SettingsRouteChildren = {
349369 SettingsArchivedRoute: SettingsArchivedRoute,
370 SettingsBetaRoute: SettingsBetaRoute,
350371 SettingsConnectionsRoute: SettingsConnectionsRoute,
351372 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
352373 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.tsx+14
......@@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react";
33import { useEffect } from "react";
44
55import { isCommandPaletteOpen } from "../commandPaletteContext";
6import { useClientSettings } from "../hooks/useSettings";
7import { openNewThreadPicker } from "../newThreadPickerBus";
8import { useProjects } from "../state/entities";
69import { dispatchPreviewAction } from "../components/preview/previewActionBus";
710import { useHandleNewThread } from "../hooks/useHandleNewThread";
811import {
......@@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() {
2528 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } =
2629 useHandleNewThread();
2730 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
31 const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled);
32 const projectCount = useProjects().length;
2833 const terminalOpen = useTerminalUiStateStore((state) =>
2934 routeThreadRef
3035 ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen
......@@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() {
7580 if (command === "chat.new") {
7681 event.preventDefault();
7782 event.stopPropagation();
83 // Sidebar v2 routes creation through its project picker whenever
84 // there is a real choice to make; v1 (and single-project setups)
85 // keep the immediate contextual create.
86 if (sidebarV2Enabled && projectCount > 1) {
87 openNewThreadPicker();
88 return;
89 }
7890 void startNewThreadFromContext({
7991 activeDraftThread,
8092 activeThread: activeThread ?? undefined,
......@@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() {
140152 keybindings,
141153 defaultProjectRef,
142154 previewOpen,
155 projectCount,
143156 routeThreadRef,
144157 selectedThreadKeysSize,
158 sidebarV2Enabled,
145159 terminalOpen,
146160 ]);
147161
apps/web/src/routes/settings.beta.tsx created+11
......@@ -0,0 +1,11 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel";
4
5function SettingsBetaRoute() {
6 return <BetaSettingsPanel />;
7}
8
9export const Route = createFileRoute("/settings/beta")({
10 component: SettingsBetaRoute,
11});
apps/web/src/state/entities.ts+10
......@@ -185,6 +185,16 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell |
185185 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
186186}
187187
188/** Whether the environment's server understands thread.settle/unsettle.
189 False for pre-settlement servers (capability defaults false on decode),
190 so clients under version skew fall back instead of erroring. */
191export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean {
192 return (
193 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
194 .threadSettlement === true
195 );
196}
197
188198export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null {
189199 return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref));
190200}
apps/web/src/worktreeCleanup.test.ts+2
......@@ -26,6 +26,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2626 createdAt: "2026-02-13T00:00:00.000Z",
2727 updatedAt: "2026-02-13T00:00:00.000Z",
2828 archivedAt: null,
29 settledOverride: null,
30 settledAt: null,
2931 deletedAt: null,
3032 latestTurn: null,
3133 branch: null,
packages/client-runtime/package.json+4
......@@ -127,6 +127,10 @@
127127 "types": "./src/state/threadSort.ts",
128128 "default": "./src/state/threadSort.ts"
129129 },
130 "./state/thread-settled": {
131 "types": "./src/state/threadSettled.ts",
132 "default": "./src/state/threadSettled.ts"
133 },
130134 "./state/vcs": {
131135 "types": "./src/state/vcs.ts",
132136 "default": "./src/state/vcs.ts"
packages/client-runtime/src/operations/commands.test.ts+38-1
......@@ -21,7 +21,13 @@ import {
2121import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2222import * as RpcSession from "../rpc/session.ts";
2323import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import { archiveThread, createProject, stopThreadSession } from "./commands.ts";
24import {
25 archiveThread,
26 createProject,
27 settleThread,
28 stopThreadSession,
29 unsettleThread,
30} from "./commands.ts";
2531
2632const TEST_CRYPTO_LAYER = Layer.succeed(
2733 Crypto.Crypto,
......@@ -134,4 +140,35 @@ describe("environment commands", () => {
134140 ]);
135141 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
136142 );
143
144 it.effect("dispatches settle and unsettle commands without timestamps", () =>
145 Effect.gen(function* () {
146 const dispatched: ClientOrchestrationCommand[] = [];
147 const supervisor = yield* makeSupervisor(dispatched);
148
149 yield* settleThread({
150 commandId: CommandId.make("settle-command"),
151 threadId: ThreadId.make("thread-1"),
152 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
153 yield* unsettleThread({
154 commandId: CommandId.make("unsettle-command"),
155 threadId: ThreadId.make("thread-1"),
156 reason: "user",
157 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
158
159 expect(dispatched).toEqual([
160 {
161 type: "thread.settle",
162 commandId: "settle-command",
163 threadId: "thread-1",
164 },
165 {
166 type: "thread.unsettle",
167 commandId: "unsettle-command",
168 threadId: "thread-1",
169 reason: "user",
170 },
171 ]);
172 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
173 );
137174});
packages/client-runtime/src/operations/commands.ts+22
......@@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">;
3535export type DeleteThreadInput = CommandInput<"thread.delete">;
3636export type ArchiveThreadInput = CommandInput<"thread.archive">;
3737export type UnarchiveThreadInput = CommandInput<"thread.unarchive">;
38export type SettleThreadInput = CommandInput<"thread.settle">;
39export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
3840export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">;
3941export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">;
4042export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">;
......@@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E
153155 });
154156});
155157
158export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn(
159 "EnvironmentCommands.settleThread",
160)(function* (input) {
161 return yield* dispatch({
162 ...input,
163 type: "thread.settle",
164 commandId: yield* commandId(input),
165 });
166});
167
168export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn(
169 "EnvironmentCommands.unsettleThread",
170)(function* (input) {
171 return yield* dispatch({
172 ...input,
173 type: "thread.unsettle",
174 commandId: yield* commandId(input),
175 });
176});
177
156178export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn(
157179 "EnvironmentCommands.updateThreadMetadata",
158180)(function* (input) {
packages/client-runtime/src/state/entities.test.ts+2
......@@ -96,6 +96,8 @@ const THREAD_SHELL = {
9696 createdAt: "2026-06-01T00:00:00.000Z",
9797 updatedAt: "2026-06-01T00:00:00.000Z",
9898 archivedAt: null,
99 settledOverride: null,
100 settledAt: null,
99101 session: null,
100102 latestUserMessageAt: null,
101103 hasPendingApprovals: false,
packages/client-runtime/src/state/shellReducer.test.ts+2
......@@ -36,6 +36,8 @@ const stubThread = {
3636 createdAt: "2026-04-01T00:00:00.000Z",
3737 updatedAt: "2026-04-01T00:00:00.000Z",
3838 archivedAt: null,
39 settledOverride: null,
40 settledAt: null,
3941 latestUserMessageAt: null,
4042 hasPendingApprovals: false,
4143 hasPendingUserInput: false,
packages/client-runtime/src/state/threadCommands.ts+18
......@@ -12,9 +12,11 @@ import {
1212 type RevertThreadCheckpointInput,
1313 type SetThreadInteractionModeInput,
1414 type SetThreadRuntimeModeInput,
15 type SettleThreadInput,
1516 type StartThreadTurnInput,
1617 type StopThreadSessionInput,
1718 type UnarchiveThreadInput,
19 type UnsettleThreadInput,
1820 type UpdateThreadMetadataInput,
1921 archiveThread,
2022 createThread,
......@@ -25,9 +27,11 @@ import {
2527 revertThreadCheckpoint,
2628 setThreadInteractionMode,
2729 setThreadRuntimeMode,
30 settleThread,
2831 startThreadTurn,
2932 stopThreadSession,
3033 unarchiveThread,
34 unsettleThread,
3135 updateThreadMetadata,
3236} from "../operations/commands.ts";
3337import type { EnvironmentRegistry } from "../connection/registry.ts";
......@@ -42,9 +46,11 @@ export type {
4246 RevertThreadCheckpointInput,
4347 SetThreadInteractionModeInput,
4448 SetThreadRuntimeModeInput,
49 SettleThreadInput,
4550 StartThreadTurnInput,
4651 StopThreadSessionInput,
4752 UnarchiveThreadInput,
53 UnsettleThreadInput,
4854 UpdateThreadMetadataInput,
4955} from "../operations/commands.ts";
5056
......@@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms<R, E>(
8288 scheduler,
8389 concurrency,
8490 }),
91 settle: createEnvironmentCommand(runtime, {
92 label: "environment-data:commands:thread:settle",
93 execute: (input: SettleThreadInput) => settleThread(input),
94 scheduler,
95 concurrency,
96 }),
97 unsettle: createEnvironmentCommand(runtime, {
98 label: "environment-data:commands:thread:unsettle",
99 execute: (input: UnsettleThreadInput) => unsettleThread(input),
100 scheduler,
101 concurrency,
102 }),
85103 updateMetadata: createEnvironmentCommand(runtime, {
86104 label: "environment-data:commands:thread:update-metadata",
87105 execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input),
packages/client-runtime/src/state/threadDetail.ts+2
......@@ -57,6 +57,8 @@ export function mergeEnvironmentThread(
5757 createdAt: shell.createdAt,
5858 updatedAt: shell.updatedAt,
5959 archivedAt: shell.archivedAt,
60 settledOverride: shell.settledOverride,
61 settledAt: shell.settledAt,
6062 session: shell.session,
6163 };
6264}
packages/client-runtime/src/state/threadReducer.test.ts+58
......@@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = {
3434 createdAt: "2026-04-01T00:00:00.000Z",
3535 updatedAt: "2026-04-01T00:00:00.000Z",
3636 archivedAt: null,
37 settledOverride: null,
38 settledAt: null,
3739 deletedAt: null,
3840 messages: [],
3941 proposedPlans: [],
......@@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => {
164166 });
165167 });
166168
169 describe("thread.settled / thread.unsettled", () => {
170 it("sets the settled override and timestamp", () => {
171 const settledAt = "2026-04-01T05:00:00.000Z";
172 const result = applyThreadDetailEvent(baseThread, {
173 ...baseEventFields,
174 sequence: 5,
175 occurredAt: settledAt,
176 aggregateKind: "thread",
177 aggregateId: ThreadId.make("thread-1"),
178 type: "thread.settled",
179 payload: {
180 threadId: ThreadId.make("thread-1"),
181 settledAt,
182 updatedAt: settledAt,
183 },
184 });
185
186 expect(result.kind).toBe("updated");
187 if (result.kind === "updated") {
188 expect(result.thread.settledOverride).toBe("settled");
189 expect(result.thread.settledAt).toBe(settledAt);
190 }
191 });
192
193 it.each([
194 ["user", "active"],
195 ["activity", null],
196 ] as const)("unsettles for %s with override %s", (reason, settledOverride) => {
197 const settledThread: OrchestrationThread = {
198 ...baseThread,
199 settledOverride: "settled",
200 settledAt: "2026-04-01T05:00:00.000Z",
201 };
202 const updatedAt = "2026-04-01T06:00:00.000Z";
203 const result = applyThreadDetailEvent(settledThread, {
204 ...baseEventFields,
205 sequence: 6,
206 occurredAt: updatedAt,
207 aggregateKind: "thread",
208 aggregateId: ThreadId.make("thread-1"),
209 type: "thread.unsettled",
210 payload: {
211 threadId: ThreadId.make("thread-1"),
212 reason,
213 updatedAt,
214 },
215 });
216
217 expect(result.kind).toBe("updated");
218 if (result.kind === "updated") {
219 expect(result.thread.settledOverride).toBe(settledOverride);
220 expect(result.thread.settledAt).toBeNull();
221 }
222 });
223 });
224
167225 describe("thread.meta-updated", () => {
168226 it("patches title and branch", () => {
169227 const result = applyThreadDetailEvent(baseThread, {
packages/client-runtime/src/state/threadReducer.ts+24
......@@ -72,6 +72,8 @@ export function applyThreadDetailEvent(
7272 createdAt: event.payload.createdAt,
7373 updatedAt: event.payload.updatedAt,
7474 archivedAt: null,
75 settledOverride: null,
76 settledAt: null,
7577 deletedAt: null,
7678 messages: [],
7779 proposedPlans: [],
......@@ -100,6 +102,28 @@ export function applyThreadDetailEvent(
100102 thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt },
101103 };
102104
105 case "thread.settled":
106 return {
107 kind: "updated",
108 thread: {
109 ...thread,
110 settledOverride: "settled",
111 settledAt: event.payload.settledAt,
112 updatedAt: event.payload.updatedAt,
113 },
114 };
115
116 case "thread.unsettled":
117 return {
118 kind: "updated",
119 thread: {
120 ...thread,
121 settledOverride: event.payload.reason === "user" ? "active" : null,
122 settledAt: null,
123 updatedAt: event.payload.updatedAt,
124 },
125 };
126
103127 // ── Thread metadata ─────────────────────────────────────────────
104128 case "thread.meta-updated":
105129 return {
packages/client-runtime/src/state/threadSettled.test.ts created+345
......@@ -0,0 +1,345 @@
1import {
2 ProjectId,
3 ProviderInstanceId,
4 ThreadId,
5 TurnId,
6 type OrchestrationThreadShell,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9
10import {
11 canSettle,
12 effectiveSettled,
13 hasQueuedTurnStart,
14 threadLastActivityAt,
15 type ChangeRequestStateLike,
16} from "./threadSettled.ts";
17
18const NOW = "2026-04-10T00:00:00.000Z";
19const FRESH = "2026-04-09T00:00:00.000Z";
20const STALE = "2026-04-06T23:59:59.999Z";
21
22function makeShell(input: {
23 readonly settledOverride?: "settled" | "active" | null;
24 readonly activityAt: string | null;
25 readonly sessionStatus?: "starting" | "running";
26 readonly pending?: "approval" | "user-input";
27}): OrchestrationThreadShell {
28 const threadId = ThreadId.make("thread-1");
29 return {
30 id: threadId,
31 projectId: ProjectId.make("project-1"),
32 title: "Thread",
33 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
34 runtimeMode: "full-access",
35 interactionMode: "default",
36 branch: null,
37 worktreePath: null,
38 latestTurn:
39 input.activityAt === null
40 ? null
41 : {
42 turnId: TurnId.make("turn-1"),
43 state: "completed",
44 requestedAt: input.activityAt,
45 startedAt: null,
46 completedAt: null,
47 assistantMessageId: null,
48 },
49 createdAt: "2026-04-01T00:00:00.000Z",
50 updatedAt: NOW,
51 archivedAt: null,
52 settledOverride: input.settledOverride ?? null,
53 settledAt: input.settledOverride === "settled" ? NOW : null,
54 session:
55 input.sessionStatus === undefined
56 ? null
57 : {
58 threadId,
59 status: input.sessionStatus,
60 providerName: "Codex",
61 runtimeMode: "full-access",
62 activeTurnId: null,
63 lastError: null,
64 updatedAt: NOW,
65 },
66 latestUserMessageAt: null,
67 hasPendingApprovals: input.pending === "approval",
68 hasPendingUserInput: input.pending === "user-input",
69 hasActionableProposedPlan: false,
70 };
71}
72
73describe("threadLastActivityAt", () => {
74 it("returns the latest real user or turn activity and ignores thread/session updates", () => {
75 const shell = makeShell({ activityAt: null, sessionStatus: "running" });
76 const withActivity: OrchestrationThreadShell = {
77 ...shell,
78 latestUserMessageAt: "2026-04-04T00:00:00.000Z",
79 latestTurn: {
80 turnId: TurnId.make("turn-1"),
81 state: "completed",
82 requestedAt: "2026-04-03T00:00:00.000Z",
83 startedAt: "2026-04-05T00:00:00.000Z",
84 completedAt: "2026-04-06T00:00:00.000Z",
85 assistantMessageId: null,
86 },
87 };
88
89 expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z");
90 expect(threadLastActivityAt(shell)).toBeNull();
91 });
92});
93
94describe("effectiveSettled", () => {
95 const overrideCases = [null, "settled", "active"] as const;
96 const changeRequestStates = [undefined, "open", "merged"] as const;
97 const inactivityCases = [
98 ["fresh", FRESH],
99 ["stale", STALE],
100 ["no-activity", null],
101 ] as const;
102 const runningCases = [false, true] as const;
103 const pendingCases = [undefined, "approval", "user-input"] as const;
104 const truthTable = overrideCases.flatMap((settledOverride) =>
105 changeRequestStates.flatMap((changeRequestState) =>
106 inactivityCases.flatMap(([inactivity, activityAt]) =>
107 runningCases.flatMap((running) =>
108 pendingCases.map((pending) => ({
109 settledOverride,
110 changeRequestState,
111 inactivity,
112 activityAt,
113 running,
114 pending,
115 // Settled iff nothing blocks (pending work / live session) AND
116 // the override says settled, or (with no override) a merged PR
117 // or staleness auto-settles. The "active" pin suppresses both
118 // auto signals.
119 expected:
120 pending === undefined &&
121 !running &&
122 (settledOverride === "settled" ||
123 (settledOverride === null &&
124 (changeRequestState === "merged" || inactivity === "stale"))),
125 })),
126 ),
127 ),
128 ),
129 );
130
131 it.each(truthTable)(
132 "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending",
133 ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => {
134 const shell = makeShell({
135 settledOverride,
136 activityAt,
137 ...(running ? { sessionStatus: "running" as const } : {}),
138 ...(pending === undefined ? {} : { pending }),
139 });
140 const changeRequestOptions =
141 changeRequestState === undefined
142 ? {}
143 : { changeRequestState: changeRequestState as ChangeRequestStateLike };
144
145 expect(
146 effectiveSettled(shell, {
147 now: NOW,
148 autoSettleAfterDays: 3,
149 ...changeRequestOptions,
150 }),
151 ).toBe(expected);
152 },
153 );
154
155 it("treats closed change requests like merged ones", () => {
156 const shell = makeShell({ activityAt: null });
157 expect(
158 effectiveSettled(shell, {
159 now: NOW,
160 autoSettleAfterDays: null,
161 changeRequestState: "closed",
162 }),
163 ).toBe(true);
164 });
165
166 it("never settles a starting session, even with a settled override", () => {
167 const shell = makeShell({
168 settledOverride: "settled",
169 activityAt: STALE,
170 sessionStatus: "starting",
171 });
172 expect(
173 effectiveSettled(shell, {
174 now: NOW,
175 autoSettleAfterDays: 3,
176 changeRequestState: "merged",
177 }),
178 ).toBe(false);
179 });
180
181 it("uses a strict inactivity boundary and honors a null threshold", () => {
182 const boundary = makeShell({
183 activityAt: "2026-04-07T00:00:00.000Z",
184 });
185 const stale = makeShell({ activityAt: STALE });
186
187 expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
188 expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false);
189 });
190});
191
192describe("hasQueuedTurnStart", () => {
193 const QUEUED_AT = "2026-04-09T12:00:00.000Z";
194 // Within the adoption grace window of the queued message.
195 const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" };
196
197 it("flags a user message no turn has picked up, within the grace window", () => {
198 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
199 expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true);
200
201 const staleTurn = {
202 ...makeShell({ activityAt: FRESH }),
203 latestUserMessageAt: QUEUED_AT,
204 };
205 expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true);
206 });
207
208 it("expires after the grace window: an unadopted message is a failed start, not queued work", () => {
209 const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null };
210 expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false);
211 // Historical shells (e.g. from servers that never carried latestTurn)
212 // must never read as queued.
213 expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false);
214 });
215
216 it("clears once a turn adopts the message or the start fails", () => {
217 const adopted = {
218 ...makeShell({ activityAt: QUEUED_AT }),
219 latestUserMessageAt: QUEUED_AT,
220 };
221 expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false);
222
223 const failed = makeShell({ activityAt: FRESH });
224 const failedShell = {
225 ...failed,
226 latestUserMessageAt: QUEUED_AT,
227 session: {
228 threadId: failed.id,
229 status: "error" as const,
230 providerName: "Codex",
231 runtimeMode: "full-access" as const,
232 activeTurnId: null,
233 lastError: "boom",
234 updatedAt: NOW,
235 },
236 };
237 expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false);
238 });
239
240 it("is quiet without user messages", () => {
241 expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false);
242 });
243
244 it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => {
245 // Message timestamps originate on other devices; a clock an hour ahead
246 // must not hold the queued state for the whole skew.
247 const skewed = {
248 latestUserMessageAt: "2026-04-09T13:00:00.000Z",
249 latestTurn: null,
250 session: null,
251 };
252 expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false);
253 // A small negative age (within the grace window) still reads as queued.
254 const slightlyAhead = {
255 latestUserMessageAt: "2026-04-09T12:00:30.000Z",
256 latestTurn: null,
257 session: null,
258 };
259 expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true);
260 });
261});
262
263describe("canSettle", () => {
264 it("blocks every state effectiveSettled refuses to classify as settled", () => {
265 expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true);
266 expect(
267 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }),
268 ).toBe(false);
269 expect(
270 canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }),
271 ).toBe(false);
272 expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe(
273 false,
274 );
275 expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe(
276 false,
277 );
278 });
279
280 it("blocks settling a queued turn start, only within the grace window", () => {
281 const queued = {
282 ...makeShell({ activityAt: FRESH }),
283 latestUserMessageAt: "2026-04-09T12:00:00.000Z",
284 };
285 const justAfter = "2026-04-09T12:00:30.000Z";
286 expect(canSettle(queued, { now: justAfter })).toBe(false);
287 // effectiveSettled must agree: queued work never auto-settles either,
288 // even with a merged PR.
289 expect(
290 effectiveSettled(queued, {
291 now: justAfter,
292 autoSettleAfterDays: 3,
293 changeRequestState: "merged",
294 }),
295 ).toBe(false);
296 // Past the window the message is a failed/stale start: settleable again.
297 expect(canSettle(queued, { now: NOW })).toBe(true);
298 });
299
300 it("lets a server-accepted settle overrule the clock-derived queued blocker", () => {
301 // The settle action ran with wall-clock `now` (past the grace window);
302 // the list partition re-evaluates with a minute-floored `now` that is
303 // still INSIDE the window. settledAt >= message time proves the server
304 // already adjudicated this exact message, so the row must not snap back
305 // to active until the coarser clock catches up.
306 const messageAt = "2026-04-09T12:00:00.000Z";
307 const flooredNow = "2026-04-09T12:01:00.000Z";
308 const base = makeShell({ settledOverride: "settled", activityAt: null });
309 const settledAfterMessage = {
310 ...base,
311 latestUserMessageAt: messageAt,
312 settledAt: "2026-04-09T12:02:10.000Z",
313 };
314 expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true);
315 expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe(
316 true,
317 );
318
319 // A message NEWER than settledAt is genuinely new work: still blocked
320 // until the server's auto-unsettle lands.
321 const messageAfterSettle = {
322 ...base,
323 latestUserMessageAt: "2026-04-09T12:03:00.000Z",
324 settledAt: "2026-04-09T12:02:10.000Z",
325 };
326 expect(
327 effectiveSettled(messageAfterSettle, {
328 now: "2026-04-09T12:03:30.000Z",
329 autoSettleAfterDays: 3,
330 }),
331 ).toBe(false);
332 });
333
334 it("agrees with effectiveSettled's blockers for explicitly settled shells", () => {
335 // Anything canSettle rejects must render as active even when the user
336 // settled it earlier.
337 const blocked = makeShell({
338 settledOverride: "settled",
339 activityAt: FRESH,
340 pending: "user-input",
341 });
342 expect(canSettle(blocked, { now: NOW })).toBe(false);
343 expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false);
344 });
345});
packages/client-runtime/src/state/threadSettled.ts created+147
......@@ -0,0 +1,147 @@
1import type { OrchestrationThreadShell } from "@t3tools/contracts";
2
3export type ChangeRequestStateLike = "open" | "closed" | "merged";
4
5const DAY_MS = 24 * 60 * 60 * 1_000;
6
7export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null {
8 const candidates = [
9 shell.latestUserMessageAt,
10 shell.latestTurn?.requestedAt,
11 shell.latestTurn?.startedAt,
12 shell.latestTurn?.completedAt,
13 ];
14 let latest: string | null = null;
15 let latestTimestamp = Number.NEGATIVE_INFINITY;
16
17 for (const candidate of candidates) {
18 if (candidate === null || candidate === undefined) continue;
19 const timestamp = Date.parse(candidate);
20 if (timestamp > latestTimestamp) {
21 latest = candidate;
22 latestTimestamp = timestamp;
23 }
24 }
25
26 return latest;
27}
28
29/**
30 * A queued turn start lives for at most this long: session adoption takes
31 * seconds, so a user message still unadopted after the grace window is a
32 * failed start (or stale data — shells from older servers can carry user
33 * messages with no latestTurn at all), not pending work. Without this bound
34 * such threads would be permanently unsettleable.
35 */
36export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
37
38/**
39 * A user message no turn has picked up yet: the turn.start command was
40 * dispatched (message-sent + turn-start-requested) but no session has
41 * adopted it, so `session` is still null and the pending work is invisible
42 * to the session-status checks. Detectable as a user message strictly newer
43 * than every timestamp on the latest turn — on adoption the new turn's
44 * requestedAt equals the message time, clearing the condition — and only
45 * within the adoption grace window.
46 */
47export function hasQueuedTurnStart(
48 shell: Pick<OrchestrationThreadShell, "latestUserMessageAt" | "latestTurn" | "session">,
49 options: { readonly now: string },
50): boolean {
51 if (shell.latestUserMessageAt == null) return false;
52 // A failed session start clears the queued state: the failure is already
53 // visible (status edge / error).
54 if (shell.session?.status === "error") return false;
55 const messageAt = Date.parse(shell.latestUserMessageAt);
56 if (Number.isNaN(messageAt)) return false;
57 const nowMs = Date.parse(options.now);
58 if (Number.isNaN(nowMs)) return false;
59 // Bounded on both sides: message timestamps originate on whichever device
60 // sent the message, so a clock ahead of this one yields a negative age
61 // that would otherwise hold the queued state for the whole skew. Mirrors
62 // the decider's guard.
63 if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false;
64 const turn = shell.latestTurn;
65 if (turn === null) return true;
66 return [turn.requestedAt, turn.startedAt, turn.completedAt].every(
67 (candidate) => candidate == null || Date.parse(candidate) < messageAt,
68 );
69}
70
71/**
72 * A thread may be settled only when none of effectiveSettled's activity
73 * blockers hold. This is deliberately the same list: anything the partition
74 * refuses to CLASSIFY as settled must also be refused as a settle TARGET.
75 * The server enforces its own invariants; this client-side twin exists so
76 * the UI can disable/reject before a round trip.
77 */
78export function canSettle(
79 shell: Pick<
80 OrchestrationThreadShell,
81 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn"
82 >,
83 options: { readonly now: string },
84): boolean {
85 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
86 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
87 // Queued work is as blocked-on-progress as a live session: settling it
88 // (or auto-settling it on a closed PR) would hide a just-requested turn.
89 if (hasQueuedTurnStart(shell, options)) return false;
90 return true;
91}
92
93/**
94 * Settled resolution over the server-backed settled lifecycle. The explicit
95 * user override (thread.settle / thread.unsettle commands, projected into
96 * settledOverride + settledAt) wins in both directions; without one, a
97 * thread auto-settles on a merged/closed PR or inactivity past the window.
98 * The server un-settles on real activity (user message, session start,
99 * approval/user-input request), so an override never goes stale silently.
100 */
101export function effectiveSettled(
102 shell: OrchestrationThreadShell,
103 options: {
104 readonly now: string;
105 readonly autoSettleAfterDays: number | null;
106 readonly changeRequestState?: ChangeRequestStateLike | null;
107 },
108): boolean {
109 // Blocked work must remain visible even when a user explicitly settled it.
110 if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false;
111 if (shell.session?.status === "starting" || shell.session?.status === "running") return false;
112 if (hasQueuedTurnStart(shell, { now: options.now })) {
113 // The queued-turn blocker alone is forgivable: it is clock-derived, and
114 // list callers pass a coarser `now` than the settle action used. When
115 // the server already adjudicated the queued message by accepting a
116 // settle after it (settledAt stamps server accept time), trust that
117 // ruling — otherwise a settle near the grace boundary leaves the row
118 // pinned active until the caller's clock ticks over. A message NEWER
119 // than settledAt is genuinely new work and keeps the block until the
120 // server's auto-unsettle lands.
121 const serverAdjudicated =
122 shell.settledOverride === "settled" &&
123 shell.settledAt !== null &&
124 shell.latestUserMessageAt !== null &&
125 Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt);
126 if (!serverAdjudicated) return false;
127 }
128 if (shell.settledOverride === "settled") return true;
129 // "active" is the explicit keep-active pin: it suppresses auto-settle
130 // until real activity clears it server-side.
131 if (shell.settledOverride === "active") return false;
132 if (options.changeRequestState === "merged" || options.changeRequestState === "closed") {
133 return true;
134 }
135 if (options.autoSettleAfterDays === null) return false;
136
137 const lastActivityAt = threadLastActivityAt(shell);
138 if (lastActivityAt === null) return false;
139
140 // threadLastActivityAt only returns candidates whose Date.parse beat
141 // -Infinity, so this parse is a real number; a malformed `now` yields NaN,
142 // the comparison is false, and the thread stays active (never a surprise
143 // auto-settle on bad input).
144 return (
145 Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS
146 );
147}
packages/client-runtime/src/state/threads-sync.test.ts+2
......@@ -69,6 +69,8 @@ const BASE_THREAD: OrchestrationThread = {
6969 createdAt: "2026-04-01T00:00:00.000Z",
7070 updatedAt: "2026-04-01T00:00:00.000Z",
7171 archivedAt: null,
72 settledOverride: null,
73 settledAt: null,
7274 deletedAt: null,
7375 messages: [],
7476 proposedPlans: [],
packages/contracts/src/environment.ts+4
......@@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T
2323export const ExecutionEnvironmentCapabilities = Schema.Struct({
2424 repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
2525 connectionProbe: Schema.optionalKey(Schema.Boolean),
26 /** Server understands thread.settle / thread.unsettle commands. Absent on
27 pre-settlement servers, so clients treat missing as unsupported and
28 never send the commands under version skew. */
29 threadSettlement: Schema.optionalKey(Schema.Boolean),
2630});
2731export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
2832
packages/contracts/src/orchestration.test.ts+115
......@@ -15,6 +15,8 @@ import {
1515 ProjectMetaUpdatedPayload,
1616 OrchestrationProposedPlan,
1717 OrchestrationSession,
18 OrchestrationThread,
19 OrchestrationThreadShell,
1820 ProjectCreateCommand,
1921 ThreadMetaUpdatedPayload,
2022 ThreadTurnStartCommand,
......@@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
3739const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn);
3840const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
3941const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
42const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread);
43const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
4044const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload);
4145
4246function getOptionValue(
......@@ -344,6 +348,75 @@ it.effect("decodes thread archive and unarchive commands", () =>
344348 }),
345349);
346350
351it.effect("decodes thread settle and unsettle commands", () =>
352 Effect.gen(function* () {
353 const settle = yield* decodeOrchestrationCommand({
354 type: "thread.settle",
355 commandId: "cmd-settle-1",
356 threadId: "thread-1",
357 });
358 const unsettle = yield* decodeOrchestrationCommand({
359 type: "thread.unsettle",
360 commandId: "cmd-unsettle-1",
361 threadId: "thread-1",
362 reason: "user",
363 });
364
365 assert.strictEqual(settle.type, "thread.settle");
366 assert.strictEqual(unsettle.type, "thread.unsettle");
367
368 // "activity" is server-owned: it exists on the event, never on the
369 // command, so a client cannot forge the neutral reset.
370 const forged = yield* decodeOrchestrationCommand({
371 type: "thread.unsettle",
372 commandId: "cmd-unsettle-2",
373 threadId: "thread-1",
374 reason: "activity",
375 }).pipe(Effect.flip);
376 assert.ok(forged);
377 }),
378);
379
380it.effect("defaults settled fields when decoding historical thread data", () =>
381 Effect.gen(function* () {
382 const common = {
383 id: "thread-1",
384 projectId: "project-1",
385 title: "Historical thread",
386 modelSelection: { provider: "codex", model: "gpt-5.4" },
387 runtimeMode: "full-access",
388 interactionMode: "default",
389 branch: null,
390 worktreePath: null,
391 latestTurn: null,
392 createdAt: "2026-01-01T00:00:00.000Z",
393 updatedAt: "2026-01-01T00:00:00.000Z",
394 archivedAt: null,
395 session: null,
396 };
397 const thread = yield* decodeOrchestrationThread({
398 ...common,
399 deletedAt: null,
400 messages: [],
401 proposedPlans: [],
402 activities: [],
403 checkpoints: [],
404 });
405 const shell = yield* decodeOrchestrationThreadShell({
406 ...common,
407 latestUserMessageAt: null,
408 hasPendingApprovals: false,
409 hasPendingUserInput: false,
410 hasActionableProposedPlan: false,
411 });
412
413 assert.strictEqual(thread.settledOverride, null);
414 assert.strictEqual(thread.settledAt, null);
415 assert.strictEqual(shell.settledOverride, null);
416 assert.strictEqual(shell.settledAt, null);
417 }),
418);
419
347420it.effect("decodes thread archived and unarchived events", () =>
348421 Effect.gen(function* () {
349422 const archived = yield* decodeOrchestrationEvent({
......@@ -388,6 +461,48 @@ it.effect("decodes thread archived and unarchived events", () =>
388461 }),
389462);
390463
464it.effect("decodes thread settled and unsettled events", () =>
465 Effect.gen(function* () {
466 const settled = yield* decodeOrchestrationEvent({
467 sequence: 1,
468 eventId: "event-settle-1",
469 aggregateKind: "thread",
470 aggregateId: "thread-1",
471 type: "thread.settled",
472 occurredAt: "2026-01-01T00:00:00.000Z",
473 commandId: "cmd-settle-1",
474 causationEventId: null,
475 correlationId: "cmd-settle-1",
476 metadata: {},
477 payload: {
478 threadId: "thread-1",
479 settledAt: "2026-01-01T00:00:00.000Z",
480 updatedAt: "2026-01-01T00:00:00.000Z",
481 },
482 });
483 const unsettled = yield* decodeOrchestrationEvent({
484 sequence: 2,
485 eventId: "event-unsettle-1",
486 aggregateKind: "thread",
487 aggregateId: "thread-1",
488 type: "thread.unsettled",
489 occurredAt: "2026-01-02T00:00:00.000Z",
490 commandId: "cmd-unsettle-1",
491 causationEventId: null,
492 correlationId: "cmd-unsettle-1",
493 metadata: {},
494 payload: {
495 threadId: "thread-1",
496 reason: "user",
497 updatedAt: "2026-01-02T00:00:00.000Z",
498 },
499 });
500
501 assert.strictEqual(settled.type, "thread.settled");
502 assert.strictEqual(unsettled.type, "thread.unsettled");
503 }),
504);
505
391506it.effect("accepts provider-scoped model options in thread.turn.start", () =>
392507 Effect.gen(function* () {
393508 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+52
......@@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({
356356 createdAt: IsoDateTime,
357357 updatedAt: IsoDateTime,
358358 archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
359 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe(
360 Schema.withDecodingDefault(Effect.succeed(null)),
361 ),
362 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
359363 deletedAt: Schema.NullOr(IsoDateTime),
360364 messages: Schema.Array(OrchestrationMessage),
361365 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
......@@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({
402406 createdAt: IsoDateTime,
403407 updatedAt: IsoDateTime,
404408 archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
409 settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe(
410 Schema.withDecodingDefault(Effect.succeed(null)),
411 ),
412 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
405413 session: Schema.NullOr(OrchestrationSession),
406414 latestUserMessageAt: Schema.NullOr(IsoDateTime),
407415 hasPendingApprovals: Schema.Boolean,
......@@ -558,6 +566,22 @@ const ThreadUnarchiveCommand = Schema.Struct({
558566 threadId: ThreadId,
559567});
560568
569const ThreadSettleCommand = Schema.Struct({
570 type: Schema.Literal("thread.settle"),
571 commandId: CommandId,
572 threadId: ThreadId,
573});
574
575const ThreadUnsettleCommand = Schema.Struct({
576 type: Schema.Literal("thread.unsettle"),
577 commandId: CommandId,
578 threadId: ThreadId,
579 // Commands only carry "user": activity un-settles are decided server-side
580 // (the decider emits thread.unsettled(reason: "activity") events directly,
581 // never through this command), so a client cannot forge the neutral reset.
582 reason: Schema.Literal("user"),
583});
584
561585const ThreadMetaUpdateCommand = Schema.Struct({
562586 type: Schema.Literal("thread.meta.update"),
563587 commandId: CommandId,
......@@ -700,6 +724,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
700724 ThreadDeleteCommand,
701725 ThreadArchiveCommand,
702726 ThreadUnarchiveCommand,
727 ThreadSettleCommand,
728 ThreadUnsettleCommand,
703729 ThreadMetaUpdateCommand,
704730 ThreadRuntimeModeSetCommand,
705731 ThreadInteractionModeSetCommand,
......@@ -721,6 +747,8 @@ export const ClientOrchestrationCommand = Schema.Union([
721747 ThreadDeleteCommand,
722748 ThreadArchiveCommand,
723749 ThreadUnarchiveCommand,
750 ThreadSettleCommand,
751 ThreadUnsettleCommand,
724752 ThreadMetaUpdateCommand,
725753 ThreadRuntimeModeSetCommand,
726754 ThreadInteractionModeSetCommand,
......@@ -823,6 +851,8 @@ export const OrchestrationEventType = Schema.Literals([
823851 "thread.deleted",
824852 "thread.archived",
825853 "thread.unarchived",
854 "thread.settled",
855 "thread.unsettled",
826856 "thread.meta-updated",
827857 "thread.runtime-mode-set",
828858 "thread.interaction-mode-set",
......@@ -902,6 +932,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({
902932 updatedAt: IsoDateTime,
903933});
904934
935export const ThreadSettledPayload = Schema.Struct({
936 threadId: ThreadId,
937 settledAt: IsoDateTime,
938 updatedAt: IsoDateTime,
939});
940
941export const ThreadUnsettledPayload = Schema.Struct({
942 threadId: ThreadId,
943 reason: Schema.Literals(["user", "activity"]),
944 updatedAt: IsoDateTime,
945});
946
905947export const ThreadMetaUpdatedPayload = Schema.Struct({
906948 threadId: ThreadId,
907949 title: Schema.optional(TrimmedNonEmptyString),
......@@ -1069,6 +1111,16 @@ export const OrchestrationEvent = Schema.Union([
10691111 type: Schema.Literal("thread.unarchived"),
10701112 payload: ThreadUnarchivedPayload,
10711113 }),
1114 Schema.Struct({
1115 ...EventBaseFields,
1116 type: Schema.Literal("thread.settled"),
1117 payload: ThreadSettledPayload,
1118 }),
1119 Schema.Struct({
1120 ...EventBaseFields,
1121 type: Schema.Literal("thread.unsettled"),
1122 payload: ThreadUnsettledPayload,
1123 }),
10721124 Schema.Struct({
10731125 ...EventBaseFields,
10741126 type: Schema.Literal("thread.meta-updated"),
packages/contracts/src/settings.test.ts+21
......@@ -4,12 +4,14 @@ import * as Schema from "effect/Schema";
44import { ProviderInstanceId } from "./providerInstance.ts";
55import {
66 ClientSettingsSchema,
7 ClientSettingsPatch,
78 DEFAULT_SERVER_SETTINGS,
89 ServerSettings,
910 ServerSettingsPatch,
1011} from "./settings.ts";
1112
1213const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
14const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
1315const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
1416const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
1517const encodeServerSettings = Schema.encodeSync(ServerSettings);
......@@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => {
3133 });
3234});
3335
36describe("ClientSettings sidebar v2", () => {
37 it("defaults the beta off with a three-day auto-settle threshold", () => {
38 const settings = decodeClientSettings({});
39 expect(settings.sidebarV2Enabled).toBe(false);
40 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
41 });
42
43 it("allows auto-settle by inactivity to be disabled", () => {
44 expect(
45 decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays,
46 ).toBeNull();
47 });
48
49 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
50 expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
51 expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
52 });
53});
54
3455describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
3556 it("defaults to an empty record so legacy configs without the key still decode", () => {
3657 expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
packages/contracts/src/settings.ts+16
......@@ -38,6 +38,16 @@ export const SidebarThreadPreviewCount = Schema.Int.check(
3838);
3939export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type;
4040export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6;
41export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1;
42export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90;
43export const SidebarAutoSettleAfterDays = Schema.Number.check(
44 Schema.isBetween({
45 minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
46 maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
47 }),
48);
49export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
50export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3;
4151
4252export const ClientSettingsSchema = Schema.Struct({
4353 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({
7282 modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
7383 }),
7484 ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
85 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
86 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
87 ),
7588 sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
7689 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
7790 ),
......@@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({
88101 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
89102 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
90103 ),
104 sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
91105 timestampFormat: TimestampFormat.pipe(
92106 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
93107 ),
......@@ -567,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({
567581 }),
568582 ),
569583 ),
584 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
570585 sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
571586 sidebarProjectGroupingOverrides: Schema.optionalKey(
572587 Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),
......@@ -574,6 +589,7 @@ export const ClientSettingsPatch = Schema.Struct({
574589 sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
575590 sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
576591 sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
592 sidebarV2Enabled: Schema.optionalKey(Schema.Boolean),
577593 timestampFormat: Schema.optionalKey(TimestampFormat),
578594 wordWrap: Schema.optionalKey(Schema.Boolean),
579595});